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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +3 -0
- data/LICENSE.txt +22 -0
- data/README.md +242 -0
- data/lib/generators/pg_ledger/install/install_generator.rb +20 -0
- data/lib/generators/pg_ledger/install/templates/create_pg_ledger_tables.rb +511 -0
- data/lib/pg_ledger/batch.rb +97 -0
- data/lib/pg_ledger/entry_builder.rb +33 -0
- data/lib/pg_ledger/ledger_scope.rb +43 -0
- data/lib/pg_ledger/models.rb +269 -0
- data/lib/pg_ledger/posting.rb +167 -0
- data/lib/pg_ledger/railtie.rb +12 -0
- data/lib/pg_ledger/version.rb +5 -0
- data/lib/pg_ledger.rb +408 -0
- metadata +76 -0
|
@@ -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
|