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,229 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Presenter provides a strftime-like token grammar for rendering `Coin` values.
|
|
4
|
+
#
|
|
5
|
+
# ## Design principles
|
|
6
|
+
#
|
|
7
|
+
# - **Declarative** — tokens map directly to named handler methods.
|
|
8
|
+
# - **Pure** — no mutation, rounding, or currency conversion.
|
|
9
|
+
# - **Direction-safe** — all text is wrapped with Unicode bidirectional
|
|
10
|
+
# isolation markers via `Coin::Bidi` to prevent display corruption in
|
|
11
|
+
# mixed LTR/RTL contexts.
|
|
12
|
+
#
|
|
13
|
+
# ## Token reference
|
|
14
|
+
#
|
|
15
|
+
# Patterns are strings containing `%x` tokens (similar to `strftime`).
|
|
16
|
+
#
|
|
17
|
+
# Tokens:
|
|
18
|
+
#
|
|
19
|
+
# - `%t`: Formatted total (symbol + amount), e.g. `$29.99`
|
|
20
|
+
# - `%m`: Minor units (raw integer), e.g. `2999`
|
|
21
|
+
# - `%M`: Major units (decimal string), e.g. `29.99`
|
|
22
|
+
# - `%c`: Currency code, e.g. `USD`
|
|
23
|
+
# - `%s`: Currency symbol, e.g. `$`
|
|
24
|
+
# - `%n`: Number only (no symbol), e.g. `29.99`
|
|
25
|
+
# - `%u`: Custom units label (see opts), e.g. `per kg`
|
|
26
|
+
# - `%p`: Custom per-exact label (see opts), e.g. `0.2997`
|
|
27
|
+
# - `%~`: Approximate marker (`≈` or empty)
|
|
28
|
+
# - `%%`: Literal percent sign
|
|
29
|
+
#
|
|
30
|
+
# ## Usage
|
|
31
|
+
#
|
|
32
|
+
# coin = Coin.value(2999, 'USD')
|
|
33
|
+
# coin.present('%s%M %c') # => "$29.99 USD"
|
|
34
|
+
# coin.present('%~%t', approx: true) # => "≈$29.99"
|
|
35
|
+
#
|
|
36
|
+
# ## Options
|
|
37
|
+
#
|
|
38
|
+
# Pass keyword options to `present` / `format` to populate optional tokens:
|
|
39
|
+
#
|
|
40
|
+
# - `approx:` [Boolean] — if true, `~` expands to `≈`; otherwise empty string.
|
|
41
|
+
# - `units:` [String] — value for `%u` token.
|
|
42
|
+
# - `per_exact:` [String] — value for `%p` token.
|
|
43
|
+
# - `currency_dir:` [Symbol] — override the currency display direction
|
|
44
|
+
# (`:ltr` or `:rtl`); defaults to the value from
|
|
45
|
+
# `WhittakerTech::Midas.currency_direction_for`.
|
|
46
|
+
#
|
|
47
|
+
# @since 0.1.0
|
|
48
|
+
module WhittakerTech::Midas::Coin::Presenter
|
|
49
|
+
# Internal rendering context. Populated from the Coin and caller-supplied options.
|
|
50
|
+
# @!visibility private
|
|
51
|
+
Context = Struct.new(
|
|
52
|
+
:coin,
|
|
53
|
+
:currency_dir,
|
|
54
|
+
:approx,
|
|
55
|
+
:units,
|
|
56
|
+
:per_exact,
|
|
57
|
+
keyword_init: true
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# Renders this Coin using a format pattern.
|
|
61
|
+
#
|
|
62
|
+
# @param pattern [String] the format pattern containing `%x` tokens
|
|
63
|
+
# @param opts [Hash] optional rendering context overrides (see module docs)
|
|
64
|
+
# @return [String]
|
|
65
|
+
# @raise [ArgumentError] if the pattern contains an unknown or unterminated token
|
|
66
|
+
#
|
|
67
|
+
# @example
|
|
68
|
+
# coin.present('%s%M') # => "$29.99"
|
|
69
|
+
# coin.present('%t (%c)') # => "$29.99 (USD)"
|
|
70
|
+
# coin.present('~%t', approx: true) # => "≈$29.99"
|
|
71
|
+
def present(pattern, **)
|
|
72
|
+
WhittakerTech::Midas::Coin::Presenter.format(self, pattern, **)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
class << self
|
|
76
|
+
# Registry of recognised tokens and their handler method names.
|
|
77
|
+
#
|
|
78
|
+
# @return [Hash{String => Symbol}]
|
|
79
|
+
TOKEN_MAP = {
|
|
80
|
+
'%' => :token_percent,
|
|
81
|
+
't' => :token_total,
|
|
82
|
+
'm' => :token_minor,
|
|
83
|
+
'M' => :token_major,
|
|
84
|
+
'c' => :token_currency_code,
|
|
85
|
+
's' => :token_currency_symbol,
|
|
86
|
+
'n' => :token_number_only,
|
|
87
|
+
'u' => :token_units,
|
|
88
|
+
'p' => :token_per_exact,
|
|
89
|
+
'~' => :token_approx
|
|
90
|
+
}.freeze
|
|
91
|
+
|
|
92
|
+
# Formats a Coin using a pattern string.
|
|
93
|
+
#
|
|
94
|
+
# This is the class-level entry point; instance-level access is via
|
|
95
|
+
# `Coin#present`.
|
|
96
|
+
#
|
|
97
|
+
# @param coin [Coin] the value to format
|
|
98
|
+
# @param pattern [String] the format pattern
|
|
99
|
+
# @param opts [Hash] optional context overrides
|
|
100
|
+
# @return [String]
|
|
101
|
+
# @raise [ArgumentError] if `pattern` is nil, contains an unknown token,
|
|
102
|
+
# or has an unterminated `%` escape
|
|
103
|
+
def format(coin, pattern, **)
|
|
104
|
+
raise ArgumentError, 'pattern required' if pattern.nil?
|
|
105
|
+
|
|
106
|
+
ctx = build_context(coin, **)
|
|
107
|
+
scan(pattern, ctx)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Builds a `Context` struct from a Coin and caller-supplied options.
|
|
111
|
+
#
|
|
112
|
+
# @param coin [Coin]
|
|
113
|
+
# @param opts [Hash]
|
|
114
|
+
# @option opts [Symbol] :currency_dir (:ltr or :rtl) override direction
|
|
115
|
+
# @option opts [Boolean] :approx whether to render `~` as `≈`
|
|
116
|
+
# @option opts [String, nil] :units value for `%u` token
|
|
117
|
+
# @option opts [String, nil] :per_exact value for `%p` token
|
|
118
|
+
# @return [Context]
|
|
119
|
+
def build_context(coin, **opts)
|
|
120
|
+
Context.new(coin:,
|
|
121
|
+
currency_dir: opts[:currency_dir] || coin.bidi_currency_dir(coin.currency_code),
|
|
122
|
+
approx: opts[:approx] || false,
|
|
123
|
+
units: opts[:units] || nil,
|
|
124
|
+
per_exact: opts[:per_exact] || nil)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Scans a pattern string, expanding `%x` tokens into rendered values.
|
|
128
|
+
#
|
|
129
|
+
# @param pattern [String]
|
|
130
|
+
# @param ctx [Context]
|
|
131
|
+
# @return [String]
|
|
132
|
+
# @raise [ArgumentError] on unknown or unterminated tokens
|
|
133
|
+
def scan(pattern, ctx)
|
|
134
|
+
is_token = false
|
|
135
|
+
out = +'' # output buffer
|
|
136
|
+
|
|
137
|
+
pattern.to_s.each_char do |char|
|
|
138
|
+
if is_token
|
|
139
|
+
out << dispatch(char, ctx)
|
|
140
|
+
is_token = false
|
|
141
|
+
elsif char == '%'
|
|
142
|
+
is_token = true
|
|
143
|
+
else
|
|
144
|
+
out << char
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
raise ArgumentError, "Unterminated token in pattern: #{pattern}" if is_token
|
|
149
|
+
|
|
150
|
+
out
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Dispatches a single token character to its handler.
|
|
154
|
+
#
|
|
155
|
+
# @param token [String] single character following `%`
|
|
156
|
+
# @param ctx [Context]
|
|
157
|
+
# @return [String]
|
|
158
|
+
# @raise [ArgumentError] if the token is not in `TOKEN_MAP`
|
|
159
|
+
def dispatch(token, ctx)
|
|
160
|
+
handler = TOKEN_MAP[token]
|
|
161
|
+
raise ArgumentError, "Unknown presenter token: %#{token}" unless handler
|
|
162
|
+
|
|
163
|
+
send(handler, **ctx.to_h)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# -------------------------
|
|
167
|
+
# Token implementations
|
|
168
|
+
# -------------------------
|
|
169
|
+
|
|
170
|
+
# @api private
|
|
171
|
+
def token_percent(**)
|
|
172
|
+
'%'
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# @api private
|
|
176
|
+
def token_total(coin:, currency_dir:, **)
|
|
177
|
+
coin.bidi_isolate(coin.amount.format, dir: currency_dir)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# @api private
|
|
181
|
+
def token_minor(coin:, **)
|
|
182
|
+
coin.bidi_isolate_number(coin.currency_minor)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# @api private
|
|
186
|
+
def token_major(coin:, **)
|
|
187
|
+
coin.bidi_isolate_number(coin.major.to_s('F'))
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# @api private
|
|
191
|
+
def token_currency_code(coin:, **)
|
|
192
|
+
# Currency codes are neutral; isolate as LTR for stability
|
|
193
|
+
coin.bidi_isolate(coin.currency_code, dir: :ltr)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# @api private
|
|
197
|
+
def token_currency_symbol(coin:, currency_dir:, **)
|
|
198
|
+
symbol = Money::Currency.new(coin.currency_code).symbol
|
|
199
|
+
coin.bidi_isolate(symbol, dir: currency_dir)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# @api private
|
|
203
|
+
def token_number_only(coin:, **)
|
|
204
|
+
# Best-effort extraction of the numeric portion
|
|
205
|
+
formatted = coin.amount.format
|
|
206
|
+
symbol = Money::Currency.new(coin.currency_code).symbol.to_s
|
|
207
|
+
|
|
208
|
+
numberish =
|
|
209
|
+
symbol.empty? ? formatted : formatted.gsub(symbol, '').strip
|
|
210
|
+
|
|
211
|
+
coin.bidi_isolate_number(numberish)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# @api private
|
|
215
|
+
def token_units(units:, **)
|
|
216
|
+
units.nil? ? '' : units.to_s
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# @api private
|
|
220
|
+
def token_per_exact(per_exact:, **)
|
|
221
|
+
per_exact.nil? ? '' : per_exact.to_s
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# @api private
|
|
225
|
+
def token_approx(approx:, **)
|
|
226
|
+
approx ? '≈' : ''
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
end
|
|
@@ -1,81 +1,319 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
3
|
+
# Coin is the canonical, persisted representation of a monetary value.
|
|
4
|
+
#
|
|
5
|
+
# ## Conceptual model
|
|
6
|
+
#
|
|
7
|
+
# - A Coin represents *payable money*: an exact integer count of minor units
|
|
8
|
+
# (cents, pence, etc.) in a specific currency.
|
|
9
|
+
# - A Coin is **immutable by convention**: arithmetic operations return new
|
|
10
|
+
# frozen Coins rather than mutating the receiver.
|
|
11
|
+
# - A Coin owns **arithmetic truth** — not presentation, pricing
|
|
12
|
+
# interpretation, or currency conversion.
|
|
13
|
+
# ## Storage
|
|
14
|
+
#
|
|
15
|
+
#
|
|
16
|
+
# Every Coin is persisted in `midas_coins` and belongs polymorphically to
|
|
17
|
+
# any domain object (`resource_type` / `resource_id`). The `resource_role`
|
|
18
|
+
# distinguishes multiple coins on the same resource (e.g. `"price"`,
|
|
19
|
+
# `"cost"`, `"tax"`).
|
|
20
|
+
#
|
|
21
|
+
# ## Modules
|
|
22
|
+
#
|
|
23
|
+
# Behavior is composed via mixins:
|
|
24
|
+
#
|
|
25
|
+
# +------------+----------------------------------------------+
|
|
26
|
+
# | Module | Responsibility |
|
|
27
|
+
# +------------+----------------------------------------------+
|
|
28
|
+
# | Arithmetic | +, -, *, /, %, negate, equality |
|
|
29
|
+
# | Bidi | Unicode bidirectional text isolation |
|
|
30
|
+
# | Converter | Currency conversion (reserved, not yet live) |
|
|
31
|
+
# | Presenter | Token-based formatting grammar |
|
|
32
|
+
# +------------+----------------------------------------------+
|
|
33
|
+
#
|
|
34
|
+
# ## Usage via Bankable
|
|
35
|
+
#
|
|
36
|
+
# The typical entry point is the `WhittakerTech::Midas::Bankable` concern; direct Coin construction
|
|
37
|
+
# is mostly used in service objects and tests.
|
|
38
|
+
#
|
|
39
|
+
# product.set_price(amount: 29.99, currency_code: 'USD')
|
|
40
|
+
# product.price # => #<WhittakerTech::Midas::Coin ...>
|
|
41
|
+
# product.price_amount # => #<Money @fractional=2999 @currency="USD">
|
|
42
|
+
#
|
|
43
|
+
# See also:
|
|
44
|
+
#
|
|
45
|
+
# - `WhittakerTech::Midas::Bankable`
|
|
46
|
+
# - `WhittakerTech::Midas::Coin::Arithmetic`
|
|
47
|
+
# - `WhittakerTech::Midas::Coin::Allocation`
|
|
48
|
+
# @since 0.1.0
|
|
49
|
+
# rubocop:disable Metrics/ClassLength
|
|
50
|
+
class WhittakerTech::Midas::Coin < WhittakerTech::Midas::ApplicationRecord
|
|
51
|
+
# Arithmetic: exact arithmetic and equality semantics
|
|
52
|
+
include Arithmetic
|
|
53
|
+
# Bidi: bidirectional currency conversion
|
|
54
|
+
include Bidi
|
|
55
|
+
# Converter: future currency conversion logic
|
|
56
|
+
include Converter
|
|
57
|
+
# Presenter: formatting and presentation logic
|
|
58
|
+
include Presenter
|
|
59
|
+
|
|
60
|
+
self.table_name = WhittakerTech::Midas.table_name('coins')
|
|
61
|
+
|
|
62
|
+
# Coins belong polymorphically to a domain object (ledger, entry, product, etc.)
|
|
63
|
+
belongs_to :resource, polymorphic: true
|
|
64
|
+
|
|
65
|
+
# Poly::Joins defines joins_resource(klass) for typed polymorphic joins
|
|
66
|
+
include Poly::Joins
|
|
67
|
+
# Poly::Role validates resource_role, normalizes it, and provides for_role scope
|
|
68
|
+
include Poly::Role
|
|
69
|
+
# Poly::Owners defines owner_resource(klass) for typed polymorphic ownership
|
|
70
|
+
include Poly::Owners
|
|
71
|
+
|
|
72
|
+
# Normalize user input before validation to ensure consistent storage
|
|
73
|
+
before_validation :normalize_currency_code
|
|
74
|
+
|
|
75
|
+
# Delegates presence, format `/\A[a-z0-9_]+\z/`, length (max 64), and
|
|
76
|
+
# before_validation normalization to Poly::Role
|
|
77
|
+
poly_role :resource
|
|
78
|
+
|
|
79
|
+
# Each resource may only have one Coin per resource_role
|
|
80
|
+
validates :resource_role,
|
|
81
|
+
uniqueness: { scope: %i[resource_type resource_id], case_sensitive: false }
|
|
82
|
+
|
|
83
|
+
# Currency code is stored as a 3-letter ISO string (e.g. "USD")
|
|
84
|
+
validates :currency_code, presence: true, length: { is: 3 }
|
|
85
|
+
|
|
86
|
+
# Minor units are always stored as integers
|
|
87
|
+
validates :currency_minor, presence: true, numericality: { only_integer: true }
|
|
88
|
+
|
|
89
|
+
# Returns a Money object representing the stored monetary value.
|
|
90
|
+
#
|
|
91
|
+
# This is a *projection*, not canonical value. Use `#currency_minor`
|
|
92
|
+
# and `#currency_code` as the source of truth.
|
|
93
|
+
#
|
|
94
|
+
# Memoized for performance. The memo is cleared automatically when
|
|
95
|
+
# `#currency_minor=` or `#currency_code=` are called.
|
|
96
|
+
#
|
|
97
|
+
# @return [Money]
|
|
98
|
+
def amount
|
|
99
|
+
@amount ||= Money.new(currency_minor, currency_code)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Sets the coin's monetary value from a Money object or raw minor-unit integer.
|
|
103
|
+
#
|
|
104
|
+
# @param value [Money, Integer, Numeric] the value to assign
|
|
105
|
+
# - `Money` — copies `cents` and `currency.iso_code` directly.
|
|
106
|
+
# - `Numeric` — treated as already-scaled minor units; requires
|
|
107
|
+
# `#currency_code` to already be set.
|
|
108
|
+
# @raise [ArgumentError] if a Numeric is given without a prior currency_code
|
|
109
|
+
# @raise [ArgumentError] if the value type is not supported
|
|
110
|
+
# @return [void]
|
|
111
|
+
def amount=(value)
|
|
112
|
+
case value
|
|
113
|
+
when Money
|
|
114
|
+
self.currency_minor = value.cents
|
|
115
|
+
self.currency_code = value.currency.iso_code
|
|
116
|
+
when Numeric
|
|
117
|
+
raise ArgumentError, 'currency_code required before setting numeric amount' if currency_code.blank?
|
|
118
|
+
|
|
119
|
+
self.currency_minor = Integer(value)
|
|
120
|
+
else
|
|
121
|
+
raise ArgumentError, "Invalid value for Coin#amount: #{value.inspect}"
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Formats the Coin for display, optionally converting to another currency first.
|
|
126
|
+
#
|
|
127
|
+
# This is a convenience wrapper around the Money gem's `#format`. For
|
|
128
|
+
# richer formatting use `#present` with a pattern string.
|
|
129
|
+
#
|
|
130
|
+
# @param to [String, nil] target ISO 4217 currency code for conversion,
|
|
131
|
+
# or `nil` to format in the native currency.
|
|
132
|
+
# @return [String] the formatted monetary string, e.g. `"$29.99"`
|
|
133
|
+
def format(to: nil)
|
|
134
|
+
if to
|
|
135
|
+
raise NotImplementedError,
|
|
136
|
+
'Currency conversion is not yet implemented. Use #amount.format for native formatting.'
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
amount.format
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# @return [Integer] the raw minor-unit count (alias for `#currency_minor`)
|
|
143
|
+
def minor
|
|
144
|
+
currency_minor
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# @return [String] the ISO currency code (alias for `#currency_code`)
|
|
148
|
+
def currency
|
|
149
|
+
currency_code
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# @return [Integer] the raw minor-unit count (alias for `#currency_minor`)
|
|
153
|
+
def fractional
|
|
154
|
+
currency_minor
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Clears the memoized Money projection when the minor-unit value changes.
|
|
158
|
+
# @param value [Integer]
|
|
159
|
+
# @return [void]
|
|
160
|
+
def currency_minor=(value)
|
|
161
|
+
@amount = nil
|
|
162
|
+
super
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Clears the memoized Money projection when the currency code changes.
|
|
166
|
+
# @param value [String]
|
|
167
|
+
# @return [void]
|
|
168
|
+
def currency_code=(value)
|
|
169
|
+
@amount = nil
|
|
170
|
+
super
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Constructs a `Coin::Allocation` that interprets this Coin as a per-unit price.
|
|
174
|
+
#
|
|
175
|
+
# The Coin itself is unchanged; `Coin::Allocation` encapsulates the
|
|
176
|
+
# per-unit interpretation and rounding policy.
|
|
177
|
+
#
|
|
178
|
+
# @param per [Numeric] number of units this Coin covers (the divisor)
|
|
179
|
+
# @param rounding_policy [Symbol] one of `WhittakerTech::Midas::ROUNDING_POLICIES`
|
|
180
|
+
# @return [Coin::Allocation]
|
|
181
|
+
#
|
|
182
|
+
# @example Price per item from a bulk price
|
|
183
|
+
# bulk = Coin.value(10_000, 'USD') # $100.00 for 6 units
|
|
184
|
+
# alloc = bulk.allocate(per: 6, rounding_policy: :ceil)
|
|
185
|
+
# alloc.value # => Coin($16.67)
|
|
186
|
+
# alloc.price(qty: 3) # => Coin($50.00)
|
|
187
|
+
def allocate(per:, rounding_policy: WhittakerTech::Midas::DEFAULT_ROUNDING_POLICY)
|
|
188
|
+
WhittakerTech::Midas::Coin::Allocation.new(
|
|
189
|
+
coin: self,
|
|
190
|
+
divisor: per,
|
|
191
|
+
rounding_policy:
|
|
192
|
+
)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Returns the number of decimal places defined by the currency specification.
|
|
196
|
+
#
|
|
197
|
+
# For example, USD = 2, JPY = 0, BHD = 3
|
|
198
|
+
# This is informational and does not imply rounding or formatting.
|
|
199
|
+
#
|
|
200
|
+
# @return [Integer]
|
|
201
|
+
def decimals
|
|
202
|
+
Money::Currency.new(currency_code).decimal_places
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Returns the scaling factor used to convert major units to minor units.
|
|
206
|
+
#
|
|
207
|
+
# Equivalent to `10 ** decimals`. For USD this is `100`; for JPY it is `1`.
|
|
208
|
+
#
|
|
209
|
+
# @return [Numeric]
|
|
210
|
+
def scale
|
|
211
|
+
10**decimals
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Returns the major-unit value as a precise BigDecimal.
|
|
215
|
+
#
|
|
216
|
+
# **This is NOT payable money.** It is intended for inspection and
|
|
217
|
+
# formatting only. No rounding policy is applied.
|
|
218
|
+
#
|
|
219
|
+
# @return [BigDecimal]
|
|
220
|
+
#
|
|
221
|
+
# @example
|
|
222
|
+
# Coin.value(2999, 'USD').major # => BigDecimal("29.99")
|
|
223
|
+
# Coin.value(100, 'JPY').major # => BigDecimal("100")
|
|
224
|
+
def major
|
|
225
|
+
BigDecimal(currency_minor) / scale
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
class << self
|
|
229
|
+
# Constructs a Coin directly from an integer minor-unit count and an ISO
|
|
230
|
+
# currency code.
|
|
231
|
+
#
|
|
232
|
+
# This is the lowest-level factory. It enforces that `currency_minor` is
|
|
233
|
+
# an Integer (fractional minor units are not permitted).
|
|
234
|
+
#
|
|
235
|
+
# @param currency_minor [Integer] the amount in minor units (e.g., cents)
|
|
236
|
+
# @param currency_code [String] ISO 4217 currency code, e.g. `"USD"`
|
|
237
|
+
# @return [Coin]
|
|
238
|
+
# @raise [TypeError] if `currency_minor` is not an Integer
|
|
239
|
+
#
|
|
240
|
+
# @example
|
|
241
|
+
# Coin.value(2999, 'USD') # => $29.99
|
|
242
|
+
# Coin.value(0, 'JPY') # => ¥0
|
|
243
|
+
def value(currency_minor, currency_code)
|
|
244
|
+
raise TypeError unless currency_minor.is_a?(Integer)
|
|
245
|
+
|
|
246
|
+
new(currency_minor:, currency_code:)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Returns a zero-valued Coin in the given currency.
|
|
250
|
+
#
|
|
251
|
+
# @param currency_code [String] ISO 4217 currency code
|
|
252
|
+
# @return [Coin]
|
|
253
|
+
#
|
|
254
|
+
# @example
|
|
255
|
+
# Coin.zero('USD') # => $0.00
|
|
256
|
+
def zero(currency_code)
|
|
257
|
+
new(currency_minor: 0, currency_code:)
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# Parses a heterogeneous input into a Coin using `Coin::Parser`.
|
|
261
|
+
#
|
|
262
|
+
# Accepted input types: `Coin`, `Money`, `Numeric`, `String`.
|
|
263
|
+
#
|
|
264
|
+
# @param value [Coin, Money, Numeric, String] the value to parse
|
|
265
|
+
# @param currency_code [String, nil] required for Numeric and bare String inputs
|
|
266
|
+
# @return [Coin]
|
|
267
|
+
# @raise [TypeError] if the input type cannot be converted
|
|
268
|
+
# @raise [ArgumentError] if a currency_code is required but not provided
|
|
269
|
+
#
|
|
270
|
+
# @example
|
|
271
|
+
# Coin.parse(Money.new(2999, 'USD'))
|
|
272
|
+
# Coin.parse(29.99, currency_code: 'USD')
|
|
273
|
+
# Coin.parse('$29.99')
|
|
274
|
+
def parse(value, currency_code: nil)
|
|
275
|
+
Parser.parse(value, currency_code:)
|
|
79
276
|
end
|
|
80
277
|
end
|
|
278
|
+
|
|
279
|
+
# ── Deprecated ────────────────────────────────────────────────────────── #
|
|
280
|
+
|
|
281
|
+
# @deprecated Use `#resource_role` instead. Will be removed in v0.3.0.
|
|
282
|
+
def resource_label
|
|
283
|
+
WhittakerTech::Midas::Deprecation.warn(
|
|
284
|
+
'Coin#resource_label is deprecated. Use Coin#resource_role instead.',
|
|
285
|
+
caller_locations(1, 1)&.first
|
|
286
|
+
)
|
|
287
|
+
resource_role
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# @deprecated Use `#resource_role=` instead. Will be removed in v0.3.0.
|
|
291
|
+
def resource_label=(value)
|
|
292
|
+
WhittakerTech::Midas::Deprecation.warn(
|
|
293
|
+
'Coin#resource_label= is deprecated. Use Coin#resource_role= instead.',
|
|
294
|
+
caller_locations(1, 1)&.first
|
|
295
|
+
)
|
|
296
|
+
self.resource_role = value
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# @deprecated Use `for_role` instead. Will be removed in v0.3.0.
|
|
300
|
+
def self.for_label(label)
|
|
301
|
+
WhittakerTech::Midas::Deprecation.warn(
|
|
302
|
+
'Coin.for_label is deprecated. Use Coin.for_role instead.',
|
|
303
|
+
caller_locations(1, 1)&.first
|
|
304
|
+
)
|
|
305
|
+
for_role(label)
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
private
|
|
309
|
+
|
|
310
|
+
# Normalizes currency_code before validation.
|
|
311
|
+
#
|
|
312
|
+
# - `currency_code` is stripped and capitalized
|
|
313
|
+
# - `resource_role` normalisation is handled by `Poly::Role`
|
|
314
|
+
# @return [void]
|
|
315
|
+
def normalize_currency_code
|
|
316
|
+
self.currency_code = currency_code.to_s.strip.upcase.presence if currency_code
|
|
317
|
+
end
|
|
81
318
|
end
|
|
319
|
+
# rubocop:enable Metrics/ClassLength
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'whittaker_tech/midas'
|
|
4
|
+
|
|
5
|
+
class CreateWhittakerTechSchema < ActiveRecord::Migration[8.0]
|
|
6
|
+
SCHEMA_NAME = (WhittakerTech::Midas.table_namespace || 'midas').freeze
|
|
7
|
+
raise 'Invalid schema name' unless SCHEMA_NAME =~ /\A[a-z_][a-z0-9_]*\z/
|
|
8
|
+
|
|
9
|
+
def up
|
|
10
|
+
return unless postgresql?
|
|
11
|
+
|
|
12
|
+
execute "CREATE SCHEMA IF NOT EXISTS #{SCHEMA_NAME}"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def down
|
|
16
|
+
return unless postgresql?
|
|
17
|
+
|
|
18
|
+
execute "DROP SCHEMA #{SCHEMA_NAME} CASCADE"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def postgresql?
|
|
24
|
+
connection.adapter_name == 'PostgreSQL'
|
|
25
|
+
end
|
|
26
|
+
end
|
data/db/migrate/{20251015160523_create_wt_midas_coins.rb → 20260101000001_create_midas_coins.rb}
RENAMED
|
@@ -1,6 +1,10 @@
|
|
|
1
|
-
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'whittaker_tech/midas'
|
|
4
|
+
|
|
5
|
+
class CreateMidasCoins < ActiveRecord::Migration[8.0]
|
|
2
6
|
def change
|
|
3
|
-
create_table
|
|
7
|
+
create_table WhittakerTech::Midas.table_name('coins') do |t|
|
|
4
8
|
t.references :resource, polymorphic: true, null: false, index: true
|
|
5
9
|
t.string :resource_label, null: false, limit: 64
|
|
6
10
|
t.string :currency_code, null: false, limit: 3
|
|
@@ -10,7 +14,7 @@ class CreateWtMidasCoins < ActiveRecord::Migration[8.0]
|
|
|
10
14
|
|
|
11
15
|
t.index %i[resource_id resource_type resource_label],
|
|
12
16
|
unique: true,
|
|
13
|
-
name: '
|
|
17
|
+
name: 'index_midas_coins_on_owner_and_label'
|
|
14
18
|
end
|
|
15
19
|
end
|
|
16
20
|
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'whittaker_tech/midas'
|
|
4
|
+
|
|
5
|
+
class RenameResourceLabelToResourceRoleInWtMidasCoins < ActiveRecord::Migration[8.0]
|
|
6
|
+
def change
|
|
7
|
+
rename_column WhittakerTech::Midas.table_name('coins'), :resource_label, :resource_role
|
|
8
|
+
rename_index WhittakerTech::Midas.table_name('coins'),
|
|
9
|
+
'index_midas_coins_on_owner_and_label',
|
|
10
|
+
'index_midas_coins_on_resource_and_role'
|
|
11
|
+
end
|
|
12
|
+
end
|