whittaker_tech-midas 0.1.1 → 0.3.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 +4 -4
- data/README.md +75 -6
- data/app/controllers/whittaker_tech/midas/application_controller.rb +1 -5
- data/app/helpers/whittaker_tech/midas/application_helper.rb +1 -5
- data/app/helpers/whittaker_tech/midas/form_helper.rb +68 -72
- data/app/jobs/whittaker_tech/midas/application_job.rb +1 -5
- data/app/mailers/whittaker_tech/midas/application_mailer.rb +3 -7
- data/app/models/concerns/whittaker_tech/midas/bankable.rb +188 -172
- data/app/models/whittaker_tech/midas/application_record.rb +2 -6
- data/app/models/whittaker_tech/midas/coin/allocation.rb +124 -0
- data/app/models/whittaker_tech/midas/coin/arithmetic.rb +196 -0
- data/app/models/whittaker_tech/midas/coin/bidi.rb +87 -0
- data/app/models/whittaker_tech/midas/coin/converter/bank_provider.rb +35 -0
- data/app/models/whittaker_tech/midas/coin/converter.rb +79 -0
- data/app/models/whittaker_tech/midas/coin/parser.rb +104 -0
- data/app/models/whittaker_tech/midas/coin/presenter.rb +229 -0
- data/app/models/whittaker_tech/midas/coin.rb +285 -76
- data/app/models/whittaker_tech/midas/exchange.rb +37 -0
- data/db/migrate/20260101000000_create_whittaker_tech_schema.rb +26 -0
- data/db/migrate/{create_wt_midas_coins.rb → 20260101000001_create_midas_coins.rb} +7 -3
- data/db/migrate/20260219120000_rename_resource_label_to_resource_role_in_wt_midas_coins.rb +12 -0
- data/db/migrate/20260219150000_rename_wt_midas_coins_to_midas_coins.rb +13 -0
- data/db/migrate/20260707000000_create_midas_exchanges.rb +15 -0
- data/lib/generators/whittaker_tech/midas/install/install_generator.rb +5 -11
- data/lib/whittaker_tech/midas/deprecation.rb +32 -0
- data/lib/whittaker_tech/midas/engine.rb +113 -115
- data/lib/whittaker_tech/midas/version.rb +4 -4
- data/lib/whittaker_tech/midas.rb +120 -3
- metadata +72 -3
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Arithmetic defines the arithmetic and equality semantics of a Coin.
|
|
4
|
+
#
|
|
5
|
+
# ## Design invariants
|
|
6
|
+
#
|
|
7
|
+
# - **Immutable** — all operations return new, frozen `Coin` objects.
|
|
8
|
+
# - **Closed** — operations always return a `Coin`, never a primitive.
|
|
9
|
+
# - **Policy-aware only where unavoidable** — division accepts a rounding
|
|
10
|
+
# policy; all other operations are exact.
|
|
11
|
+
# - **Currency-strict** — binary operations (`+`, `-`, `==`) raise
|
|
12
|
+
# `ArgumentError` when the operands have different currency codes.
|
|
13
|
+
#
|
|
14
|
+
# ## Convenience division helpers
|
|
15
|
+
#
|
|
16
|
+
# For each key in `WhittakerTech::Midas::ROUNDING_POLICIES` a named shorthand
|
|
17
|
+
# is generated:
|
|
18
|
+
#
|
|
19
|
+
# coin.divide_round(3) # same as coin.divide(3, rounding_policy: :round)
|
|
20
|
+
# coin.divide_ceil(3)
|
|
21
|
+
# coin.divide_floor(3)
|
|
22
|
+
# coin.divide_bankers(3)
|
|
23
|
+
#
|
|
24
|
+
# @since 0.1.0
|
|
25
|
+
module WhittakerTech::Midas::Coin::Arithmetic
|
|
26
|
+
# Tests value equality with another Coin.
|
|
27
|
+
#
|
|
28
|
+
# Two Coins are equal when they have the same currency code **and** the
|
|
29
|
+
# same minor-unit amount. A Coin is never equal to a non-Coin object.
|
|
30
|
+
#
|
|
31
|
+
# @param other [Object] the object to compare
|
|
32
|
+
# @return [Boolean]
|
|
33
|
+
def ==(other)
|
|
34
|
+
other.is_a?(WhittakerTech::Midas::Coin) &&
|
|
35
|
+
currency_code == other.currency_code &&
|
|
36
|
+
currency_minor == other.currency_minor
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Value-based equality alias, compatible with `#hash`.
|
|
40
|
+
#
|
|
41
|
+
# Suitable for use in Sets and as Hash keys.
|
|
42
|
+
alias eql? ==
|
|
43
|
+
|
|
44
|
+
# Returns a hash value consistent with `#eql?`.
|
|
45
|
+
#
|
|
46
|
+
# Coins with the same currency code and minor-unit amount produce the same
|
|
47
|
+
# hash, making them interchangeable as Hash keys or Set members.
|
|
48
|
+
#
|
|
49
|
+
# @return [Integer]
|
|
50
|
+
def hash
|
|
51
|
+
[currency_code, currency_minor].hash
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Adds two Coins and returns the sum as a new Coin.
|
|
55
|
+
#
|
|
56
|
+
# @param other [Coin] must have the same currency code as the receiver
|
|
57
|
+
# @return [Coin] a new, frozen Coin
|
|
58
|
+
# @raise [TypeError] if `other` is not a `Coin`
|
|
59
|
+
# @raise [ArgumentError] if the currencies do not match
|
|
60
|
+
#
|
|
61
|
+
# @example
|
|
62
|
+
# a = Coin.value(1000, 'USD')
|
|
63
|
+
# b = Coin.value(500, 'USD')
|
|
64
|
+
# a + b # => Coin($15.00)
|
|
65
|
+
def +(other)
|
|
66
|
+
ensure_same_currency!(other)
|
|
67
|
+
WhittakerTech::Midas::Coin.value(currency_minor + other.currency_minor, currency_code).freeze
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Subtracts another Coin from the receiver and returns the difference.
|
|
71
|
+
#
|
|
72
|
+
# @param other [Coin] must have the same currency code as the receiver
|
|
73
|
+
# @return [Coin] a new, frozen Coin
|
|
74
|
+
# @raise [TypeError] if `other` is not a `Coin`
|
|
75
|
+
# @raise [ArgumentError] if the currencies do not match
|
|
76
|
+
#
|
|
77
|
+
# @example
|
|
78
|
+
# a = Coin.value(1000, 'USD')
|
|
79
|
+
# b = Coin.value(300, 'USD')
|
|
80
|
+
# a - b # => Coin($7.00)
|
|
81
|
+
def -(other)
|
|
82
|
+
ensure_same_currency!(other)
|
|
83
|
+
WhittakerTech::Midas::Coin.value(currency_minor - other.currency_minor, currency_code).freeze
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Multiplies the Coin by an integer scalar.
|
|
87
|
+
#
|
|
88
|
+
# @param other [Integer] the multiplier
|
|
89
|
+
# @return [Coin] a new, frozen Coin
|
|
90
|
+
# @raise [TypeError] if `other` is not an Integer
|
|
91
|
+
#
|
|
92
|
+
# @example
|
|
93
|
+
# Coin.value(500, 'USD') * 3 # => Coin($15.00)
|
|
94
|
+
def *(other)
|
|
95
|
+
raise TypeError unless other.is_a?(Integer)
|
|
96
|
+
|
|
97
|
+
WhittakerTech::Midas::Coin.value(currency_minor * other, currency_code).freeze
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Returns the remainder after dividing by an integer.
|
|
101
|
+
#
|
|
102
|
+
# @param other [Integer] the divisor
|
|
103
|
+
# @return [Coin] a new, frozen Coin representing the remainder
|
|
104
|
+
# @raise [TypeError] if `other` is not an Integer
|
|
105
|
+
# @raise [ZeroDivisionError] if `other` is zero
|
|
106
|
+
#
|
|
107
|
+
# @example
|
|
108
|
+
# Coin.value(1001, 'USD') % 3 # => Coin($0.02) (1001 % 3 == 2 cents)
|
|
109
|
+
def %(other)
|
|
110
|
+
raise TypeError unless other.is_a?(Integer)
|
|
111
|
+
raise ZeroDivisionError if other.zero?
|
|
112
|
+
|
|
113
|
+
WhittakerTech::Midas::Coin.value(currency_minor % other, currency_code).freeze
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Returns a new Coin with the sign reversed.
|
|
117
|
+
#
|
|
118
|
+
# @return [Coin] a new, frozen Coin
|
|
119
|
+
#
|
|
120
|
+
# @example
|
|
121
|
+
# Coin.value(500, 'USD').negate # => Coin(-$5.00)
|
|
122
|
+
def negate
|
|
123
|
+
WhittakerTech::Midas::Coin.value(-currency_minor, currency_code).freeze
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Unary minus operator — shorthand for `#negate`.
|
|
127
|
+
#
|
|
128
|
+
# @return [Coin]
|
|
129
|
+
def -@
|
|
130
|
+
negate
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Divides the Coin by a numeric divisor and returns the quotient.
|
|
134
|
+
#
|
|
135
|
+
# Division is the only arithmetic operation that requires a rounding policy
|
|
136
|
+
# because dividing integer minor units can produce a non-integer result.
|
|
137
|
+
#
|
|
138
|
+
# @param divisor [Numeric] the divisor; must be non-zero
|
|
139
|
+
# @param rounding_policy [Symbol] one of the keys in
|
|
140
|
+
# `WhittakerTech::Midas::ROUNDING_POLICIES` (default:
|
|
141
|
+
# `WhittakerTech::Midas::DEFAULT_ROUNDING_POLICY`)
|
|
142
|
+
# @return [Coin] a new, frozen Coin
|
|
143
|
+
# @raise [TypeError] if `divisor` is not Numeric
|
|
144
|
+
# @raise [ZeroDivisionError] if `divisor` is zero
|
|
145
|
+
#
|
|
146
|
+
# @example
|
|
147
|
+
# Coin.value(1000, 'USD').divide(3, rounding_policy: :ceil) # => Coin($3.34)
|
|
148
|
+
# Coin.value(1000, 'USD').divide(3, rounding_policy: :floor) # => Coin($3.33)
|
|
149
|
+
# Coin.value(1000, 'USD').divide(3, rounding_policy: :bankers)# => Coin($3.33)
|
|
150
|
+
def divide(divisor, rounding_policy: WhittakerTech::Midas::DEFAULT_ROUNDING_POLICY)
|
|
151
|
+
raise TypeError unless divisor.is_a?(Numeric)
|
|
152
|
+
raise ZeroDivisionError if divisor.zero?
|
|
153
|
+
|
|
154
|
+
mode = WhittakerTech::Midas::ROUNDING_POLICIES[rounding_policy]
|
|
155
|
+
|
|
156
|
+
unless mode
|
|
157
|
+
warn "Unknown rounding mode: #{rounding_policy.inspect}. Using default rounding mode."
|
|
158
|
+
mode = WhittakerTech::Midas::ROUNDING_POLICIES[
|
|
159
|
+
WhittakerTech::Midas::DEFAULT_ROUNDING_POLICY
|
|
160
|
+
]
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
raw = currency_minor.to_f / divisor
|
|
164
|
+
minor = mode.call(raw).to_i
|
|
165
|
+
|
|
166
|
+
WhittakerTech::Midas::Coin.value(minor, currency_code).freeze
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Convenience helpers generated from the rounding policy dictionary.
|
|
170
|
+
#
|
|
171
|
+
# Example:
|
|
172
|
+
# coin.divide_ceil(10)
|
|
173
|
+
# coin.divide_floor(3)
|
|
174
|
+
#
|
|
175
|
+
# These methods exist for semantic clarity and readability.
|
|
176
|
+
WhittakerTech::Midas::ROUNDING_POLICIES.each_key do |rounding_policy|
|
|
177
|
+
method_name = :"divide_#{rounding_policy}"
|
|
178
|
+
|
|
179
|
+
next if method_defined?(method_name)
|
|
180
|
+
|
|
181
|
+
define_method(method_name) do |divisor|
|
|
182
|
+
divide(divisor, rounding_policy: rounding_policy)
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
private
|
|
187
|
+
|
|
188
|
+
# Raises if `other` is not a `Coin` with the same currency code.
|
|
189
|
+
# @param other [Object]
|
|
190
|
+
# @raise [TypeError] if `other` is not a Coin
|
|
191
|
+
# @raise [ArgumentError] if currencies differ
|
|
192
|
+
def ensure_same_currency!(other)
|
|
193
|
+
raise TypeError unless other.is_a?(WhittakerTech::Midas::Coin)
|
|
194
|
+
raise ArgumentError, 'Currencies must match' unless currency_code == other.currency_code
|
|
195
|
+
end
|
|
196
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Bidi provides Unicode bidirectional-text isolation helpers so that currency
|
|
4
|
+
# strings render correctly when embedded in mixed LTR/RTL sentences.
|
|
5
|
+
#
|
|
6
|
+
# ## Background
|
|
7
|
+
#
|
|
8
|
+
# In HTML, the Unicode Bidirectional Algorithm can reorder numeric and symbol
|
|
9
|
+
# characters in ways that produce confusing output when currency strings appear
|
|
10
|
+
# inside an RTL sentence (e.g. Arabic or Hebrew UI). Wrapping currency text in
|
|
11
|
+
# Unicode isolation marks prevents the algorithm from treating it as part of
|
|
12
|
+
# the surrounding paragraph direction.
|
|
13
|
+
#
|
|
14
|
+
# ## Isolation marks used
|
|
15
|
+
#
|
|
16
|
+
# Constants:
|
|
17
|
+
#
|
|
18
|
+
# - `LRI` (U+2066): Left-to-Right Isolate
|
|
19
|
+
# - `RLI` (U+2067): Right-to-Left Isolate
|
|
20
|
+
# - `PDI` (U+2069): Pop Directional Isolate (close)
|
|
21
|
+
#
|
|
22
|
+
# ## Direction configuration
|
|
23
|
+
#
|
|
24
|
+
# Currency direction defaults to `:ltr` for all currencies. Override per
|
|
25
|
+
# currency in an initializer:
|
|
26
|
+
#
|
|
27
|
+
# WhittakerTech::Midas.currency_directions['ILS'] = :rtl
|
|
28
|
+
# WhittakerTech::Midas.currency_directions['AED'] = :rtl
|
|
29
|
+
#
|
|
30
|
+
# See also: `WhittakerTech::Midas.currency_direction_for`
|
|
31
|
+
# @since 0.1.0
|
|
32
|
+
module WhittakerTech::Midas::Coin::Bidi
|
|
33
|
+
# Unicode Left-to-Right Isolate mark (U+2066)
|
|
34
|
+
LRI = "\u2066"
|
|
35
|
+
|
|
36
|
+
# Unicode Right-to-Left Isolate mark (U+2067)
|
|
37
|
+
RLI = "\u2067"
|
|
38
|
+
|
|
39
|
+
# Unicode Pop Directional Isolate mark — closes an isolation span (U+2069)
|
|
40
|
+
PDI = "\u2069"
|
|
41
|
+
|
|
42
|
+
# Wraps `text` in a Unicode directional-isolate span.
|
|
43
|
+
#
|
|
44
|
+
# @param text [String, nil] the text to isolate; `nil` returns `""`
|
|
45
|
+
# @param dir [Symbol, nil] `:ltr`, `:rtl`, or `nil` (no wrapping)
|
|
46
|
+
# @return [String]
|
|
47
|
+
# @raise [ArgumentError] if `dir` is not `:ltr`, `:rtl`, or `nil`
|
|
48
|
+
#
|
|
49
|
+
# @example
|
|
50
|
+
# bidi_isolate('$29.99', dir: :ltr) # => "\u2066$29.99\u2069"
|
|
51
|
+
# bidi_isolate('29.99', dir: nil) # => "29.99"
|
|
52
|
+
def bidi_isolate(text, dir:)
|
|
53
|
+
return '' if text.nil?
|
|
54
|
+
|
|
55
|
+
s = text.to_s
|
|
56
|
+
return s if dir.nil?
|
|
57
|
+
|
|
58
|
+
case dir
|
|
59
|
+
when :ltr then "#{LRI}#{s}#{PDI}"
|
|
60
|
+
when :rtl then "#{RLI}#{s}#{PDI}"
|
|
61
|
+
else
|
|
62
|
+
raise ArgumentError, "Unknown bidi dir: #{dir.inspect}"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Wraps a number string with LTR isolation.
|
|
67
|
+
#
|
|
68
|
+
# Numbers are always rendered LTR. This prevents the bidi algorithm from
|
|
69
|
+
# reordering digit sequences when they appear in an RTL context.
|
|
70
|
+
#
|
|
71
|
+
# @param text [String, Integer, BigDecimal] the numeric value to isolate
|
|
72
|
+
# @return [String]
|
|
73
|
+
def bidi_isolate_number(text)
|
|
74
|
+
bidi_isolate(text, dir: :ltr)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Resolves the configured display direction for a currency code.
|
|
78
|
+
#
|
|
79
|
+
# Reads from `WhittakerTech::Midas.currency_directions`. Defaults to
|
|
80
|
+
# `:ltr` for any currency not explicitly configured.
|
|
81
|
+
#
|
|
82
|
+
# @param currency_code [String] ISO 4217 currency code
|
|
83
|
+
# @return [Symbol] `:ltr` or `:rtl`
|
|
84
|
+
def bidi_currency_dir(currency_code)
|
|
85
|
+
WhittakerTech::Midas.currency_direction_for(currency_code)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Default currency-conversion provider: wraps Money.default_bank (or any
|
|
4
|
+
# explicit Money::Bank::* instance) behind the adapter interface
|
|
5
|
+
# WhittakerTech::Midas::Coin::Converter expects.
|
|
6
|
+
#
|
|
7
|
+
# Adapter interface (duck-typed):
|
|
8
|
+
# #exchange(money, currency_code) -> Money
|
|
9
|
+
# #name -> String (recorded as Exchange#source)
|
|
10
|
+
# #exchange_at(money, currency_code, at:) -> Money (optional; only
|
|
11
|
+
# needed to support a non-default at:. This provider does not define
|
|
12
|
+
# it — the default Money::Bank::VariableExchange has no historical
|
|
13
|
+
# capability at all.)
|
|
14
|
+
#
|
|
15
|
+
# @since 0.3.0
|
|
16
|
+
class WhittakerTech::Midas::Coin::Converter::BankProvider
|
|
17
|
+
def initialize(bank = Money.default_bank)
|
|
18
|
+
@bank = bank
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# @param money [Money] the value to convert (native currency)
|
|
22
|
+
# @param currency_code [String] target ISO 4217 code
|
|
23
|
+
# @return [Money]
|
|
24
|
+
def exchange(money, currency_code)
|
|
25
|
+
# NOTE: deliberately NOT `money.exchange_to(currency_code)` — that
|
|
26
|
+
# method hardcodes Money.default_bank internally (money gem 6.x) and
|
|
27
|
+
# would silently ignore a `using:` override. Call the bank directly.
|
|
28
|
+
@bank.exchange_with(money, Money::Currency.new(currency_code))
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @return [String] recorded on Exchange#source
|
|
32
|
+
def name
|
|
33
|
+
"money:#{@bank.class.name}"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Converter implements cross-currency conversion for Coin, backed by a
|
|
4
|
+
# provider-agnostic adapter (default: Coin::Converter::BankProvider wrapping
|
|
5
|
+
# Money.default_bank). Every successful conversion writes an immutable
|
|
6
|
+
# WhittakerTech::Midas::Exchange audit row, which owns both a copy of the
|
|
7
|
+
# source value (`from`) and the converted result (`to`).
|
|
8
|
+
#
|
|
9
|
+
# @since 0.3.0
|
|
10
|
+
module WhittakerTech::Midas::Coin::Converter
|
|
11
|
+
# Converts this Coin to another currency.
|
|
12
|
+
#
|
|
13
|
+
# The receiver does not need to be persisted — it's only ever read, never
|
|
14
|
+
# mutated or reassigned. The result is a brand-new, persisted Coin owned
|
|
15
|
+
# by a newly created Exchange audit row (not linked back to this Coin's
|
|
16
|
+
# own resource).
|
|
17
|
+
#
|
|
18
|
+
# @param currency_code [String] target ISO 4217 currency code
|
|
19
|
+
# @param at [Time, nil] rate timestamp; nil (default) means "now". A
|
|
20
|
+
# non-nil value requires `using:` to supply a provider implementing
|
|
21
|
+
# `#exchange_at`.
|
|
22
|
+
# @param using [Object, Money::Bank::Base, nil] provider override; any
|
|
23
|
+
# object responding to `#exchange(money, currency_code)` and `#name`,
|
|
24
|
+
# or a raw `Money::Bank::*` instance (auto-wrapped in BankProvider)
|
|
25
|
+
# @return [Coin] the persisted `to` Coin (the converted result), owned by
|
|
26
|
+
# the newly created Exchange audit row
|
|
27
|
+
# @raise [ArgumentError] if `at` is given and the resolved provider has
|
|
28
|
+
# no `#exchange_at`
|
|
29
|
+
def convert_to(currency_code, at: nil, using: nil)
|
|
30
|
+
provider = resolve_provider(using)
|
|
31
|
+
iso = currency_code.to_s.strip.upcase
|
|
32
|
+
|
|
33
|
+
if currency_minor.zero?
|
|
34
|
+
converted_cents = 0
|
|
35
|
+
rate = BigDecimal(0)
|
|
36
|
+
else
|
|
37
|
+
converted = fetch_rate(provider, iso, at)
|
|
38
|
+
converted_cents = converted.cents
|
|
39
|
+
rate = BigDecimal(converted_cents) / BigDecimal(currency_minor)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
persist_conversion(provider, iso, at, converted_cents, rate)
|
|
43
|
+
end
|
|
44
|
+
alias exchange_to convert_to
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def resolve_provider(using)
|
|
49
|
+
case using
|
|
50
|
+
when nil then WhittakerTech::Midas::Coin::Converter::BankProvider.new
|
|
51
|
+
when Money::Bank::Base then WhittakerTech::Midas::Coin::Converter::BankProvider.new(using)
|
|
52
|
+
else using
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def fetch_rate(provider, iso, at)
|
|
57
|
+
return provider.exchange(amount, iso) if at.nil?
|
|
58
|
+
|
|
59
|
+
unless provider.respond_to?(:exchange_at)
|
|
60
|
+
raise ArgumentError,
|
|
61
|
+
"#{provider.respond_to?(:name) ? provider.name : provider.class.name} does not support " \
|
|
62
|
+
'historical rates (at:); omit at: or supply a provider implementing #exchange_at'
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
provider.exchange_at(amount, iso, at:)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def persist_conversion(provider, iso, at, minor, rate)
|
|
69
|
+
source = provider.respond_to?(:name) ? provider.name : provider.class.name
|
|
70
|
+
stamp = at || Time.current
|
|
71
|
+
|
|
72
|
+
WhittakerTech::Midas::Coin.transaction do
|
|
73
|
+
exchange = WhittakerTech::Midas::Exchange.create!(rate:, source:, at: stamp)
|
|
74
|
+
exchange.set_from(amount: currency_minor, currency_code: currency_code)
|
|
75
|
+
exchange.set_to(amount: minor, currency_code: iso)
|
|
76
|
+
exchange.to
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Parser coerces heterogeneous inputs into a `Coin`.
|
|
4
|
+
#
|
|
5
|
+
# Accepted input types:
|
|
6
|
+
#
|
|
7
|
+
# Accepted input types:
|
|
8
|
+
#
|
|
9
|
+
# - `Coin`: Returned as-is (no conversion)
|
|
10
|
+
# - `Money`: Wraps `money.cents` + `money.currency.iso_code` into a Coin
|
|
11
|
+
# - `Numeric`: Treated as a major-unit amount; `currency_code` required
|
|
12
|
+
# - `String`: Strips non-numeric characters; `currency_code` recommended
|
|
13
|
+
#
|
|
14
|
+
# ### Numeric vs. Integer semantics
|
|
15
|
+
#
|
|
16
|
+
# The Parser treats all Numeric inputs (including Integer) as **major units**
|
|
17
|
+
# (dollars, euros, etc.) and scales them to minor units using
|
|
18
|
+
# `Money.from_amount`. This differs from `Coin#amount=` and `Coin.value`,
|
|
19
|
+
# which expect minor units directly.
|
|
20
|
+
#
|
|
21
|
+
# If you already have cents use `Coin.value(2999, 'USD')` instead.
|
|
22
|
+
#
|
|
23
|
+
# @example
|
|
24
|
+
# Coin.parse(Money.new(2999, 'USD')) # => Coin(2999 USD)
|
|
25
|
+
# Coin.parse(29.99, currency_code: 'USD') # => Coin(2999 USD)
|
|
26
|
+
# Coin.parse('$29.99') # => Coin(2999 USD) (uses Money.default_currency)
|
|
27
|
+
# Coin.parse('29.99', currency_code: 'USD') # => Coin(2999 USD)
|
|
28
|
+
#
|
|
29
|
+
# @since 0.1.0
|
|
30
|
+
class WhittakerTech::Midas::Coin::Parser
|
|
31
|
+
class << self
|
|
32
|
+
# Parses a value into a `Coin`.
|
|
33
|
+
#
|
|
34
|
+
# @param value [Coin, Money, Numeric, String] the value to parse
|
|
35
|
+
# @param currency_code [String, nil] required for bare Numeric and plain
|
|
36
|
+
# String values
|
|
37
|
+
# @return [Coin]
|
|
38
|
+
# @raise [TypeError] if `value` is an unsupported type
|
|
39
|
+
# @raise [ArgumentError] if `currency_code` is required but not provided
|
|
40
|
+
def parse(value, currency_code: nil)
|
|
41
|
+
case value
|
|
42
|
+
when WhittakerTech::Midas::Coin
|
|
43
|
+
value
|
|
44
|
+
when Money
|
|
45
|
+
WhittakerTech::Midas::Coin.value(value.cents, value.currency.iso_code)
|
|
46
|
+
when Numeric
|
|
47
|
+
parse_numeric(value, currency_code)
|
|
48
|
+
when String
|
|
49
|
+
parse_string(value, currency_code)
|
|
50
|
+
else
|
|
51
|
+
raise TypeError, "Cannot convert #{value.class} to Coin"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
# Converts a Numeric major-unit amount to a Coin.
|
|
58
|
+
#
|
|
59
|
+
# @param value [Numeric] major-unit amount (e.g. `29.99` for $29.99)
|
|
60
|
+
# @param currency_code [String, nil]
|
|
61
|
+
# @return [Coin]
|
|
62
|
+
# @raise [ArgumentError] if `currency_code` is nil
|
|
63
|
+
def parse_numeric(value, currency_code)
|
|
64
|
+
raise ArgumentError, 'currency_code required' unless currency_code
|
|
65
|
+
|
|
66
|
+
money = Money.from_amount(value, currency_code)
|
|
67
|
+
WhittakerTech::Midas::Coin.value(money.cents, money.currency.iso_code)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Converts a String amount to a Coin.
|
|
71
|
+
#
|
|
72
|
+
# If the string contains non-numeric, non-decimal characters (e.g. `$`)
|
|
73
|
+
# and no `currency_code` is given, `Money.default_currency` is used.
|
|
74
|
+
# A bare numeric string (e.g. `"29.99"`) without a `currency_code` raises.
|
|
75
|
+
#
|
|
76
|
+
# @param str [String]
|
|
77
|
+
# @param currency_code [String, nil]
|
|
78
|
+
# @return [Coin]
|
|
79
|
+
# @raise [ArgumentError] if the string has no embedded currency marker and
|
|
80
|
+
# `currency_code` is nil
|
|
81
|
+
def parse_string(str, currency_code)
|
|
82
|
+
stripped = str.strip
|
|
83
|
+
|
|
84
|
+
money =
|
|
85
|
+
if currency_code
|
|
86
|
+
Money.from_amount(extract_number(stripped), currency_code)
|
|
87
|
+
elsif stripped =~ /[^\d.\s]/
|
|
88
|
+
Money.from_amount(extract_number(stripped), Money.default_currency)
|
|
89
|
+
else
|
|
90
|
+
raise ArgumentError, 'Currency code required for string amounts'
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
WhittakerTech::Midas::Coin.value(money.cents, money.currency.iso_code)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Strips all characters that are not digits or a decimal point.
|
|
97
|
+
#
|
|
98
|
+
# @param str [String]
|
|
99
|
+
# @return [BigDecimal]
|
|
100
|
+
def extract_number(str)
|
|
101
|
+
BigDecimal(str.gsub(/[^\d.]/, ''))
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|