Running the Engine in a Rails App
Turn records into facts, run a blueprint from a service object, show its working, and keep its worked examples in your specs.
After reading this guide, you will know:
- Where the line falls between your app and a blueprint.
- How to turn records into facts.
- How to run a calculation and handle what can go wrong.
- How to run the version people published in Studio, falling back to a shipped blueprint.
- How to keep worked examples as the contract in your specs.
The Line Between the App and the Rules
The app owns its records, permissions and screens. Rules, rates and tables live in a blueprint that people can change in Studio, prove against worked examples and publish, and every number it gives back can say how it was worked out.
flowchart LR
records["Deal, QuarterTarget<br/>your models"] -->|"facts: hashes, Ruby dates"| bp["Blueprint#call<br/>shipped or published"]
bp -->|"outputs: exact numbers, Dates"| statement["CommissionStatement"]
bp -->|"Blueprint#explain"| working["how it was worked out"]
The examples use the shipped sales_commission blueprint. The models are illustrative; the Reckoning
calls are the real API, and the gem’s spec/acceptance/host_app_spec.rb runs them.
Installing
Licensed on request
The engine (the Ruby gem that runs blueprints in your own process, with its local CLI, Studio and the Rails integration) is not published. It is licensed on request: email rhys117@gmail.com to ask about a license.
# Gemfile, from the source your license gives you
gem 'reckoning'
require 'reckoning' is enough to run blueprints. Add require 'reckoning/studio' only if you mount
Studio (see Studio).
Records to Facts
A blueprint reads plain hashes, and Ruby dates are fine:
class Deal < ApplicationRecord
def to_fact
{id: "D#{id}", closed: closed_on, total_value:, years: term_years, discount:, kind:, share: split_share}
end
end
Running a Calculation
Run it in a service object:
class CommissionStatement
def initialize(rep, year, blueprint: CommissionPlan.current) = (@rep, @year, @blueprint = rep, year, blueprint)
def call
@blueprint.call(facts, calculation: :year)
# => {by_quarter: [{credited:, attainment:, commission:, spiffs:, earned:, payout:, balance_after:, ...}],
# total_earned:, total_paid:, closing_balance:}
rescue Reckoning::Blueprint::Failure => e
{problems: e.problems}
end
def explain = @blueprint.explain(facts, calculation: :year) # {outputs:, steps:}, for an audit view
private
def facts
{deals: @rep.deals.closed_in(@year).map(&:to_fact),
cancellations: @rep.cancellations.in(@year).map { {deal: "D#{_1.deal_id}", on: _1.cancelled_on} },
quarters: @rep.quarter_targets.in(@year).map(&:to_fact),
opening_balance: @rep.closing_balance_for(@year - 1)}
end
end
For one new-logo deal of 72,000 over two years at 10% off, and a first quarter with a 60,000 quota, a 10,000 target incentive and a 5,000 draw:
blueprint = Reckoning::Blueprint.shipped(:sales_commission)
blueprint.call(facts, calculation: :year)
# => {by_quarter: [{start: Date(2026-01-01), finish: Date(2026-03-31), quota: 60000, target_incentive: 10000, draw: 5000,
# credited: 36000.0, attainment: 0.6, commission: 2100.0, spiffs: 1000, earned: 3100.0,
# payout: 5000, balance_after: 1900.0}],
# total_earned: 3100.0, total_paid: 5000, closing_balance: 1900.0}
The same facts as JSON, through the CLI:
$ reckoning run lib/reckoning/blueprints/sales_commission/blueprint.yml --calculation year --facts commission_facts.json
by_quarter
start finish quota target_incentive draw credited attainment commission spiffs earned payout balance_after
2026-01-01 2026-03-31 60000 10000 5000 36000 0.6 2100 1000 3100 5000 1900
total_earned 3100
total_paid 5000
closing_balance 1900
What Can Go Wrong
call checks the top-level facts before anything runs and raises Reckoning::Blueprint::Refused with
their problems:
blueprint.fact_problems(facts.except(:quarters), calculation: :year) # => ["facts need quarters"]
A row of a list that breaks the rules fails the run with Reckoning::Blueprint::StepFailed, naming the
step and the input:
blueprint.call(facts.merge(deals: [deal.merge(kind: 'upsell')]), calculation: :year)
# Reckoning::Blueprint::StepFailed:
# year > credits: deal_credit needs kind to be one of new_logo, expansion, renewal, not "upsell"
Both are a Reckoning::Blueprint::Failure carrying problems, so one rescue covers them.
fact_problems lists the same problems without running, for checking a form first.
Running What People Published
Run the version people published in Studio, and fall back to the shipped blueprint until there is one. Mount Studio behind your own authentication; in it, choose New, start from the shipped blueprint, change the tables and publish:
# config/routes.rb
authenticate :user, -> (user) { user.admin? } do
mount Reckoning::Studio::Engine, at: '/reckoning'
end
module CommissionPlan
def self.current
definition = Reckoning::Studio::Definition.find_by(name: 'Sales commission')
definition&.published ? definition.template : Reckoning::Blueprint.shipped(:sales_commission)
end
end
definition.template builds the latest published version, never the draft, so an edit in progress never
reaches a statement.
To run blueprints on the hosted service instead of in the app, use the reason_me_this gem, which
speaks the service’s API and keeps numbers exact both ways (see
The Hosted Service).
Showing the Result
Outputs are plain hashes with exact numbers and Dates:
<% @result[:by_quarter].each do |quarter| %>
<tr>
<td><%= quarter[:start].strftime('%b %Y') %></td>
<td><%= number_to_percentage quarter[:attainment] * 100, precision: 0 %></td>
<td><%= number_to_currency quarter[:payout] %></td>
</tr>
<% end %>
explain gives {outputs:, steps:}: every step with its value and the formula, row or rules that
decided it. Render it for whoever asks why a figure is what it is.
Worked Examples as the Contract
Keep the worked examples in the app’s own specs, so a change to the plan that breaks a hand-worked case fails the build:
it 'passes its check and every worked example' do
directory = Reckoning::Blueprint.shipped_directory('sales_commission')
path = File.join(directory, 'blueprint.yml')
cases = Reckoning::Blueprint.read(File.read(File.join(directory, 'cases.yml')))
expect(Reckoning::Blueprint.check_file(path)).to eq([])
expect(Reckoning::Blueprint::Tester.new(Reckoning::Blueprint.load_file(path)).call(cases)).to include(failed: 0)
end
Your Own Plan in a Private Gem
A plan of your own ships the same way from a private gem: a shelf of name/blueprint.yml folders added
with Reckoning::Blueprint.shelves << File.expand_path('blueprints', __dir__), then
Reckoning::Blueprint.shipped(:name). See Packs.
Next Steps
- Studio - Let people edit, try and publish the rules.
- Exact Numbers, Dates and Errors - What comes back, and every failure.
- Calculations in Ruby - The whole Ruby API.