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.