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 +4 -4
- data/README.md +68 -0
- data/app/controllers/concerns/whittaker_tech/midas/paramable.rb +91 -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/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 +9 -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
|
@@ -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
|
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
|
|
@@ -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
|