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.
@@ -0,0 +1,511 @@
1
+ class CreatePgLedgerTables < ActiveRecord::Migration<%= migration_version %>
2
+ def change
3
+ # Isolated ledgers (brands, legal entities, platform tenants). Everything
4
+ # is scoped to one; the default ledger "main" keeps single-ledger apps
5
+ # entirely unaware of this layer.
6
+ create_table :pg_ledger_ledgers do |t|
7
+ t.string :name, null: false, index: { unique: true }
8
+ # Who this book belongs to (platform tenant, brand, legal entity).
9
+ # Structural membership of accounts is ledger_id; domain ownership of a
10
+ # wallet stays accounts.owner — two different relations.
11
+ t.references :owner, polymorphic: true
12
+ t.datetime :created_at, null: false
13
+ end
14
+
15
+ create_table :pg_ledger_accounts do |t|
16
+ t.bigint :ledger_id, null: false, default: 1
17
+ t.string :code, null: false
18
+ t.string :currency, null: false
19
+ t.string :normal_balance, null: false
20
+ t.references :owner, polymorphic: true
21
+ t.bigint :min_balance
22
+ t.datetime :frozen_at
23
+ t.integer :balance_shards, null: false, default: 1
24
+ t.jsonb :metadata, null: false, default: {}
25
+ t.timestamps
26
+
27
+ t.check_constraint "normal_balance IN ('debit', 'credit')", name: "pg_ledger_accounts_normal_balance"
28
+ t.check_constraint "currency = upper(currency)", name: "pg_ledger_accounts_currency_upcase"
29
+ t.check_constraint "balance_shards >= 1", name: "pg_ledger_accounts_shards_positive"
30
+ t.check_constraint "min_balance IS NULL OR balance_shards = 1", name: "pg_ledger_accounts_no_sharded_min"
31
+ end
32
+
33
+ add_index :pg_ledger_accounts,
34
+ "ledger_id, code, currency, coalesce(owner_type, ''), coalesce(owner_id, 0)",
35
+ unique: true, name: "index_pg_ledger_accounts_uniqueness"
36
+ add_foreign_key :pg_ledger_accounts, :pg_ledger_ledgers, column: :ledger_id
37
+
38
+ create_table :pg_ledger_entries do |t|
39
+ t.bigint :ledger_id, null: false, default: 1
40
+ t.string :idempotency_key
41
+ t.string :request_digest
42
+ t.bigint :reverses_entry_id
43
+ t.jsonb :metadata, null: false, default: {}
44
+ t.datetime :posted_at, null: false
45
+ t.datetime :created_at, null: false
46
+
47
+ t.index :reverses_entry_id, where: "reverses_entry_id IS NOT NULL"
48
+
49
+ t.index [:ledger_id, :idempotency_key], unique: true,
50
+ where: "idempotency_key IS NOT NULL",
51
+ name: "index_pg_ledger_entries_on_idempotency_key"
52
+ t.index :posted_at
53
+ end
54
+ add_foreign_key :pg_ledger_entries, :pg_ledger_ledgers, column: :ledger_id
55
+
56
+ # One journal row IS one double entry: money moves debit_account →
57
+ # credit_account in one currency. Any posting decomposes into pairs
58
+ # (after per-account netting), so every set of rows is balanced by
59
+ # construction — no balance trigger exists. The composite foreign keys
60
+ # below make a cross-currency or cross-ledger pair physically
61
+ # unrepresentable.
62
+ create_table :pg_ledger_lines do |t|
63
+ t.references :entry, null: false, index: false, foreign_key: { to_table: :pg_ledger_entries }
64
+ t.bigint :ledger_id, null: false
65
+ t.string :currency, null: false
66
+ t.bigint :debit_account_id, null: false
67
+ t.bigint :credit_account_id, null: false
68
+ t.bigint :amount, null: false
69
+ # Denormalized from the entry so reporting scans lines without a join.
70
+ t.datetime :posted_at, null: false
71
+ t.datetime :created_at, null: false
72
+
73
+ t.check_constraint "amount > 0", name: "pg_ledger_lines_amount_positive"
74
+ t.check_constraint "debit_account_id <> credit_account_id", name: "pg_ledger_lines_distinct_accounts"
75
+ t.index :entry_id
76
+ t.index [:debit_account_id, :posted_at]
77
+ t.index [:credit_account_id, :posted_at]
78
+ # Lines are appended in time order, so a BRIN index is tiny and makes
79
+ # recent-window scans (load detection, ops queries) cheap.
80
+ t.index :posted_at, using: :brin
81
+ end
82
+
83
+ # The uniqueness pillar the composite FKs stand on: an account id always
84
+ # carries exactly this (ledger, currency).
85
+ add_index :pg_ledger_accounts, [:id, :ledger_id, :currency], unique: true,
86
+ name: "index_pg_ledger_accounts_identity"
87
+ add_foreign_key :pg_ledger_lines, :pg_ledger_accounts,
88
+ column: [:debit_account_id, :ledger_id, :currency],
89
+ primary_key: [:id, :ledger_id, :currency],
90
+ name: "fk_pg_ledger_lines_debit_identity"
91
+ add_foreign_key :pg_ledger_lines, :pg_ledger_accounts,
92
+ column: [:credit_account_id, :ledger_id, :currency],
93
+ primary_key: [:id, :ledger_id, :currency],
94
+ name: "fk_pg_ledger_lines_credit_identity"
95
+
96
+ # Per-ledger config. closed_before: entries with posted_at earlier than
97
+ # this date are rejected — that ledger's accounting period is closed.
98
+ create_table :pg_ledger_config, id: false do |t|
99
+ t.bigint :ledger_id, primary_key: true
100
+ t.date :closed_before
101
+ end
102
+ add_foreign_key :pg_ledger_config, :pg_ledger_ledgers, column: :ledger_id
103
+
104
+ # Daily per-account rollups. Filled by PgLedger.rollup! (idempotent, run it
105
+ # from a scheduled job); reporting reads snapshot + today's tail from lines.
106
+ # The write path never touches this table.
107
+ create_table :pg_ledger_account_days, primary_key: [:account_id, :day] do |t|
108
+ t.bigint :account_id, null: false
109
+ t.date :day, null: false
110
+ t.bigint :debits, null: false, default: 0
111
+ t.bigint :credits, null: false, default: 0
112
+ end
113
+
114
+ # Update-heavy: low fillfactor leaves page room so balance updates stay
115
+ # HOT (no index maintenance); the only index is the (account_id, shard) key.
116
+ create_table :pg_ledger_balances, options: "WITH (fillfactor = 80)" do |t|
117
+ t.references :account, null: false, foreign_key: { to_table: :pg_ledger_accounts }
118
+ t.integer :shard, null: false, default: 0
119
+ t.bigint :amount, null: false, default: 0
120
+ t.timestamps
121
+
122
+ t.index [:account_id, :shard], unique: true
123
+ end
124
+
125
+ # Invariants enforced by Postgres itself. Ruby checks are a convenience;
126
+ # these triggers and functions are the guarantee.
127
+ reversible do |dir|
128
+ dir.up do
129
+ execute <<~SQL
130
+ INSERT INTO pg_ledger_ledgers (id, name, created_at) VALUES (1, 'main', now());
131
+ SELECT setval('pg_ledger_ledgers_id_seq', 1);
132
+ SQL
133
+
134
+ # 1. Balance-per-currency needs no trigger in this schema: a journal
135
+ # row IS a balanced pair, and the composite FKs pin its currency
136
+ # and ledger. There is nothing left to check at write time.
137
+ # 2. The ledger is append-only. Corrections are reversal entries.
138
+ execute <<~SQL
139
+ CREATE FUNCTION pg_ledger_immutable() RETURNS trigger AS $$
140
+ BEGIN
141
+ RAISE EXCEPTION 'pg_ledger: % rows are immutable', TG_TABLE_NAME;
142
+ END $$ LANGUAGE plpgsql;
143
+
144
+ CREATE TRIGGER pg_ledger_lines_immutable
145
+ BEFORE UPDATE OR DELETE ON pg_ledger_lines
146
+ FOR EACH ROW EXECUTE FUNCTION pg_ledger_immutable();
147
+
148
+ CREATE TRIGGER pg_ledger_entries_immutable
149
+ BEFORE UPDATE OR DELETE ON pg_ledger_entries
150
+ FOR EACH ROW EXECUTE FUNCTION pg_ledger_immutable();
151
+ SQL
152
+
153
+ # 3. Balance application. Deltas arrive pre-sorted per posting; the loop
154
+ # locks/updates one row at a time in (account_id, shard) order — the
155
+ # deadlock-prevention invariant. A plpgsql loop guarantees lock order
156
+ # where ORDER BY + FOR UPDATE does not (seq scan locks in scan order).
157
+ execute <<~SQL
158
+ -- Set-based: one lock pass, one upsert, one min_balance check.
159
+ -- Locks are taken in canonical (account_id, shard) order via the
160
+ -- sorted LATERAL — a plain ORDER BY + FOR UPDATE does not guarantee
161
+ -- acquisition order, a nested loop over a sorted outer side does.
162
+ -- min_balance is enforced on the post's final amounts (the posting
163
+ -- is atomic; intermediate per-leg states are unobservable).
164
+ CREATE FUNCTION pg_ledger_apply_deltas(p_deltas jsonb, p_check boolean DEFAULT true) RETURNS void AS $$
165
+ DECLARE
166
+ viol record;
167
+ BEGIN
168
+ PERFORM 1
169
+ FROM (SELECT DISTINCT (x->>'account_id')::bigint AS account_id,
170
+ coalesce((x->>'shard')::int, 0) AS shard
171
+ FROM jsonb_array_elements(p_deltas) x
172
+ ORDER BY 1, 2) t,
173
+ LATERAL (SELECT 1 FROM pg_ledger_balances b
174
+ WHERE b.account_id = t.account_id AND b.shard = t.shard
175
+ FOR UPDATE) l;
176
+
177
+ WITH d AS (
178
+ SELECT (x->>'account_id')::bigint AS account_id,
179
+ coalesce((x->>'shard')::int, 0) AS shard,
180
+ SUM((x->>'delta')::bigint) AS delta,
181
+ MAX((x->>'min_balance')::bigint) AS min_balance
182
+ FROM jsonb_array_elements(p_deltas) x
183
+ GROUP BY 1, 2
184
+ ),
185
+ ups AS (
186
+ INSERT INTO pg_ledger_balances (account_id, shard, amount, created_at, updated_at)
187
+ SELECT account_id, shard, delta, now(), now() FROM d ORDER BY account_id, shard
188
+ ON CONFLICT (account_id, shard) DO UPDATE
189
+ SET amount = pg_ledger_balances.amount + EXCLUDED.amount, updated_at = now()
190
+ RETURNING account_id, shard, amount
191
+ )
192
+ SELECT u.account_id, u.amount, d.min_balance INTO viol
193
+ FROM ups u JOIN d USING (account_id, shard)
194
+ WHERE p_check AND d.min_balance IS NOT NULL AND u.amount < d.min_balance
195
+ LIMIT 1;
196
+
197
+ IF viol.account_id IS NOT NULL THEN
198
+ RAISE EXCEPTION 'pg_ledger:insufficient_balance account=% amount=% min=%',
199
+ viol.account_id, viol.amount, viol.min_balance;
200
+ END IF;
201
+ END $$ LANGUAGE plpgsql;
202
+ SQL
203
+
204
+ # 4. One posting = one statement (implicit transaction, single fsync).
205
+ # Also enforces: no postings on frozen accounts, no postings into a
206
+ # closed period, and reversal limits — per account, the sum of all
207
+ # reversals of an entry can never exceed the original legs.
208
+ execute <<~SQL
209
+ CREATE FUNCTION pg_ledger_post(
210
+ p_key text, p_digest text, p_metadata jsonb, p_posted_at timestamptz,
211
+ p_legs jsonb, p_deltas jsonb, p_reverses bigint DEFAULT NULL,
212
+ p_ledger bigint DEFAULT 1, p_check boolean DEFAULT true
213
+ ) RETURNS pg_ledger_entries AS $$
214
+ DECLARE
215
+ v_entry pg_ledger_entries;
216
+ v_bad record;
217
+ v_closed date;
218
+ r record;
219
+ v_orig bigint;
220
+ v_prev bigint;
221
+ BEGIN
222
+ IF p_key IS NOT NULL THEN
223
+ SELECT * INTO v_entry FROM pg_ledger_entries
224
+ WHERE ledger_id = p_ledger AND idempotency_key = p_key;
225
+ IF FOUND THEN
226
+ IF v_entry.request_digest = p_digest THEN RETURN v_entry; END IF;
227
+ RAISE EXCEPTION 'pg_ledger:idempotency_conflict key=% entry=%', p_key, v_entry.id;
228
+ END IF;
229
+ END IF;
230
+
231
+ -- Both sides of every pair; LATERAL forces an index probe per id.
232
+ SELECT a.code, a.frozen_at, a.ledger_id INTO v_bad
233
+ FROM jsonb_array_elements(p_legs) l,
234
+ LATERAL (SELECT unnest(ARRAY[(l->>'debit_account_id')::bigint,
235
+ (l->>'credit_account_id')::bigint]) AS id) s,
236
+ LATERAL (SELECT code, frozen_at, ledger_id FROM pg_ledger_accounts
237
+ WHERE id = s.id) a
238
+ WHERE a.frozen_at IS NOT NULL OR a.ledger_id <> p_ledger
239
+ LIMIT 1;
240
+ IF v_bad.code IS NOT NULL THEN
241
+ IF v_bad.ledger_id <> p_ledger THEN
242
+ RAISE EXCEPTION 'pg_ledger:cross_ledger account % belongs to ledger %, entry targets %',
243
+ v_bad.code, v_bad.ledger_id, p_ledger;
244
+ END IF;
245
+ RAISE EXCEPTION 'pg_ledger:frozen_account %', v_bad.code;
246
+ END IF;
247
+
248
+ SELECT closed_before INTO v_closed FROM pg_ledger_config WHERE ledger_id = p_ledger;
249
+ IF v_closed IS NOT NULL AND coalesce(p_posted_at, now()) < v_closed THEN
250
+ RAISE EXCEPTION 'pg_ledger:period_closed posted_at % is before %',
251
+ coalesce(p_posted_at, now()), v_closed;
252
+ END IF;
253
+
254
+ IF p_reverses IS NOT NULL THEN
255
+ -- Lock the original entry row: concurrent reversals serialize here.
256
+ PERFORM 1 FROM pg_ledger_entries
257
+ WHERE id = p_reverses AND ledger_id = p_ledger FOR UPDATE;
258
+ IF NOT FOUND THEN
259
+ RAISE EXCEPTION 'pg_ledger:invalid_reversal entry % not found in ledger %', p_reverses, p_ledger;
260
+ END IF;
261
+
262
+ -- A reversal pair (d -> c) undoes original flow (c -> d); per
263
+ -- directed pair the reversals may never exceed the original.
264
+ FOR r IN
265
+ SELECT (x->>'debit_account_id')::bigint AS d,
266
+ (x->>'credit_account_id')::bigint AS c,
267
+ (x->>'amount')::bigint AS amount
268
+ FROM jsonb_array_elements(p_legs) x
269
+ LOOP
270
+ SELECT COALESCE(SUM(amount), 0) INTO v_orig FROM pg_ledger_lines
271
+ WHERE entry_id = p_reverses AND debit_account_id = r.c AND credit_account_id = r.d;
272
+ IF v_orig = 0 THEN
273
+ RAISE EXCEPTION 'pg_ledger:invalid_reversal entry % has no opposite leg on account %',
274
+ p_reverses, r.d;
275
+ END IF;
276
+
277
+ SELECT COALESCE(SUM(l.amount), 0) INTO v_prev
278
+ FROM pg_ledger_lines l
279
+ JOIN pg_ledger_entries e ON e.id = l.entry_id
280
+ WHERE e.reverses_entry_id = p_reverses
281
+ AND l.debit_account_id = r.d AND l.credit_account_id = r.c;
282
+ IF v_prev + r.amount > v_orig THEN
283
+ RAISE EXCEPTION 'pg_ledger:over_reversal account % already reversed % of %, cannot add %',
284
+ r.d, v_prev, v_orig, r.amount;
285
+ END IF;
286
+ END LOOP;
287
+ END IF;
288
+
289
+ BEGIN
290
+ INSERT INTO pg_ledger_entries (ledger_id, idempotency_key, request_digest, reverses_entry_id, metadata, posted_at, created_at)
291
+ VALUES (p_ledger, p_key, p_digest, p_reverses, coalesce(p_metadata, '{}'), coalesce(p_posted_at, now()), now())
292
+ RETURNING * INTO v_entry;
293
+ EXCEPTION WHEN unique_violation THEN
294
+ SELECT * INTO v_entry FROM pg_ledger_entries
295
+ WHERE ledger_id = p_ledger AND idempotency_key = p_key;
296
+ IF v_entry.request_digest = p_digest THEN RETURN v_entry; END IF;
297
+ RAISE EXCEPTION 'pg_ledger:idempotency_conflict key=% entry=%', p_key, v_entry.id;
298
+ END;
299
+
300
+ INSERT INTO pg_ledger_lines (entry_id, ledger_id, currency, debit_account_id, credit_account_id, amount, posted_at, created_at)
301
+ SELECT v_entry.id, p_ledger, l->>'currency',
302
+ (l->>'debit_account_id')::bigint, (l->>'credit_account_id')::bigint,
303
+ (l->>'amount')::bigint, v_entry.posted_at, now()
304
+ FROM jsonb_array_elements(p_legs) l;
305
+
306
+ PERFORM pg_ledger_apply_deltas(p_deltas, p_check);
307
+ RETURN v_entry;
308
+ END $$ LANGUAGE plpgsql;
309
+ SQL
310
+
311
+ # 5. Batch: N postings = one statement, one fsync, atomic as a whole.
312
+ # Pre-locks the union of touched balance rows in global order so
313
+ # per-posting lock sequences cannot interleave into a deadlock.
314
+ execute <<~SQL
315
+ CREATE FUNCTION pg_ledger_post_batch(
316
+ p_ledger bigint[], p_key text[], p_digest text[], p_metadata jsonb[],
317
+ p_posted timestamptz[], p_reverses bigint[],
318
+ p_leg_ent int[], p_leg_debit bigint[], p_leg_credit bigint[], p_leg_cur text[], p_leg_amount bigint[],
319
+ p_d_ent int[], p_d_account bigint[], p_d_shard int[], p_d_delta bigint[], p_d_min bigint[]
320
+ ) RETURNS SETOF pg_ledger_entries AS $$
321
+ DECLARE
322
+ e record;
323
+ v_bad record;
324
+ v_ids bigint[];
325
+ v_inserted bigint[];
326
+ BEGIN
327
+ -- Typed arrays land pre-parsed: no jsonb walking on the hot path.
328
+ CREATE TEMP TABLE IF NOT EXISTS batch_req (
329
+ ord int, ledger_id bigint, key text, digest text,
330
+ metadata jsonb, posted_at timestamptz, reverses bigint, entry_id bigint
331
+ ) ON COMMIT DELETE ROWS;
332
+ CREATE TEMP TABLE IF NOT EXISTS batch_legs (
333
+ ord int, debit_account_id bigint, credit_account_id bigint, currency text, amount bigint
334
+ ) ON COMMIT DELETE ROWS;
335
+ CREATE INDEX IF NOT EXISTS batch_legs_ord ON batch_legs (ord);
336
+ CREATE TEMP TABLE IF NOT EXISTS batch_deltas (
337
+ ord int, account_id bigint, shard int, delta bigint, min_balance bigint
338
+ ) ON COMMIT DELETE ROWS;
339
+ CREATE INDEX IF NOT EXISTS batch_deltas_ord ON batch_deltas (ord);
340
+ DELETE FROM batch_req; DELETE FROM batch_legs; DELETE FROM batch_deltas;
341
+
342
+ INSERT INTO batch_req
343
+ SELECT i, p_ledger[i], p_key[i], p_digest[i], coalesce(p_metadata[i], '{}'::jsonb),
344
+ coalesce(p_posted[i], now()), p_reverses[i], NULL
345
+ FROM generate_subscripts(p_ledger, 1) i;
346
+
347
+ INSERT INTO batch_legs
348
+ SELECT p_leg_ent[i], p_leg_debit[i], p_leg_credit[i], p_leg_cur[i], p_leg_amount[i]
349
+ FROM generate_subscripts(p_leg_ent, 1) i;
350
+
351
+ INSERT INTO batch_deltas
352
+ SELECT p_d_ent[i], p_d_account[i], p_d_shard[i], p_d_delta[i], p_d_min[i]
353
+ FROM generate_subscripts(p_d_ent, 1) i;
354
+
355
+ ANALYZE batch_req;
356
+
357
+ -- Canonical pre-lock over every touched balance row.
358
+ PERFORM 1
359
+ FROM (SELECT DISTINCT account_id, shard FROM batch_deltas ORDER BY 1, 2) t,
360
+ LATERAL (SELECT 1 FROM pg_ledger_balances b
361
+ WHERE b.account_id = t.account_id AND b.shard = t.shard
362
+ FOR UPDATE) l;
363
+
364
+ -- Replays return their entries and move no money; digest mismatch
365
+ -- is a hard conflict.
366
+ UPDATE batch_req r SET entry_id = en.id
367
+ FROM pg_ledger_entries en
368
+ WHERE r.key IS NOT NULL AND r.reverses IS NULL
369
+ AND en.ledger_id = r.ledger_id AND en.idempotency_key = r.key;
370
+
371
+ SELECT r.key, en.id, en.request_digest INTO v_bad
372
+ FROM batch_req r JOIN pg_ledger_entries en ON en.id = r.entry_id
373
+ WHERE en.request_digest IS DISTINCT FROM r.digest
374
+ LIMIT 1;
375
+ IF v_bad.key IS NOT NULL THEN
376
+ RAISE EXCEPTION 'pg_ledger:idempotency_conflict key=% entry=%', v_bad.key, v_bad.id;
377
+ END IF;
378
+
379
+ -- Batch-wide account validation for the fast path.
380
+ SELECT a.code, a.frozen_at, a.ledger_id, r.ledger_id AS want INTO v_bad
381
+ FROM batch_req r JOIN batch_legs g ON g.ord = r.ord,
382
+ LATERAL (SELECT unnest(ARRAY[g.debit_account_id, g.credit_account_id]) AS id) s,
383
+ LATERAL (SELECT code, frozen_at, ledger_id FROM pg_ledger_accounts
384
+ WHERE id = s.id) a
385
+ WHERE r.entry_id IS NULL AND r.reverses IS NULL
386
+ AND (a.frozen_at IS NOT NULL OR a.ledger_id <> r.ledger_id)
387
+ LIMIT 1;
388
+ IF v_bad.code IS NOT NULL THEN
389
+ IF v_bad.ledger_id <> v_bad.want THEN
390
+ RAISE EXCEPTION 'pg_ledger:cross_ledger account % belongs to ledger %, entry targets %',
391
+ v_bad.code, v_bad.ledger_id, v_bad.want;
392
+ END IF;
393
+ RAISE EXCEPTION 'pg_ledger:frozen_account %', v_bad.code;
394
+ END IF;
395
+
396
+ SELECT r.posted_at, c.closed_before INTO v_bad
397
+ FROM batch_req r JOIN pg_ledger_config c ON c.ledger_id = r.ledger_id
398
+ WHERE r.entry_id IS NULL AND r.reverses IS NULL
399
+ AND c.closed_before IS NOT NULL AND r.posted_at < c.closed_before
400
+ LIMIT 1;
401
+ IF v_bad.posted_at IS NOT NULL THEN
402
+ RAISE EXCEPTION 'pg_ledger:period_closed posted_at % is before %',
403
+ v_bad.posted_at, v_bad.closed_before;
404
+ END IF;
405
+
406
+ -- Fast path: three set-based writes for all plain postings.
407
+ SELECT array_agg(nextval('pg_ledger_entries_id_seq')) INTO v_ids
408
+ FROM batch_req WHERE entry_id IS NULL AND reverses IS NULL;
409
+
410
+ WITH todo AS (
411
+ SELECT r.ord, r.ledger_id, r.key, r.digest, r.metadata, r.posted_at,
412
+ v_ids[row_number() OVER (ORDER BY r.ord)] AS new_id
413
+ FROM batch_req r WHERE r.entry_id IS NULL AND r.reverses IS NULL
414
+ ),
415
+ ins AS (
416
+ INSERT INTO pg_ledger_entries (id, ledger_id, idempotency_key, request_digest, metadata, posted_at, created_at)
417
+ SELECT new_id, ledger_id, key, digest, metadata, posted_at, now() FROM todo
418
+ ORDER BY ledger_id, key NULLS LAST
419
+ ON CONFLICT (ledger_id, idempotency_key) WHERE idempotency_key IS NOT NULL DO NOTHING
420
+ RETURNING id
421
+ )
422
+ SELECT array_agg(id) INTO v_inserted FROM ins;
423
+
424
+ UPDATE batch_req r SET entry_id = t.new_id
425
+ FROM (SELECT r2.ord, v_ids[row_number() OVER (ORDER BY r2.ord)] AS new_id
426
+ FROM batch_req r2 WHERE r2.entry_id IS NULL AND r2.reverses IS NULL) t
427
+ WHERE r.ord = t.ord AND t.new_id = ANY(coalesce(v_inserted, '{}'));
428
+
429
+ UPDATE batch_req r SET entry_id = en.id
430
+ FROM pg_ledger_entries en
431
+ WHERE r.entry_id IS NULL AND r.reverses IS NULL AND r.key IS NOT NULL
432
+ AND en.ledger_id = r.ledger_id AND en.idempotency_key = r.key;
433
+
434
+ SELECT r.key, en.id, en.request_digest INTO v_bad
435
+ FROM batch_req r JOIN pg_ledger_entries en ON en.id = r.entry_id
436
+ WHERE en.request_digest IS DISTINCT FROM r.digest
437
+ LIMIT 1;
438
+ IF v_bad.key IS NOT NULL THEN
439
+ RAISE EXCEPTION 'pg_ledger:idempotency_conflict key=% entry=%', v_bad.key, v_bad.id;
440
+ END IF;
441
+
442
+ INSERT INTO pg_ledger_lines (entry_id, ledger_id, currency, debit_account_id, credit_account_id, amount, posted_at, created_at)
443
+ SELECT r.entry_id, r.ledger_id, g.currency, g.debit_account_id, g.credit_account_id,
444
+ g.amount, r.posted_at, now()
445
+ FROM batch_req r JOIN batch_legs g ON g.ord = r.ord
446
+ WHERE r.reverses IS NULL AND r.entry_id = ANY(coalesce(v_inserted, '{}'));
447
+
448
+ -- Deltas of the fresh entries, applied set-wise without the
449
+ -- per-post check; min_balance holds on the batch's final state.
450
+ PERFORM pg_ledger_apply_deltas(
451
+ (SELECT jsonb_agg(jsonb_build_object(
452
+ 'account_id', d.account_id, 'shard', d.shard,
453
+ 'delta', d.delta, 'min_balance', d.min_balance))
454
+ FROM batch_req r JOIN batch_deltas d ON d.ord = r.ord
455
+ WHERE r.reverses IS NULL AND r.entry_id = ANY(coalesce(v_inserted, '{}'))), false)
456
+ WHERE EXISTS (SELECT 1 FROM batch_req r WHERE r.reverses IS NULL
457
+ AND r.entry_id = ANY(coalesce(v_inserted, '{}')));
458
+
459
+ -- Reversals stay on the guarded single-post path (rare).
460
+ FOR e IN SELECT * FROM batch_req WHERE reverses IS NOT NULL ORDER BY ord LOOP
461
+ UPDATE batch_req SET entry_id = (
462
+ SELECT id FROM pg_ledger_post(
463
+ e.key, e.digest, e.metadata, e.posted_at,
464
+ (SELECT jsonb_agg(jsonb_build_object(
465
+ 'debit_account_id', g.debit_account_id,
466
+ 'credit_account_id', g.credit_account_id,
467
+ 'currency', g.currency,
468
+ 'amount', g.amount))
469
+ FROM batch_legs g WHERE g.ord = e.ord),
470
+ (SELECT jsonb_agg(jsonb_build_object(
471
+ 'account_id', d.account_id, 'shard', d.shard,
472
+ 'delta', d.delta, 'min_balance', d.min_balance))
473
+ FROM batch_deltas d WHERE d.ord = e.ord),
474
+ e.reverses, e.ledger_id, false)
475
+ ) WHERE ord = e.ord;
476
+ END LOOP;
477
+
478
+ -- min_balance on the batch's FINAL account state.
479
+ SELECT a.id AS account_id, s.amount, a.min_balance INTO v_bad
480
+ FROM (SELECT DISTINCT account_id FROM batch_deltas) t
481
+ JOIN pg_ledger_accounts a ON a.id = t.account_id AND a.min_balance IS NOT NULL,
482
+ LATERAL (SELECT SUM(amount) AS amount FROM pg_ledger_balances
483
+ WHERE account_id = t.account_id) s
484
+ WHERE s.amount < a.min_balance
485
+ LIMIT 1;
486
+ IF v_bad.account_id IS NOT NULL THEN
487
+ RAISE EXCEPTION 'pg_ledger:insufficient_balance account=% amount=% min=%',
488
+ v_bad.account_id, v_bad.amount, v_bad.min_balance;
489
+ END IF;
490
+
491
+ RETURN QUERY
492
+ SELECT en.* FROM batch_req r
493
+ JOIN pg_ledger_entries en ON en.id = r.entry_id
494
+ ORDER BY r.ord;
495
+ END $$ LANGUAGE plpgsql;
496
+ SQL
497
+ end
498
+
499
+ dir.down do
500
+ execute <<~SQL
501
+ DROP FUNCTION pg_ledger_post_batch(bigint[], text[], text[], jsonb[], timestamptz[], bigint[], int[], bigint[], bigint[], text[], bigint[], int[], bigint[], int[], bigint[], bigint[]);
502
+ DROP FUNCTION pg_ledger_post(text, text, jsonb, timestamptz, jsonb, jsonb, bigint, bigint, boolean);
503
+ DROP FUNCTION pg_ledger_apply_deltas(jsonb, boolean);
504
+ DROP TRIGGER pg_ledger_entries_immutable ON pg_ledger_entries;
505
+ DROP TRIGGER pg_ledger_lines_immutable ON pg_ledger_lines;
506
+ DROP FUNCTION pg_ledger_immutable();
507
+ SQL
508
+ end
509
+ end
510
+ end
511
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgLedger
4
+ # Collects postings and commits them as ONE statement / one fsync.
5
+ # Atomic: if any posting fails, the whole batch rolls back.
6
+ class Batch
7
+ # Typed arrays instead of one big jsonb: the server unnests pre-parsed
8
+ # values straight into the batch temp tables — no jsonb walking.
9
+ CALL_SQL = <<~SQL
10
+ SELECT * FROM pg_ledger_post_batch(
11
+ $1::bigint[], $2::text[], $3::text[], $4::jsonb[], $5::timestamptz[], $6::bigint[],
12
+ $7::int[], $8::bigint[], $9::bigint[], $10::text[], $11::bigint[],
13
+ $12::int[], $13::bigint[], $14::int[], $15::bigint[], $16::bigint[])
14
+ SQL
15
+
16
+ def initialize(default_ledger_id: DEFAULT_LEDGER_ID)
17
+ @default_ledger_id = default_ledger_id
18
+ @postings = []
19
+ # Shard affinity: every posting in this batch hits the same shard per
20
+ # account, so the batch pre-locks one row per account and concurrent
21
+ # batches (with different hints) don't contend.
22
+ @shard_hint = rand
23
+ end
24
+
25
+ def post!(idempotency_key: nil, metadata: {}, posted_at: nil, reverses: nil,
26
+ ledger_id: nil)
27
+ ledger_id ||= @default_ledger_id
28
+ builder = EntryBuilder.new
29
+ yield builder
30
+ @postings << Posting.new(
31
+ legs: builder.legs, idempotency_key: idempotency_key,
32
+ metadata: metadata, posted_at: posted_at,
33
+ reverses: reverses.is_a?(Entry) ? reverses.id : reverses,
34
+ shard_hint: @shard_hint,
35
+ ledger_id: ledger_id
36
+ )
37
+ nil
38
+ end
39
+
40
+ def transfer!(from:, to:, amount:, idempotency_key: nil, metadata: {}, posted_at: nil,
41
+ ledger_id: nil)
42
+ ledger_id ||= @default_ledger_id
43
+ from = Account.resolve!(from, ledger_id: ledger_id)
44
+ to = Account.resolve!(to, ledger_id: ledger_id)
45
+ post!(idempotency_key: idempotency_key, metadata: metadata, posted_at: posted_at,
46
+ ledger_id: from.ledger_id) do |entry|
47
+ PgLedger.build_transfer(entry, from, to, amount)
48
+ end
49
+ end
50
+
51
+ def commit!
52
+ return [] if @postings.empty?
53
+
54
+ encoder = PG::TextEncoder::Array.new
55
+ result = Entry.connection.raw_connection.exec_params(
56
+ CALL_SQL, column_arrays.map { |a| encoder.encode(a) }
57
+ )
58
+ entries = result.map { |row| Entry.instantiate(row) }
59
+ result.clear
60
+ entries
61
+ rescue PG::Error, ActiveRecord::StatementInvalid => e
62
+ raise Posting.map_pg_error(e)
63
+ end
64
+
65
+ private
66
+
67
+ # The batch flattened into the 16 parallel arrays the SQL function takes:
68
+ # entry headers, then legs and deltas keyed by their entry's ordinal.
69
+ def column_arrays
70
+ cols = Array.new(16) { [] }
71
+ @postings.map(&:payload).each_with_index do |p, idx|
72
+ ord = idx + 1
73
+ cols[0] << p[:ledger_id]
74
+ cols[1] << p[:idempotency_key]
75
+ cols[2] << p[:request_digest]
76
+ cols[3] << JSON.generate(p[:metadata] || {})
77
+ cols[4] << p[:posted_at]&.iso8601(6)
78
+ cols[5] << p[:reverses_entry_id]
79
+ p[:legs].each do |l|
80
+ cols[6] << ord
81
+ cols[7] << l[:debit_account_id]
82
+ cols[8] << l[:credit_account_id]
83
+ cols[9] << l[:currency]
84
+ cols[10] << l[:amount]
85
+ end
86
+ p[:deltas].each do |d|
87
+ cols[11] << ord
88
+ cols[12] << d[:account_id]
89
+ cols[13] << d[:shard]
90
+ cols[14] << d[:delta]
91
+ cols[15] << d[:min_balance]
92
+ end
93
+ end
94
+ cols
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgLedger
4
+ class EntryBuilder
5
+ Leg = Struct.new(:direction, :account_ref, :amount)
6
+
7
+ attr_reader :legs
8
+
9
+ def initialize
10
+ @legs = []
11
+ end
12
+
13
+ def debit(account, amount)
14
+ add(:debit, account, amount)
15
+ end
16
+
17
+ def credit(account, amount)
18
+ add(:credit, account, amount)
19
+ end
20
+
21
+ private
22
+
23
+ def add(direction, account, amount)
24
+ unless amount.is_a?(Integer)
25
+ raise ArgumentError, "amount must be an Integer in minor units, got #{amount.class}"
26
+ end
27
+ raise ArgumentError, "amount must be positive, got #{amount}" unless amount.positive?
28
+
29
+ @legs << Leg.new(direction.to_s, account, amount)
30
+ self
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgLedger
4
+ # Every module operation, pinned to one ledger:
5
+ # billing = PgLedger.ledger("billing")
6
+ # billing.create_account!(code: "cash", currency: "USD", normal_balance: :debit)
7
+ # billing.transfer!(from: "cash", to: "fees", amount: 100)
8
+ class LedgerScope
9
+ attr_reader :record
10
+
11
+ def initialize(record)
12
+ @record = record
13
+ end
14
+
15
+ def id = record.id
16
+ def name = record.name
17
+
18
+ SCOPED = %i[
19
+ post! transfer! reverse! create_account! freeze_account! unfreeze_account!
20
+ close_period! balances
21
+ ].freeze
22
+
23
+ SCOPED.each do |m|
24
+ define_method(m) do |*args, **kwargs, &block|
25
+ PgLedger.public_send(m, *args, **kwargs.merge(ledger_id: id), &block)
26
+ end
27
+ end
28
+
29
+ def batch
30
+ collector = Batch.new(default_ledger_id: id)
31
+ yield collector
32
+ collector.commit!
33
+ end
34
+
35
+ def resolve!(ref, currency: nil)
36
+ Account.resolve!(ref, currency: currency, ledger_id: id)
37
+ end
38
+
39
+ def accounts
40
+ Account.where(ledger_id: id)
41
+ end
42
+ end
43
+ end