Ruby on Rails occupies that sweet, middle spot between "no-code/low-code" systems and enterprise systems. Rails philosophy pushes you to outsource as many things as possible from the design of your applications, and in turn, the Rails community flourished to become one of the richest ecosystems for web development. It's hard to find things not yet invented in the OSS scene of Rails, and in many ways, this feeling pushes people to think "if there is no gem to do this, it is probably because my use case is too unique to fit other applications."
A lot of people have dealt with building implementations to calculate taxes, discounts, and contributions over a price. Some people have built very intricate payroll engines with a lot of angles and edges; other people may have built insurance calculators for car agencies. And I can name a ton of other examples.
acts_as_calculator aims to abstract these implementations into a Ruby mixin that can be directly embedded into an AR model to provide a fully functional API to handle everything you may need to calculate complex business logic.
It provides an interface for Dentaku to safely eval formulas, a lot of helper methods to handle common necessities (apportionment of amounts, proportionality, etc.), a fully auditable trail of versions for each formula, a history of calculations, a basic ownership relationship (with no policies, so you're responsible for authorisation), and even a templating engine (using Liquid).
To illustrate what the gem does:
class Payroll < ApplicationRecord
belongs_to :employee # employee has_many payrolls
include ActsAsCalculator::Calculable
end
formula = ActsAsCalculator::Formula.create!(
key: "net_pay",
scope: "payroll"
)
formula.versions.create!(
expression: "gross - tax - deductions",
effective_from: Date.new(2026, 1, 1),
status: "active",
variables: [
{ name: "gross", source_type: "attribute" },
{ name: "tax", source_type: "context" },
{ name: "deductions", source_type: "context" }
]
)
result = payroll.calculate(
:net_pay,
as_of: payroll.pays_at,
tax: 500,
deductions: 200
)
result.value
# => #<BigDecimal "2300.00">
result.breakdown
# => { expression: "...", inputs: {...}, calls: [...] }
It drops in as a simple mixin into any model, which allows the model to access a series of modules and methods to handle Formula classes and tables you generate using an initializer after installing the gem.
Then, you can create a formula with a series of variables and with a time range in which it's effective, which you can use in your model to explicitly reference with the var as_of: #<DateTime xxx>.
Formulas are also allowed to call other formulas, to allow a fully fine-grained experience of handling complex, multi-level variables.
For example, imagine you want to keep a history and a unique calculation for deductions; deductions may have a pension contribution, an insurance discount, etc. So, you can simply do this:
ActsAsCalculator::Formula.create!(
key: "total_deductions",
scope: "payroll"
)
total_deductions = ActsAsCalculator::Formula.find_by(
key: "total_deductions"
)
total_deductions.versions.create!(
expression: "@insurance_discount + @pension_contribution + @union_fee",
effective_from: Date.new(2026, 1, 1),
status: "active",
formula_calls: [
{ key: "insurance_discount" },
{ key: "pension_contribution" },
{ key: "union_fee" }
]
)
ActsAsCalculator::Formula.create!(
key: "net_pay",
scope: "payroll"
)
net_pay = ActsAsCalculator::Formula.find_by(
key: "net_pay"
)
net_pay.versions.create!(
expression: "gross - tax - @total_deductions",
effective_from: Date.new(2026, 1, 1),
status: "active",
variables: [
{ name: "gross", source_type: "attribute" }
],
formula_calls: [
{ key: "total_deductions" }
]
)
result = payroll.calculate(
:net_pay,
as_of: Date.new(2026, 3, 15),
tax: 500
)
result.breakdown[:calls]
# => [
# {
# key: "total_deductions",
# formula_version_id: 7,
# placeholder: "__call_0",
# value: #<BigDecimal "612.50">
# }
# ]
It uses a @key syntax to call other formulas, and as you grow in complexity, you can extend this behaviour to handle a lot of different rules. The scope attr is what keeps key from being a single global namespace. Two teams (or two domains: payroll, billing, insurance) can both have a formula called net_pay or discount without stepping on each other, and a model can pin itself to one scope with calculator_scope so every calculate call defaults there without you passing scope: by hand each time.
For example, your employee may vary its pension contribution rate depending on its number of children, or the insurance rate may vary depending on the work the employee does (higher risk, etc.). You can easily define that behaviour in the formulas itself without having to resort to hand-writing code.
Under the hood, the engine also runs a call graph to avoid circular callbacks during formula execution.
As I stated in the beginning of this article, this gem has been created with a profound Rails philosophy; the gem does a lot of work and it tries to abstract logic that could be the business centerpiece for a lot of applications out there.
As such, the development received a lot of input from my experience and opinion on how the "batteries" should fit together, which is problematic if you feel you only need a part of what acts_as_calculator does. But at the same time, I did develop the gem with the idea that it has to be extensible, configurable, and generic, as far as the intrusive nature of the gem allows.
There are the usual trade-offs you encounter every time you integrate this kind of library, most notably, what happens when your app grows too large and you need to go to the design table and redo the architecture.
The best solution I've imagined for this problem while designing the gem is the fact that the data trail is complete. If you have each one of your formulas stored in SQL tables, along with the calculations they have run, the changes they have suffered, the owners, and the timestamps, migrating an architecture becomes trivially simple compared to migrating a ton of DSL-specific files, JSON configs, embedded rules in the code, and etcetera.
The gem is currently in its version 0.2.0. I still have a labyrinthic roadmap I want to complete to launch the 1.0.0. Most of my work is currently focused on polishing and untangling the templating step. The README has a fuller walkthrough if you want to go deeper, and I'd love feedback from anyone who tries it on a real formula set.