ActsAsCalculator
A pricing and calculation engine built on Dentaku. Mix Calculable into any model to get effective-dated, versioned formulas; apportionment and aggregation helpers; and Liquid-rendered output — reusable across payroll, e-commerce, and insurance domains.
This gem is under active development. See the architecture plan for the full design: data model, calculation flow, extensibility points, and build order.
Installation
Not yet released to RubyGems. Once published:
$ bundle add acts_as_calculator
JSON import
Formulas, lookup tables and templates can be authored as JSON and imported either once through a generator or repeatedly from a deploy script:
$ rails generate acts_as_calculator:import config/calculator/payroll.json
$ rake acts_as_calculator:import[config/calculator/payroll.json]
Both run the same code and print the same created/updated/skipped/failed summary; the rake task exits non-zero if any entry failed. One file may declare any of the three sections.
{
"lookup_tables": [
{ "key": "federal_2026", "scope": "payroll",
"entries": [{ "from": 0, "to": 20000, "value": 0.1 },
{ "from": 20000, "to": null, "value": 0.25 }] }
],
"formulas": [
{ "key": "net_pay", "scope": "payroll",
"expression": "salary - (salary * federal_2026)",
"effective_from": "2026-01-01", "effective_to": null,
"status": "active", "change_note": "2026 rates",
"variables": [
{ "name": "salary", "source_type": "attribute" },
{ "name": "federal_2026", "source_type": "lookup",
"source_config": { "table": "federal_2026", "using": "salary" } }
] }
],
"templates": [
{ "key": "payslip", "scope": "payroll", "format": "text",
"body": "Net: {{ result.value | currency }}" }
]
}
scope defaults to "default", a formula's status to "active", a template's format
to "html", and a variable's source_type to "context" and required to true. Any
entry may carry "owner": { "type": "Company", "id": 7 } to import into a tenant-scoped
row instead of the global one.
Importing is idempotent, keyed on [key, scope, owner]:
- Formulas — re-importing unchanged content is a no-op. Changed content adds a new
version; existing versions are never edited. Publishing an active version closes out or
retires the one it supersedes, but never rewrites its expression — and it is refused
outright if superseding would leave part of the incumbent's range with no active version,
rather than silently deleting coverage a past calculation relied on. Extend the new
version's
effective_to, or declare the version taking over the tail earlier in the file. - Lookup tables — unchanged entries are a no-op, and changed entries are replaced in place only if no active or retired formula version resolves to that table. If one does, the import fails that entry rather than retroactively changing what an audited calculation was measured against; author a new table key and a new formula version pointing at it.
- Templates — unchanged body and format are a no-op; a change publishes a new version and demotes the previous one, which stays in the history for rollback.
The API
The gem ships a mountable REST/JSON API that is off by default — not guarded, off. The
routes are behind a routing constraint, so with enable_api false they do not match at
all: requests 404 because there is no such path, not because a controller declined.
# config/initializers/acts_as_calculator.rb
ActsAsCalculator.configure { |c| c.enable_api = true }
# config/routes.rb
mount ActsAsCalculator::Engine => "/calculator"
The gem implements no authentication or authorization and never will — wrap the mount point in whatever your app already uses.
| Method | Path | What it does |
|---|---|---|
GET |
/formulas |
Identity only (key, scope, owner); filter with ?key=&scope= |
POST |
/formulas |
Creates a formula identity |
GET |
/formulas/:id |
The formula plus its version history |
PATCH |
/formulas/:id |
Updates only the identity attributes that were sent — including owner, unguarded; re-owning a formula silently re-targets every future #calculate resolution for that key, it isn't a rename-only endpoint |
DELETE |
/formulas/:id |
409s instead of deleting a formula whose versions have runs |
GET |
/formulas/:id/versions |
Versions in version order |
GET |
/formulas/:id/versions/:id |
One version with its declared variables |
POST |
/formulas/:id/versions |
Publishes a version and its variables |
GET |
/templates |
Every version; ?current=true narrows to the published one |
POST |
/templates |
Publishes a new version and demotes the outgoing one |
GET/DELETE |
/templates/:id |
|
POST |
/templates/:id/preview |
Renders against a posted context → { body:, format: } |
POST |
/templates/:id/promote |
Rollback — points current at this version |
POST |
/import |
The JSON import document above, over HTTP |
Writes go through the same Decrees the other authoring paths use, so the API is not a way
around the rules: publishing a version supersedes the one in force rather than overlapping
it, and a supersede that would leave part of the incumbent's range with no active version
comes back as 409 Conflict rather than silently creating a gap. POST /import is
ImportDefinitions handed the parsed body — same idempotency, same lookup-table guard, and
the same per-entry created/updated/skipped/failed summary the rake task prints, as JSON.
Errors are { "error": { "type": ..., "message": ..., "details": {...} } }: 422 on
validation failure (with the model's own messages), 404 on a record or template that isn't
there, 409 on a conflict with existing state, 400 on a malformed request body.
Lookup tables have no CRUD endpoints of their own. docs/PLAN.md names formulas, versions
and templates as the API's surface, and lookup tables are already authorable over HTTP
through POST /import — which is also the only path that enforces the guard against
editing a table an audited formula version resolves to.
Development
$ bin/setup
$ rake spec
bin/console gives an interactive prompt for experimenting.
Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/lautarograc/acts_as_calculator. This project follows the code of conduct.
License
Available as open source under the MIT License.