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.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +38 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE.txt +21 -0
- data/README.md +133 -0
- data/Rakefile +12 -0
- data/app/controllers/acts_as_calculator/application_controller.rb +76 -0
- data/app/controllers/acts_as_calculator/formula_versions_controller.rb +50 -0
- data/app/controllers/acts_as_calculator/formulas_controller.rb +58 -0
- data/app/controllers/acts_as_calculator/imports_controller.rb +33 -0
- data/app/controllers/acts_as_calculator/templates_controller.rb +66 -0
- data/app/models/acts_as_calculator/formula.rb +21 -0
- data/app/models/acts_as_calculator/formula_version.rb +88 -0
- data/app/models/acts_as_calculator/lookup_table.rb +25 -0
- data/app/models/acts_as_calculator/lookup_table_entry.rb +26 -0
- data/app/models/acts_as_calculator/record.rb +12 -0
- data/app/models/acts_as_calculator/run.rb +24 -0
- data/app/models/acts_as_calculator/template.rb +56 -0
- data/app/models/acts_as_calculator/variable.rb +20 -0
- data/config/routes.rb +36 -0
- data/lib/acts_as_calculator/aggregate_results.rb +57 -0
- data/lib/acts_as_calculator/aggregation.rb +9 -0
- data/lib/acts_as_calculator/apportion_amount.rb +62 -0
- data/lib/acts_as_calculator/apportionment.rb +57 -0
- data/lib/acts_as_calculator/build_calculator.rb +24 -0
- data/lib/acts_as_calculator/build_lookups.rb +37 -0
- data/lib/acts_as_calculator/calculable.rb +84 -0
- data/lib/acts_as_calculator/calculator_cache.rb +36 -0
- data/lib/acts_as_calculator/cast_date.rb +16 -0
- data/lib/acts_as_calculator/cast_decimal.rb +15 -0
- data/lib/acts_as_calculator/cast_json_safe.rb +37 -0
- data/lib/acts_as_calculator/cast_liquid_value.rb +58 -0
- data/lib/acts_as_calculator/cast_variable_attributes.rb +17 -0
- data/lib/acts_as_calculator/configuration.rb +31 -0
- data/lib/acts_as_calculator/distribute_remainder.rb +49 -0
- data/lib/acts_as_calculator/divide_proportionally.rb +27 -0
- data/lib/acts_as_calculator/engine.rb +19 -0
- data/lib/acts_as_calculator/errors.rb +23 -0
- data/lib/acts_as_calculator/evaluate_expression.rb +49 -0
- data/lib/acts_as_calculator/evaluate_formula.rb +53 -0
- data/lib/acts_as_calculator/find_lookup_table_references.rb +68 -0
- data/lib/acts_as_calculator/find_owned_record.rb +38 -0
- data/lib/acts_as_calculator/find_tier.rb +25 -0
- data/lib/acts_as_calculator/formula_version_drop.rb +55 -0
- data/lib/acts_as_calculator/function_registry.rb +83 -0
- data/lib/acts_as_calculator/import_definitions.rb +70 -0
- data/lib/acts_as_calculator/import_formula.rb +102 -0
- data/lib/acts_as_calculator/import_lookup_table.rb +94 -0
- data/lib/acts_as_calculator/import_outcome.rb +20 -0
- data/lib/acts_as_calculator/import_summary.rb +29 -0
- data/lib/acts_as_calculator/import_template.rb +57 -0
- data/lib/acts_as_calculator/liquid_filters.rb +70 -0
- data/lib/acts_as_calculator/persist_run.rb +31 -0
- data/lib/acts_as_calculator/promote_template.rb +21 -0
- data/lib/acts_as_calculator/publish_formula_version.rb +54 -0
- data/lib/acts_as_calculator/publish_template.rb +18 -0
- data/lib/acts_as_calculator/read_import_file.rb +23 -0
- data/lib/acts_as_calculator/render_liquid.rb +113 -0
- data/lib/acts_as_calculator/render_template.rb +43 -0
- data/lib/acts_as_calculator/resolve_formula_version.rb +57 -0
- data/lib/acts_as_calculator/resolve_import_owner.rb +38 -0
- data/lib/acts_as_calculator/resolve_template.rb +41 -0
- data/lib/acts_as_calculator/resolve_variables.rb +105 -0
- data/lib/acts_as_calculator/result.rb +32 -0
- data/lib/acts_as_calculator/result_drop.rb +46 -0
- data/lib/acts_as_calculator/serialize_formula.rb +18 -0
- data/lib/acts_as_calculator/serialize_formula_version.rb +26 -0
- data/lib/acts_as_calculator/serialize_import_summary.rb +21 -0
- data/lib/acts_as_calculator/serialize_template.rb +13 -0
- data/lib/acts_as_calculator/supersede_formula_versions.rb +83 -0
- data/lib/acts_as_calculator/tier.rb +31 -0
- data/lib/acts_as_calculator/variable_spec.rb +33 -0
- data/lib/acts_as_calculator/version.rb +5 -0
- data/lib/acts_as_calculator.rb +78 -0
- data/lib/generators/acts_as_calculator/import/import_generator.rb +26 -0
- data/lib/generators/acts_as_calculator/install/install_generator.rb +31 -0
- data/lib/generators/acts_as_calculator/install/templates/create_acts_as_calculator_tables.rb.tt +133 -0
- data/lib/tasks/acts_as_calculator.rake +21 -0
- data/sig/acts_as_calculator.rbs +4 -0
- 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
|