acts_as_calculator 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +0 -5
  3. data/README.md +147 -93
  4. data/app/controllers/acts_as_calculator/application_controller.rb +0 -16
  5. data/app/controllers/acts_as_calculator/formula_versions_controller.rb +0 -4
  6. data/app/controllers/acts_as_calculator/formulas_controller.rb +0 -4
  7. data/app/controllers/acts_as_calculator/imports_controller.rb +0 -9
  8. data/app/controllers/acts_as_calculator/templates_controller.rb +0 -6
  9. data/app/models/acts_as_calculator/formula_version.rb +0 -2
  10. data/app/models/acts_as_calculator/lookup_table_entry.rb +0 -3
  11. data/app/models/acts_as_calculator/record.rb +0 -3
  12. data/app/models/acts_as_calculator/run.rb +0 -2
  13. data/app/models/acts_as_calculator/template.rb +0 -2
  14. data/config/routes.rb +0 -18
  15. data/lib/acts_as_calculator/calculable.rb +0 -11
  16. data/lib/acts_as_calculator/calculator_cache.rb +0 -4
  17. data/lib/acts_as_calculator/cast_json_safe.rb +0 -2
  18. data/lib/acts_as_calculator/cast_liquid_value.rb +0 -10
  19. data/lib/acts_as_calculator/cast_variable_attributes.rb +0 -3
  20. data/lib/acts_as_calculator/configuration.rb +0 -8
  21. data/lib/acts_as_calculator/detect_formula_call_cycles.rb +54 -0
  22. data/lib/acts_as_calculator/engine.rb +0 -3
  23. data/lib/acts_as_calculator/errors.rb +2 -0
  24. data/lib/acts_as_calculator/evaluate_expression.rb +0 -3
  25. data/lib/acts_as_calculator/evaluate_formula.rb +3 -19
  26. data/lib/acts_as_calculator/evaluate_formula_call.rb +124 -0
  27. data/lib/acts_as_calculator/find_lookup_table_references.rb +0 -16
  28. data/lib/acts_as_calculator/formula_call.rb +49 -0
  29. data/lib/acts_as_calculator/formula_call_cache.rb +56 -0
  30. data/lib/acts_as_calculator/formula_version_drop.rb +0 -3
  31. data/lib/acts_as_calculator/function_registry.rb +0 -3
  32. data/lib/acts_as_calculator/import_definitions.rb +0 -8
  33. data/lib/acts_as_calculator/import_formula.rb +12 -10
  34. data/lib/acts_as_calculator/import_lookup_table.rb +0 -2
  35. data/lib/acts_as_calculator/import_outcome.rb +0 -1
  36. data/lib/acts_as_calculator/import_template.rb +0 -4
  37. data/lib/acts_as_calculator/liquid_filters.rb +0 -5
  38. data/lib/acts_as_calculator/parse_formula_expression.rb +53 -0
  39. data/lib/acts_as_calculator/promote_template.rb +0 -6
  40. data/lib/acts_as_calculator/publish_formula_version.rb +17 -14
  41. data/lib/acts_as_calculator/publish_template.rb +0 -5
  42. data/lib/acts_as_calculator/render_liquid.rb +0 -26
  43. data/lib/acts_as_calculator/render_template.rb +0 -3
  44. data/lib/acts_as_calculator/resolve_formula_call.rb +57 -0
  45. data/lib/acts_as_calculator/resolve_formula_version.rb +0 -3
  46. data/lib/acts_as_calculator/resolve_import_owner.rb +0 -4
  47. data/lib/acts_as_calculator/resolve_template.rb +0 -3
  48. data/lib/acts_as_calculator/result.rb +0 -4
  49. data/lib/acts_as_calculator/serialize_formula.rb +0 -3
  50. data/lib/acts_as_calculator/serialize_formula_version.rb +6 -0
  51. data/lib/acts_as_calculator/serialize_import_summary.rb +0 -3
  52. data/lib/acts_as_calculator/supersede_formula_versions.rb +0 -16
  53. data/lib/acts_as_calculator/version.rb +1 -1
  54. data/lib/acts_as_calculator.rb +6 -9
  55. data/lib/generators/acts_as_calculator/import/import_generator.rb +0 -2
  56. data/lib/generators/acts_as_calculator/install/templates/create_acts_as_calculator_tables.rb.tt +1 -11
  57. data/lib/tasks/acts_as_calculator.rake +0 -2
  58. metadata +8 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3c4c2ae76d289d7d038706eaace2037b440d0043375970acbe19e10df2aef610
