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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 79ce2a5c7e59f15a9ca7af2ea759c514a32fcdc67915618d01d4f1cf2c00bd9a
4
- data.tar.gz: 149f3fa609b856dffdef559835f668bb47758c00262d5cb2d4517ef21f3c1892
3
+ metadata.gz: fe9c21222c40c9da18f01c21076e775b5d00f6f7b817f2c06c05223cdd88de8b
4
+ data.tar.gz: 50ec83ec5d2b1fbe82e3856180ce63c8bc35f458679f14ab3fdd800229ef519d
5
5
  SHA512:
6
- metadata.gz: f446717fbf1acd4e4a3648eec3aeadbaa71e6263b60e77b68e3c960cfaa10a958783888d5bfa0df53c39cbff8e4849193a173aaa423fd4e92830bd7a954c22a5
7
- data.tar.gz: 538185c60138515129e23ad762985a2bed02ebb369c6c02311107a06c876f481a66df71c4d4437ff99d39d43d67974e18f81b4c172c4d50dde16945dff0b1d34
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](https://img.shields.io/badge/license-MIT-green.svg)](MIT-LICENSE)
4
- ![Ruby 3.4](https://img.shields.io/badge/ruby-3.4+-red.svg)
4
+ ![Ruby 3.2](https://img.shields.io/badge/ruby-3.2+-red.svg)
5
5
  ![Rails 7.1](https://img.shields.io/badge/rails-7.1+-crimson.svg)
6
6
  [![Gem Version](https://badge.fury.io/rb/whittaker_tech-midas.svg)](https://badge.fury.io/rb/whittaker_tech-midas)
7
7
  [![CI](https://github.com/WhittakerTech/midas/actions/workflows/ci.yml/badge.svg)](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" (if exchange rates configured)
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 for currency conversion:
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" (with exchange rate)
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
- # define_method("#{name}_in") { |to| public_send(name)&.exchange_to(to)&.format }
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 is a reserved module for future currency conversion logic.
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
- # 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
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] 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
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 (reserved, not yet live) |
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: future currency conversion logic
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
- if to
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
@@ -1,5 +1,5 @@
1
1
  module WhittakerTech; end
2
2
 
3
3
  module WhittakerTech::Midas
4
- VERSION = '0.2.0'.freeze
4
+ VERSION = '0.4.0'.freeze
5
5
  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.2.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