whittaker_tech-midas 0.2.0 → 0.4.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 +142 -5
- data/app/controllers/concerns/whittaker_tech/midas/paramable.rb +91 -0
- data/app/models/concerns/whittaker_tech/midas/bankable.rb +3 -1
- data/app/models/whittaker_tech/midas/coin/converter/bank_provider.rb +35 -0
- data/app/models/whittaker_tech/midas/coin/converter.rb +70 -23
- data/app/models/whittaker_tech/midas/coin.rb +9 -38
- data/app/models/whittaker_tech/midas/exchange.rb +37 -0
- data/app/models/whittaker_tech/midas/ledger/account.rb +99 -0
- data/app/models/whittaker_tech/midas/ledger/entry.rb +136 -0
- data/app/models/whittaker_tech/midas/ledger/posting.rb +82 -0
- data/app/models/whittaker_tech/midas/ledger/unbalanced_entry_error.rb +10 -0
- data/db/migrate/20260707000000_create_midas_exchanges.rb +15 -0
- data/db/migrate/20260713000001_create_midas_ledger_accounts.rb +26 -0
- data/db/migrate/20260713000002_create_midas_ledger_entries.rb +17 -0
- data/db/migrate/20260713000003_create_midas_ledger_postings.rb +34 -0
- data/lib/whittaker_tech/midas/version.rb +1 -1
- metadata +12 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fe9c21222c40c9da18f01c21076e775b5d00f6f7b817f2c06c05223cdd88de8b
|
|
4
|
+
data.tar.gz: 50ec83ec5d2b1fbe82e3856180ce63c8bc35f458679f14ab3fdd800229ef519d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4833df22795e85d8e837bd2c0fa81ee6cf593a401463daf7ca42b6adf659be45cecd497213a0641d363c1523019fd2ac616265212aca85e531ee56d9d50efb7f
|
|
7
|
+
data.tar.gz: bdcf127c769350759698cfefb12babe569c5c0c2ed907b3127212e2d806ff3c8eec0d9dedde9f62b6ec59721683e8fa99b21eeef883a6148166d567673dce20a
|
data/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# WhittakerTech::Midas
|
|
2
2
|
|
|
3
3
|
[](MIT-LICENSE)
|
|
4
|
-

|
|
5
5
|

