whittaker_tech-midas 0.3.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: a3fd5f9cd2e7d3ffac65eaedd6353fca8f73ad4e786207f8402791b485454ff0
4
- data.tar.gz: 30445ce5ed3a5ebf775f56c8a05afaf9eb401bac0f0a84a624cc9d4ddafbe9b8
3
+ metadata.gz: fe9c21222c40c9da18f01c21076e775b5d00f6f7b817f2c06c05223cdd88de8b
4
+ data.tar.gz: 50ec83ec5d2b1fbe82e3856180ce63c8bc35f458679f14ab3fdd800229ef519d
5
5
  SHA512:
6
- metadata.gz: 82e65cff44ba88558f2ee2563f107c88399ad41d65e008ae23b8d89846982c292be06c5dfbe3518810b11a1986359156b29119831614ac93d87d2ba5d28c99ef
7
- data.tar.gz: b14740dd0c7ec5fc0d195fabf5bb81da564e456693a4cef8bca03f042a5964a03a7754776fa24cf9c2dca0b6bfb551ec075ef4da841cdfe674e1c50c9cb4b1c5
6
+ metadata.gz: 4833df22795e85d8e837bd2c0fa81ee6cf593a401463daf7ca42b6adf659be45cecd497213a0641d363c1523019fd2ac616265212aca85e531ee56d9d50efb7f
7
+ data.tar.gz: bdcf127c769350759698cfefb12babe569c5c0c2ed907b3127212e2d806ff3c8eec0d9dedde9f62b6ec59721683e8fa99b21eeef883a6148166d567673dce20a
data/README.md CHANGED
@@ -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
 
@@ -273,6 +274,73 @@ provider — useful for testing or wiring in a rate API directly:
273
274
  coin.convert_to('EUR', using: my_provider)
274
275
  ```
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
+
276
344
  ## Advanced Usage
277
345
 
278
346
  ### Multiple Coins on One Resource
@@ -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
@@ -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,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.3.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.3.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
@@ -224,6 +225,10 @@ files:
224
225
  - app/models/whittaker_tech/midas/coin/parser.rb
225
226
  - app/models/whittaker_tech/midas/coin/presenter.rb
226
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
227
232
  - app/views/layouts/whittaker_tech/midas/application.html.erb
228
233
  - app/views/layouts/whittaker_tech/midas/shared/_currency_field.html.erb
229
234
  - config/locales/midas.en.yml
@@ -232,6 +237,9 @@ files:
232
237
  - db/migrate/20260219120000_rename_resource_label_to_resource_role_in_wt_midas_coins.rb
233
238
  - db/migrate/20260219150000_rename_wt_midas_coins_to_midas_coins.rb
234
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
235
243
  - lib/generators/whittaker_tech/midas/install/install_generator.rb
236
244
  - lib/tasks/whittaker_tech/midas_tasks.rake
237
245
  - lib/whittaker_tech/midas.rb