4
- data.tar.gz: 5785e9931c5bee714ab33c56c1d3f6fb1f7e2fbf866e5bb2534a59c49d0fe678
3
+ metadata.gz: 6ad149828fc403a8faf199be49400d0f5742df5d131fee9c45d61dd4aa961477
4
+ data.tar.gz: 7a24e2b53e7b408e597d1e08c3eda46b03d61b3c26ceb4b7f9c27f9ca7f6814a
5
5
  SHA512:
6
- metadata.gz: f3758535be10ef2eb3cbc481232a5450be2ff2223ad0ea89eb0c7eca34ca09a4673fbe16a77df16fcc04fdab031aa8022a3f5bec8a6e3c503d455cd4cd7dc8a6
7
- data.tar.gz: 62e82cdda250fc79160066529fcedbecb5e49d6032a3731a48e958131197bcdcdb0fbf7ab96227d3a0de64d29abe55cf877247023d7c73ff95fb9c432a2b6a39
6
+ metadata.gz: ccf113444d6697e1be4ffa9e05642cbff4c0846720d1c884360a13ec480d97842d43bd96ffb99199c7e99ce58d9f099628ee678bb34831058da3ebcbaf5b3cfc
7
+ data.tar.gz: 76fec437da064bbf0d9ebc5f45d51992e4c82721e635a82a79851b2a9301db3f2b2873b902a4ad511af0cbecbde420d3375173220c854db50414e9c098a85584
data/.rubocop.yml CHANGED
@@ -3,14 +3,9 @@ AllCops:
3
3
  NewCops: disable
4
4
  SuggestExtensions: false
5
5
 
6
- # Decrees are invoked as `DoSomething.(...)` at call sites but delegate with an explicit
7
- # `new(...).call` — see the decree-service-objects skill. Neither style is wrong here.
8
6
  Style/LambdaCall:
9
7
  Enabled: false
10
8
 
11
- # Decrees take keyword arguments by design (docs/PLAN.md, "Code guidelines"), and a
12
- # keyword argument costs a reader nothing at the call site — counting them would turn the
13
- # gem's mandated style into an offence. Positional parameters are still capped.
14
9
  Metrics/ParameterLists:
15
10
  CountKeywordArgs: false
16
11
 
data/README.md CHANGED
@@ -1,43 +1,152 @@
1
1
  # ActsAsCalculator
2
2
 
