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,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActsAsCalculator
4
+ class FormulaVersion < Record
5
+ self.table_name = "calculator_formula_versions"
6
+
7
+ DRAFT = "draft"
8
+ ACTIVE = "active"
9
+ RETIRED = "retired"
10
+ STATUSES = [DRAFT, ACTIVE, RETIRED].freeze
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
+ IMMUTABLE_ONCE_ACTIVE = %w[formula_id version_number expression effective_from].freeze
15
+
16
+ belongs_to :formula, inverse_of: :versions
17
+
18
+ has_many :variables,
19
+ class_name: "ActsAsCalculator::Variable",
20
+ foreign_key: :formula_version_id,
21
+ inverse_of: :formula_version,
22
+ dependent: :destroy
23
+
24
+ has_many :runs,
25
+ class_name: "ActsAsCalculator::Run",
26
+ foreign_key: :formula_version_id,
27
+ inverse_of: :formula_version,
28
+ dependent: :restrict_with_error
29
+
30
+ validates :expression, presence: true
31
+ validates :effective_from, presence: true
32
+ validates :status, inclusion: { in: STATUSES }
33
+ validates :version_number,
34
+ presence: true,
35
+ numericality: { only_integer: true, greater_than: 0 },
36
+ uniqueness: { scope: :formula_id }
37
+
38
+ validate :effective_range_ordered
39
+ validate :no_overlapping_active_version
40
+ validate :active_content_unchanged, on: :update
41
+
42
+ scope :active, -> { where(status: ACTIVE) }
43
+ scope :open_ended_or_ending_on_or_after, lambda { |date|
44
+ where("#{quoted_table_name}.effective_to IS NULL OR #{quoted_table_name}.effective_to >= :date", date:)
45
+ }
46
+ scope :covering, ->(date) { where(effective_from: ..date).open_ended_or_ending_on_or_after(date) }
47
+
48
+ def active?
49
+ status == ACTIVE
50
+ end
51
+
52
+ def covers?(date)
53
+ return false if effective_from.nil? || effective_from > date
54
+
55
+ effective_to.nil? || effective_to >= date
56
+ end
57
+
58
+ private
59
+
60
+ def effective_range_ordered
61
+ return if effective_from.nil? || effective_to.nil? || effective_to >= effective_from
62
+
63
+ errors.add(:effective_to, "must be on or after effective_from")
64
+ end
65
+
66
+ def no_overlapping_active_version
67
+ return unless active?
68
+ return if formula_id.nil? || effective_from.nil?
69
+
70
+ errors.add(:effective_from, "overlaps an active version of this formula") if overlapping_siblings.exists?
71
+ end
72
+
73
+ def overlapping_siblings
74
+ siblings = self.class.active.where(formula_id:)
75
+ siblings = siblings.where.not(id:) if id
76
+ siblings = siblings.where(effective_from: ..effective_to) if effective_to
77
+ siblings.open_ended_or_ending_on_or_after(effective_from)
78
+ end
79
+
80
+ def active_content_unchanged
81
+ return unless status_was == ACTIVE
82
+
83
+ (changed & IMMUTABLE_ONCE_ACTIVE).each do |attribute|
84
+ errors.add(attribute, "cannot be changed on an active version — create a new version instead")
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActsAsCalculator
4
+ class LookupTable < Record
5
+ self.table_name = "calculator_lookup_tables"
6
+
7
+ belongs_to :owner, polymorphic: true, optional: true
8
+
9
+ has_many :entries,
10
+ class_name: "ActsAsCalculator::LookupTableEntry",
11
+ foreign_key: :lookup_table_id,
12
+ inverse_of: :lookup_table,
13
+ dependent: :destroy
14
+
15
+ validates :key, presence: true, uniqueness: { scope: %i[scope owner_type owner_id] }
16
+ validates :scope, presence: true
17
+
18
+ scope :owned_by, ->(owner) { where(owner_type: owner&.class&.polymorphic_name, owner_id: owner&.id) }
19
+ scope :global, -> { where(owner_type: nil, owner_id: nil) }
20
+
21
+ def tiers
22
+ entries.ordered.map { |entry| Tier.new(from: entry.from, to: entry.to, value: entry.value) }
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActsAsCalculator
4
+ class LookupTableEntry < Record
5
+ self.table_name = "calculator_lookup_table_entries"
6
+
7
+ belongs_to :lookup_table, inverse_of: :entries
8
+
9
+ validates :value, presence: true
10
+ validates :position, presence: true, numericality: { only_integer: true }
11
+ validate :bounds_ordered
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
+ scope :ordered, -> { order(:position, :id) }
17
+
18
+ private
19
+
20
+ def bounds_ordered
21
+ return if from.nil? || to.nil? || to > from
22
+
23
+ errors.add(:to, "must be greater than from")
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActsAsCalculator
4
+ class Record < ::ActiveRecord::Base
5
+ self.abstract_class = true
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
+ self.belongs_to_required_by_default = true
11
+ end
12
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActsAsCalculator
4
+ class Run < Record
5
+ self.table_name = "calculator_runs"
6
+
7
+ belongs_to :calculable, polymorphic: true
8
+ belongs_to :formula_version, inverse_of: :runs
9
+
10
+ validates :as_of_date, presence: true
11
+ validates :result, presence: true
12
+
13
+ scope :recent_first, -> { order(as_of_date: :desc, id: :desc) }
14
+ scope :for_formula_key, lambda { |key|
15
+ joins(formula_version: :formula).where(calculator_formulas: { key: key.to_s })
16
+ }
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
+ def readonly?
21
+ persisted?
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActsAsCalculator
4
+ class Template < Record
5
+ self.table_name = "calculator_templates"
6
+
7
+ HTML = "html"
8
+ TEXT = "text"
9
+ FORMATS = [HTML, TEXT].freeze
10
+
11
+ belongs_to :owner, polymorphic: true, optional: true
12
+
13
+ validates :key, presence: true, uniqueness: { scope: %i[scope owner_type owner_id version_number] }
14
+ validates :scope, presence: true
15
+ validates :body, presence: true
16
+ validates :format, inclusion: { in: FORMATS }
17
+ validates :version_number, presence: true, numericality: { only_integer: true, greater_than: 0 }
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
+ validate :single_current_version, on: :update
22
+
23
+ before_create :demote_other_versions, if: :current?
24
+
25
+ scope :owned_by, ->(owner) { where(owner_type: owner&.class&.polymorphic_name, owner_id: owner&.id) }
26
+ scope :global, -> { where(owner_type: nil, owner_id: nil) }
27
+ scope :latest_first, -> { order(version_number: :desc, id: :desc) }
28
+ scope :current, -> { where(current: true) }
29
+ scope :version_siblings_of, lambda { |template|
30
+ where(key: template.key, scope: template.scope,
31
+ owner_type: template.owner_type, owner_id: template.owner_id)
32
+ }
33
+
34
+ def next_version_number
35
+ (self.class.version_siblings_of(self).maximum(:version_number) || 0) + 1
36
+ end
37
+
38
+ private
39
+
40
+ def other_versions
41
+ siblings = self.class.version_siblings_of(self)
42
+ persisted? ? siblings.where.not(id:) : siblings
43
+ end
44
+
45
+ def demote_other_versions
46
+ other_versions.current.update_all(current: false, updated_at: Time.current)
47
+ end
48
+
49
+ def single_current_version
50
+ return unless current?
51
+ return unless other_versions.current.exists?
52
+
53
+ errors.add(:current, "another version of this template is already current — use PromoteTemplate")
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActsAsCalculator
4
+ class Variable < Record
5
+ self.table_name = "calculator_variables"
6
+
7
+ SOURCE_TYPES = ResolveVariables::SOURCE_TYPES.map(&:to_s).freeze
8
+
9
+ belongs_to :formula_version, inverse_of: :variables
10
+
11
+ validates :name, presence: true, uniqueness: { scope: :formula_version_id }
12
+ validates :source_type, inclusion: { in: SOURCE_TYPES }
13
+
14
+ def lookup_table_key
15
+ return nil unless source_type == "lookup"
16
+
17
+ (source_config || {}).fetch("table", name).to_s
18
+ end
19
+ end
20
+ end
data/config/routes.rb ADDED
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
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
+ ActsAsCalculator::Engine.routes.draw do
17
+ constraints(->(_request) { ActsAsCalculator.configuration.enable_api }) do
18
+ resources :formulas, only: %i[index show create update destroy] do
19
+ resources :versions, only: %i[index show create], controller: "formula_versions"
20
+ end
21
+
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
+ resources :templates, only: %i[index show create destroy] do
26
+ member do
27
+ post :preview
28
+ post :promote
29
+ end
30
+ end
31
+
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
+ post "import", to: "imports#create"
35
+ end
36
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+
5
+ module ActsAsCalculator
6
+ class AggregateResults
7
+ def self.call(...)
8
+ new(...).call
9
+ end
10
+
11
+ def initialize(records:, formula:, as_of: nil, group_by: nil)
12
+ @records = records
13
+ @formula = formula
14
+ @as_of = as_of
15
+ @group_by = group_by
16
+ end
17
+
18
+ def call
19
+ return total(rows) if group_by.nil?
20
+
21
+ rows.group_by { |record| group_key(record) }.transform_values { |group| total(group) }
22
+ end
23
+
24
+ private
25
+
26
+ attr_reader :records, :formula, :as_of, :group_by
27
+
28
+ def rows
29
+ @rows ||= records.to_a
30
+ end
31
+
32
+ def total(group)
33
+ group.sum(BigDecimal(0)) { |record| CastDecimal.(value_of(record)) }
34
+ end
35
+
36
+ def value_of(record)
37
+ outcome = calculate(record)
38
+ return outcome.value if outcome.respond_to?(:value)
39
+ return outcome if outcome.is_a?(Numeric)
40
+
41
+ raise AggregationError, "expected a Result or Numeric from #{record.inspect}, got #{outcome.inspect}"
42
+ end
43
+
44
+ def calculate(record)
45
+ return formula.call(record) if formula.respond_to?(:call)
46
+ raise AggregationError, "#{record.class} does not respond to #calculate" unless record.respond_to?(:calculate)
47
+
48
+ as_of.nil? ? record.calculate(formula) : record.calculate(formula, as_of:)
49
+ end
50
+
51
+ def group_key(record)
52
+ return group_by.call(record) if group_by.respond_to?(:call)
53
+
54
+ record.public_send(group_by)
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActsAsCalculator
4
+ module Aggregation
5
+ def self.sum(records, formula:, as_of: nil, group_by: nil)
6
+ AggregateResults.(records:, formula:, as_of:, group_by:)
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+
5
+ module ActsAsCalculator
6
+ class ApportionAmount
7
+ def self.call(...)
8
+ new(...).call
9
+ end
10
+
11
+ def initialize(amount:, among:, by: nil, strategy: :proportional,
12
+ precision: Apportionment::DEFAULT_PRECISION)
13
+ @amount = amount
14
+ @among = among
15
+ @by = by
16
+ @strategy = strategy
17
+ @precision = precision
18
+ end
19
+
20
+ def call
21
+ raise ApportionmentError, "cannot apportion among an empty collection" if members.empty?
22
+
23
+ shares = Apportionment.strategy(strategy).call(amount: total, weights:, precision:)
24
+
25
+ members.zip(weights, shares).map do |member, weight, share|
26
+ Apportionment::Share.new(member:, weight:, amount: share)
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ attr_reader :amount, :among, :by, :strategy, :precision
33
+
34
+ def total
35
+ @total ||= CastDecimal.(amount)
36
+ end
37
+
38
+ def members
39
+ @members ||= among.to_a
40
+ end
41
+
42
+ def weights
43
+ @weights ||= members.map { |member| weight_for(member) }
44
+ end
45
+
46
+ def weight_for(member)
47
+ return BigDecimal(1) if by.nil? || strategy.to_sym == :equal
48
+
49
+ weight = CastDecimal.(raw_weight(member))
50
+ raise ApportionmentError, "negative weight #{weight.to_f} from #{by.inspect}" if weight.negative?
51
+
52
+ weight
53
+ end
54
+
55
+ def raw_weight(member)
56
+ value = by.respond_to?(:call) ? by.call(member) : member.public_send(by)
57
+ raise ApportionmentError, "#{by.inspect} returned nil for #{member.inspect}" if value.nil?
58
+
59
+ value
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+
5
+ module ActsAsCalculator
6
+ module Apportionment
7
+ Share = Data.define(:member, :weight, :amount)
8
+
9
+ DEFAULT_PRECISION = 2
10
+
11
+ PROPORTIONAL = lambda { |amount:, weights:, **|
12
+ DivideProportionally.(amount:, weights:)
13
+ }
14
+
15
+ EQUAL = lambda { |amount:, weights:, **|
16
+ Array.new(weights.size) { amount / weights.size }
17
+ }
18
+
19
+ LARGEST_REMAINDER = lambda { |amount:, weights:, precision:|
20
+ DistributeRemainder.(amount:, weights:, precision:)
21
+ }
22
+
23
+ def self.split(amount:, among:, by: nil, strategy: :proportional, precision: DEFAULT_PRECISION)
24
+ ApportionAmount.(amount:, among:, by:, strategy:, precision:)
25
+ end
26
+
27
+ def self.register_strategy(name, strategy)
28
+ registry[name.to_sym] = strategy
29
+ self
30
+ end
31
+
32
+ def self.unregister_strategy(name)
33
+ registry.delete(name.to_sym)
34
+ self
35
+ end
36
+
37
+ def self.strategy(name)
38
+ registry.fetch(name.to_sym) do
39
+ raise UnknownStrategyError,
40
+ "unknown apportionment strategy #{name.inspect} (known: #{known_strategies.join(", ")})"
41
+ end
42
+ end
43
+
44
+ def self.known_strategies
45
+ registry.keys
46
+ end
47
+
48
+ def self.registry
49
+ @registry ||= {}
50
+ end
51
+ private_class_method :registry
52
+
53
+ register_strategy(:proportional, PROPORTIONAL)
54
+ register_strategy(:equal, EQUAL)
55
+ register_strategy(:largest_remainder, LARGEST_REMAINDER)
56
+ end
57
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dentaku"
4
+
5
+ module ActsAsCalculator
6
+ class BuildCalculator
7
+ def self.call(...)
8
+ new(...).call
9
+ end
10
+
11
+ def initialize(functions: FunctionRegistry.default, case_sensitive: false)
12
+ @functions = functions
13
+ @case_sensitive = case_sensitive
14
+ end
15
+
16
+ def call
17
+ functions.install(Dentaku::Calculator.new(case_sensitive:))
18
+ end
19
+
20
+ private
21
+
22
+ attr_reader :functions, :case_sensitive
23
+ end
24
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActsAsCalculator
4
+ class BuildLookups
5
+ def self.call(...)
6
+ new(...).call
7
+ end
8
+
9
+ def initialize(formula_version:, owner: nil)
10
+ @formula_version = formula_version
11
+ @owner = owner
12
+ end
13
+
14
+ def call
15
+ table_keys.to_h { |key| [key, tiers_for(key)] }
16
+ end
17
+
18
+ private
19
+
20
+ attr_reader :formula_version, :owner
21
+
22
+ def table_keys
23
+ formula_version.variables.filter_map(&:lookup_table_key).uniq
24
+ end
25
+
26
+ def tiers_for(key)
27
+ table = FindOwnedRecord.(relation: LookupTable.all, key:, scope: formula.scope, owner: owner || formula.owner)
28
+ raise MissingLookupTableError, "no lookup table #{key.inspect} in scope #{formula.scope.inspect}" if table.nil?
29
+
30
+ table.tiers
31
+ end
32
+
33
+ def formula
34
+ @formula ||= formula_version.formula
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+ require "active_support/core_ext/class/attribute"
5
+
6
+ module ActsAsCalculator
7
+ module Calculable
8
+ extend ::ActiveSupport::Concern
9
+
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
+ has_many :calculator_runs,
14
+ class_name: "ActsAsCalculator::Run",
15
+ as: :calculable,
16
+ inverse_of: :calculable
17
+
18
+ class_attribute :calculator_scope_name, instance_writer: false, default: DEFAULT_SCOPE
19
+ end
20
+
21
+ class_methods do
22
+ def calculator_scope(name)
23
+ self.calculator_scope_name = name.to_s
24
+ end
25
+ end
26
+
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
+ def calculate(key, as_of: nil, scope: nil, owner: nil, dry_run: false, context: {}, **extra)
31
+ EvaluateFormula.(
32
+ calculable: self,
33
+ key:,
34
+ scope: scope || calculator_scope_name,
35
+ owner: owner || calculator_owner,
36
+ as_of:,
37
+ context: extra.merge(context),
38
+ dry_run:
39
+ )
40
+ end
41
+
42
+ def calculate_as_of(key, as_of, **context)
43
+ calculate(key, as_of:, **context)
44
+ end
45
+
46
+ def calculation_history(key = nil, limit: nil)
47
+ runs = calculator_runs.recent_first
48
+ runs = runs.for_formula_key(key) unless key.nil?
49
+ limit.nil? ? runs : runs.limit(limit)
50
+ end
51
+
52
+ def calculator_owner
53
+ nil
54
+ end
55
+
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
+ def render(key, calculate: nil, result: nil, results: {}, as_of: nil, scope: nil,
61
+ owner: nil, version_number: nil, dry_run: false, context: {}, **extra)
62
+ assigns = extra.merge(context)
63
+ computed = calculator_render_results(calculate, as_of:, scope:, owner:, dry_run:, context: assigns)
64
+
65
+ RenderTemplate.(
66
+ key:, version_number:, context: assigns,
67
+ scope: scope || calculator_scope_name,
68
+ owner: owner || calculator_owner,
69
+ result: result || (computed.values.first unless calculate.is_a?(Array)),
70
+ results: results.merge(computed)
71
+ )
72
+ end
73
+
74
+ private
75
+
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
+ def calculator_render_results(keys, as_of:, scope:, owner:, dry_run:, context:)
79
+ Array(keys).to_h do |formula_key|
80
+ [formula_key.to_s, calculate(formula_key, as_of:, scope:, owner:, dry_run:, context:)]
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActsAsCalculator
4
+ class CalculatorCache
5
+ def self.default
6
+ @default ||= new
7
+ end
8
+
9
+ def initialize(functions: FunctionRegistry.default)
10
+ @functions = functions
11
+ @key = :"acts_as_calculator_calculators_#{object_id}"
12
+ end
13
+
14
+ def fetch(formula_version_id)
15
+ raise ArgumentError, "a calculator cache key must be a formula_version_id" if formula_version_id.nil?
16
+
17
+ store[formula_version_id] ||= BuildCalculator.(functions:)
18
+ end
19
+
20
+ def clear
21
+ store.clear
22
+ end
23
+
24
+ private
25
+
26
+ attr_reader :functions, :key
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
+ def store
33
+ Thread.current[key] ||= {}
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ module ActsAsCalculator
6
+ class CastDate
7
+ def self.call(value)
8
+ return value if value.instance_of?(Date)
9
+ return value.to_date if value.respond_to?(:to_date)
10
+
11
+ Date.parse(value.to_s)
12
+ rescue ArgumentError, TypeError, RangeError
13
+ raise Error, "cannot cast #{value.inspect} to a date"
14
+ end
15
+ end
16
+ end