acts_as_calculator 0.1.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 (81) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +38 -0
  4. data/CODE_OF_CONDUCT.md +132 -0
  5. data/LICENSE.txt +21 -0
  6. data/README.md +133 -0
  7. data/Rakefile +12 -0
  8. data/app/controllers/acts_as_calculator/application_controller.rb +76 -0
  9. data/app/controllers/acts_as_calculator/formula_versions_controller.rb +50 -0
  10. data/app/controllers/acts_as_calculator/formulas_controller.rb +58 -0
  11. data/app/controllers/acts_as_calculator/imports_controller.rb +33 -0
  12. data/app/controllers/acts_as_calculator/templates_controller.rb +66 -0
  13. data/app/models/acts_as_calculator/formula.rb +21 -0
  14. data/app/models/acts_as_calculator/formula_version.rb +88 -0
  15. data/app/models/acts_as_calculator/lookup_table.rb +25 -0
  16. data/app/models/acts_as_calculator/lookup_table_entry.rb +26 -0
  17. data/app/models/acts_as_calculator/record.rb +12 -0
  18. data/app/models/acts_as_calculator/run.rb +24 -0
  19. data/app/models/acts_as_calculator/template.rb +56 -0
  20. data/app/models/acts_as_calculator/variable.rb +20 -0
  21. data/config/routes.rb +36 -0
  22. data/lib/acts_as_calculator/aggregate_results.rb +57 -0
  23. data/lib/acts_as_calculator/aggregation.rb +9 -0
  24. data/lib/acts_as_calculator/apportion_amount.rb +62 -0
  25. data/lib/acts_as_calculator/apportionment.rb +57 -0
  26. data/lib/acts_as_calculator/build_calculator.rb +24 -0
  27. data/lib/acts_as_calculator/build_lookups.rb +37 -0
  28. data/lib/acts_as_calculator/calculable.rb +84 -0
  29. data/lib/acts_as_calculator/calculator_cache.rb +36 -0
  30. data/lib/acts_as_calculator/cast_date.rb +16 -0
  31. data/lib/acts_as_calculator/cast_decimal.rb +15 -0
  32. data/lib/acts_as_calculator/cast_json_safe.rb +37 -0
  33. data/lib/acts_as_calculator/cast_liquid_value.rb +58 -0
  34. data/lib/acts_as_calculator/cast_variable_attributes.rb +17 -0
  35. data/lib/acts_as_calculator/configuration.rb +31 -0
  36. data/lib/acts_as_calculator/distribute_remainder.rb +49 -0
  37. data/lib/acts_as_calculator/divide_proportionally.rb +27 -0
  38. data/lib/acts_as_calculator/engine.rb +19 -0
  39. data/lib/acts_as_calculator/errors.rb +23 -0
  40. data/lib/acts_as_calculator/evaluate_expression.rb +49 -0
  41. data/lib/acts_as_calculator/evaluate_formula.rb +53 -0
  42. data/lib/acts_as_calculator/find_lookup_table_references.rb +68 -0
  43. data/lib/acts_as_calculator/find_owned_record.rb +38 -0
  44. data/lib/acts_as_calculator/find_tier.rb +25 -0
  45. data/lib/acts_as_calculator/formula_version_drop.rb +55 -0
  46. data/lib/acts_as_calculator/function_registry.rb +83 -0
  47. data/lib/acts_as_calculator/import_definitions.rb +70 -0
  48. data/lib/acts_as_calculator/import_formula.rb +102 -0
  49. data/lib/acts_as_calculator/import_lookup_table.rb +94 -0
  50. data/lib/acts_as_calculator/import_outcome.rb +20 -0
  51. data/lib/acts_as_calculator/import_summary.rb +29 -0
  52. data/lib/acts_as_calculator/import_template.rb +57 -0
  53. data/lib/acts_as_calculator/liquid_filters.rb +70 -0
  54. data/lib/acts_as_calculator/persist_run.rb +31 -0
  55. data/lib/acts_as_calculator/promote_template.rb +21 -0
  56. data/lib/acts_as_calculator/publish_formula_version.rb +54 -0
  57. data/lib/acts_as_calculator/publish_template.rb +18 -0
  58. data/lib/acts_as_calculator/read_import_file.rb +23 -0
  59. data/lib/acts_as_calculator/render_liquid.rb +113 -0
  60. data/lib/acts_as_calculator/render_template.rb +43 -0
  61. data/lib/acts_as_calculator/resolve_formula_version.rb +57 -0
  62. data/lib/acts_as_calculator/resolve_import_owner.rb +38 -0
  63. data/lib/acts_as_calculator/resolve_template.rb +41 -0
  64. data/lib/acts_as_calculator/resolve_variables.rb +105 -0
  65. data/lib/acts_as_calculator/result.rb +32 -0
  66. data/lib/acts_as_calculator/result_drop.rb +46 -0
  67. data/lib/acts_as_calculator/serialize_formula.rb +18 -0
  68. data/lib/acts_as_calculator/serialize_formula_version.rb +26 -0
  69. data/lib/acts_as_calculator/serialize_import_summary.rb +21 -0
  70. data/lib/acts_as_calculator/serialize_template.rb +13 -0
  71. data/lib/acts_as_calculator/supersede_formula_versions.rb +83 -0
  72. data/lib/acts_as_calculator/tier.rb +31 -0
  73. data/lib/acts_as_calculator/variable_spec.rb +33 -0
  74. data/lib/acts_as_calculator/version.rb +5 -0
  75. data/lib/acts_as_calculator.rb +78 -0
  76. data/lib/generators/acts_as_calculator/import/import_generator.rb +26 -0
  77. data/lib/generators/acts_as_calculator/install/install_generator.rb +31 -0
  78. data/lib/generators/acts_as_calculator/install/templates/create_acts_as_calculator_tables.rb.tt +133 -0
  79. data/lib/tasks/acts_as_calculator.rake +21 -0
  80. data/sig/acts_as_calculator.rbs +4 -0
  81. metadata +251 -0
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActsAsCalculator
4
+ # The one place import happens. Both authoring paths — the generator and the rake task —
5
+ # are argument parsing and printing around this call, so neither can drift into having
6
+ # its own idea of what "already imported" means.
7
+ class ImportDefinitions
8
+ # Lookup tables first: a formula version's variables name tables that have to resolve
9
+ # before a calculation using them can run.
10
+ SECTIONS = {
11
+ "lookup_tables" => ImportLookupTable,
12
+ "formulas" => ImportFormula,
13
+ "templates" => ImportTemplate
14
+ }.freeze
15
+
16
+ def self.call(...)
17
+ new(...).call
18
+ end
19
+
20
+ def initialize(path: nil, data: nil)
21
+ raise ImportError, "give ImportDefinitions either a path or data, not both" if path && data
22
+ raise ImportError, "ImportDefinitions needs a path or data" if path.nil? && data.nil?
23
+
24
+ @path = path
25
+ @document = (data || ReadImportFile.(path)).transform_keys(&:to_s)
26
+ end
27
+
28
+ def call
29
+ reject_unknown_sections
30
+
31
+ ImportSummary.new(source: path&.to_s || "inline data", outcomes:)
32
+ end
33
+
34
+ private
35
+
36
+ attr_reader :path, :document
37
+
38
+ def outcomes
39
+ SECTIONS.flat_map do |section, decree|
40
+ Array(document[section]).map { |attributes| import(decree, attributes, section) }
41
+ end
42
+ end
43
+
44
+ def reject_unknown_sections
45
+ unknown = document.keys - SECTIONS.keys
46
+ return if unknown.empty?
47
+
48
+ raise ImportError, "unknown import section(s) #{unknown.join(", ")} (known: #{SECTIONS.keys.join(", ")})"
49
+ end
50
+
51
+ # One savepoint per entry, not one transaction per file: the summary reports what each
52
+ # entry did, so a half-written formula must not survive its own failure, and the twenty
53
+ # entries that imported cleanly must not be rolled back by the twenty-first.
54
+ def import(decree, attributes, section)
55
+ raise ImportError, "#{section} must be a list of objects, got #{attributes.inspect}" unless attributes.is_a?(Hash)
56
+
57
+ Record.transaction(requires_new: true) { decree.(attributes:) }
58
+ rescue Error, ::ActiveRecord::ActiveRecordError => e
59
+ failure(attributes, section, e)
60
+ end
61
+
62
+ def failure(attributes, section, error)
63
+ attributes = attributes.is_a?(Hash) ? attributes.transform_keys(&:to_s) : {}
64
+
65
+ ImportOutcome.build(kind: section.delete_suffix("s"), status: :failed,
66
+ key: attributes["key"] || "(no key)", scope: attributes["scope"] || DEFAULT_SCOPE,
67
+ detail: error.message)
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActsAsCalculator
4
+ class ImportFormula
5
+ def self.call(...)
6
+ new(...).call
7
+ end
8
+
9
+ def initialize(attributes:)
10
+ @attributes = attributes.transform_keys(&:to_s)
11
+ @key = @attributes.fetch("key") { raise ImportError, "a formulas entry has no key" }.to_s
12
+ @scope = (@attributes["scope"] || DEFAULT_SCOPE).to_s
13
+ @owner = ResolveImportOwner.(@attributes["owner"])
14
+ end
15
+
16
+ def call
17
+ return outcome(:created, add_version(Formula.create!(key:, scope:, owner:))) if formula.nil?
18
+ return outcome(:skipped, matching_version) if matching_version
19
+
20
+ outcome(:updated, add_version(formula))
21
+ end
22
+
23
+ private
24
+
25
+ attr_reader :attributes, :key, :scope, :owner
26
+
27
+ # The exact [key, scope, owner] triple, with none of `FindOwnedRecord`'s fallback to the
28
+ # global row: an owner-scoped import that quietly rewrote the global formula would be
29
+ # the opposite of what the file asked for.
30
+ def formula
31
+ return @formula if defined?(@formula)
32
+
33
+ @formula = Formula.owned_by(owner).find_by(key:, scope:)
34
+ end
35
+
36
+ # Content, not status: a file that flips a version from draft to active is asking to
37
+ # publish the version it already declared, not to write a second copy of it.
38
+ def matching_version
39
+ @matching_version ||= formula.versions.includes(:variables).find { |version| same_content?(version) }
40
+ end
41
+
42
+ def same_content?(version)
43
+ version.expression == expression &&
44
+ version.effective_from == effective_from &&
45
+ version.effective_to == effective_to &&
46
+ specs(version.variables) == declared_specs
47
+ end
48
+
49
+ def add_version(target)
50
+ PublishFormulaVersion.(formula: target, expression:, effective_from:, effective_to:, status:,
51
+ change_note: attributes["change_note"], variables: declared_variables)
52
+ end
53
+
54
+ def expression
55
+ @expression ||= required_attribute("expression").to_s
56
+ end
57
+
58
+ def effective_from
59
+ @effective_from ||= CastDate.(required_attribute("effective_from"))
60
+ end
61
+
62
+ def effective_to
63
+ return @effective_to if defined?(@effective_to)
64
+
65
+ @effective_to = attributes["effective_to"] && CastDate.(attributes["effective_to"])
66
+ end
67
+
68
+ def status
69
+ @status ||= (attributes["status"] || FormulaVersion::ACTIVE).to_s
70
+ end
71
+
72
+ def required_attribute(name)
73
+ attributes.fetch(name) { raise ImportError, "formula #{key.inspect} has no #{name}" }
74
+ end
75
+
76
+ def declared_variables
77
+ @declared_variables ||= Array(attributes["variables"]).map { |variable| declared_variable(variable) }
78
+ end
79
+
80
+ def declared_variable(variable)
81
+ variable = variable.transform_keys(&:to_s)
82
+ variable.fetch("name") { raise ImportError, "formula #{key.inspect} has a variable with no name" }
83
+
84
+ CastVariableAttributes.(variable)
85
+ end
86
+
87
+ def declared_specs
88
+ @declared_specs ||= specs(declared_variables)
89
+ end
90
+
91
+ # `VariableSpec` already normalises a Hash and an AR `Variable` onto the same shape, so
92
+ # comparing through it is the same reading `ResolveVariables` gets at calculation time.
93
+ # Declaration order carries no meaning — a reordered file is the same formula.
94
+ def specs(variables)
95
+ variables.map { |variable| VariableSpec.build(variable) }.sort_by(&:name)
96
+ end
97
+
98
+ def outcome(result, version)
99
+ ImportOutcome.build(kind: :formula, status: result, key:, scope:, detail: "version #{version.version_number}")
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActsAsCalculator
4
+ class ImportLookupTable
5
+ def self.call(...)
6
+ new(...).call
7
+ end
8
+
9
+ def initialize(attributes:)
10
+ @attributes = attributes.transform_keys(&:to_s)
11
+ @key = @attributes.fetch("key") { raise ImportError, "a lookup_tables entry has no key" }.to_s
12
+ @scope = (@attributes["scope"] || DEFAULT_SCOPE).to_s
13
+ @owner = ResolveImportOwner.(@attributes["owner"])
14
+ end
15
+
16
+ def call
17
+ return create if existing.nil?
18
+ return outcome(:skipped) if entries_unchanged?
19
+
20
+ guard_against_rewriting_audited_brackets
21
+ replace_entries
22
+ outcome(:updated)
23
+ end
24
+
25
+ private
26
+
27
+ attr_reader :attributes, :key, :scope, :owner
28
+
29
+ def existing
30
+ return @existing if defined?(@existing)
31
+
32
+ @existing = LookupTable.owned_by(owner).find_by(key:, scope:)
33
+ end
34
+
35
+ def create
36
+ table = LookupTable.create!(key:, scope:, owner:)
37
+ write_entries(table)
38
+ outcome(:created)
39
+ end
40
+
41
+ def replace_entries
42
+ existing.entries.destroy_all
43
+ write_entries(existing)
44
+ end
45
+
46
+ def write_entries(table)
47
+ declared_entries.each_with_index do |entry, position|
48
+ table.entries.create!(position:, from: entry[:from], to: entry[:to], value: entry[:value])
49
+ end
50
+ end
51
+
52
+ def declared_entries
53
+ @declared_entries ||= Array(attributes["entries"]).map { |entry| declared_entry(entry.transform_keys(&:to_s)) }
54
+ end
55
+
56
+ def declared_entry(entry)
57
+ value = entry.fetch("value") { raise ImportError, "lookup table #{key.inspect} has an entry with no value" }
58
+
59
+ { from: cast_bound(entry["from"]), to: cast_bound(entry["to"]), value: CastDecimal.(value) }
60
+ end
61
+
62
+ def cast_bound(value)
63
+ value.nil? ? nil : CastDecimal.(value)
64
+ end
65
+
66
+ def entries_unchanged?
67
+ existing.entries.ordered.map { |entry| { from: entry.from, to: entry.to, value: entry.value } } ==
68
+ declared_entries
69
+ end
70
+
71
+ # Entries a draft version merely mentions are still free to change; entries an active
72
+ # or retired version resolved are not, because a run has been audited against them.
73
+ def guard_against_rewriting_audited_brackets
74
+ statuses = [FormulaVersion::ACTIVE, FormulaVersion::RETIRED]
75
+ references = FindLookupTableReferences.(lookup_table: existing, statuses:)
76
+ return if references.empty?
77
+
78
+ raise LookupTableInUseError, in_use_message(references)
79
+ end
80
+
81
+ def in_use_message(references)
82
+ used_by = references.map { |version| "#{version.formula.key}##{version.version_number} (#{version.status})" }
83
+
84
+ "lookup table #{key.inspect} in scope #{scope.inspect} already backs #{used_by.join(", ")}; " \
85
+ "changing its entries would retroactively change what those calculations were audited " \
86
+ "against. Import a new table key instead (e.g. #{key.inspect} → \"#{key}_v2\") and a new " \
87
+ "formula version pointing at it."
88
+ end
89
+
90
+ def outcome(status)
91
+ ImportOutcome.build(kind: :lookup_table, status:, key:, scope:)
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActsAsCalculator
4
+ # One line of an import report: what was touched, and what happened to it.
5
+ IMPORT_STATUSES = %i[created updated skipped failed].freeze
6
+
7
+ ImportOutcome = Data.define(:kind, :status, :key, :scope, :detail) do
8
+ def self.build(kind:, status:, key:, scope:, detail: nil)
9
+ new(kind: kind.to_sym, status: status.to_sym, key: key.to_s, scope: scope.to_s, detail:)
10
+ end
11
+
12
+ def failed?
13
+ status == :failed
14
+ end
15
+
16
+ def to_s
17
+ "#{status} #{kind} #{key.inspect} (scope #{scope.inspect})#{detail && " — #{detail}"}"
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActsAsCalculator
4
+ ImportSummary = Data.define(:source, :outcomes) do
5
+ def success?
6
+ failures.empty?
7
+ end
8
+
9
+ def failures
10
+ outcomes.select(&:failed?)
11
+ end
12
+
13
+ def counts
14
+ IMPORT_STATUSES.to_h { |status| [status, outcomes.count { |outcome| outcome.status == status }] }
15
+ end
16
+
17
+ def to_s
18
+ [headline, *outcomes.map { |outcome| " #{outcome}" }].join("\n")
19
+ end
20
+
21
+ private
22
+
23
+ def headline
24
+ tally = counts.map { |status, count| "#{count} #{status}" }.join(", ")
25
+
26
+ "acts_as_calculator: imported #{source} — #{tally}"
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActsAsCalculator
4
+ class ImportTemplate
5
+ def self.call(...)
6
+ new(...).call
7
+ end
8
+
9
+ def initialize(attributes:)
10
+ @attributes = attributes.transform_keys(&:to_s)
11
+ @key = @attributes.fetch("key") { raise ImportError, "a templates entry has no key" }.to_s
12
+ @scope = (@attributes["scope"] || DEFAULT_SCOPE).to_s
13
+ @owner = ResolveImportOwner.(@attributes["owner"])
14
+ end
15
+
16
+ # An import publishes. The rake task's job is to make a deploy's templates the live
17
+ # ones; a version that imported but stayed staged would look like it had worked and
18
+ # render the previous body forever. Rollback stays `PromoteTemplate.(template: older)`,
19
+ # which the next import then reverses — the file is the source of truth.
20
+ def call
21
+ return outcome(:skipped, current) if current && current.body == body && current.format == format
22
+
23
+ outcome(versions.exists? ? :updated : :created, publish)
24
+ end
25
+
26
+ private
27
+
28
+ attr_reader :attributes, :key, :scope, :owner
29
+
30
+ def versions
31
+ Template.owned_by(owner).where(key:, scope:)
32
+ end
33
+
34
+ def current
35
+ return @current if defined?(@current)
36
+
37
+ @current = versions.current.first
38
+ end
39
+
40
+ def publish
41
+ PublishTemplate.(key:, scope:, owner:, body:, format:)
42
+ end
43
+
44
+ def body
45
+ @body ||= attributes.fetch("body") { raise ImportError, "template #{key.inspect} has no body" }.to_s
46
+ end
47
+
48
+ def format
49
+ @format ||= (attributes["format"] || Template::HTML).to_s
50
+ end
51
+
52
+ def outcome(result, template)
53
+ ImportOutcome.build(kind: :template, status: result, key:, scope:,
54
+ detail: "version #{template.version_number}")
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+ require "date"
5
+
6
+ module ActsAsCalculator
7
+ # Registered on this gem's own Liquid::Environment, never on Liquid's global default, so
8
+ # a host app's other Liquid usage neither gains these nor loses its own `date` filter.
9
+ # Only public methods here become filters; the helpers below stay private on purpose.
10
+ module LiquidFilters
11
+ DELIMITER = ","
12
+ ISO_DATE = "%Y-%m-%d"
13
+
14
+ def currency(input, unit = "", precision = 2)
15
+ return "" if blank_input?(input)
16
+
17
+ places = Integer(precision)
18
+ "#{unit}#{delimited(FunctionRegistry::ROUND_CURRENCY.(numeric(input), places), places)}"
19
+ end
20
+
21
+ # Takes a rate expressed as a fraction of one — the form this gem's bracket tiers and
22
+ # lookup tables hold — so 0.2205 renders as "22.05%".
23
+ def percentage(input, precision = 2)
24
+ return "" if blank_input?(input)
25
+
26
+ places = Integer(precision)
27
+ "#{delimited(FunctionRegistry::ROUND_CURRENCY.(numeric(input) * 100, places), places)}%"
28
+ end
29
+
30
+ def date(input, format = ISO_DATE)
31
+ point = time_like(input)
32
+
33
+ point.nil? ? "" : point.strftime(format.to_s)
34
+ end
35
+
36
+ private
37
+
38
+ def blank_input?(input)
39
+ input.nil? || input == ""
40
+ end
41
+
42
+ def numeric(input)
43
+ CastDecimal.(input.is_a?(ResultDrop) ? input.value : input)
44
+ end
45
+
46
+ def delimited(decimal, places)
47
+ whole, fraction = decimal.abs.to_s("F").split(".")
48
+ grouped = whole.reverse.scan(/\d{1,3}/).join(DELIMITER).reverse
49
+ sign = decimal.negative? ? "-" : ""
50
+
51
+ places.positive? ? "#{sign}#{grouped}.#{fraction.to_s.ljust(places, "0")[0, places]}" : "#{sign}#{grouped}"
52
+ end
53
+
54
+ def time_like(input)
55
+ case input
56
+ when nil, "" then nil
57
+ when ::Date, ::Time then input
58
+ when ::Integer then ::Time.at(input)
59
+ when ::String then parsed_date(input)
60
+ else input if input.respond_to?(:strftime)
61
+ end
62
+ end
63
+
64
+ def parsed_date(input)
65
+ ::Date.parse(input)
66
+ rescue ::Date::Error
67
+ nil
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActsAsCalculator
4
+ class PersistRun
5
+ def self.call(...)
6
+ new(...).call
7
+ end
8
+
9
+ def initialize(calculable:, formula_version:, result:, as_of: nil)
10
+ @calculable = calculable
11
+ @formula_version = formula_version
12
+ @result = result
13
+ @as_of = CastDate.(as_of || result.as_of || Date.current)
14
+ end
15
+
16
+ def call
17
+ Run.create!(
18
+ calculable:,
19
+ formula_version:,
20
+ as_of_date: as_of,
21
+ inputs: CastJsonSafe.(result.inputs),
22
+ breakdown: CastJsonSafe.(result.breakdown),
23
+ result: result.value
24
+ )
25
+ end
26
+
27
+ private
28
+
29
+ attr_reader :calculable, :formula_version, :result, :as_of
30
+ end
31
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActsAsCalculator
4
+ # Rollback. Pointing `current` at an older row leaves every version in place, which is
5
+ # what "simple version history for rollback" (docs/PLAN.md) buys over deleting the row
6
+ # that went wrong or racing MAX(version_number).
7
+ class PromoteTemplate
8
+ def self.call(template:)
9
+ Template.transaction do
10
+ Template.version_siblings_of(template).current.where.not(id: template.id)
11
+ .update_all(current: false, updated_at: Time.current)
12
+ # A caller's copy of an already-demoted row still reads `current == true` in memory
13
+ # — publishing a newer version demotes it with update_all — so without this the
14
+ # write below looks like a no-op change and never reaches the database.
15
+ template.reload.update!(current: true)
16
+ end
17
+
18
+ template
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActsAsCalculator
4
+ # Adding a version is never just an insert. `FormulaVersion` refuses two active versions
5
+ # whose effective ranges overlap, so publishing a rule that replaces one already in force
6
+ # has to supersede the incumbent first — and `SupersedeFormulaVersions` refuses outright
7
+ # where that would leave part of the incumbent's range uncovered.
8
+ #
9
+ # Every authoring path goes through here (JSON import, the API), so none of them can grow
10
+ # its own idea of what replacing a rule means or of how version numbers are handed out.
11
+ class PublishFormulaVersion
12
+ def self.call(...)
13
+ new(...).call
14
+ end
15
+
16
+ def initialize(formula:, expression:, effective_from:, effective_to: nil,
17
+ status: FormulaVersion::ACTIVE, change_note: nil, variables: [])
18
+ @formula = formula
19
+ @expression = expression.to_s
20
+ @effective_from = CastDate.(effective_from)
21
+ @effective_to = effective_to && CastDate.(effective_to)
22
+ @status = status.to_s
23
+ @change_note = change_note
24
+ @variables = Array(variables).map { |variable| CastVariableAttributes.(variable) }
25
+ end
26
+
27
+ # `requires_new` so this is one unit of work even when the caller already has a
28
+ # transaction open: a rejected variable must not leave behind a version claiming to
29
+ # declare it, and a supersede must not outlive the version it made room for. A caller
30
+ # that rescues and carries on is exactly the case a joined transaction would not cover.
31
+ def call
32
+ Record.transaction(requires_new: true) do
33
+ SupersedeFormulaVersions.(formula:, effective_from:, effective_to:) if status == FormulaVersion::ACTIVE
34
+
35
+ create_version
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ attr_reader :formula, :expression, :effective_from, :effective_to, :status, :change_note, :variables
42
+
43
+ def create_version
44
+ version = formula.versions.create!(version_number: next_version_number, expression:,
45
+ effective_from:, effective_to:, status:, change_note:)
46
+ variables.each { |variable| version.variables.create!(**variable) }
47
+ version
48
+ end
49
+
50
+ def next_version_number
51
+ (formula.versions.maximum(:version_number) || 0) + 1
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActsAsCalculator
4
+ # Authoring a template body publishes it: a new row, numbered one past its siblings, with
5
+ # the outgoing version demoted by the model's own before_create hook. Never `update!` on
6
+ # an existing row and never `update_all(current:)` — the one-current-version index refuses
7
+ # the latter, and the former would rewrite a version the history exists to keep.
8
+ # Rollback is `PromoteTemplate`, not deleting the row that went wrong.
9
+ class PublishTemplate
10
+ def self.call(key:, body:, scope: nil, owner: nil, format: Template::HTML)
11
+ template = Template.new(key: key.to_s, scope: (scope || DEFAULT_SCOPE).to_s, owner:,
12
+ body: body.to_s, format: format.to_s, current: true)
13
+ template.version_number = template.next_version_number
14
+ template.save!
15
+ template
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module ActsAsCalculator
6
+ class ReadImportFile
7
+ def self.call(path)
8
+ raise ImportError, "no import file at #{path}" unless File.file?(path.to_s)
9
+
10
+ parse(File.read(path.to_s), path)
11
+ end
12
+
13
+ def self.parse(source, path)
14
+ document = JSON.parse(source)
15
+ return document if document.is_a?(Hash)
16
+
17
+ raise ImportError, "#{path} must contain a JSON object, got #{document.class}"
18
+ rescue JSON::ParserError => e
19
+ raise ImportError, "#{path} is not valid JSON: #{e.message}"
20
+ end
21
+ private_class_method :parse
22
+ end
23
+ end