pg_ledger 0.1.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.
data/lib/pg_ledger.rb ADDED
@@ -0,0 +1,408 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ require_relative "pg_ledger/version"
6
+ require_relative "pg_ledger/entry_builder"
7
+ require_relative "pg_ledger/posting"
8
+ require_relative "pg_ledger/batch"
9
+ require_relative "pg_ledger/ledger_scope"
10
+
11
+ require_relative "pg_ledger/railtie" if defined?(Rails::Railtie)
12
+
13
+ module PgLedger
14
+ class Error < StandardError; end
15
+ class Unbalanced < Error; end
16
+ class InsufficientBalance < Error; end
17
+ class IdempotencyConflict < Error; end
18
+ class ImmutableRecord < Error; end
19
+ class UnknownAccount < Error; end
20
+ class FrozenAccount < Error; end
21
+ class PeriodClosed < Error; end
22
+ class OverReversal < Error; end
23
+ class InvalidReversal < Error; end
24
+ class CrossLedger < Error; end
25
+
26
+ DEFAULT_LEDGER_ID = 1
27
+
28
+ class << self
29
+ # +reverses+: link this entry as a (possibly partial) reversal of another.
30
+ # The database enforces that per account, the sum of all reversals never
31
+ # exceeds the original legs, and that legs oppose original directions.
32
+ def post!(idempotency_key: nil, metadata: {}, posted_at: nil, reverses: nil,
33
+ ledger_id: DEFAULT_LEDGER_ID)
34
+ builder = EntryBuilder.new
35
+ yield builder
36
+ posting = Posting.new(
37
+ legs: builder.legs,
38
+ idempotency_key: idempotency_key,
39
+ metadata: metadata,
40
+ posted_at: posted_at,
41
+ reverses: reverses.is_a?(Entry) ? reverses.id : reverses,
42
+ ledger_id: ledger_id
43
+ )
44
+ ActiveSupport::Notifications.instrument("post.pg_ledger") do |payload|
45
+ started_at = Time.now
46
+ entry = posting.call
47
+ payload.merge!(
48
+ entry_id: entry.id,
49
+ legs: builder.legs.size,
50
+ idempotency_key: idempotency_key,
51
+ reverses_entry_id: entry.reverses_entry_id,
52
+ # An entry that predates this call is an idempotent replay, not a post.
53
+ replayed: idempotency_key ? entry.created_at < started_at - 1 : false
54
+ )
55
+ entry
56
+ end
57
+ end
58
+
59
+ # Decreases the natural balance of +from+ and increases the natural
60
+ # balance of +to+. Same-currency transfers balance only for accounts of
61
+ # the same polarity — mixed movements (deposits, issuance) need post!.
62
+ #
63
+ # Cross-currency transfers route through per-currency trading accounts
64
+ # (one balanced pair of legs per currency). Pass either the exact
65
+ # +to_amount+, or +rate+ as a String/Rational — never a Float — which is
66
+ # rounded with banker's rounding and recorded in the entry metadata.
67
+ def transfer!(from:, to:, amount:, idempotency_key: nil, metadata: {}, posted_at: nil,
68
+ rate: nil, to_amount: nil, ledger_id: DEFAULT_LEDGER_ID)
69
+ from = Account.resolve!(from, ledger_id: ledger_id)
70
+ to = Account.resolve!(to, ledger_id: ledger_id)
71
+ # Account objects carry their own ledger; it wins over the keyword.
72
+ ledger_id = from.ledger_id
73
+
74
+ if from.currency == to.currency
75
+ raise ArgumentError, "rate/to_amount are for cross-currency transfers" if rate || to_amount
76
+ return post!(idempotency_key: idempotency_key, metadata: metadata, posted_at: posted_at,
77
+ ledger_id: ledger_id) do |entry|
78
+ build_transfer(entry, from, to, amount)
79
+ end
80
+ end
81
+
82
+ to_amount ||= convert(amount, rate)
83
+ metadata = metadata.merge(
84
+ "fx" => { "rate" => rate&.to_s, "from_amount" => amount, "from_currency" => from.currency,
85
+ "to_amount" => to_amount, "to_currency" => to.currency }
86
+ )
87
+ post!(idempotency_key: idempotency_key, metadata: metadata, posted_at: posted_at,
88
+ ledger_id: ledger_id) do |entry|
89
+ # One balanced pair per currency: the trading leg mirrors the user leg.
90
+ build_fx_pair(entry, from, trading_account(from.currency, ledger_id: ledger_id), amount, decrease: true)
91
+ build_fx_pair(entry, to, trading_account(to.currency, ledger_id: ledger_id), to_amount, decrease: false)
92
+ end
93
+ end
94
+
95
+ def build_fx_pair(entry, account, trading, amount, decrease:)
96
+ direction =
97
+ if decrease
98
+ account.normal_balance == "debit" ? :credit : :debit
99
+ else
100
+ account.normal_balance == "debit" ? :debit : :credit
101
+ end
102
+ entry.public_send(direction, account, amount)
103
+ entry.public_send(direction == :debit ? :credit : :debit, trading, amount)
104
+ end
105
+
106
+ def build_transfer(entry, from, to, amount)
107
+ entry.public_send(from.normal_balance == "debit" ? :credit : :debit, from, amount)
108
+ entry.public_send(to.normal_balance == "debit" ? :debit : :credit, to, amount)
109
+ end
110
+
111
+ # Balances per currency, optionally for one owner's accounts / one ledger.
112
+ def balances(owner: nil, ledger_id: nil, at: nil)
113
+ unless at
114
+ scope = Balance.joins(:account)
115
+ scope = scope.where(pg_ledger_accounts: { owner: owner }) if owner
116
+ scope = scope.where(pg_ledger_accounts: { ledger_id: ledger_id }) if ledger_id
117
+ return scope.group("pg_ledger_accounts.currency").sum(:amount)
118
+ end
119
+
120
+ raise ArgumentError, "owner is not supported with at: — resolve accounts and use balance(at:)" if owner
121
+
122
+ # As-of balances: daily rollups below the global watermark plus the line
123
+ # tail up to :at. rollup! advances every account to one shared boundary,
124
+ # so MAX(day) + 1 is a valid watermark for the whole table. Bound as a
125
+ # literal — a subquery here blinds the planner (see Account#balance).
126
+ wm_day = AccountDay.maximum(:day)
127
+ wm_next = wm_day ? wm_day + 1 : Date.new(1970, 1, 1)
128
+ ledger_cond = ledger_id ? "AND a.ledger_id = :ledger_id" : ""
129
+ sql = <<~SQL
130
+ SELECT currency, SUM(v)::bigint FROM (
131
+ SELECT a.currency,
132
+ SUM(CASE WHEN a.normal_balance = 'debit'
133
+ THEN d.debits - d.credits ELSE d.credits - d.debits END) AS v
134
+ FROM pg_ledger_account_days d
135
+ JOIN pg_ledger_accounts a ON a.id = d.account_id
136
+ WHERE d.day < LEAST(:wm_next::date, :at::timestamptz::date) #{ledger_cond}
137
+ GROUP BY a.currency
138
+ UNION ALL
139
+ SELECT a.currency,
140
+ SUM(CASE WHEN a.normal_balance = 'debit' THEN l.amount ELSE -l.amount END)
141
+ FROM pg_ledger_lines l
142
+ JOIN pg_ledger_accounts a ON a.id = l.debit_account_id
143
+ WHERE l.posted_at >= LEAST(:wm_next::date, :at::timestamptz::date)::timestamptz
144
+ AND l.posted_at <= :at #{ledger_cond}
145
+ GROUP BY a.currency
146
+ UNION ALL
147
+ SELECT a.currency,
148
+ SUM(CASE WHEN a.normal_balance = 'credit' THEN l.amount ELSE -l.amount END)
149
+ FROM pg_ledger_lines l
150
+ JOIN pg_ledger_accounts a ON a.id = l.credit_account_id
151
+ WHERE l.posted_at >= LEAST(:wm_next::date, :at::timestamptz::date)::timestamptz
152
+ AND l.posted_at <= :at #{ledger_cond}
153
+ GROUP BY a.currency
154
+ ) t GROUP BY currency ORDER BY currency
155
+ SQL
156
+ Record.connection.select_rows(Record.sanitize_sql_array(
157
+ [sql, { at: at, wm_next: wm_next, ledger_id: ledger_id }]
158
+ )).to_h { |currency, amount| [currency, Integer(amount || 0)] }
159
+ end
160
+
161
+ # Scoped facade: every operation of the module, pinned to one ledger.
162
+ # billing = PgLedger.ledger("billing")
163
+ # billing.transfer!(from: "a", to: "b", amount: 100)
164
+ def ledger(name = "main", owner: nil)
165
+ record = Ledger.find_or_create_by!(name: name.to_s) { |l| l.owner = owner }
166
+ LedgerScope.new(record)
167
+ end
168
+
169
+ # Changes the shard count of an account's balance. Expansion is instant
170
+ # and safe under concurrent postings (a posting holding a stale shard
171
+ # count still hits a valid row; the balance is always the SUM of all
172
+ # rows). Shrinking lowers the count so new postings use the narrow range,
173
+ # then merges leftovers on excess shards into shard 0 — concurrent
174
+ # postings may land on an excess shard with a stale count; a later merge
175
+ # (or the next autoscale pass) folds them in. The sum is correct at every
176
+ # moment.
177
+ def resize_shards!(account, target)
178
+ account = Account.resolve!(account)
179
+ raise ArgumentError, "shard count must be >= 1, got #{target}" if target < 1
180
+ if account.min_balance && target > 1
181
+ raise ArgumentError, "sharded balances cannot enforce min_balance"
182
+ end
183
+ return account if target == account.balance_shards
184
+
185
+ Account.transaction do
186
+ if target > account.balance_shards
187
+ Balance.insert_all(
188
+ (account.balance_shards...target).map { |s| { account_id: account.id, shard: s, amount: 0 } },
189
+ unique_by: [:account_id, :shard]
190
+ )
191
+ end
192
+ account.update!(balance_shards: target)
193
+ end
194
+ merge_excess_shards!(account)
195
+ account
196
+ end
197
+
198
+ # Idempotent: folds balances of shards >= balance_shards into shard 0.
199
+ # Locks rows one at a time in ascending shard order — same global lock
200
+ # order as posting, so it cannot deadlock against apply_deltas.
201
+ def merge_excess_shards!(account)
202
+ account = Account.resolve!(account)
203
+ Record.transaction do
204
+ moved = 0
205
+ Balance.where(account_id: account.id).where("shard >= ? AND shard > 0", account.balance_shards)
206
+ .order(:shard).pluck(:shard).each_with_index do |shard, i|
207
+ if i.zero?
208
+ Balance.where(account_id: account.id, shard: 0).lock.pick(:id)
209
+ end
210
+ row = Balance.where(account_id: account.id, shard: shard).lock.first
211
+ next if row.amount.zero?
212
+
213
+ moved += row.amount
214
+ row.update!(amount: 0)
215
+ end
216
+ if moved != 0
217
+ Balance.where(account_id: account.id, shard: 0)
218
+ .update_all(["amount = amount + ?, updated_at = now()", moved])
219
+ end
220
+ end
221
+ nil
222
+ end
223
+
224
+ # One pass of shard autoscaling: doubles shards on accounts whose posting
225
+ # rate over the window exceeds +up_at+ (per minute), halves when below
226
+ # +down_at+. Hysteresis (default down_at = up_at / 4) prevents flapping.
227
+ # Only accounts without min_balance participate. Run it from a periodic
228
+ # job, like rollup!. Returns the applied changes.
229
+ def autoscale_shards!(up_at: 12_000, down_at: up_at / 4, window: 60, max_shards: 64)
230
+ rates = Record.connection.select_rows(Record.sanitize_sql_array([<<~SQL, { window: window }]))
231
+ SELECT account_id, count(*) * (60.0 / :window) AS per_minute FROM (
232
+ SELECT debit_account_id AS account_id FROM pg_ledger_lines
233
+ WHERE posted_at >= now() - make_interval(secs => :window)
234
+ UNION ALL
235
+ SELECT credit_account_id FROM pg_ledger_lines
236
+ WHERE posted_at >= now() - make_interval(secs => :window)
237
+ ) t GROUP BY 1
238
+ SQL
239
+ rates = rates.to_h { |id, rate| [id, rate.to_f] }
240
+
241
+ changes = []
242
+ # Busy accounts are candidates to grow; every sharded account is a
243
+ # candidate to shrink — a fully idle one has no rows in the window.
244
+ candidates = Account.where(min_balance: nil)
245
+ .where("id IN (?) OR balance_shards > 1", rates.keys.presence || [0])
246
+ candidates.find_each do |account|
247
+ rate = rates.fetch(account.id, 0.0)
248
+ from = account.balance_shards
249
+ target =
250
+ if rate >= up_at && from < max_shards
251
+ [from * 2, max_shards].min
252
+ elsif rate < down_at && from > 1
253
+ from / 2
254
+ end
255
+ next unless target
256
+
257
+ resize_shards!(account, target)
258
+ changes << { account: account.code, currency: account.currency,
259
+ from: from, to: target, per_minute: rate.round(1) }
260
+ end
261
+ changes
262
+ end
263
+
264
+ attr_writer :trading_account_code
265
+
266
+ def trading_account_code
267
+ @trading_account_code ||= "trading"
268
+ end
269
+
270
+ private
271
+
272
+ def convert(amount, rate)
273
+ case rate
274
+ when nil
275
+ raise ArgumentError, "cross-currency transfer needs rate: or to_amount:"
276
+ when Float
277
+ raise ArgumentError, "rate must be a String or Rational, not Float (floats corrupt money)"
278
+ end
279
+ converted = (Rational(rate) * amount).round(half: :even)
280
+ raise ArgumentError, "rate #{rate} converts #{amount} to a non-positive amount" unless converted.positive?
281
+ converted
282
+ end
283
+
284
+ def trading_account(currency, ledger_id: DEFAULT_LEDGER_ID)
285
+ Account.resolve!(trading_account_code, currency: currency, ledger_id: ledger_id)
286
+ rescue UnknownAccount
287
+ begin
288
+ create_account!(code: trading_account_code, currency: currency,
289
+ normal_balance: :credit, min_balance: nil, ledger_id: ledger_id)
290
+ rescue ActiveRecord::RecordNotUnique
291
+ Account.resolve!(trading_account_code, currency: currency, ledger_id: ledger_id)
292
+ end
293
+ end
294
+ end
295
+ end
296
+
297
+ module PgLedger
298
+ class << self
299
+
300
+ # N postings in one statement and one fsync. Atomic as a whole.
301
+ def batch
302
+ collector = Batch.new
303
+ yield collector
304
+ collector.commit!
305
+ end
306
+
307
+ # Full reversal mirrors every leg. Partial reversal (amount:) is only
308
+ # defined for two-leg entries — reversing part of a multi-leg entry means
309
+ # deciding which legs shrink, and that is the caller's call: build it with
310
+ # post!(reverses: entry) and explicit legs.
311
+ def reverse!(entry, amount: nil, idempotency_key: nil, metadata: {})
312
+ lines = entry.lines.includes(:debit_account, :credit_account).to_a
313
+ if amount && lines.size != 1
314
+ raise ArgumentError,
315
+ "amount: is only supported for single-pair entries; use post!(reverses:) with explicit legs"
316
+ end
317
+
318
+ post!(idempotency_key: idempotency_key, metadata: metadata, reverses: entry.id,
319
+ ledger_id: entry.ledger_id) do |reversal|
320
+ lines.each do |line|
321
+ # Storno mirrors the pair: money flows back the way it came.
322
+ reversal.debit(line.credit_account, amount || line.amount)
323
+ reversal.credit(line.debit_account, amount || line.amount)
324
+ end
325
+ end
326
+ end
327
+
328
+ def freeze_account!(account, ledger_id: DEFAULT_LEDGER_ID)
329
+ Account.resolve!(account, ledger_id: ledger_id).update!(frozen_at: Time.current)
330
+ end
331
+
332
+ def unfreeze_account!(account, ledger_id: DEFAULT_LEDGER_ID)
333
+ Account.resolve!(account, ledger_id: ledger_id).update!(frozen_at: nil)
334
+ end
335
+
336
+ # Entries with posted_at earlier than +before+ are rejected from now on.
337
+ def close_period!(before:, ledger_id: DEFAULT_LEDGER_ID)
338
+ Record.connection.execute(Record.sanitize_sql_array([<<~SQL, { before: before, ledger: ledger_id }]))
339
+ INSERT INTO pg_ledger_config (ledger_id, closed_before) VALUES (:ledger, :before)
340
+ ON CONFLICT (ledger_id) DO UPDATE SET closed_before = EXCLUDED.closed_before
341
+ SQL
342
+ nil
343
+ end
344
+
345
+ # Rolls completed days into pg_ledger_account_days. Idempotent; run from a
346
+ # scheduled job. Days from the current watermark up to (not including)
347
+ # +upto+ are aggregated from lines in one pass.
348
+ def rollup!(upto: Date.today)
349
+ Record.transaction do
350
+ # The backfill aggregates the whole lines table; without enough
351
+ # work_mem the hash aggregate spills gigabytes to disk.
352
+ Record.connection.execute("SET LOCAL work_mem = '256MB'")
353
+ Record.connection.execute(Record.sanitize_sql_array([<<~SQL, { upto: upto }]))
354
+ INSERT INTO pg_ledger_account_days (account_id, day, debits, credits)
355
+ SELECT t.account_id, t.day, SUM(t.d), SUM(t.c) FROM (
356
+ SELECT l.debit_account_id AS account_id, date_trunc('day', l.posted_at)::date AS day,
357
+ l.amount AS d, 0 AS c
358
+ FROM pg_ledger_lines l
359
+ WHERE l.posted_at >= (SELECT COALESCE(max(day), '-infinity'::date) FROM pg_ledger_account_days) + interval '1 day'
360
+ AND l.posted_at < :upto::date
361
+ UNION ALL
362
+ SELECT l.credit_account_id, date_trunc('day', l.posted_at)::date, 0, l.amount
363
+ FROM pg_ledger_lines l
364
+ WHERE l.posted_at >= (SELECT COALESCE(max(day), '-infinity'::date) FROM pg_ledger_account_days) + interval '1 day'
365
+ AND l.posted_at < :upto::date
366
+ ) t
367
+ GROUP BY 1, 2
368
+ ON CONFLICT (account_id, day) DO UPDATE
369
+ SET debits = EXCLUDED.debits, credits = EXCLUDED.credits
370
+ SQL
371
+ end
372
+ nil
373
+ end
374
+
375
+ def create_account!(code:, currency:, normal_balance:, owner: nil, min_balance: 0,
376
+ balance_shards: 1, metadata: {}, ledger_id: DEFAULT_LEDGER_ID)
377
+ if min_balance && balance_shards > 1
378
+ raise ArgumentError, "sharded balances cannot enforce min_balance — pass min_balance: nil"
379
+ end
380
+
381
+ Account.transaction do
382
+ account = Account.create!(
383
+ ledger_id: ledger_id,
384
+ code: code,
385
+ currency: currency.to_s.upcase,
386
+ normal_balance: normal_balance.to_s,
387
+ owner: owner,
388
+ min_balance: min_balance,
389
+ balance_shards: balance_shards,
390
+ metadata: metadata
391
+ )
392
+ Balance.insert_all(
393
+ balance_shards.times.map { |shard| { account_id: account.id, shard: shard, amount: 0 } }
394
+ )
395
+ account
396
+ end
397
+ end
398
+ end
399
+ end
400
+
401
+ if defined?(ActiveSupport)
402
+ ActiveSupport.on_load(:active_record) do
403
+ require_relative "pg_ledger/models"
404
+ end
405
+ else
406
+ require "active_record"
407
+ require_relative "pg_ledger/models"
408
+ end
metadata ADDED
@@ -0,0 +1,76 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: pg_ledger
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Aliaksei Hrakovich
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-09-10 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activerecord
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '7.1'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '7.1'
27
+ description: 'Append-only double-entry ledger on PostgreSQL: balanced-by-construction
28
+ paired journal rows, idempotent postings, atomic batches, sharded hot balances,
29
+ point-in-time reporting.'
30
+ email: hrakovich.dev@gmail.com
31
+ executables: []
32
+ extensions: []
33
+ extra_rdoc_files: []
34
+ files:
35
+ - CHANGELOG.md
36
+ - LICENSE.txt
37
+ - README.md
38
+ - lib/generators/pg_ledger/install/install_generator.rb
39
+ - lib/generators/pg_ledger/install/templates/create_pg_ledger_tables.rb
40
+ - lib/pg_ledger.rb
41
+ - lib/pg_ledger/batch.rb
42
+ - lib/pg_ledger/entry_builder.rb
43
+ - lib/pg_ledger/ledger_scope.rb
44
+ - lib/pg_ledger/models.rb
45
+ - lib/pg_ledger/posting.rb
46
+ - lib/pg_ledger/railtie.rb
47
+ - lib/pg_ledger/version.rb
48
+ homepage: https://github.com/Slaurmagan/pg_ledger
49
+ licenses:
50
+ - MIT
51
+ metadata:
52
+ homepage_uri: https://github.com/Slaurmagan/pg_ledger
53
+ source_code_uri: https://github.com/Slaurmagan/pg_ledger
54
+ changelog_uri: https://github.com/Slaurmagan/pg_ledger/blob/main/CHANGELOG.md
55
+ bug_tracker_uri: https://github.com/Slaurmagan/pg_ledger/issues
56
+ rubygems_mfa_required: 'true'
57
+ post_install_message:
58
+ rdoc_options: []
59
+ require_paths:
60
+ - lib
61
+ required_ruby_version: !ruby/object:Gem::Requirement
62
+ requirements:
63
+ - - ">="
64
+ - !ruby/object:Gem::Version
65
+ version: '3.1'
66
+ required_rubygems_version: !ruby/object:Gem::Requirement
67
+ requirements:
68
+ - - ">="
69
+ - !ruby/object:Gem::Version
70
+ version: '0'
71
+ requirements: []
72
+ rubygems_version: 3.5.11
73
+ signing_key:
74
+ specification_version: 4
75
+ summary: Double-entry ledger for Rails, backed by Postgres invariants
76
+ test_files: []