|
|
6
6
|
[](https://badge.fury.io/rb/whittaker_tech-midas)
|
|
7
7
|
[](https://github.com/WhittakerTech/midas/actions)
|
|
@@ -22,6 +22,7 @@ This design keeps your pricing, billing, and financial reporting consistent acro
|
|
|
22
22
|
- Automatic minor-unit conversion for all input types (int, float, Money)
|
|
23
23
|
- Multi-currency support with configurable exchange rates
|
|
24
24
|
- Headless currency input UI for form builders
|
|
25
|
+
- **Additive double-entry bookkeeping** via `Ledger` (accounts + balanced, immutable postings) — see [Ledger](#ledger--double-entry-bookkeeping) below
|
|
25
26
|
- Test suite with >90% coverage
|
|
26
27
|
- Zero schema duplication—no proliferation of `_cents` columns
|
|
27
28
|
|
|
@@ -77,7 +78,7 @@ product.set_price(amount: 2999, currency_code: 'USD')
|
|
|
77
78
|
product.price # => Coin object
|
|
78
79
|
product.price_amount # => Money object (#<Money @cents=2999 @currency="USD">)
|
|
79
80
|
product.price_format # => "$29.99"
|
|
80
|
-
product.price_in('EUR') # => "€26.85"
|
|
81
|
+
product.price_in('EUR') # => "€26.85"
|
|
81
82
|
```
|
|
82
83
|
|
|
83
84
|
## Usage Guide
|
|
@@ -196,7 +197,7 @@ Money.default_formatting_rules = {
|
|
|
196
197
|
|
|
197
198
|
### Exchange Rates
|
|
198
199
|
|
|
199
|
-
Set up exchange rates
|
|
200
|
+
Set up exchange rates on `Money.default_bank` as usual:
|
|
200
201
|
```ruby
|
|
201
202
|
# In your app
|
|
202
203
|
Money.default_bank.add_rate('USD', 'EUR', 0.85)
|
|
@@ -211,6 +212,135 @@ For production, integrate with an exchange rate API:
|
|
|
211
212
|
- [money-open-exchange-rates](https://github.com/spk/money-open-exchange-rates)
|
|
212
213
|
- [google_currency](https://github.com/RubyMoney/google_currency)
|
|
213
214
|
|
|
215
|
+
#### How conversion works
|
|
216
|
+
|
|
217
|
+
`Coin` conversion is provider-agnostic. By default it wraps whatever bank is
|
|
218
|
+
set on `Money.default_bank` (via `Coin::Converter::BankProvider`), so any of
|
|
219
|
+
the gems above work unmodified. You can also convert directly:
|
|
220
|
+
|
|
221
|
+
```ruby
|
|
222
|
+
coin = product.price
|
|
223
|
+
coin.convert_to('EUR') # => new, persisted Coin in EUR
|
|
224
|
+
coin.exchange_to('EUR') # => alias for convert_to
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
Every conversion — whether via `convert_to`, `exchange_to`, `#{name}_in`, or
|
|
228
|
+
`Coin#format(to:)` — writes an immutable `WhittakerTech::Midas::Exchange`
|
|
229
|
+
audit row recording the `from`/`to` coins, the `rate` used, the provider
|
|
230
|
+
`source`, and the timestamp (`at`). This is a write-only audit log, not a
|
|
231
|
+
rate cache: `convert_to` never reads past `Exchange` rows back to resolve a
|
|
232
|
+
rate, it always asks the provider fresh.
|
|
233
|
+
|
|
234
|
+
```ruby
|
|
235
|
+
result = coin.convert_to('EUR')
|
|
236
|
+
exchange = WhittakerTech::Midas::Exchange.last
|
|
237
|
+
exchange.from # => Coin copy of the original value (USD)
|
|
238
|
+
exchange.to # => the converted result (== `result`)
|
|
239
|
+
exchange.rate # => BigDecimal rate used
|
|
240
|
+
exchange.source # => "money:Money::Bank::VariableExchange"
|
|
241
|
+
exchange.at # => Time the conversion was made
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
**`format(to:)` converts on every call.** If you need the same converted
|
|
245
|
+
value multiple times, convert once and reuse the result instead of calling
|
|
246
|
+
`format(to:)` repeatedly — each call performs a live conversion and writes a
|
|
247
|
+
new `Exchange` row:
|
|
248
|
+
|
|
249
|
+
```ruby
|
|
250
|
+
# Avoid — converts and audits twice
|
|
251
|
+
coin.format(to: 'EUR')
|
|
252
|
+
coin.format(to: 'EUR')
|
|
253
|
+
|
|
254
|
+
# Prefer — convert once, format many times
|
|
255
|
+
converted = coin.convert_to('EUR')
|
|
256
|
+
converted.amount.format
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
**Historical rates.** Passing `at:` requires a provider that implements
|
|
260
|
+
`#exchange_at(money, currency_code, at:)` — the default `BankProvider` does
|
|
261
|
+
not, since `Money::Bank::VariableExchange` has no historical capability.
|
|
262
|
+
Passing `at:` against the default provider raises `ArgumentError`. Supply a
|
|
263
|
+
custom provider via `using:` for historical support:
|
|
264
|
+
|
|
265
|
+
```ruby
|
|
266
|
+
coin.convert_to('EUR', at: 3.months.ago, using: my_historical_provider)
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
**Custom providers.** Any object responding to `#exchange(money, currency_code)`
|
|
270
|
+
and `#name` can be passed as `using:` to override the default bank-backed
|
|
271
|
+
provider — useful for testing or wiring in a rate API directly:
|
|
272
|
+
|
|
273
|
+
```ruby
|
|
274
|
+
coin.convert_to('EUR', using: my_provider)
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
## Ledger — Double-Entry Bookkeeping
|
|
278
|
+
|
|
279
|
+
*Since 0.4.0.* `Ledger` is **additive** to Coin/Bankable, not a replacement — most monetary
|
|
280
|
+
attributes should keep using `has_coin`/`has_coins`. Reach for `Ledger` when you need a full,
|
|
281
|
+
audited double-entry trail (billing, subscriptions, anything where "why is this balance what it
|
|
282
|
+
is" needs a real answer).
|
|
283
|
+
|
|
284
|
+
### Accounts
|
|
285
|
+
|
|
286
|
+
An `Ledger::Account` is either a **system account** (no owner — e.g. a per-currency suspense or
|
|
287
|
+
revenue account, disambiguated by `slug`) or an **owned account** (a polymorphic `owner`, e.g. a
|
|
288
|
+
Customer):
|
|
289
|
+
|
|
290
|
+
```ruby
|
|
291
|
+
revenue = WhittakerTech::Midas::Ledger::Account.create!(kind: :revenue, slug: 'revenue', currency_code: 'USD')
|
|
292
|
+
customer = WhittakerTech::Midas::Ledger::Account.create!(kind: :asset, owner: current_customer, currency_code: 'USD')
|
|
293
|
+
|
|
294
|
+
# Per-currency suspense account, for posting out-of-order events against
|
|
295
|
+
suspense = WhittakerTech::Midas::Ledger::Account.suspense_for('USD')
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
`kind` is one of `asset`, `liability`, `equity`, `revenue`, `expense`, `suspense`.
|
|
299
|
+
|
|
300
|
+
### Recording a balanced entry
|
|
301
|
+
|
|
302
|
+
`Ledger::Entry.record!` is the **only** sanctioned way to create an entry — it's the one call
|
|
303
|
+
that guarantees the result balances:
|
|
304
|
+
|
|
305
|
+
```ruby
|
|
306
|
+
WhittakerTech::Midas::Ledger::Entry.record!(
|
|
307
|
+
currency_code: 'USD',
|
|
308
|
+
occurred_at: Time.current,
|
|
309
|
+
lines: [
|
|
310
|
+
{ account: customer, direction: :debit, amount: 1000 },
|
|
311
|
+
{ account: revenue, direction: :credit, amount: 1000 }
|
|
312
|
+
]
|
|
313
|
+
)
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
An entry with mismatched debits/credits, a mixed-currency line, a zero-amount posting, or no
|
|
317
|
+
lines at all raises `ActiveRecord::RecordInvalid` and rolls back entirely — nothing partial is
|
|
318
|
+
ever left behind.
|
|
319
|
+
|
|
320
|
+
Entries and their postings are **immutable** once the entry finalizes. Attempting to add,
|
|
321
|
+
destroy, or reattach an amount to a posting on an already-finalized entry raises
|
|
322
|
+
`WhittakerTech::Midas::Ledger::UnbalancedEntryError`.
|
|
323
|
+
|
|
324
|
+
### Balances
|
|
325
|
+
|
|
326
|
+
```ruby
|
|
327
|
+
customer.balance # => 1000 (raw debit-normal; only counts postings on finalized entries)
|
|
328
|
+
revenue.balance # => -1000
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
### Suspense accounts for out-of-order events
|
|
332
|
+
|
|
333
|
+
If an external event arrives out of order (e.g. a refund webhook before its charge), post it
|
|
334
|
+
against `Account.suspense_for(currency_code)` — reclassifying later is just recording a second
|
|
335
|
+
balanced entry that debits suspense and credits the now-known correct account; entries are
|
|
336
|
+
immutable, so reclassification is never a mutation of the original.
|
|
337
|
+
|
|
338
|
+
### What's deferred
|
|
339
|
+
|
|
340
|
+
Monthly partitioning of the postings table, a DB-level balance-invariant backstop,
|
|
341
|
+
reclassification tooling/aging alerts, and multi-currency entries are intentionally out of scope
|
|
342
|
+
for this release — see `CHANGELOG.md`.
|
|
343
|
+
|
|
214
344
|
## Advanced Usage
|
|
215
345
|
|
|
216
346
|
### Multiple Coins on One Resource
|
|
@@ -230,7 +360,7 @@ order.set_shipping(amount: 850, currency_code: 'EUR')
|
|
|
230
360
|
|
|
231
361
|
order.subtotal_format # => "$100.00"
|
|
232
362
|
order.shipping_format # => "€8.50"
|
|
233
|
-
order.shipping_in('USD') # => "$10.00"
|
|
363
|
+
order.shipping_in('USD') # => "$10.00"
|
|
234
364
|
```
|
|
235
365
|
|
|
236
366
|
### Working with Coin Objects Directly
|
|
@@ -366,11 +496,18 @@ bin/rails server
|
|
|
366
496
|
|
|
367
497
|
### Exchange rates not working
|
|
368
498
|
|
|
369
|
-
Make sure you've configured exchange rates
|
|
499
|
+
Make sure you've configured exchange rates on `Money.default_bank` (the
|
|
500
|
+
default provider raises whatever error the underlying bank raises, e.g.
|
|
501
|
+
`Money::Bank::UnknownRate`, if a rate is missing):
|
|
370
502
|
```ruby
|
|
371
503
|
Money.default_bank.add_rate('USD', 'EUR', 0.85)
|
|
372
504
|
```
|
|
373
505
|
|
|
506
|
+
### `ArgumentError` mentioning "historical" from `convert_to`
|
|
507
|
+
|
|
508
|
+
You passed `at:` without a provider that supports it. Either omit `at:` or
|
|
509
|
+
supply `using:` with a provider implementing `#exchange_at`.
|
|
510
|
+
|
|
374
511
|
### Input field not formatting
|
|
375
512
|
|
|
376
513
|
Check that Stimulus is loaded and the controller is registered:
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Paramable provides Strong Params integration for Bankable coin attributes.
|
|
4
|
+
#
|
|
5
|
+
# Mix into any controller that needs to permit and apply coin parameters.
|
|
6
|
+
# Accepts two input formats per role:
|
|
7
|
+
#
|
|
8
|
+
# **Combined** — one field, colon-delimited minor units and ISO currency code:
|
|
9
|
+
#
|
|
10
|
+
# price: "1245:USD"
|
|
11
|
+
#
|
|
12
|
+
# **Split** — two fields, minor units and currency code separately:
|
|
13
|
+
#
|
|
14
|
+
# price_minor: 1245, price_currency: "USD"
|
|
15
|
+
#
|
|
16
|
+
# Both formats coerce to the same `set_*` call on the record. The combined
|
|
17
|
+
# format is convenient for JSON APIs; the split format matches what
|
|
18
|
+
# `midas_currency_field` emits from the browser.
|
|
19
|
+
#
|
|
20
|
+
# @example
|
|
21
|
+
# class ProductsController < ApplicationController
|
|
22
|
+
# include WhittakerTech::Midas::Paramable
|
|
23
|
+
#
|
|
24
|
+
# def create
|
|
25
|
+
# @product = Product.new(product_params.except(*coin_permit_keys(:price, :cost)))
|
|
26
|
+
# @product.save!
|
|
27
|
+
# assign_coins(@product, product_params, :price, :cost)
|
|
28
|
+
# end
|
|
29
|
+
#
|
|
30
|
+
# private
|
|
31
|
+
#
|
|
32
|
+
# def product_params
|
|
33
|
+
# params.require(:product).permit(:name, *coin_permit_keys(:price, :cost))
|
|
34
|
+
# end
|
|
35
|
+
# end
|
|
36
|
+
#
|
|
37
|
+
# @since 0.3.0
|
|
38
|
+
module WhittakerTech::Midas::Paramable
|
|
39
|
+
extend ActiveSupport::Concern
|
|
40
|
+
|
|
41
|
+
COMBINED_PATTERN = /\A(-?\d+):([A-Za-z]{3})\z/
|
|
42
|
+
|
|
43
|
+
# Returns the list of param field names to pass to ActionController::Parameters#permit
|
|
44
|
+
# for the given coin roles. Always includes keys for both formats so either works.
|
|
45
|
+
#
|
|
46
|
+
# @param roles [Array<Symbol>]
|
|
47
|
+
# @return [Array<Symbol>]
|
|
48
|
+
#
|
|
49
|
+
# @example
|
|
50
|
+
# coin_permit_keys(:price, :cost)
|
|
51
|
+
# # => [:price, :price_minor, :price_currency, :cost, :cost_minor, :cost_currency]
|
|
52
|
+
def coin_permit_keys(*roles)
|
|
53
|
+
roles.flat_map { |r| [r.to_sym, :"#{r}_minor", :"#{r}_currency"] }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Calls `set_#{role}` on +record+ for each role that has params present.
|
|
57
|
+
# Skips roles whose params are entirely absent; raises nothing for missing data.
|
|
58
|
+
#
|
|
59
|
+
# Combined format takes priority when both formats appear for the same role.
|
|
60
|
+
#
|
|
61
|
+
# @param record [ActiveRecord::Base] a model that includes Bankable
|
|
62
|
+
# @param permitted_params [ActionController::Parameters, Hash]
|
|
63
|
+
# @param roles [Array<Symbol>]
|
|
64
|
+
def assign_coins(record, permitted_params, *roles)
|
|
65
|
+
roles.each do |role|
|
|
66
|
+
data = extract_coin(permitted_params, role)
|
|
67
|
+
record.public_send(:"set_#{role}", **data) if data
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def extract_coin(params, role)
|
|
74
|
+
combined = params[role]
|
|
75
|
+
if combined.present?
|
|
76
|
+
parse_combined_coin(combined.to_s)
|
|
77
|
+
elsif params[:"#{role}_minor"].present? && params[:"#{role}_currency"].present?
|
|
78
|
+
{
|
|
79
|
+
amount: params[:"#{role}_minor"].to_i,
|
|
80
|
+
currency_code: params[:"#{role}_currency"].to_s.upcase
|
|
81
|
+
}
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def parse_combined_coin(value)
|
|
86
|
+
match = COMBINED_PATTERN.match(value.strip)
|
|
87
|
+
return nil unless match
|
|
88
|
+
|
|
89
|
+
{ amount: match[1].to_i, currency_code: match[2].upcase }
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -52,6 +52,8 @@
|
|
|
52
52
|
# - `name`: Returns the associated Coin object
|
|
53
53
|
# - `name_amount`: Returns the Money object representing the amount
|
|
54
54
|
# - `name_format`: Returns a formatted string representation of the amount
|
|
55
|
+
# - `name_in(currency_code)`: Converts and formats the coin in another currency
|
|
56
|
+
# (writes an Exchange audit row on every call — see `Coin::Converter`)
|
|
55
57
|
# - `set_name(amount:, currency_code:)`: Sets the coin value with the given amount and currency
|
|
56
58
|
#
|
|
57
59
|
# == Supported Amount Types
|
|
@@ -129,7 +131,7 @@ module WhittakerTech::Midas::Bankable
|
|
|
129
131
|
|
|
130
132
|
define_method("#{name}_amount") { public_send(name)&.amount }
|
|
131
133
|
define_method("#{name}_format") { public_send(name)&.amount&.format }
|
|
132
|
-
|
|
134
|
+
define_method("#{name}_in") { |to| public_send(name)&.exchange_to(to)&.format }
|
|
133
135
|
|
|
134
136
|
# Sets the coin value with the specified amount and currency.
|
|
135
137
|
#
|
|
@@ -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
|
|
@@ -1,32 +1,79 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# Converter
|
|
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`).
|
|
4
8
|
#
|
|
5
|
-
#
|
|
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
|
|
9
|
+
# @since 0.3.0
|
|
21
10
|
module WhittakerTech::Midas::Coin::Converter
|
|
22
11
|
# Converts this Coin to another currency.
|
|
23
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
|
+
#
|
|
24
18
|
# @param currency_code [String] target ISO 4217 currency code
|
|
25
|
-
# @param at [Time]
|
|
26
|
-
#
|
|
27
|
-
#
|
|
28
|
-
# @
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
31
78
|
end
|
|
32
79
|
end
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
# +------------+----------------------------------------------+
|
|
28
28
|
# | Arithmetic | +, -, *, /, %, negate, equality |
|
|
29
29
|
# | Bidi | Unicode bidirectional text isolation |
|
|
30
|
-
# | Converter | Currency conversion (
|
|
30
|
+
# | Converter | Currency conversion (live, audited via Exchange) |
|
|
31
31
|
# | Presenter | Token-based formatting grammar |
|
|
32
32
|
# +------------+----------------------------------------------+
|
|
33
33
|
#
|
|
@@ -46,13 +46,12 @@
|
|
|
46
46
|
# - `WhittakerTech::Midas::Coin::Arithmetic`
|
|
47
47
|
# - `WhittakerTech::Midas::Coin::Allocation`
|
|
48
48
|
# @since 0.1.0
|
|
49
|
-
# rubocop:disable Metrics/ClassLength
|
|
50
49
|
class WhittakerTech::Midas::Coin < WhittakerTech::Midas::ApplicationRecord
|
|
51
50
|
# Arithmetic: exact arithmetic and equality semantics
|
|
52
51
|
include Arithmetic
|
|
53
52
|
# Bidi: bidirectional currency conversion
|
|
54
53
|
include Bidi
|
|
55
|
-
# Converter:
|
|
54
|
+
# Converter: live currency conversion + Exchange audit trail
|
|
56
55
|
include Converter
|
|
57
56
|
# Presenter: formatting and presentation logic
|
|
58
57
|
include Presenter
|
|
@@ -127,16 +126,18 @@ class WhittakerTech::Midas::Coin < WhittakerTech::Midas::ApplicationRecord
|
|
|
127
126
|
# This is a convenience wrapper around the Money gem's `#format`. For
|
|
128
127
|
# richer formatting use `#present` with a pattern string.
|
|
129
128
|
#
|
|
129
|
+
# @note When `to` is given, this performs a live conversion (via
|
|
130
|
+
# `#convert_to`) and writes an Exchange audit row on every call. If
|
|
131
|
+
# formatting the same converted value repeatedly (e.g. in a view loop),
|
|
132
|
+
# convert once and reuse the result's `#amount.format` instead.
|
|
133
|
+
#
|
|
130
134
|
# @param to [String, nil] target ISO 4217 currency code for conversion,
|
|
131
135
|
# or `nil` to format in the native currency.
|
|
132
136
|
# @return [String] the formatted monetary string, e.g. `"$29.99"`
|
|
133
137
|
def format(to: nil)
|
|
134
|
-
|
|
135
|
-
raise NotImplementedError,
|
|
136
|
-
'Currency conversion is not yet implemented. Use #amount.format for native formatting.'
|
|
137
|
-
end
|
|
138
|
+
return amount.format unless to
|
|
138
139
|
|
|
139
|
-
amount.format
|
|
140
|
+
convert_to(to).amount.format
|
|
140
141
|
end
|
|
141
142
|
|
|
142
143
|
# @return [Integer] the raw minor-unit count (alias for `#currency_minor`)
|
|
@@ -276,35 +277,6 @@ class WhittakerTech::Midas::Coin < WhittakerTech::Midas::ApplicationRecord
|
|
|
276
277
|
end
|
|
277
278
|
end
|
|
278
279
|
|
|
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
280
|
private
|
|
309
281
|
|
|
310
282
|
# Normalizes currency_code before validation.
|
|
@@ -316,4 +288,3 @@ class WhittakerTech::Midas::Coin < WhittakerTech::Midas::ApplicationRecord
|
|
|
316
288
|
self.currency_code = currency_code.to_s.strip.upcase.presence if currency_code
|
|
317
289
|
end
|
|
318
290
|
end
|
|
319
|
-
# rubocop:enable Metrics/ClassLength
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Exchange is an immutable audit record of a single currency conversion.
|
|
4
|
+
#
|
|
5
|
+
# It owns two Coins via the standard Bankable DSL — `from` (a copy of the
|
|
6
|
+
# value being converted) and `to` (the converted result) — plus the rate,
|
|
7
|
+
# provider, and timestamp used.
|
|
8
|
+
#
|
|
9
|
+
# Exchange is write-only: nothing reads it back to resolve future
|
|
10
|
+
# conversions. It exists purely as an auditable record of what was
|
|
11
|
+
# converted, at what rate, using which provider, and when.
|
|
12
|
+
#
|
|
13
|
+
# @since 0.3.0
|
|
14
|
+
class WhittakerTech::Midas::Exchange < WhittakerTech::Midas::ApplicationRecord
|
|
15
|
+
include WhittakerTech::Midas::Bankable
|
|
16
|
+
|
|
17
|
+
self.table_name = WhittakerTech::Midas.table_name('exchanges')
|
|
18
|
+
|
|
19
|
+
# Gives #from/#to (Coin readers), #from_amount/#to_amount,
|
|
20
|
+
# #from_format/#to_format, and #set_from/#set_to.
|
|
21
|
+
has_coins :from, :to
|
|
22
|
+
|
|
23
|
+
validates :rate, presence: true
|
|
24
|
+
validates :source, presence: true
|
|
25
|
+
validates :at, presence: true
|
|
26
|
+
|
|
27
|
+
before_update :block_updates
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
# Exchange rows are set once at creation and never touched again — the
|
|
32
|
+
# coins it owns attach separately via Bankable and don't trigger this
|
|
33
|
+
# callback. Does not block #destroy.
|
|
34
|
+
def block_updates
|
|
35
|
+
raise ActiveRecord::ReadOnlyRecord, 'WhittakerTech::Midas::Exchange records are immutable after creation'
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Ledger::Account is a chart-of-accounts entry for Midas's double-entry
|
|
4
|
+
# ledger. An Account is either a system account (no owner — e.g. a
|
|
5
|
+
# per-currency suspense or platform-revenue account) or an owned account
|
|
6
|
+
# (belongs polymorphically to some domain resource, e.g. a Customer or
|
|
7
|
+
# Organization).
|
|
8
|
+
#
|
|
9
|
+
# Accounts are additive to Coin/Bankable, not a replacement — most Midas
|
|
10
|
+
# consumers should keep using `has_coin`/`has_coins` for simple stored
|
|
11
|
+
# values. Ledger::Account exists for models that need a full double-entry
|
|
12
|
+
# audit trail.
|
|
13
|
+
#
|
|
14
|
+
# @since 0.4.0
|
|
15
|
+
class WhittakerTech::Midas::Ledger::Account < WhittakerTech::Midas::ApplicationRecord
|
|
16
|
+
include Poly::Joins
|
|
17
|
+
|
|
18
|
+
self.table_name = WhittakerTech::Midas.table_name('ledger_accounts')
|
|
19
|
+
|
|
20
|
+
# nil owner => a system-level account (suspense, platform revenue, ...).
|
|
21
|
+
# present owner => a per-resource account (Customer, Organization, ...).
|
|
22
|
+
belongs_to :owner, polymorphic: true, optional: true
|
|
23
|
+
|
|
24
|
+
has_many :postings,
|
|
25
|
+
class_name: 'WhittakerTech::Midas::Ledger::Posting',
|
|
26
|
+
dependent: :restrict_with_error,
|
|
27
|
+
inverse_of: :account
|
|
28
|
+
|
|
29
|
+
enum :kind, {
|
|
30
|
+
asset: 'asset',
|
|
31
|
+
liability: 'liability',
|
|
32
|
+
equity: 'equity',
|
|
33
|
+
revenue: 'revenue',
|
|
34
|
+
expense: 'expense',
|
|
35
|
+
suspense: 'suspense'
|
|
36
|
+
}, validate: true
|
|
37
|
+
|
|
38
|
+
before_validation :normalize_currency_code
|
|
39
|
+
|
|
40
|
+
validates :currency_code, presence: true, length: { is: 3 }
|
|
41
|
+
validates :slug, presence: true, if: -> { owner_type.nil? }
|
|
42
|
+
# System accounts are disambiguated by slug, not kind — e.g. a
|
|
43
|
+
# 'platform-revenue' and a 'fees-revenue' system account can coexist in
|
|
44
|
+
# the same currency, both kind: :revenue. Matches the DB partial unique
|
|
45
|
+
# index on (slug, currency_code) WHERE owner_id IS NULL.
|
|
46
|
+
validates :slug, uniqueness: { scope: :currency_code },
|
|
47
|
+
if: -> { owner_type.nil? }
|
|
48
|
+
validates :kind, uniqueness: { scope: %i[owner_type owner_id currency_code] },
|
|
49
|
+
if: -> { owner_type.present? }
|
|
50
|
+
|
|
51
|
+
class << self
|
|
52
|
+
# Finds or creates the singleton suspense account for a given currency.
|
|
53
|
+
#
|
|
54
|
+
# Wrapped in a bounded retry: concurrent callers (e.g. two webhooks
|
|
55
|
+
# racing to post the first out-of-order event in a currency) can both
|
|
56
|
+
# miss the `find_by` and attempt to create — the partial unique index
|
|
57
|
+
# on `(slug, currency_code) WHERE owner_id IS NULL` makes the loser's
|
|
58
|
+
# insert raise `RecordNotUnique` rather than silently duplicating, and
|
|
59
|
+
# the retry then finds the winner's row.
|
|
60
|
+
#
|
|
61
|
+
# @param currency_code [String] ISO 4217 currency code
|
|
62
|
+
# @return [Account]
|
|
63
|
+
def suspense_for(currency_code)
|
|
64
|
+
iso = currency_code.to_s.strip.upcase
|
|
65
|
+
attempts = 0
|
|
66
|
+
begin
|
|
67
|
+
find_or_create_by!(kind: :suspense, slug: 'suspense', currency_code: iso)
|
|
68
|
+
rescue ActiveRecord::RecordNotUnique
|
|
69
|
+
attempts += 1
|
|
70
|
+
retry if attempts <= 1
|
|
71
|
+
raise
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Raw debit-normal balance: sum(debits) - sum(credits), reading the
|
|
77
|
+
# denormalized `currency_minor` column on Posting directly (not joining
|
|
78
|
+
# Coin) so this stays cheap even before Phase 2 partitioning. Only counts
|
|
79
|
+
# postings on finalized entries — Entry.create! (bypassing `record!`) is
|
|
80
|
+
# a live, if unsanctioned, path to an unfinalized entry with postings
|
|
81
|
+
# attached, and those must never contribute to a real balance.
|
|
82
|
+
#
|
|
83
|
+
# This is intentionally kind-agnostic. Presenting a liability/equity/
|
|
84
|
+
# revenue account's balance as conventionally credit-positive is a
|
|
85
|
+
# display concern layered on top of this raw number, not baked in here.
|
|
86
|
+
#
|
|
87
|
+
# @return [Integer]
|
|
88
|
+
def balance
|
|
89
|
+
entry_class = WhittakerTech::Midas::Ledger::Entry
|
|
90
|
+
finalized = postings.joins(:entry).merge(entry_class.where.not(finalized_at: nil))
|
|
91
|
+
finalized.debit.sum(:currency_minor) - finalized.credit.sum(:currency_minor)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def normalize_currency_code
|
|
97
|
+
self.currency_code = currency_code.to_s.strip.upcase.presence if currency_code
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Ledger::Entry is a balanced double-entry transaction: a group of Postings
|
|
4
|
+
# (debits and credits) that must sum to zero within a single currency.
|
|
5
|
+
#
|
|
6
|
+
# `Ledger::Entry.record!` is the only sanctioned way to create an Entry —
|
|
7
|
+
# see its documentation for why. Entries (and their Postings) are immutable
|
|
8
|
+
# once finalized.
|
|
9
|
+
#
|
|
10
|
+
# @since 0.4.0
|
|
11
|
+
class WhittakerTech::Midas::Ledger::Entry < WhittakerTech::Midas::ApplicationRecord
|
|
12
|
+
self.table_name = WhittakerTech::Midas.table_name('ledger_entries')
|
|
13
|
+
|
|
14
|
+
belongs_to :source, polymorphic: true, optional: true
|
|
15
|
+
|
|
16
|
+
has_many :postings,
|
|
17
|
+
class_name: 'WhittakerTech::Midas::Ledger::Posting',
|
|
18
|
+
dependent: :restrict_with_error,
|
|
19
|
+
inverse_of: :entry
|
|
20
|
+
|
|
21
|
+
before_validation :normalize_currency_code
|
|
22
|
+
|
|
23
|
+
validates :currency_code, presence: true, length: { is: 3 }
|
|
24
|
+
validates :occurred_at, presence: true
|
|
25
|
+
|
|
26
|
+
# These only run meaningfully in the :finalize context (see `finalize!`)
|
|
27
|
+
# — at initial `create!` time postings don't exist yet (Coin's FK
|
|
28
|
+
# requires a persisted Posting before it can attach, which in turn
|
|
29
|
+
# requires a persisted Entry — see `record!`), so validating balance at
|
|
30
|
+
# `:create` would either see zero postings or incomplete data. This is
|
|
31
|
+
# also why amount-positivity and per-posting currency-match checks live
|
|
32
|
+
# here rather than on Posting itself — by finalize time every Posting's
|
|
33
|
+
# Coin actually exists.
|
|
34
|
+
validate :postings_balance, on: :finalize
|
|
35
|
+
validate :postings_single_currency, on: :finalize
|
|
36
|
+
validate :postings_amounts_positive, on: :finalize
|
|
37
|
+
validate :postings_present, on: :finalize
|
|
38
|
+
|
|
39
|
+
before_update :block_updates
|
|
40
|
+
|
|
41
|
+
class << self
|
|
42
|
+
# The only sanctioned way to build a balanced Entry. Constructs the
|
|
43
|
+
# Entry, then each Posting, then each Posting's Coin, sequentially
|
|
44
|
+
# (required by Coin's own FK — see WhittakerTech::Midas::Coin::Converter
|
|
45
|
+
# for the identical Exchange precedent), all inside one transaction —
|
|
46
|
+
# then finalizes the Entry, which validates the fully-persisted result
|
|
47
|
+
# actually balances before stamping it immutable-and-closed. Any
|
|
48
|
+
# failure (unbalanced, mixed currency, invalid line) rolls the whole
|
|
49
|
+
# transaction back — nothing partial is ever left behind.
|
|
50
|
+
#
|
|
51
|
+
# @param currency_code [String] ISO 4217 currency code for the entry
|
|
52
|
+
# @param occurred_at [Time] business-meaningful time of the transaction
|
|
53
|
+
# @param lines [Array<Hash>] each a `{account:, direction:, amount:,
|
|
54
|
+
# currency_code: (optional, defaults to the entry's)}` hash
|
|
55
|
+
# @param source [ActiveRecord::Base, nil] optional polymorphic source
|
|
56
|
+
# (an Invoice, a webhook event, a reclassification's original Entry)
|
|
57
|
+
# @param memo [String, nil]
|
|
58
|
+
# @return [Entry] the persisted, finalized Entry
|
|
59
|
+
# @raise [ActiveRecord::RecordInvalid] if any line or the final balance
|
|
60
|
+
# check fails
|
|
61
|
+
def record!(currency_code:, occurred_at:, lines:, source: nil, memo: nil)
|
|
62
|
+
iso = currency_code.to_s.strip.upcase
|
|
63
|
+
|
|
64
|
+
transaction do
|
|
65
|
+
entry = create!(currency_code: iso, occurred_at:, source:, memo:)
|
|
66
|
+
|
|
67
|
+
lines.each do |line|
|
|
68
|
+
posting = entry.postings.create!(
|
|
69
|
+
account: line.fetch(:account),
|
|
70
|
+
direction: line.fetch(:direction),
|
|
71
|
+
occurred_at: line[:occurred_at] || occurred_at
|
|
72
|
+
)
|
|
73
|
+
posting.set_amount(amount: line.fetch(:amount), currency_code: line[:currency_code] || iso)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
entry.send(:finalize!)
|
|
77
|
+
entry
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# @return [Boolean] whether this Entry has completed construction —
|
|
83
|
+
# Postings may only be added/removed while an Entry is not yet
|
|
84
|
+
# finalized (i.e. during `record!`'s own construction).
|
|
85
|
+
def finalized?
|
|
86
|
+
finalized_at.present?
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
# Reloads postings fresh from the DB (so amounts attached via `set_amount`
|
|
92
|
+
# after each Posting's own creation are visible), re-validates balance
|
|
93
|
+
# and currency consistency in the :finalize context, and — only if
|
|
94
|
+
# valid — stamps `finalized_at` via `update_column` (deliberately
|
|
95
|
+
# bypassing `block_updates`, since this is the one sanctioned internal
|
|
96
|
+
# state transition, not an external mutation).
|
|
97
|
+
def finalize!
|
|
98
|
+
postings.reload
|
|
99
|
+
raise ActiveRecord::RecordInvalid, self unless valid?(:finalize)
|
|
100
|
+
|
|
101
|
+
update_column(:finalized_at, Time.current) # rubocop:disable Rails/SkipsModelValidations
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Only checks non-empty, not "at least one debit and one credit" — the
|
|
105
|
+
# balance and positive-amount checks together already rule out a
|
|
106
|
+
# one-sided entry (a lone debit or lone credit can never balance against
|
|
107
|
+
# nothing), so this doesn't need to duplicate that logic.
|
|
108
|
+
def postings_present
|
|
109
|
+
errors.add(:base, 'an entry must have at least one posting') if postings.empty?
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def postings_balance
|
|
113
|
+
debits = postings.debit.sum(:currency_minor)
|
|
114
|
+
credits = postings.credit.sum(:currency_minor)
|
|
115
|
+
errors.add(:base, "postings do not balance (#{debits} debit vs #{credits} credit)") if debits != credits
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def postings_single_currency
|
|
119
|
+
coin_table = WhittakerTech::Midas::Coin.table_name
|
|
120
|
+
mismatched = postings.joins(:amount_coin).where.not(coin_table => { currency_code: currency_code }).exists?
|
|
121
|
+
errors.add(:base, 'all postings must share the entry currency_code') if mismatched
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def postings_amounts_positive
|
|
125
|
+
missing = postings.where('currency_minor IS NULL OR currency_minor <= 0').exists?
|
|
126
|
+
errors.add(:base, 'all postings must have a positive amount') if missing
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def normalize_currency_code
|
|
130
|
+
self.currency_code = currency_code.to_s.strip.upcase.presence if currency_code
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def block_updates
|
|
134
|
+
raise ActiveRecord::ReadOnlyRecord, 'WhittakerTech::Midas::Ledger::Entry records are immutable after creation'
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Ledger::Posting is a single debit or credit line within a balanced
|
|
4
|
+
# Ledger::Entry. Its amount is stored twice, deliberately: via the standard
|
|
5
|
+
# Bankable/Coin mechanism (`amount`, `amount_format`, `amount_in`, etc. —
|
|
6
|
+
# for presentation/conversion parity with the rest of the engine) and
|
|
7
|
+
# denormalized onto `currency_minor` directly on this table, which is what
|
|
8
|
+
# every balance/aggregation query actually reads. This exists so Phase 2
|
|
9
|
+
# partitioning of this table (by `occurred_at`) pays off — if the amount
|
|
10
|
+
# only lived on the joined Coin row, partitioning postings wouldn't help
|
|
11
|
+
# any query that also needs the amount.
|
|
12
|
+
#
|
|
13
|
+
# Postings are immutable once created, and may only be added to or removed
|
|
14
|
+
# from an Entry before that Entry is finalized — see `Ledger::Entry.record!`
|
|
15
|
+
# and `Ledger::Entry#finalized?`.
|
|
16
|
+
#
|
|
17
|
+
# @since 0.4.0
|
|
18
|
+
class WhittakerTech::Midas::Ledger::Posting < WhittakerTech::Midas::ApplicationRecord
|
|
19
|
+
include WhittakerTech::Midas::Bankable
|
|
20
|
+
|
|
21
|
+
self.table_name = WhittakerTech::Midas.table_name('ledger_postings')
|
|
22
|
+
|
|
23
|
+
belongs_to :entry, class_name: 'WhittakerTech::Midas::Ledger::Entry', inverse_of: :postings
|
|
24
|
+
belongs_to :account, class_name: 'WhittakerTech::Midas::Ledger::Account', inverse_of: :postings
|
|
25
|
+
|
|
26
|
+
has_coin :amount
|
|
27
|
+
# Bankable's `has_coin` defines `set_amount` directly on this class (via
|
|
28
|
+
# `define_method` called with `self` as the includer), not through a
|
|
29
|
+
# separate module in the ancestor chain — so a `def set_amount; super;
|
|
30
|
+
# end` below would have no ancestor to reach. Alias the original before
|
|
31
|
+
# overriding so it can still be called.
|
|
32
|
+
alias attach_amount_coin set_amount
|
|
33
|
+
|
|
34
|
+
enum :direction, { debit: 'debit', credit: 'credit' }, validate: true
|
|
35
|
+
|
|
36
|
+
validates :occurred_at, presence: true
|
|
37
|
+
# account/entry are both present at creation time (before any amount is
|
|
38
|
+
# attached — see `set_amount` below), so this validates meaningfully.
|
|
39
|
+
# Amount-dependent checks (positivity, currency match) can't run at
|
|
40
|
+
# Posting-creation time at all — Coin requires a persisted `resource`
|
|
41
|
+
# before it can attach (the Exchange/Converter precedent), so `amount`
|
|
42
|
+
# is always nil until `set_amount` is called afterward. Those checks
|
|
43
|
+
# live in `Entry#finalize!` instead, where real amount data exists.
|
|
44
|
+
validate :account_currency_matches_entry, if: -> { entry && account }
|
|
45
|
+
|
|
46
|
+
before_create :ensure_entry_not_finalized!
|
|
47
|
+
before_update :block_updates
|
|
48
|
+
before_destroy :ensure_entry_not_finalized!
|
|
49
|
+
|
|
50
|
+
# Overrides Bankable's generated `set_amount` to keep the denormalized
|
|
51
|
+
# `currency_minor` column in sync with the Coin it just attached. Business
|
|
52
|
+
# validation of the resulting amount (positive, currency matches entry)
|
|
53
|
+
# happens in `Entry#finalize!`, not here — see class comment.
|
|
54
|
+
#
|
|
55
|
+
# @param amount [Money, Integer, Numeric]
|
|
56
|
+
# @param currency_code [String]
|
|
57
|
+
# @return [WhittakerTech::Midas::Coin]
|
|
58
|
+
def set_amount(amount:, currency_code:)
|
|
59
|
+
ensure_entry_not_finalized!
|
|
60
|
+
|
|
61
|
+
coin = attach_amount_coin(amount:, currency_code:)
|
|
62
|
+
update_column(:currency_minor, coin.currency_minor) # rubocop:disable Rails/SkipsModelValidations
|
|
63
|
+
coin
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def account_currency_matches_entry
|
|
69
|
+
errors.add(:account, 'currency must match the entry currency') if account.currency_code != entry.currency_code
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def ensure_entry_not_finalized!
|
|
73
|
+
return unless entry.finalized?
|
|
74
|
+
|
|
75
|
+
raise WhittakerTech::Midas::Ledger::UnbalancedEntryError,
|
|
76
|
+
'cannot add or remove postings on a finalized entry'
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def block_updates
|
|
80
|
+
raise ActiveRecord::ReadOnlyRecord, 'WhittakerTech::Midas::Ledger::Posting records are immutable after creation'
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Raised when a Posting write (create, destroy, or reattaching its amount
|
|
4
|
+
# via #set_amount) is attempted against an already-finalized Entry. This is
|
|
5
|
+
# the defense-in-depth backstop for any write path that bypasses
|
|
6
|
+
# `Ledger::Entry.record!` — see `Ledger::Posting`'s `before_create`/
|
|
7
|
+
# `before_destroy` guard (and the `set_amount` override).
|
|
8
|
+
#
|
|
9
|
+
# @since 0.4.0
|
|
10
|
+
class WhittakerTech::Midas::Ledger::UnbalancedEntryError < StandardError; end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'whittaker_tech/midas'
|
|
4
|
+
|
|
5
|
+
class CreateMidasExchanges < ActiveRecord::Migration[8.0]
|
|
6
|
+
def change
|
|
7
|
+
create_table WhittakerTech::Midas.table_name('exchanges') do |t|
|
|
8
|
+
t.decimal :rate, precision: 24, scale: 12, null: false
|
|
9
|
+
t.string :source, null: false
|
|
10
|
+
t.datetime :at, null: false
|
|
11
|
+
|
|
12
|
+
t.timestamps
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'whittaker_tech/midas'
|
|
4
|
+
|
|
5
|
+
class CreateMidasLedgerAccounts < ActiveRecord::Migration[8.0]
|
|
6
|
+
def change
|
|
7
|
+
create_table WhittakerTech::Midas.table_name('ledger_accounts') do |t|
|
|
8
|
+
t.references :owner, polymorphic: true, null: true, index: true
|
|
9
|
+
t.string :kind, null: false
|
|
10
|
+
t.string :slug, limit: 64
|
|
11
|
+
t.string :currency_code, null: false, limit: 3
|
|
12
|
+
t.string :name
|
|
13
|
+
|
|
14
|
+
t.timestamps
|
|
15
|
+
|
|
16
|
+
t.index %i[owner_type owner_id kind currency_code],
|
|
17
|
+
unique: true,
|
|
18
|
+
name: 'index_ledger_accounts_on_owner_kind_currency'
|
|
19
|
+
|
|
20
|
+
t.index %i[slug currency_code],
|
|
21
|
+
unique: true,
|
|
22
|
+
where: 'owner_id IS NULL',
|
|
23
|
+
name: 'index_ledger_accounts_on_slug_currency_when_system'
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'whittaker_tech/midas'
|
|
4
|
+
|
|
5
|
+
class CreateMidasLedgerEntries < ActiveRecord::Migration[8.0]
|
|
6
|
+
def change
|
|
7
|
+
create_table WhittakerTech::Midas.table_name('ledger_entries') do |t|
|
|
8
|
+
t.string :currency_code, null: false, limit: 3
|
|
9
|
+
t.datetime :occurred_at, null: false
|
|
10
|
+
t.text :memo
|
|
11
|
+
t.references :source, polymorphic: true, null: true, index: true
|
|
12
|
+
t.datetime :finalized_at
|
|
13
|
+
|
|
14
|
+
t.timestamps
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'whittaker_tech/midas'
|
|
4
|
+
|
|
5
|
+
class CreateMidasLedgerPostings < ActiveRecord::Migration[8.0]
|
|
6
|
+
def change
|
|
7
|
+
create_table WhittakerTech::Midas.table_name('ledger_postings') do |t|
|
|
8
|
+
t.references(
|
|
9
|
+
:entry, null: false, index: true,
|
|
10
|
+
foreign_key: { to_table: WhittakerTech::Midas.table_name('ledger_entries') }
|
|
11
|
+
)
|
|
12
|
+
t.references(
|
|
13
|
+
:account, null: false, index: true,
|
|
14
|
+
foreign_key: { to_table: WhittakerTech::Midas.table_name('ledger_accounts') }
|
|
15
|
+
)
|
|
16
|
+
t.string :direction, null: false
|
|
17
|
+
# Nullable: populated by #set_amount after the row is created (Coin
|
|
18
|
+
# requires a persisted `resource`, so a Posting always exists briefly
|
|
19
|
+
# without an amount). Entry#finalize! rejects any posting still nil.
|
|
20
|
+
t.bigint :currency_minor
|
|
21
|
+
t.datetime :occurred_at, null: false
|
|
22
|
+
|
|
23
|
+
t.timestamps
|
|
24
|
+
|
|
25
|
+
t.index %i[account_id occurred_at], name: 'index_ledger_postings_on_account_and_occurred_at'
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
add_check_constraint(
|
|
29
|
+
WhittakerTech::Midas.table_name('ledger_postings'),
|
|
30
|
+
"direction IN ('debit', 'credit')",
|
|
31
|
+
name: 'ledger_postings_direction_check'
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: whittaker_tech-midas
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Lee Whittaker
|
|
@@ -206,6 +206,7 @@ files:
|
|
|
206
206
|
- Rakefile
|
|
207
207
|
- app/assets/config/whittaker_tech_midas_manifest.js
|
|
208
208
|
- app/assets/stylesheets/whittaker_tech/midas/application.css
|
|
209
|
+
- app/controllers/concerns/whittaker_tech/midas/paramable.rb
|
|
209
210
|
- app/controllers/whittaker_tech/midas/application_controller.rb
|
|
210
211
|
- app/helpers/whittaker_tech/midas/application_helper.rb
|
|
211
212
|
- app/helpers/whittaker_tech/midas/form_helper.rb
|
|
@@ -220,8 +221,14 @@ files:
|
|
|
220
221
|
- app/models/whittaker_tech/midas/coin/arithmetic.rb
|
|
221
222
|
- app/models/whittaker_tech/midas/coin/bidi.rb
|
|
222
223
|
- app/models/whittaker_tech/midas/coin/converter.rb
|
|
224
|
+
- app/models/whittaker_tech/midas/coin/converter/bank_provider.rb
|
|
223
225
|
- app/models/whittaker_tech/midas/coin/parser.rb
|
|
224
226
|
- app/models/whittaker_tech/midas/coin/presenter.rb
|
|
227
|
+
- app/models/whittaker_tech/midas/exchange.rb
|
|
228
|
+
- app/models/whittaker_tech/midas/ledger/account.rb
|
|
229
|
+
- app/models/whittaker_tech/midas/ledger/entry.rb
|
|
230
|
+
- app/models/whittaker_tech/midas/ledger/posting.rb
|
|
231
|
+
- app/models/whittaker_tech/midas/ledger/unbalanced_entry_error.rb
|
|
225
232
|
- app/views/layouts/whittaker_tech/midas/application.html.erb
|
|
226
233
|
- app/views/layouts/whittaker_tech/midas/shared/_currency_field.html.erb
|
|
227
234
|
- config/locales/midas.en.yml
|
|
@@ -229,6 +236,10 @@ files:
|
|
|
229
236
|
- db/migrate/20260101000001_create_midas_coins.rb
|
|
230
237
|
- db/migrate/20260219120000_rename_resource_label_to_resource_role_in_wt_midas_coins.rb
|
|
231
238
|
- db/migrate/20260219150000_rename_wt_midas_coins_to_midas_coins.rb
|
|
239
|
+
- db/migrate/20260707000000_create_midas_exchanges.rb
|
|
240
|
+
- db/migrate/20260713000001_create_midas_ledger_accounts.rb
|
|
241
|
+
- db/migrate/20260713000002_create_midas_ledger_entries.rb
|
|
242
|
+
- db/migrate/20260713000003_create_midas_ledger_postings.rb
|
|
232
243
|
- lib/generators/whittaker_tech/midas/install/install_generator.rb
|
|
233
244
|
- lib/tasks/whittaker_tech/midas_tasks.rake
|
|
234
245
|
- lib/whittaker_tech/midas.rb
|