3
- A pricing and calculation engine built on [Dentaku](https://github.com/rubysolo/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.
3
+ A calculation engine for pricing, payroll, tax, and insurance domains. Built on [Dentaku](https://github.com/rubysolo/dentaku).
4
4
 
5
- This gem is under active development. See the [architecture plan](https://claude.ai/code/artifact/b02e27b6-eb9c-4b8d-8037-ef69f177c282) for the full design: data model, calculation flow, extensibility points, and build order.
5
+ Add formula-based calculations to any model with `Calculable`. Formulas are versioned, effective-dated, can call other formulas, and render to Liquid templates.
6
6
 
7
- ## Installation
7
+ ## Install
8
8
 
9
- Not yet released to RubyGems. Once published:
9
+ ```ruby
10
+ bundle add acts_as_calculator
11
+ rails generate acts_as_calculator:install
12
+ rails db:migrate
13
+ ```
14
+
15
+ ## Quick Start
16
+
17
+ Mix `Calculable` into your model:
18
+
19
+ ```ruby
20
+ class Employee < ApplicationRecord
21
+ include ActsAsCalculator::Calculable
22
+ end
23
+ ```
24
+
25
+ Create a formula:
26
+
27
+ ```ruby
28
+ ActsAsCalculator::Formula.create!(key: "net_pay", scope: "payroll")
29
+ formula = ActsAsCalculator::Formula.find_by(key: "net_pay")
30
+
31
+ formula.versions.create!(
32
+ expression: "gross - tax - deductions",
33
+ effective_from: Date.new(2026, 1, 1),
34
+ effective_to: nil,
35
+ status: "active",
36
+ variables: [
37
+ { name: "gross", source_type: "attribute" },
38
+ { name: "tax", source_type: "context" },
39
+ { name: "deductions", source_type: "context" }
40
+ ]
41
+ )
42
+ ```
43
+
44
+ Calculate:
45
+
46
+ ```ruby
47
+ employee = Employee.find(1)
48
+
49
+ result = employee.calculate(
50
+ :net_pay,
51
+ as_of: Date.new(2026, 3, 15),
52
+ tax: BigDecimal("500"),
53
+ deductions: BigDecimal("200")
54
+ )
55
+
56
+ result.value # => #<BigDecimal "2300.00">
57
+ result.breakdown # => { expression: "...", inputs: {...}, calls: [...] }
58
+ ```
59
+
60
+ ## Formulas Calling Formulas
61
+
62
+ Use `@formula_key` syntax to call other formulas:
63
+
64
+ ```ruby
65
+ formula = ActsAsCalculator::Formula.create!(key: "total_deductions", scope: "payroll")
66
+
67
+ formula.versions.create!(
68
+ expression: "@tax + @insurance + @retirement",
69
+ effective_from: Date.new(2026, 1, 1),
70
+ status: "active",
71
+ variables: [
72
+ { name: "salary", source_type: "attribute" }
73
+ ],
74
+ formula_calls: [
75
+ { key: "tax" },
76
+ { key: "insurance" },
77
+ { key: "retirement" }
78
+ ]
79
+ )
80
+ ```
81
+
82
+ Each called formula resolves independently at the calculation date. Pin a specific version:
83
+
84
+ ```ruby
85
+ formula_calls: [
86
+ { key: "tax", version_id: 2 } # Always use version 2, ignore as_of
87
+ ]
88
+ ```
89
+
90
+ ## Lookup Tables
91
+
92
+ Use lookup tables for tiered calculations:
93
+
94
+ ```ruby
95
+ table = ActsAsCalculator::LookupTable.create!(key: "tax_brackets", scope: "payroll")
96
+
97
+ table.entries.create!([
98
+ { from: 0, to: 20000, value: BigDecimal("0.10") },
99
+ { from: 20000, to: 50000, value: BigDecimal("0.22") },
100
+ { from: 50000, to: nil, value: BigDecimal("0.32") }
101
+ ])
102
+ ```
103
+
104
+ Reference in formulas:
10
105
 
11
- $ bundle add acts_as_calculator
106
+ ```ruby
107
+ formula.versions.create!(
108
+ expression: "salary * bracket",
109
+ variables: [
110
+ { name: "salary", source_type: "attribute" },
111
+ { name: "bracket", source_type: "lookup",
112
+ source_config: { table: "tax_brackets", using: "salary" } }
113
+ ]
114
+ )
115
+ ```
12
116
 
13
- ## JSON import
117
+ ## Templates
118
+
119
+ Render results to Liquid templates:
120
+
121
+ ```ruby
122
+ ActsAsCalculator::Template.create!(
123
+ key: "payslip",
124
+ scope: "payroll",
125
+ format: "text",
126
+ body: "Net: {{ result.value | currency }}"
127
+ )
128
+
129
+ rendered = employee.render(:payslip, as_of: Date.today, net_pay: result.value)
130
+ ```
14
131
 
15
- Formulas, lookup tables and templates can be authored as JSON and imported either once
16
- through a generator or repeatedly from a deploy script:
132
+ Available filters: `currency`, `percentage`, `date`.
17
133
 
18
- $ rails generate acts_as_calculator:import config/calculator/payroll.json
19
- $ rake acts_as_calculator:import[config/calculator/payroll.json]
134
+ ## JSON Import
20
135
 
21
- Both run the same code and print the same created/updated/skipped/failed summary; the rake
22
- task exits non-zero if any entry failed. One file may declare any of the three sections.
136
+ Define formulas, lookup tables, and templates in JSON:
23
137
 
24
138
  ```json
25
139
  {
26
140
  "lookup_tables": [
27
- { "key": "federal_2026", "scope": "payroll",
28
- "entries": [{ "from": 0, "to": 20000, "value": 0.1 },
29
- { "from": 20000, "to": null, "value": 0.25 }] }
141
+ { "key": "tax_brackets", "scope": "payroll",
142
+ "entries": [{ "from": 0, "to": 20000, "value": 0.1 }] }
30
143
  ],
31
144
  "formulas": [
32
145
  { "key": "net_pay", "scope": "payroll",
33
- "expression": "salary - (salary * federal_2026)",
34
- "effective_from": "2026-01-01", "effective_to": null,
35
- "status": "active", "change_note": "2026 rates",
36
- "variables": [
37
- { "name": "salary", "source_type": "attribute" },
38
- { "name": "federal_2026", "source_type": "lookup",
39
- "source_config": { "table": "federal_2026", "using": "salary" } }
40
- ] }
146
+ "expression": "salary - tax",
147
+ "effective_from": "2026-01-01",
148
+ "status": "active",
149
+ "variables": [{ "name": "salary", "source_type": "attribute" }] }
41
150
  ],
42
151
  "templates": [
43
152
  { "key": "payslip", "scope": "payroll", "format": "text",
@@ -46,32 +155,18 @@ task exits non-zero if any entry failed. One file may declare any of the three s
46
155
  }
47
156
  ```
48
157
 
49
- `scope` defaults to `"default"`, a formula's `status` to `"active"`, a template's `format`
50
- to `"html"`, and a variable's `source_type` to `"context"` and `required` to `true`. Any
51
- entry may carry `"owner": { "type": "Company", "id": 7 }` to import into a tenant-scoped
52
- row instead of the global one.
53
-
54
- Importing is idempotent, keyed on `[key, scope, owner]`:
55
-
56
- - **Formulas** — re-importing unchanged content is a no-op. Changed content adds a *new*
57
- version; existing versions are never edited. Publishing an active version closes out or
58
- retires the one it supersedes, but never rewrites its expression — and it is refused
59
- outright if superseding would leave part of the incumbent's range with no active version,
60
- rather than silently deleting coverage a past calculation relied on. Extend the new
61
- version's `effective_to`, or declare the version taking over the tail earlier in the file.
62
- - **Lookup tables** — unchanged entries are a no-op, and changed entries are replaced in
63
- place *only* if no active or retired formula version resolves to that table. If one does,
64
- the import fails that entry rather than retroactively changing what an audited
65
- calculation was measured against; author a new table key and a new formula version
66
- pointing at it.
67
- - **Templates** — unchanged body and format are a no-op; a change publishes a new version
68
- and demotes the previous one, which stays in the history for rollback.
69
-
70
- ## The API
71
-
72
- The gem ships a mountable REST/JSON API that is **off by default** — not guarded, off. The
73
- routes are behind a routing constraint, so with `enable_api` false they do not match at
74
- all: requests 404 because there is no such path, not because a controller declined.
158
+ Import once or repeatedly:
159
+
160
+ ```bash
161
+ rails generate acts_as_calculator:import config/payroll.json
162
+ rake acts_as_calculator:import[config/payroll.json]
163
+ ```
164
+
165
+ Both run the same logic and report created/updated/skipped counts. Importing is idempotent — unchanged content is skipped, changed content creates a new version.
166
+
167
+ ## API
168
+
169
+ Enable the REST/JSON API (disabled by default):
75
170
 
76
171
  ```ruby
77
172
  # config/initializers/acts_as_calculator.rb
@@ -81,53 +176,12 @@ ActsAsCalculator.configure { |c| c.enable_api = true }
81
176
  mount ActsAsCalculator::Engine => "/calculator"
82
177
  ```
83
178
 
84
- The gem implements **no authentication or authorization** and never will — wrap the mount
85
- point in whatever your app already uses.
86
-
87
- | Method | Path | What it does |
88
- |---|---|---|
89
- | `GET` | `/formulas` | Identity only (`key`, `scope`, `owner`); filter with `?key=&scope=` |
90
- | `POST` | `/formulas` | Creates a formula identity |
91
- | `GET` | `/formulas/:id` | The formula plus its version history |
92
- | `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 |
93
- | `DELETE` | `/formulas/:id` | 409s instead of deleting a formula whose versions have runs |
94
- | `GET` | `/formulas/:id/versions` | Versions in version order |
95
- | `GET` | `/formulas/:id/versions/:id` | One version with its declared variables |
96
- | `POST` | `/formulas/:id/versions` | Publishes a version and its variables |
97
- | `GET` | `/templates` | Every version; `?current=true` narrows to the published one |
98
- | `POST` | `/templates` | Publishes a new version and demotes the outgoing one |
99
- | `GET`/`DELETE` | `/templates/:id` | |
100
- | `POST` | `/templates/:id/preview` | Renders against a posted `context` → `{ body:, format: }` |
101
- | `POST` | `/templates/:id/promote` | Rollback — points `current` at this version |
102
- | `POST` | `/import` | The JSON import document above, over HTTP |
103
-
104
- Writes go through the same Decrees the other authoring paths use, so the API is not a way
105
- around the rules: publishing a version supersedes the one in force rather than overlapping
106
- it, and a supersede that would leave part of the incumbent's range with no active version
107
- comes back as `409 Conflict` rather than silently creating a gap. `POST /import` is
108
- `ImportDefinitions` handed the parsed body — same idempotency, same lookup-table guard, and
109
- the same per-entry created/updated/skipped/failed summary the rake task prints, as JSON.
110
-
111
- Errors are `{ "error": { "type": ..., "message": ..., "details": {...} } }`: 422 on
112
- validation failure (with the model's own messages), 404 on a record or template that isn't
113
- there, 409 on a conflict with existing state, 400 on a malformed request body.
114
-
115
- Lookup tables have no CRUD endpoints of their own. `docs/PLAN.md` names formulas, versions
116
- and templates as the API's surface, and lookup tables are already authorable over HTTP
117
- through `POST /import` — which is also the only path that enforces the guard against
118
- editing a table an audited formula version resolves to.
119
-
120
- ## Development
121
-
122
- $ bin/setup
123
- $ rake spec
124
-
125
- `bin/console` gives an interactive prompt for experimenting.
179
+ Endpoints: `GET/POST /formulas`, `GET/PATCH/DELETE /formulas/:id`, `GET/POST /formulas/:id/versions`, `GET/POST /templates`, `GET/DELETE /templates/:id`, `POST /templates/:id/preview`, `POST /import`.
126
180
 
127
181
  ## Contributing
128
182
 
129
- Bug reports and pull requests are welcome on GitHub at https://github.com/lautarograc/acts_as_calculator. This project follows the [code of conduct](https://github.com/lautarograc/acts_as_calculator/blob/main/CODE_OF_CONDUCT.md).
183
+ Issues and PRs welcome at https://github.com/lautarograc/acts_as_calculator.
130
184
 
131
185
  ## License
132
186
 
133
- Available as open source under the [MIT License](https://opensource.org/licenses/MIT).
187
+ MIT
@@ -1,13 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActsAsCalculator
4
- # There is deliberately no authentication or authorization here, and no `before_action`
5
- # stub inviting one (docs/PLAN.md, "Constraints & non-goals"). The gem's only gate is the
6
- # routing constraint in config/routes.rb; a host that opts the API on wraps the mounted
7
- # engine in its own auth, at its own mount point.
8
4
  class ApplicationController < ActionController::API
9
- # rescue_from matches the most recently registered handler first, so the catch-all for
10
- # the gem's own errors goes first and the narrower ones override it below.
11
5
  rescue_from Error, with: :render_unprocessable
12
6
  rescue_from FormulaNotFoundError, TemplateNotFoundError, NoEffectiveVersionError, with: :render_not_found
13
7
  rescue_from PartialSupersedeError, with: :render_conflict
@@ -37,8 +31,6 @@ module ActsAsCalculator
37
31
  render_error(error, :bad_request)
38
32
  end
39
33
 
40
- # 409, not 422: the request is well-formed and the state is valid — the two are just
41
- # irreconcilable without the operator saying which range they meant to keep.
42
34
  def render_conflict(error)
43
35
  render_error(error, :conflict)
44
36
  end
@@ -47,28 +39,20 @@ module ActsAsCalculator
47
39
  render_error(error, :unprocessable_entity, details: error.record.errors.messages)
48
40
  end
49
41
 
50
- # Reached when a version still has runs: the audit trail is never deleted to make room
51
- # for a delete of something else (docs/PLAN.md, "Data model").
52
42
  def render_record_not_destroyed(error)
53
43
  render_error(error, :conflict, details: error.record.errors.messages)
54
44
  end
55
45
 
56
- # Nested params arrive as ActionController::Parameters, which is not a Hash — anything
57
- # handed to a Decree has to be a plain one first.
58
46
  def plain_hash(value)
59
47
  value.respond_to?(:to_unsafe_h) ? value.to_unsafe_h.to_h : (value || {}).to_h
60
48
  end
61
49
 
62
- # `key` alone never identifies a row in this gem (docs/PLAN.md, "Data model"), so an
63
- # index filters on the pair.
64
50
  def filter_by_key_and_scope(relation)
65
51
  relation = relation.where(key: params[:key]) if params[:key].present?
66
52
  relation = relation.where(scope: params[:scope]) if params[:scope].present?
67
53
  relation
68
54
  end
69
55
 
70
- # `{"type": "Company", "id": 7}` — the same owner reference the JSON import takes, run
71
- # through the same guard that refuses a type which isn't an ActiveRecord model.
72
56
  def resolve_owner(reference)
73
57
  ResolveImportOwner.(plain_hash(reference).presence)
74
58
  end
@@ -12,10 +12,6 @@ module ActsAsCalculator
12
12
  render json: { version: SerializeFormulaVersion.(version:, variables: true) }
13
13
  end
14
14
 
15
- # Straight through `PublishFormulaVersion`, so the API takes exactly the same route
16
- # through the effective-dating rules as the JSON import does: the incumbent is
17
- # superseded, never rewritten, and a supersede that would leave part of its range with
18
- # no active version raises `PartialSupersedeError` — surfaced as a 409, not swallowed.
19
15
  def create
20
16
  published = PublishFormulaVersion.(formula:, **version_attributes)
21
17
 
@@ -1,8 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActsAsCalculator
4
- # Identity only — `key`, `scope`, `owner`. Everything that can change about a formula is
5
- # a version, which is why there is a separate controller for those.
6
4
  class FormulasController < ApplicationController
7
5
  def index
8
6
  render json: { formulas: scoped_formulas.map { |formula| SerializeFormula.(formula:) } }
@@ -44,8 +42,6 @@ module ActsAsCalculator
44
42
  params.require(:formula).permit(:key, :scope, owner: %i[type id])
45
43
  end
46
44
 
47
- # Only what was sent, so a PATCH that renames a formula doesn't blank its scope. `scope`
48
- # is left to the column default on create rather than defaulted here.
49
45
  def formula_attributes
50
46
  permitted = formula_params
51
47
  attributes = {}
@@ -1,10 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActsAsCalculator
4
- # docs/PLAN.md lists JSON import and the API as two authoring paths, not two import
5
- # mechanisms: this is `ImportDefinitions` handed the parsed request body, so an HTTP
6
- # import means exactly what the generator and the rake task mean, idempotency and
7
- # lookup-table guard included.
8
4
  class ImportsController < ApplicationController
9
5
  def create
10
6
  summary = ImportDefinitions.(data: definitions)
@@ -15,11 +11,6 @@ module ActsAsCalculator
15
11
 
16
12
  private
17
13
 
18
- # The request body itself, not `params` — which carries `controller` and `action`, and
19
- # `ImportDefinitions` rejects a section it doesn't recognise rather than ignoring it.
20
- #
21
- # An empty document is refused for the same reason a misspelled section is: reporting
22
- # "0 created, 0 failed" to a caller whose body never parsed looks exactly like success.
23
14
  def definitions
24
15
  body = request.request_parameters
25
16
  if body.empty?
@@ -22,16 +22,12 @@ module ActsAsCalculator
22
22
  head :no_content
23
23
  end
24
24
 
25
- # `{ body:, format: }`, never `html_safe` — whether API-authored markup is safe to
26
- # inject into a page is the host's call, not this gem's (Phase 3).
27
25
  def preview
28
26
  body = RenderTemplate.(template:, context: plain_hash(params[:context]))
29
27
 
30
28
  render json: { preview: { body:, format: template.format } }
31
29
  end
32
30
 
33
- # Rollback: point `current` at an older version instead of deleting the one that went
34
- # wrong, which is the whole reason the version history exists.
35
31
  def promote
36
32
  render json: { template: SerializeTemplate.(template: PromoteTemplate.(template:)) }
37
33
  end
@@ -47,8 +43,6 @@ module ActsAsCalculator
47
43
  current_only? ? templates.current : templates
48
44
  end
49
45
 
50
- # Spelled out rather than reaching for ActiveModel::Type::Boolean, which the gemspec
51
- # doesn't declare and which is more machinery than one query parameter is worth.
52
46
  def current_only?
53
47
  %w[true 1].include?(params[:current].to_s)
54
48
  end
@@ -9,8 +9,6 @@ module ActsAsCalculator
9
9
  RETIRED = "retired"
10
10
  STATUSES = [DRAFT, ACTIVE, RETIRED].freeze
11
11
 
12
- # `effective_to` and `status` stay writable so an active version can be closed out or
13
- # retired; rewriting what was in force on a date already calculated is what's banned.
14
12
  IMMUTABLE_ONCE_ACTIVE = %w[formula_id version_number expression effective_from].freeze
15
13
 
16
14
  belongs_to :formula, inverse_of: :versions
@@ -10,9 +10,6 @@ module ActsAsCalculator
10
10
  validates :position, presence: true, numericality: { only_integer: true }
11
11
  validate :bounds_ordered
12
12
 
13
- # `from`/`to` are half-open and nullable on both ends, so neither is orderable in SQL
14
- # in a way that puts an unbounded floor first across every adapter — position is the
15
- # explicit, adapter-independent answer.
16
13
  scope :ordered, -> { order(:position, :id) }
17
14
 
18
15
  private
@@ -4,9 +4,6 @@ module ActsAsCalculator
4
4
  class Record < ::ActiveRecord::Base
5
5
  self.abstract_class = true
6
6
 
7
- # Set here rather than inherited from the host: `belongs_to_required_by_default` only
8
- # becomes true via `config.load_defaults`, and whether a run may be saved without the
9
- # formula version it applied is not the host's decision to make.
10
7
  self.belongs_to_required_by_default = true
11
8
  end
12
9
  end
@@ -15,8 +15,6 @@ module ActsAsCalculator
15
15
  joins(formula_version: :formula).where(calculator_formulas: { key: key.to_s })
16
16
  }
17
17
 
18
- # The audit trail is append-only: a correction is a new run, never an edit of the row
19
- # that recorded what was actually applied.
20
18
  def readonly?
21
19
  persisted?
22
20
  end
@@ -16,8 +16,6 @@ module ActsAsCalculator
16
16
  validates :format, inclusion: { in: FORMATS }
17
17
  validates :version_number, presence: true, numericality: { only_integer: true, greater_than: 0 }
18
18
 
19
- # Only on update: a create demotes its siblings in before_create, which runs *after*
20
- # validation, so running this on create would flag the version it is about to replace.
21
19
  validate :single_current_version, on: :update
22
20
 
23
21
  before_create :demote_other_versions, if: :current?
data/config/routes.rb CHANGED
@@ -1,27 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # The API ships off (docs/PLAN.md, "Authoring paths"): a host opts in with
4
- # `ActsAsCalculator.configure { |c| c.enable_api = true }`.
5
- #
6
- # The gate is a routing constraint rather than a `before_action` on purpose. A controller
7
- # guard leaves the route registered — the router matches it, instantiates the controller,
8
- # and the controller declines — which means the endpoints exist, answer HEAD/OPTIONS, show
9
- # up in `rails routes`, and are one forgotten `skip_before_action` away from being live.
10
- # A constraint that returns false makes the route not match at all: the engine's router
11
- # passes, the host's router finds nothing, and the request 404s because there is no such
12
- # path. Off means absent, not refused.
13
- #
14
- # The lambda is evaluated per request, so the flag is read at match time, not captured when
15
- # the routes were drawn.
16
3
  ActsAsCalculator::Engine.routes.draw do
17
4
  constraints(->(_request) { ActsAsCalculator.configuration.enable_api }) do
18
5
  resources :formulas, only: %i[index show create update destroy] do
19
6
  resources :versions, only: %i[index show create], controller: "formula_versions"
20
7
  end
21
8
 
22
- # No `update`: a template body is versioned for rollback, so a change publishes a new
23
- # version (POST) and going back promotes an old one — editing a row in place would
24
- # rewrite the history that makes rollback possible.
25
9
  resources :templates, only: %i[index show create destroy] do
26
10
  member do
27
11
  post :preview
@@ -29,8 +13,6 @@ ActsAsCalculator::Engine.routes.draw do
29
13
  end
30
14
  end
31
15
 
32
- # docs/PLAN.md's JSON import, reachable over HTTP — the same ImportDefinitions the
33
- # generator and the rake task call, handed a parsed body instead of a file.
34
16
  post "import", to: "imports#create"
35
17
  end
36
18
  end
@@ -8,8 +8,6 @@ module ActsAsCalculator
8
8
  extend ::ActiveSupport::Concern
9
9
 
10
10
  included do
11
- # No `dependent:` on purpose. Whether an audit trail outlives the record it audited
12
- # is the host app's retention policy, and Run#readonly? makes `:destroy` raise.
13
11
  has_many :calculator_runs,
14
12
  class_name: "ActsAsCalculator::Run",
15
13
  as: :calculable,
@@ -24,9 +22,6 @@ module ActsAsCalculator
24
22
  end
25
23
  end
26
24
 
27
- # Leftover keywords splat into the calculation context, which makes `as_of`, `scope`,
28
- # `owner` and `dry_run` unusable as Dentaku variable names; `context:` is the escape
29
- # hatch for a formula that genuinely declares one, and wins over the splat.
30
25
  def calculate(key, as_of: nil, scope: nil, owner: nil, dry_run: false, context: {}, **extra)
31
26
  EvaluateFormula.(
32
27
  calculable: self,
@@ -53,10 +48,6 @@ module ActsAsCalculator
53
48
  nil
54
49
  end
55
50
 
56
- # Chains a calculation and a render in one call — `calculate:` names one formula key
57
- # (assigned as `result`) or several (assigned as `results`) — or renders a Result the
58
- # caller already has. Leftover keywords splat into the template's assigns *and* into
59
- # the calculation's context, so one call can feed both.
60
51
  def render(key, calculate: nil, result: nil, results: {}, as_of: nil, scope: nil,
61
52
  owner: nil, version_number: nil, dry_run: false, context: {}, **extra)
62
53
  assigns = extra.merge(context)
@@ -73,8 +64,6 @@ module ActsAsCalculator
73
64
 
74
65
  private
75
66
 
76
- # Forwards the assigns as `context:` rather than splatting them, so a key named
77
- # `scope` reaches the formula instead of redirecting its lookup.
78
67
  def calculator_render_results(keys, as_of:, scope:, owner:, dry_run:, context:)
79
68
  Array(keys).to_h do |formula_key|
80
69
  [formula_key.to_s, calculate(formula_key, as_of:, scope:, owner:, dry_run:, context:)]
@@ -25,10 +25,6 @@ module ActsAsCalculator
25
25
 
26
26
  attr_reader :functions, :key
27
27
 
28
- # Keyed by formula_version_id because version content is immutable, so an entry can
29
- # never go stale. Held per thread because Dentaku::Calculator mutates its own memory
30
- # while evaluating — one shared instance across a Puma thread pool would interleave
31
- # two requests' variables.
32
28
  def store
33
29
  Thread.current[key] ||= {}
34
30
  end
@@ -9,8 +9,6 @@ module ActsAsCalculator
9
9
  def self.call(value)
10
10
  case value
11
11
  when Hash, Array then container(value)
12
- # BigDecimal#to_json emits "0.1234e4"; a plain decimal string round-trips into the
13
- # jsonb column as something a human (and a Phase 3 template) can read.
14
12
  when BigDecimal then value.to_s("F")
15
13
  when Symbol then value.to_s
16
14
  when Rational then value.to_f
@@ -5,10 +5,6 @@ require "date"
5
5
  require "liquid"
6
6
 
7
7
  module ActsAsCalculator
8
- # The one gate into a template's render context. Liquid only dispatches methods on
9
- # `Liquid::Drop`s and hash-likes, so the sandbox holds exactly as long as nothing else
10
- # is assigned — an allowlist here is what makes that a property of the code rather than
11
- # a habit every caller has to remember.
12
8
  class CastLiquidValue
13
9
  PASS_THROUGH = [
14
10
  ::NilClass, ::TrueClass, ::FalseClass, ::String, ::Integer, ::Float,
@@ -27,18 +23,12 @@ module ActsAsCalculator
27
23
  def self.scalar(value)
28
24
  return value if PASS_THROUGH.any? { |type| value.is_a?(type) }
29
25
  return value.to_s if value.is_a?(::Symbol)
30
- # BigDecimal#to_s emits engineering notation ("0.1e4"), which is not what belongs in
31
- # rendered output; the plain-decimal form round-trips exactly back through CastDecimal.
32
26
  return value.to_s("F") if value.is_a?(::BigDecimal)
33
27
 
34
28
  time_like(value) || refuse(value)
35
29
  end
36
30
  private_class_method :scalar
37
31
 
38
- # The one class a Rails host will hit that is neither a Date nor a Time, since
39
- # `record.created_at` is the obvious thing to pass. Matched by name because the core
40
- # layer must stay loadable without ActiveSupport, and named rather than duck-typed so
41
- # this stays an allowlist — `respond_to?(:strftime)` would admit anything.
42
32
  TIME_WITH_ZONE = "ActiveSupport::TimeWithZone"
43
33
 
44
34
  # rubocop:disable Style/ClassEqualityComparison -- instance_of? would need the constant
@@ -1,9 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActsAsCalculator
4
- # Both authoring paths hand over the same loose variable declaration — an import file's
5
- # `variables` list and the API's — and both have to land on the same row, with the same
6
- # defaults. Keeping the defaulting here is what stops the two from drifting.
7
4
  class CastVariableAttributes
8
5
  def self.call(variable)
9
6
  variable = variable.to_h.transform_keys(&:to_s)
@@ -1,13 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActsAsCalculator
4
- # Host-facing settings, set from an initializer:
5
- #
6
- # ActsAsCalculator.configure { |c| c.enable_api = true }
7
- #
8
- # `enable_api` is read per request by the routing constraint in `config/routes.rb`, not
9
- # captured when the routes are drawn — so flipping it takes effect without a reload, and
10
- # a host can gate the API on something it computes at boot.
11
4
  class Configuration
12
5
  attr_accessor :enable_api
13
6
 
@@ -24,7 +17,6 @@ module ActsAsCalculator
24
17
  yield(configuration)
25
18
  end
26
19
 
27
- # Test-suite affordance: a host's own specs need to flip `enable_api` and put it back.
28
20
  def self.reset_configuration!
29
21
  @configuration = Configuration.new
30
22
  end