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,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "liquid"
4
+
5
+ module ActsAsCalculator
6
+ # The security boundary. Template bodies are authored by non-developers (eventually
7
+ # through Phase 5's API), so this deliberately renders inside a private
8
+ # Liquid::Environment rather than Liquid's process-global default, which a host app is
9
+ # free to have repointed at a real file system or loaded its own tags into.
10
+ class RenderLiquid
11
+ # `include`/`render` are the only tags that read from outside the assigns. A
12
+ # BlankFileSystem already refuses them; dropping the tags means the template fails at
13
+ # parse time with a syntax error instead of at render time with a file system error.
14
+ FILE_SYSTEM_TAGS = %w[include render].freeze
15
+
16
+ # `tablerow` iterates a collection the same way `for` does, but through its own
17
+ # render_to_output_buffer rather than a subclassable collection_segment. It is a
18
+ # storefront grid-layout tag with no use in rendering a calculation, so it is dropped
19
+ # rather than given a second, version-fragile bounded subclass.
20
+ ITERATING_TAGS = %w[tablerow].freeze
21
+
22
+ # Bounds output size and assign churn. These do NOT bound iteration on their own —
23
+ # render_score counts rendered nodes, so an empty loop body scores zero per pass. That
24
+ # gap is closed by BoundedFor/MAX_ITERATIONS below, not here.
25
+ RESOURCE_LIMITS = {
26
+ render_length_limit: 2_000_000,
27
+ render_score_limit: 200_000,
28
+ assign_score_limit: 2_000_000
29
+ }.freeze
30
+
31
+ # Cumulative across every loop in one render, so nested loops multiply into the same
32
+ # budget instead of each staying under a per-loop cap. Generous for presenting a
33
+ # calculation; a template needing more than this is not a template.
34
+ MAX_ITERATIONS = 10_000
35
+
36
+ class IterationBudget
37
+ def initialize(limit)
38
+ @limit = limit
39
+ @remaining = limit
40
+ end
41
+
42
+ # Checked against the collection as declared, *before* Liquid reaches Range#to_a on
43
+ # it — `{% for i in (1..2000000000) %}` is a heap bomb no post-hoc count can undo.
44
+ def reserve(collection)
45
+ size = countable_size(collection)
46
+
47
+ exhausted! if !size.nil? && size > remaining
48
+ end
49
+
50
+ def spend(count)
51
+ @remaining -= count
52
+
53
+ exhausted! if remaining.negative?
54
+ end
55
+
56
+ private
57
+
58
+ attr_reader :limit, :remaining
59
+
60
+ def countable_size(collection)
61
+ case collection
62
+ when ::Range, ::Array, ::Hash then collection.size
63
+ end
64
+ end
65
+
66
+ # Liquid::MemoryError is the one exception BlockBody.rescue_render_node re-raises
67
+ # instead of routing through the error mode, so a resource bound reported this way
68
+ # cannot be swallowed into the rendered output.
69
+ def exhausted!
70
+ raise Liquid::MemoryError, "template exceeded #{limit} loop iterations"
71
+ end
72
+ end
73
+
74
+ class BoundedFor < Liquid::For
75
+ private
76
+
77
+ # The collection expression is evaluated once more than stock Liquid does, to size
78
+ # it before the superclass materialises it. Everything reachable from a template is
79
+ # a Drop, Hash or Array by construction, so re-evaluating is cheap and side-effect
80
+ # free.
81
+ def collection_segment(context)
82
+ budget = context.registers[:iteration_budget]
83
+ budget&.reserve(context.evaluate(@collection_name))
84
+
85
+ super.tap { |segment| budget&.spend(segment.length) }
86
+ end
87
+ end
88
+
89
+ SANDBOX_TAGS = Liquid::Tags::STANDARD_TAGS
90
+ .reject { |name, _| (FILE_SYSTEM_TAGS + ITERATING_TAGS).include?(name) }
91
+ .merge("for" => BoundedFor)
92
+ .freeze
93
+
94
+ SANDBOX = Liquid::Environment.build(
95
+ error_mode: :strict,
96
+ file_system: Liquid::BlankFileSystem.new,
97
+ tags: SANDBOX_TAGS
98
+ ) do |environment|
99
+ environment.default_resource_limits = RESOURCE_LIMITS
100
+ environment.register_filter(LiquidFilters)
101
+ end
102
+
103
+ def self.call(source:, assigns: {})
104
+ Liquid::Template
105
+ .parse(source.to_s, environment: SANDBOX, line_numbers: true)
106
+ .render!(assigns,
107
+ registers: { iteration_budget: IterationBudget.new(MAX_ITERATIONS) },
108
+ strict_filters: true)
109
+ rescue Liquid::Error => e
110
+ raise TemplateRenderError, e.to_s
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActsAsCalculator
4
+ class RenderTemplate
5
+ def self.call(...)
6
+ new(...).call
7
+ end
8
+
9
+ def initialize(key: nil, scope: nil, owner: nil, version_number: nil,
10
+ template: nil, result: nil, results: {}, context: {})
11
+ @key = key
12
+ @scope = scope
13
+ @owner = owner
14
+ @version_number = version_number
15
+ @template = template
16
+ @result = result
17
+ @results = results
18
+ @context = context
19
+ end
20
+
21
+ def call
22
+ RenderLiquid.(source: template.body, assigns:)
23
+ end
24
+
25
+ private
26
+
27
+ attr_reader :key, :scope, :owner, :version_number, :result, :results, :context
28
+
29
+ def template
30
+ @template ||= ResolveTemplate.(key:, scope:, owner:, version_number:)
31
+ end
32
+
33
+ # Context is merged under the result assigns rather than over them: a host that happens
34
+ # to pass a `result` key in context must not be able to shadow the calculation the
35
+ # template exists to display.
36
+ def assigns
37
+ CastLiquidValue.(context).merge(
38
+ "result" => CastLiquidValue.(result),
39
+ "results" => CastLiquidValue.(results)
40
+ )
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActsAsCalculator
4
+ class ResolveFormulaVersion
5
+ def self.call(...)
6
+ new(...).call
7
+ end
8
+
9
+ def initialize(key:, scope: nil, owner: nil, as_of: nil)
10
+ @key = key.to_s
11
+ @scope = (scope || DEFAULT_SCOPE).to_s
12
+ @owner = owner
13
+ @as_of = CastDate.(as_of || Date.current)
14
+ end
15
+
16
+ def call
17
+ covering_version || raise_no_effective_version
18
+ end
19
+
20
+ private
21
+
22
+ attr_reader :key, :scope, :owner, :as_of
23
+
24
+ def formula
25
+ @formula ||= FindOwnedRecord.(relation: Formula.all, key:, scope:, owner:) || raise_formula_not_found
26
+ end
27
+
28
+ def covering_version
29
+ formula.versions.active.covering(as_of).order(effective_from: :desc, id: :desc).first
30
+ end
31
+
32
+ def raise_formula_not_found
33
+ raise FormulaNotFoundError, "no formula #{key.inspect} in scope #{scope.inspect} for #{describe_owner}"
34
+ end
35
+
36
+ # Deliberately not "fall back to the most recent active version" — that would apply
37
+ # today's rules to a date they were never in force for, which is the exact failure
38
+ # effective-dating exists to prevent (docs/PLAN.md, "Data model").
39
+ def raise_no_effective_version
40
+ raise NoEffectiveVersionError,
41
+ "formula #{key.inspect} in scope #{scope.inspect} has no active version " \
42
+ "covering #{as_of.iso8601} (versions: #{described_versions})"
43
+ end
44
+
45
+ def describe_owner
46
+ owner.nil? ? "no owner" : "#{owner.class}##{owner.id} (or globally)"
47
+ end
48
+
49
+ def described_versions
50
+ ranges = formula.versions.active.order(:effective_from).map do |version|
51
+ "#{version.effective_from.iso8601}..#{version.effective_to&.iso8601 || "open"}"
52
+ end
53
+
54
+ ranges.empty? ? "none active" : ranges.join(", ")
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActsAsCalculator
4
+ # `{"owner": {"type": "Company", "id": 7}}` — omitted means the global (nil-owner) row,
5
+ # which is what a single-tenant host wants and what `FindOwnedRecord` falls back to.
6
+ class ResolveImportOwner
7
+ def self.call(reference)
8
+ return nil if reference.nil?
9
+
10
+ attributes = symbolize(reference)
11
+ model = model_for(attributes[:type])
12
+ id = attributes[:id]
13
+ raise ImportError, "owner reference #{reference.inspect} needs both a type and an id" if id.nil?
14
+
15
+ model.find_by(model.primary_key => id) ||
16
+ raise(ImportError, "no #{model.name} with id #{id.inspect} to own the imported record")
17
+ end
18
+
19
+ def self.symbolize(reference)
20
+ raise ImportError, "owner must be an object like {\"type\": \"Company\", \"id\": 1}" unless reference.is_a?(Hash)
21
+
22
+ reference.transform_keys(&:to_sym)
23
+ end
24
+ private_class_method :symbolize
25
+
26
+ # An import file is operator-authored, but it is still data — resolving an arbitrary
27
+ # string to a constant is only safe if the result is checked, not just loaded.
28
+ def self.model_for(type)
29
+ model = type.to_s.safe_constantize
30
+ unless model.is_a?(Class) && model < ::ActiveRecord::Base
31
+ raise ImportError, "owner type #{type.inspect} is not an ActiveRecord model"
32
+ end
33
+
34
+ model
35
+ end
36
+ private_class_method :model_for
37
+ end
38
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActsAsCalculator
4
+ class ResolveTemplate
5
+ def self.call(...)
6
+ new(...).call
7
+ end
8
+
9
+ def initialize(key:, scope: nil, owner: nil, version_number: nil)
10
+ @key = key.to_s
11
+ @scope = (scope || DEFAULT_SCOPE).to_s
12
+ @owner = owner
13
+ @version_number = version_number
14
+ end
15
+
16
+ def call
17
+ FindOwnedRecord.(relation:, key:, scope:, owner:) || raise_template_not_found
18
+ end
19
+
20
+ private
21
+
22
+ attr_reader :key, :scope, :owner, :version_number
23
+
24
+ # Presentation, not an audited rule, so there is no effective-dating to resolve: the
25
+ # current version is whichever row carries the flag, and asking for a version_number
26
+ # pins an exact row for previewing or diffing one that isn't live.
27
+ def relation
28
+ version_number.nil? ? Template.current : Template.where(version_number:)
29
+ end
30
+
31
+ def raise_template_not_found
32
+ raise TemplateNotFoundError,
33
+ "no #{version_number.nil? ? "current" : "version #{version_number}"} template " \
34
+ "#{key.inspect} in scope #{scope.inspect} for #{describe_owner}"
35
+ end
36
+
37
+ def describe_owner
38
+ owner.nil? ? "no owner" : "#{owner.class}##{owner.id} (or globally)"
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActsAsCalculator
4
+ class ResolveVariables
5
+ SOURCE_TYPES = %i[attribute method lookup context].freeze
6
+
7
+ def self.call(...)
8
+ new(...).call
9
+ end
10
+
11
+ def initialize(specs:, calculable: nil, context: {}, lookups: {})
12
+ @specs = specs
13
+ @calculable = calculable
14
+ @context = context
15
+ @lookups = lookups
16
+ end
17
+
18
+ def call
19
+ variables.each_with_object({}) do |spec, inputs|
20
+ inputs[spec.name] = resolve(spec)
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ attr_reader :specs, :calculable, :context, :lookups
27
+
28
+ def variables
29
+ specs.map { |spec| VariableSpec.build(spec) }
30
+ end
31
+
32
+ def resolve(spec)
33
+ value = fetch(spec)
34
+ return value unless value.nil?
35
+ raise MissingVariableError, "required variable #{spec.name.inspect} resolved to nil" if spec.required
36
+
37
+ spec.default
38
+ end
39
+
40
+ def fetch(spec)
41
+ case spec.source_type
42
+ when :attribute then attribute_value(spec)
43
+ when :method then method_value(spec)
44
+ when :lookup then lookup_value(spec)
45
+ when :context then context_value(spec.source_config[:key] || spec.name)
46
+ else raise_unknown_source_type(spec)
47
+ end
48
+ end
49
+
50
+ def attribute_value(spec)
51
+ send_to_calculable(spec.source_config[:attribute] || spec.name)
52
+ end
53
+
54
+ def method_value(spec)
55
+ send_to_calculable(spec.source_config[:method] || spec.name, *Array(spec.source_config[:args]))
56
+ end
57
+
58
+ def send_to_calculable(name, *args)
59
+ return calculable.public_send(name, *args) if calculable.respond_to?(name)
60
+ return calculable[name] if args.empty? && calculable.respond_to?(:[])
61
+
62
+ raise_unreadable(name)
63
+ end
64
+
65
+ def raise_unreadable(name)
66
+ raise VariableResolutionError, "no calculable given to read #{name.inspect} from" if calculable.nil?
67
+
68
+ raise VariableResolutionError, "#{calculable.class} does not respond to #{name.inspect}"
69
+ end
70
+
71
+ def raise_unknown_source_type(spec)
72
+ raise UnknownSourceTypeError,
73
+ "unknown source_type #{spec.source_type.inspect} for #{spec.name.inspect} " \
74
+ "(known: #{SOURCE_TYPES.join(", ")})"
75
+ end
76
+
77
+ def lookup_value(spec)
78
+ tiers = tiers_for(spec.source_config[:table] || spec.name)
79
+ using = spec.source_config[:using]
80
+ return tiers if using.nil?
81
+
82
+ FindTier.(tiers:, amount: lookup_amount(using)).value
83
+ end
84
+
85
+ def lookup_amount(source)
86
+ return context_value(source) if context.key?(source.to_s) || context.key?(source.to_sym)
87
+
88
+ send_to_calculable(source)
89
+ end
90
+
91
+ def tiers_for(table)
92
+ lookups.fetch(table.to_s) do
93
+ lookups.fetch(table.to_sym) do
94
+ raise MissingLookupTableError, "no lookup table registered for #{table.inspect}"
95
+ end
96
+ end
97
+ end
98
+
99
+ def context_value(key)
100
+ return context[key.to_s] if context.key?(key.to_s)
101
+
102
+ context[key.to_sym]
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActsAsCalculator
4
+ Result = Data.define(:value, :breakdown, :formula_version, :as_of) do
5
+ def initialize(value:, breakdown: {}, formula_version: nil, as_of: nil)
6
+ super(value:, breakdown: deep_copy(breakdown), formula_version:, as_of:)
7
+ end
8
+
9
+ def inputs
10
+ breakdown.fetch(:inputs, {})
11
+ end
12
+
13
+ def expression
14
+ breakdown[:expression]
15
+ end
16
+
17
+ private
18
+
19
+ # Copies the containers rather than freezing them in place: the breakdown is built
20
+ # from the caller's own inputs hash, and freezing that would reach back out of the
21
+ # Result. Leaves are left alone for the same reason — a formula_version or a record
22
+ # passed through the breakdown stays the caller's to mutate.
23
+ def deep_copy(value)
24
+ case value
25
+ when Hash then value.to_h { |key, nested| [deep_copy(key), deep_copy(nested)] }.freeze
26
+ when Array then value.map { |nested| deep_copy(nested) }.freeze
27
+ when String then -value
28
+ else value
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "liquid"
4
+
5
+ module ActsAsCalculator
6
+ class ResultDrop < Liquid::Drop
7
+ def initialize(result)
8
+ super()
9
+ @result = result
10
+ end
11
+
12
+ def value
13
+ CastLiquidValue.(result.value)
14
+ end
15
+
16
+ def as_of
17
+ result.as_of
18
+ end
19
+
20
+ def expression
21
+ result.expression
22
+ end
23
+
24
+ def inputs
25
+ CastLiquidValue.(result.inputs)
26
+ end
27
+
28
+ def breakdown
29
+ CastLiquidValue.(result.breakdown)
30
+ end
31
+
32
+ def formula_version
33
+ version = result.formula_version
34
+
35
+ FormulaVersionDrop.new(version) unless version.nil?
36
+ end
37
+
38
+ def to_s
39
+ value.to_s
40
+ end
41
+
42
+ private
43
+
44
+ attr_reader :result
45
+ end
46
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActsAsCalculator
4
+ # Identity only by default — content lives on the versions, and a formulas index that
5
+ # inlined every version of every formula would be the wrong default for the one endpoint
6
+ # a host hits most.
7
+ class SerializeFormula
8
+ def self.call(formula:, versions: false)
9
+ payload = { id: formula.id, key: formula.key, scope: formula.scope,
10
+ owner_type: formula.owner_type, owner_id: formula.owner_id,
11
+ created_at: formula.created_at&.iso8601, updated_at: formula.updated_at&.iso8601 }
12
+ return payload unless versions
13
+
14
+ payload.merge(versions: formula.versions.order(:version_number)
15
+ .map { |version| SerializeFormulaVersion.(version:) })
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActsAsCalculator
4
+ class SerializeFormulaVersion
5
+ def self.call(version:, variables: false)
6
+ payload = attributes(version)
7
+ return payload unless variables
8
+
9
+ payload.merge(variables: version.variables.order(:name).map { |variable| serialize_variable(variable) })
10
+ end
11
+
12
+ def self.attributes(version)
13
+ { id: version.id, formula_id: version.formula_id, version_number: version.version_number,
14
+ expression: version.expression, status: version.status, change_note: version.change_note,
15
+ effective_from: version.effective_from&.iso8601, effective_to: version.effective_to&.iso8601,
16
+ created_at: version.created_at&.iso8601, updated_at: version.updated_at&.iso8601 }
17
+ end
18
+ private_class_method :attributes
19
+
20
+ def self.serialize_variable(variable)
21
+ { id: variable.id, name: variable.name, source_type: variable.source_type,
22
+ source_config: variable.source_config, required: variable.required }
23
+ end
24
+ private_class_method :serialize_variable
25
+ end
26
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActsAsCalculator
4
+ # The same per-entry report the rake task prints, as JSON: failures are per entry, so a
5
+ # caller has to be told which entries landed and which did not, not just that "the import
6
+ # failed".
7
+ class SerializeImportSummary
8
+ def self.call(summary:)
9
+ { source: summary.source,
10
+ success: summary.success?,
11
+ counts: summary.counts,
12
+ outcomes: summary.outcomes.map { |outcome| serialize_outcome(outcome) } }
13
+ end
14
+
15
+ def self.serialize_outcome(outcome)
16
+ { kind: outcome.kind, status: outcome.status, key: outcome.key,
17
+ scope: outcome.scope, detail: outcome.detail }
18
+ end
19
+ private_class_method :serialize_outcome
20
+ end
21
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActsAsCalculator
4
+ class SerializeTemplate
5
+ def self.call(template:)
6
+ { id: template.id, key: template.key, scope: template.scope,
7
+ owner_type: template.owner_type, owner_id: template.owner_id,
8
+ body: template.body, format: template.format,
9
+ version_number: template.version_number, current: template.current,
10
+ created_at: template.created_at&.iso8601, updated_at: template.updated_at&.iso8601 }
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActsAsCalculator
4
+ # Makes room for a new active version. `FormulaVersion` refuses two active versions whose
5
+ # effective ranges overlap, so publishing a correction to a rule that is currently in
6
+ # force has to close the incumbent out first — by moving its `effective_to` or retiring
7
+ # it, never by touching its `expression`, which is what stays immutable.
8
+ #
9
+ # An incumbent is only ever superseded into a range the newcomer takes over. Where the
10
+ # newcomer would leave part of the incumbent's range with no rule at all, this raises:
11
+ # `effective_from` is immutable on an active version, so there is no way to keep just the
12
+ # tail, and quietly retiring the whole row would delete coverage a past calculation had.
13
+ # Same principle as the resolver's "no covering version raises, never fall back" — a gap
14
+ # the operator did not ask for is not this Decree's to create (docs/PLAN.md, "Data model").
15
+ class SupersedeFormulaVersions
16
+ def self.call(...)
17
+ new(...).call
18
+ end
19
+
20
+ def initialize(formula:, effective_from:, effective_to: nil)
21
+ @formula = formula
22
+ @effective_from = CastDate.(effective_from)
23
+ @effective_to = effective_to && CastDate.(effective_to)
24
+ end
25
+
26
+ # Every incumbent is checked before any is written, so a refusal never leaves half the
27
+ # supersede applied.
28
+ def call
29
+ incumbents = overlapping
30
+ orphaned = incumbents.find { |version| !covers_tail_of?(version) }
31
+ raise PartialSupersedeError, uncovered_tail_message(orphaned) if orphaned
32
+
33
+ incumbents.each { |version| supersede(version) }
34
+ end
35
+
36
+ private
37
+
38
+ attr_reader :formula, :effective_from, :effective_to
39
+
40
+ def overlapping
41
+ versions = formula.versions.active
42
+ versions = versions.where(effective_from: ..effective_to) if effective_to
43
+
44
+ versions.open_ended_or_ending_on_or_after(effective_from).to_a
45
+ end
46
+
47
+ # An open-ended newcomer runs to the end of time, so nothing can outlast it.
48
+ def covers_tail_of?(version)
49
+ return true if effective_to.nil?
50
+
51
+ !version.effective_to.nil? && version.effective_to <= effective_to
52
+ end
53
+
54
+ # Starting on or after the newcomer leaves no range to shorten the incumbent to, so
55
+ # retiring is the only move — and by here the newcomer is known to cover all of it.
56
+ def supersede(version)
57
+ if version.effective_from >= effective_from
58
+ version.update!(status: FormulaVersion::RETIRED)
59
+ else
60
+ version.update!(effective_to: effective_from - 1)
61
+ end
62
+ end
63
+
64
+ def uncovered_tail_message(version)
65
+ tail = describe(effective_to + 1, version.effective_to)
66
+
67
+ "version #{version.version_number} of formula #{formula.key.inspect} is effective " \
68
+ "#{describe(version.effective_from, version.effective_to)}, which outlasts the new version's " \
69
+ "#{describe(effective_from, effective_to)}. Superseding it would leave #{tail} with no active " \
70
+ "version at all. Either extend the new version's effective_to to " \
71
+ "#{describe_bound(version.effective_to)}, or declare a version covering #{tail} first — entries " \
72
+ "are applied in order, so the one taking over the tail has to land before the one giving it up."
73
+ end
74
+
75
+ def describe(from, to)
76
+ "#{from.iso8601}..#{describe_bound(to)}"
77
+ end
78
+
79
+ def describe_bound(date)
80
+ date.nil? ? "open" : date.iso8601
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActsAsCalculator
4
+ Tier = Data.define(:from, :to, :value) do
5
+ def self.build(source)
6
+ return source if source.is_a?(Tier)
7
+
8
+ attributes = source.respond_to?(:to_h) ? source.to_h : read_from(source)
9
+ attributes = attributes.transform_keys(&:to_sym)
10
+
11
+ new(from: attributes[:from], to: attributes[:to], value: attributes[:value])
12
+ end
13
+
14
+ def self.read_from(source)
15
+ { from: source.from, to: source.to, value: source.value }
16
+ end
17
+ private_class_method :read_from
18
+
19
+ def covers?(amount)
20
+ amount >= lower_bound && amount < upper_bound
21
+ end
22
+
23
+ def lower_bound
24
+ from || -Float::INFINITY
25
+ end
26
+
27
+ def upper_bound
28
+ to || Float::INFINITY
29
+ end
30
+ end
31
+ end