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
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: []
|