whittaker_tech-midas 0.1.0 → 0.2.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 +4 -4
- 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 +193 -174
- 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.rb +32 -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 +314 -76
- data/db/migrate/20260101000000_create_whittaker_tech_schema.rb +26 -0
- data/db/migrate/{20251015160523_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/lib/generators/whittaker_tech/midas/install/install_generator.rb +10 -0
- data/lib/tasks/whittaker_tech/midas_tasks.rake +27 -4
- data/lib/whittaker_tech/midas/deprecation.rb +32 -0
- data/lib/whittaker_tech/midas/engine.rb +113 -110
- data/lib/whittaker_tech/midas/version.rb +4 -4
- data/lib/whittaker_tech/midas.rb +120 -3
- metadata +71 -19
- data/config/routes.rb +0 -2
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Allocation represents a per-unit interpretation of a `Coin`.
|
|
4
|
+
#
|
|
5
|
+
# ## Conceptual model
|
|
6
|
+
#
|
|
7
|
+
# ```
|
|
8
|
+
# Coin -> canonical, payable money (what exists)
|
|
9
|
+
# Allocation -> "this Coin, spread across N units, using policy X"
|
|
10
|
+
# ```
|
|
11
|
+
#
|
|
12
|
+
# An Allocation answers two questions:
|
|
13
|
+
#
|
|
14
|
+
# 1. **What does one unit cost?** — `#value`
|
|
15
|
+
# 2. **What does a given quantity cost?** — `#price`
|
|
16
|
+
#
|
|
17
|
+
# ## Immutability
|
|
18
|
+
#
|
|
19
|
+
# Allocation is:
|
|
20
|
+
# - **immutable** — `@coin`, `@divisor`, and `@rounding_policy` are frozen on
|
|
21
|
+
# construction and never mutated.
|
|
22
|
+
# - **not persisted** — Allocation is a pure value object.
|
|
23
|
+
# - **minor-unit safe** — it never stores sub-minor-unit amounts; rounding is
|
|
24
|
+
# applied before returning a Coin.
|
|
25
|
+
#
|
|
26
|
+
# @example Per-unit price from a bulk price
|
|
27
|
+
# bulk = Coin.value(10_000, 'USD') # $100.00 for a pack of 6
|
|
28
|
+
# alloc = bulk.allocate(per: 6, rounding_policy: :ceil)
|
|
29
|
+
# alloc.value # => Coin($16.67)
|
|
30
|
+
# alloc.price(qty: 2) # => Coin($33.34)
|
|
31
|
+
#
|
|
32
|
+
# See also: `Coin#allocate`
|
|
33
|
+
# @since 0.1.0
|
|
34
|
+
class WhittakerTech::Midas::Coin::Allocation
|
|
35
|
+
# @return [Coin] the total monetary amount this Allocation is based on
|
|
36
|
+
attr_reader :coin
|
|
37
|
+
|
|
38
|
+
# @return [Numeric] the number of units the coin covers (the divisor)
|
|
39
|
+
attr_reader :divisor
|
|
40
|
+
|
|
41
|
+
# @return [Symbol] the rounding policy applied to per-unit calculations
|
|
42
|
+
attr_reader :rounding_policy
|
|
43
|
+
|
|
44
|
+
# Alias for `#divisor`. Reads as "this coin, per N units".
|
|
45
|
+
alias per divisor
|
|
46
|
+
|
|
47
|
+
# Delegate invariant facts from the underlying Coin.
|
|
48
|
+
# These values do not change under scaling.
|
|
49
|
+
delegate :currency_code,
|
|
50
|
+
:currency,
|
|
51
|
+
:decimals,
|
|
52
|
+
:scale,
|
|
53
|
+
:zero?,
|
|
54
|
+
:positive?,
|
|
55
|
+
:negative?,
|
|
56
|
+
to: :coin
|
|
57
|
+
|
|
58
|
+
# @param coin [Coin] the total monetary value
|
|
59
|
+
# @param divisor [Numeric] the number of units; must be positive
|
|
60
|
+
# @param rounding_policy [Symbol] one of `WhittakerTech::Midas::ROUNDING_POLICIES`
|
|
61
|
+
# @raise [TypeError] if `coin` is not a `Coin`
|
|
62
|
+
# @raise [TypeError] if `divisor` is not a positive Numeric
|
|
63
|
+
# @raise [ArgumentError] if `rounding_policy` is not a recognised policy key
|
|
64
|
+
def initialize(coin:, divisor:, rounding_policy:)
|
|
65
|
+
raise TypeError unless coin.is_a?(WhittakerTech::Midas::Coin)
|
|
66
|
+
raise TypeError unless divisor.is_a?(Numeric) && divisor.positive?
|
|
67
|
+
raise ArgumentError unless WhittakerTech::Midas::ROUNDING_POLICIES.key?(rounding_policy)
|
|
68
|
+
|
|
69
|
+
@coin = coin.freeze
|
|
70
|
+
@divisor = divisor
|
|
71
|
+
@rounding_policy = rounding_policy.freeze
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Returns the per-unit Coin after dividing by `#divisor` and applying the
|
|
75
|
+
# `#rounding_policy`.
|
|
76
|
+
#
|
|
77
|
+
# The result is still a `Coin` — payable money representing a single unit.
|
|
78
|
+
#
|
|
79
|
+
# @return [Coin]
|
|
80
|
+
def value
|
|
81
|
+
coin.divide(divisor, rounding_policy: rounding_policy)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Returns the total price for a given quantity of units.
|
|
85
|
+
#
|
|
86
|
+
# Uses `(total_minor * qty) / divisor` rounded via `#rounding_policy`.
|
|
87
|
+
# Equivalent to `qty` units at the per-unit rate, but computed from the
|
|
88
|
+
# original total to minimise accumulated rounding error.
|
|
89
|
+
#
|
|
90
|
+
# @param qty [Numeric] the number of units; must be >= 0
|
|
91
|
+
# @return [Coin]
|
|
92
|
+
# @raise [ArgumentError] if `qty` is negative or not Numeric
|
|
93
|
+
#
|
|
94
|
+
# @example
|
|
95
|
+
# alloc = Coin.value(10_000, 'USD').allocate(per: 6)
|
|
96
|
+
# alloc.price(qty: 3) # => Coin($50.00) (10000 * 3 / 6 = 5000 cents)
|
|
97
|
+
def price(qty: 1)
|
|
98
|
+
raise ArgumentError unless qty.is_a?(Numeric) && qty >= 0
|
|
99
|
+
|
|
100
|
+
WhittakerTech::Midas::Coin.value(
|
|
101
|
+
round((coin.currency_minor * qty) / divisor),
|
|
102
|
+
coin.currency_code
|
|
103
|
+
).freeze
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Human-readable representation for logs and console output.
|
|
107
|
+
# @return [String]
|
|
108
|
+
def inspect
|
|
109
|
+
"#<#{self.class.name} coin=#{coin.inspect} divisor=#{divisor} rounding_policy=#{rounding_policy}>"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
# Applies the configured `#rounding_policy` to a raw Numeric amount and
|
|
115
|
+
# returns an Integer suitable for Coin construction.
|
|
116
|
+
# @param amount [Numeric]
|
|
117
|
+
# @return [Integer]
|
|
118
|
+
def round(amount)
|
|
119
|
+
WhittakerTech::Midas::ROUNDING_POLICIES
|
|
120
|
+
.fetch(rounding_policy)
|
|
121
|
+
.call(amount)
|
|
122
|
+
.to_i
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -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,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Converter is a reserved module for future currency conversion logic.
|
|
4
|
+
#
|
|
5
|
+
# It exists as a clear architectural placeholder to separate concerns:
|
|
6
|
+
#
|
|
7
|
+
# Related modules:
|
|
8
|
+
#
|
|
9
|
+
# - `Arithmetic`: Integer arithmetic on minor units
|
|
10
|
+
# - `Allocation`: Per-unit pricing interpretation
|
|
11
|
+
# - `Converter`: Cross-currency rate conversion (planned)
|
|
12
|
+
#
|
|
13
|
+
# When implemented, Converter will handle:
|
|
14
|
+
# - Exchange rate sources (live API, snapshot, historical)
|
|
15
|
+
# - Historical conversions with a specific timestamp
|
|
16
|
+
# - Regulatory rounding rules per jurisdiction
|
|
17
|
+
#
|
|
18
|
+
# @note All methods in this module raise `NotImplementedError` intentionally.
|
|
19
|
+
# Use the Money gem's exchange rate infrastructure directly for now.
|
|
20
|
+
# @since 0.1.0
|
|
21
|
+
module WhittakerTech::Midas::Coin::Converter
|
|
22
|
+
# Converts this Coin to another currency.
|
|
23
|
+
#
|
|
24
|
+
# @param currency_code [String] target ISO 4217 currency code
|
|
25
|
+
# @param at [Time] the rate timestamp (for historical conversions)
|
|
26
|
+
# @param using [Object, nil] exchange rate provider / bank override
|
|
27
|
+
# @return [Coin]
|
|
28
|
+
# @raise [NotImplementedError] — not yet implemented
|
|
29
|
+
def convert_to(currency_code, at: Time.current, using: nil)
|
|
30
|
+
raise NotImplementedError
|
|
31
|
+
end
|
|
32
|
+
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
|