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,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+
5
+ module ActsAsCalculator
6
+ class CastDecimal
7
+ def self.call(value)
8
+ return value if value.is_a?(BigDecimal)
9
+
10
+ BigDecimal(value.to_s)
11
+ rescue ArgumentError, TypeError
12
+ raise Error, "cannot cast #{value.inspect} to a decimal"
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+
5
+ module ActsAsCalculator
6
+ class CastJsonSafe
7
+ NATIVE = [NilClass, TrueClass, FalseClass, String, Integer, Float].freeze
8
+
9
+ def self.call(value)
10
+ case value
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
+ when BigDecimal then value.to_s("F")
15
+ when Symbol then value.to_s
16
+ when Rational then value.to_f
17
+ else scalar(value)
18
+ end
19
+ end
20
+
21
+ def self.container(value)
22
+ return value.map { |nested| call(nested) } if value.is_a?(Array)
23
+
24
+ value.to_h { |key, nested| [key.to_s, call(nested)] }
25
+ end
26
+ private_class_method :container
27
+
28
+ def self.scalar(value)
29
+ return value if NATIVE.any? { |type| value.is_a?(type) }
30
+ return value.iso8601 if value.respond_to?(:iso8601)
31
+ return call(value.to_h) if value.respond_to?(:to_h)
32
+
33
+ value.to_s
34
+ end
35
+ private_class_method :scalar
36
+ end
37
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+ require "date"
5
+ require "liquid"
6
+
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
+ class CastLiquidValue
13
+ PASS_THROUGH = [
14
+ ::NilClass, ::TrueClass, ::FalseClass, ::String, ::Integer, ::Float,
15
+ ::Date, ::Time, ::Liquid::Drop
16
+ ].freeze
17
+
18
+ def self.call(value)
19
+ case value
20
+ when ::Hash then value.to_h { |key, nested| [key.to_s, call(nested)] }
21
+ when ::Array then value.map { |nested| call(nested) }
22
+ when Result then ResultDrop.new(value)
23
+ else scalar(value)
24
+ end
25
+ end
26
+
27
+ def self.scalar(value)
28
+ return value if PASS_THROUGH.any? { |type| value.is_a?(type) }
29
+ 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
+ return value.to_s("F") if value.is_a?(::BigDecimal)
33
+
34
+ time_like(value) || refuse(value)
35
+ end
36
+ private_class_method :scalar
37
+
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
+ TIME_WITH_ZONE = "ActiveSupport::TimeWithZone"
43
+
44
+ # rubocop:disable Style/ClassEqualityComparison -- instance_of? would need the constant
45
+ def self.time_like(value)
46
+ value if value.class.name == TIME_WITH_ZONE
47
+ end
48
+ # rubocop:enable Style/ClassEqualityComparison
49
+ private_class_method :time_like
50
+
51
+ def self.refuse(value)
52
+ raise UnsafeAssignError,
53
+ "#{value.class} cannot be assigned into a template: wrap it in a Liquid::Drop " \
54
+ "that exposes only the methods a template may call, or pass a primitive"
55
+ end
56
+ private_class_method :refuse
57
+ end
58
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
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
+ class CastVariableAttributes
8
+ def self.call(variable)
9
+ variable = variable.to_h.transform_keys(&:to_s)
10
+
11
+ { name: variable["name"].to_s,
12
+ source_type: (variable["source_type"] || "context").to_s,
13
+ source_config: CastJsonSafe.(variable["source_config"] || {}),
14
+ required: variable.fetch("required", true) != false }
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
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
+ class Configuration
12
+ attr_accessor :enable_api
13
+
14
+ def initialize
15
+ @enable_api = false
16
+ end
17
+ end
18
+
19
+ def self.configuration
20
+ @configuration ||= Configuration.new
21
+ end
22
+
23
+ def self.configure
24
+ yield(configuration)
25
+ end
26
+
27
+ # Test-suite affordance: a host's own specs need to flip `enable_api` and put it back.
28
+ def self.reset_configuration!
29
+ @configuration = Configuration.new
30
+ end
31
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+
5
+ module ActsAsCalculator
6
+ class DistributeRemainder
7
+ def self.call(...)
8
+ new(...).call
9
+ end
10
+
11
+ def initialize(amount:, weights:, precision: Apportionment::DEFAULT_PRECISION)
12
+ @amount = amount
13
+ @weights = weights
14
+ @precision = precision
15
+ end
16
+
17
+ def call
18
+ shares = floors.dup
19
+ priority.first(leftover_units).each { |index| shares[index] += unit }
20
+ shares
21
+ end
22
+
23
+ private
24
+
25
+ attr_reader :amount, :weights, :precision
26
+
27
+ def unit
28
+ @unit ||= BigDecimal(1) / (10**precision)
29
+ end
30
+
31
+ def exact
32
+ @exact ||= DivideProportionally.(amount:, weights:)
33
+ end
34
+
35
+ def floors
36
+ @floors ||= exact.map { |share| (share / unit).floor * unit }
37
+ end
38
+
39
+ def leftover_units
40
+ ((amount - floors.sum) / unit).round
41
+ end
42
+
43
+ def priority
44
+ remainders = exact.zip(floors).map { |share, floor| share - floor }
45
+
46
+ (0...weights.size).sort_by { |index| [-remainders[index], index] }
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+
5
+ module ActsAsCalculator
6
+ class DivideProportionally
7
+ def self.call(...)
8
+ new(...).call
9
+ end
10
+
11
+ def initialize(amount:, weights:)
12
+ @amount = amount
13
+ @weights = weights
14
+ end
15
+
16
+ def call
17
+ total = weights.sum
18
+ raise ApportionmentError, "cannot apportion across zero total weight" if total.zero?
19
+
20
+ weights.map { |weight| amount * weight / total }
21
+ end
22
+
23
+ private
24
+
25
+ attr_reader :amount, :weights
26
+ end
27
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Rails::Engine::Configuration reaches for ActionDispatch::Routing::RouteSet the moment
4
+ # isolate_namespace touches `config`, and railties depends on actionpack anyway, so this
5
+ # is a load-order fix rather than an extra dependency.
6
+ require "action_dispatch"
7
+ require "rails"
8
+ require "rails/engine"
9
+ require "active_record"
10
+
11
+ module ActsAsCalculator
12
+ class Engine < ::Rails::Engine
13
+ isolate_namespace ActsAsCalculator
14
+
15
+ config.generators do |generators|
16
+ generators.test_framework :rspec
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActsAsCalculator
4
+ class Error < StandardError; end
5
+
6
+ class EvaluationError < Error; end
7
+ class MissingVariableError < Error; end
8
+ class VariableResolutionError < Error; end
9
+ class UnknownSourceTypeError < Error; end
10
+ class MissingLookupTableError < Error; end
11
+ class TierNotFoundError < Error; end
12
+ class UnknownStrategyError < Error; end
13
+ class ApportionmentError < Error; end
14
+ class AggregationError < Error; end
15
+ class FormulaNotFoundError < Error; end
16
+ class NoEffectiveVersionError < Error; end
17
+ class TemplateNotFoundError < Error; end
18
+ class TemplateRenderError < Error; end
19
+ class UnsafeAssignError < Error; end
20
+ class PartialSupersedeError < Error; end
21
+ class ImportError < Error; end
22
+ class LookupTableInUseError < ImportError; end
23
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dentaku"
4
+
5
+ module ActsAsCalculator
6
+ class EvaluateExpression
7
+ def self.call(...)
8
+ new(...).call
9
+ end
10
+
11
+ def initialize(expression:, inputs: {}, calculator: nil, formula_version: nil, as_of: nil)
12
+ @expression = expression
13
+ @inputs = inputs
14
+ @calculator = calculator
15
+ @formula_version = formula_version
16
+ @as_of = as_of
17
+ end
18
+
19
+ def call
20
+ value = evaluate
21
+
22
+ Result.new(value:, breakdown: breakdown_for(value), formula_version:, as_of:)
23
+ end
24
+
25
+ private
26
+
27
+ attr_reader :expression, :inputs, :formula_version, :as_of
28
+
29
+ # Dentaku evaluates an expression as one unit, so the trace is the resolved inputs
30
+ # and the final value; a step-by-step breakdown needs formulas decomposed into named
31
+ # sub-expressions, which is deliberately deferred (docs/PLAN.md, "Open risks").
32
+ def breakdown_for(value)
33
+ { expression:, inputs:, value: }
34
+ end
35
+
36
+ def evaluate
37
+ calculator.evaluate!(expression, inputs)
38
+ rescue Dentaku::UnboundVariableError => e
39
+ raise MissingVariableError,
40
+ "#{expression.inspect} references unresolved variables: #{e.unbound_variables.join(", ")}"
41
+ rescue Dentaku::Error, ::ZeroDivisionError => e
42
+ raise EvaluationError, "could not evaluate #{expression.inspect}: #{e.message}"
43
+ end
44
+
45
+ def calculator
46
+ @calculator ||= BuildCalculator.()
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActsAsCalculator
4
+ class EvaluateFormula
5
+ def self.call(...)
6
+ new(...).call
7
+ end
8
+
9
+ def initialize(calculable:, key:, scope: nil, owner: nil, as_of: nil,
10
+ context: {}, dry_run: false, calculators: CalculatorCache.default)
11
+ @calculable = calculable
12
+ @key = key
13
+ @scope = scope
14
+ @owner = owner
15
+ @as_of = CastDate.(as_of || Date.current)
16
+ @context = context
17
+ @dry_run = dry_run
18
+ @calculators = calculators
19
+ end
20
+
21
+ def call
22
+ version = ResolveFormulaVersion.(key:, scope:, owner:, as_of:)
23
+ inputs = resolve_variables(version)
24
+ result = evaluate(version, inputs)
25
+
26
+ PersistRun.(calculable:, formula_version: version, as_of:, result:) unless dry_run
27
+ result
28
+ end
29
+
30
+ private
31
+
32
+ attr_reader :calculable, :key, :scope, :owner, :as_of, :context, :dry_run, :calculators
33
+
34
+ def resolve_variables(version)
35
+ ResolveVariables.(
36
+ specs: version.variables.to_a,
37
+ calculable:,
38
+ context:,
39
+ lookups: BuildLookups.(formula_version: version, owner:)
40
+ )
41
+ end
42
+
43
+ def evaluate(version, inputs)
44
+ EvaluateExpression.(
45
+ expression: version.expression,
46
+ inputs:,
47
+ calculator: calculators.fetch(version.id),
48
+ formula_version: version,
49
+ as_of:
50
+ )
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActsAsCalculator
4
+ # Which formula versions would resolve to *this* lookup table if they ran now.
5
+ #
6
+ # A variable names a table by key, never by id, and `BuildLookups` turns that key into a
7
+ # row through `FindOwnedRecord` — owner first, global second. So "does version X use
8
+ # table Y" cannot be answered by a foreign key or by matching keys alone: a global table
9
+ # is used by an owned formula only when that owner has no table of its own with the same
10
+ # key. Re-running the very same resolution is what keeps this answer from drifting away
11
+ # from what an actual calculation would do.
12
+ class FindLookupTableReferences
13
+ def self.call(...)
14
+ new(...).call
15
+ end
16
+
17
+ def initialize(lookup_table:, statuses: nil)
18
+ @lookup_table = lookup_table
19
+ @statuses = statuses
20
+ end
21
+
22
+ def call
23
+ candidates.select { |variable| resolves_here?(variable.formula_version.formula) }
24
+ .map(&:formula_version)
25
+ .uniq
26
+ end
27
+
28
+ private
29
+
30
+ attr_reader :lookup_table, :statuses
31
+
32
+ def candidates
33
+ by_key(scoped_variables.includes(formula_version: :formula))
34
+ end
35
+
36
+ def scoped_variables
37
+ variables = Variable.where(source_type: "lookup")
38
+ .joins(formula_version: :formula)
39
+ .where(Formula.table_name => { scope: lookup_table.scope })
40
+ statuses.nil? ? variables : variables.where(FormulaVersion.table_name => { status: statuses })
41
+ end
42
+
43
+ # `lookup_table_key` reads source_config, whose shape is JSON — portable across the
44
+ # adapters this gem supports only in Ruby.
45
+ def by_key(variables)
46
+ variables.select { |variable| variable.lookup_table_key == lookup_table.key }
47
+ end
48
+
49
+ def resolves_here?(formula)
50
+ candidate_owners(formula).any? do |owner|
51
+ FindOwnedRecord.(relation: LookupTable.all, key: lookup_table.key,
52
+ scope: formula.scope, owner:) == lookup_table
53
+ end
54
+ end
55
+
56
+ # Which owner a version's tables resolve under is the *caller's* owner, not the formula's:
57
+ # `Calculable#calculate` forwards `owner:` through `EvaluateFormula` to `BuildLookups`. An
58
+ # owned formula can only ever have been reached by its own owner, so that is the one
59
+ # candidate. A global formula is reached by every owner — but it can only land on *this*
60
+ # row as the global table (owner nil) or as this row's owner shadowing it, so those two
61
+ # are the whole candidate set, and checking more would pin tables nothing can reach.
62
+ def candidate_owners(formula)
63
+ return [formula.owner] if formula.owner
64
+
65
+ [nil, lookup_table.owner].uniq
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActsAsCalculator
4
+ class FindOwnedRecord
5
+ def self.call(...)
6
+ new(...).call
7
+ end
8
+
9
+ def initialize(relation:, key:, scope: nil, owner: nil)
10
+ @relation = relation
11
+ @key = key.to_s
12
+ @scope = (scope || DEFAULT_SCOPE).to_s
13
+ @owner = owner
14
+ end
15
+
16
+ def call
17
+ owned || global
18
+ end
19
+
20
+ private
21
+
22
+ attr_reader :relation, :key, :scope, :owner
23
+
24
+ def owned
25
+ return nil if owner.nil?
26
+
27
+ candidates.owned_by(owner).first
28
+ end
29
+
30
+ def global
31
+ candidates.global.first
32
+ end
33
+
34
+ def candidates
35
+ relation.where(key:, scope:)
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActsAsCalculator
4
+ class FindTier
5
+ def self.call(...)
6
+ new(...).call
7
+ end
8
+
9
+ def initialize(tiers:, amount:)
10
+ @tiers = tiers
11
+ @amount = amount
12
+ end
13
+
14
+ def call
15
+ built = tiers.map { |tier| Tier.build(tier) }
16
+
17
+ built.find { |tier| tier.covers?(amount) } ||
18
+ raise(TierNotFoundError, "no tier covers #{amount.inspect} in #{built.inspect}")
19
+ end
20
+
21
+ private
22
+
23
+ attr_reader :tiers, :amount
24
+ end
25
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "liquid"
4
+
5
+ module ActsAsCalculator
6
+ # Liquid::Drop only invokes public methods declared on the subclass itself — everything
7
+ # Object and Liquid::Drop already answer to is blacklisted — so this list *is* the set of
8
+ # things a template can reach on a formula version.
9
+ class FormulaVersionDrop < Liquid::Drop
10
+ def initialize(formula_version)
11
+ super()
12
+ @formula_version = formula_version
13
+ end
14
+
15
+ def key
16
+ formula_version.formula.key
17
+ end
18
+
19
+ def scope
20
+ formula_version.formula.scope
21
+ end
22
+
23
+ def version_number
24
+ formula_version.version_number
25
+ end
26
+
27
+ def expression
28
+ formula_version.expression
29
+ end
30
+
31
+ def status
32
+ formula_version.status
33
+ end
34
+
35
+ def effective_from
36
+ formula_version.effective_from
37
+ end
38
+
39
+ def effective_to
40
+ formula_version.effective_to
41
+ end
42
+
43
+ def change_note
44
+ formula_version.change_note
45
+ end
46
+
47
+ def to_s
48
+ key.to_s
49
+ end
50
+
51
+ private
52
+
53
+ attr_reader :formula_version
54
+ end
55
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+
5
+ module ActsAsCalculator
6
+ class FunctionRegistry
7
+ Definition = Data.define(:name, :type, :implementation)
8
+
9
+ BRACKET = lambda { |amount, tiers|
10
+ FindTier.(tiers:, amount:).value
11
+ }
12
+
13
+ PROGRESSIVE_BRACKET = lambda { |amount, tiers|
14
+ total = CastDecimal.(amount)
15
+
16
+ tiers.map { |tier| Tier.build(tier) }.sum(BigDecimal(0)) do |tier|
17
+ floor = CastDecimal.(tier.from || 0)
18
+ ceiling = tier.to.nil? ? total : [total, CastDecimal.(tier.to)].min
19
+ band = ceiling - floor
20
+
21
+ band.positive? ? band * CastDecimal.(tier.value) : BigDecimal(0)
22
+ end
23
+ }
24
+
25
+ ROUND_CURRENCY = lambda { |amount, precision = 2|
26
+ CastDecimal.(amount).round(Integer(precision), :half_up)
27
+ }
28
+
29
+ PRORATE = lambda { |amount, part, whole|
30
+ divisor = CastDecimal.(whole)
31
+ raise EvaluationError, "cannot prorate over a whole of zero" if divisor.zero?
32
+
33
+ CastDecimal.(amount) * CastDecimal.(part) / divisor
34
+ }
35
+
36
+ BUILTINS = {
37
+ bracket: BRACKET,
38
+ progressive_bracket: PROGRESSIVE_BRACKET,
39
+ round_currency: ROUND_CURRENCY,
40
+ prorate: PRORATE
41
+ }.freeze
42
+
43
+ def self.default
44
+ @default ||= new
45
+ end
46
+
47
+ def initialize
48
+ @definitions = {}
49
+ BUILTINS.each { |name, implementation| register(name:, implementation:) }
50
+ end
51
+
52
+ def register(name:, implementation:, type: :numeric)
53
+ # Dentaku derives a function's arity from its implementation's #parameters, and a
54
+ # non-lambda proc reports every parameter as optional, so arity checking silently
55
+ # stops working for anything registered as a bare block or proc.
56
+ raise ArgumentError, "implementation for #{name.inspect} must be a lambda" unless implementation.lambda?
57
+
58
+ definitions[key_for(name)] = Definition.new(name: key_for(name), type:, implementation:)
59
+ self
60
+ end
61
+
62
+ def registered?(name)
63
+ definitions.key?(key_for(name))
64
+ end
65
+
66
+ def to_a
67
+ definitions.values
68
+ end
69
+
70
+ def install(calculator)
71
+ to_a.each { |it| calculator.add_function(it.name, it.type, it.implementation) }
72
+ calculator
73
+ end
74
+
75
+ private
76
+
77
+ attr_reader :definitions
78
+
79
+ def key_for(name)
80
+ name.to_s.downcase
81
+ end
82
+ end
83
+ end