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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 6c688a0301c6da6cfeb96f113b0e66367151d0990e4030aaf0261837cb77c852
|
|
4
|
+
data.tar.gz: b6106076e234cc756e97a3d1825b250e8675583b4ca490a99270b132637b6c0c
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: c6827ffe798268ee109792fb52e8a3c4e80ab639596b62dba5bd4aa578a94e1881e5b2ae45f2a146d257a0a083606c15b63ec33dbfc19b711cc29d4f8fa6ecc6
|
|
7
|
+
data.tar.gz: 63a2729c6eaa841d92aa686afa7b5184aea18d6ecc921e411d30390368d0486cad4497c631f09b909e12391bfc68f9aa825d2aa182a9eade850dbd4a4c936d47
|
data/CHANGELOG.md
ADDED
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
Copyright (c) 2026 Aliaksei Hrakovich
|
|
2
|
+
|
|
3
|
+
MIT License
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
6
|
+
a copy of this software and associated documentation files (the
|
|
7
|
+
"Software"), to deal in the Software without restriction, including
|
|
8
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
9
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
10
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
11
|
+
the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be
|
|
14
|
+
included in all copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
17
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
18
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
19
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
20
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
21
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
22
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
# pg_ledger
|
|
2
|
+
|
|
3
|
+
Double-entry ledger for Rails, backed by Postgres invariants.
|
|
4
|
+
|
|
5
|
+
- **Correct under concurrency** — row-level locks with strict ordering, no lost updates, no negative balances
|
|
6
|
+
- **Idempotent by design** — retry-safe money movement with idempotency keys
|
|
7
|
+
- **Append-only** — entries are immutable at the database level; corrections are reversals
|
|
8
|
+
- **Balanced by construction** — a journal row is a debit→credit pair in one currency; an unbalanced entry is unrepresentable, no trigger needed
|
|
9
|
+
|
|
10
|
+
Amounts are integers in minor units. Floats are rejected.
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
Add to your Gemfile:
|
|
15
|
+
|
|
16
|
+
```ruby
|
|
17
|
+
gem "pg_ledger"
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Then run:
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
bundle install
|
|
24
|
+
rails generate pg_ledger:install
|
|
25
|
+
rails db:migrate
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Postgres 13+ required.
|
|
29
|
+
|
|
30
|
+
## Quick start
|
|
31
|
+
|
|
32
|
+
Create accounts:
|
|
33
|
+
|
|
34
|
+
```ruby
|
|
35
|
+
cash = PgLedger.create_account!(code: "cash:main", currency: "USD", normal_balance: :debit, min_balance: nil)
|
|
36
|
+
wallet = PgLedger.create_account!(code: "wallet", currency: "USD", normal_balance: :credit, owner: user)
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Record a deposit (both accounts grow — debit the asset, credit the liability):
|
|
40
|
+
|
|
41
|
+
```ruby
|
|
42
|
+
PgLedger.post!(idempotency_key: "deposit:#{payment.id}") do |entry|
|
|
43
|
+
entry.debit cash, 10_00
|
|
44
|
+
entry.credit wallet, 10_00
|
|
45
|
+
end
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Move money between accounts of the same polarity — `from` decreases, `to` increases:
|
|
49
|
+
|
|
50
|
+
```ruby
|
|
51
|
+
PgLedger.transfer!(
|
|
52
|
+
from: wallet,
|
|
53
|
+
to: other_wallet,
|
|
54
|
+
amount: 10_00,
|
|
55
|
+
idempotency_key: "p2p:#{payment.id}"
|
|
56
|
+
)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Mixed-polarity movements need explicit `post!` — `transfer!` raises `PgLedger::Unbalanced` instead of guessing directions.
|
|
60
|
+
|
|
61
|
+
Check balances:
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
wallet.balance # => 1000
|
|
65
|
+
wallet.balance(at: 1.day.ago)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Multi-leg entries
|
|
69
|
+
|
|
70
|
+
An entry can touch any number of accounts. It must balance per currency:
|
|
71
|
+
|
|
72
|
+
```ruby
|
|
73
|
+
PgLedger.post!(idempotency_key: "payout:#{payout.id}", metadata: { order_id: order.id }) do |entry|
|
|
74
|
+
entry.debit wallet, 100_00
|
|
75
|
+
entry.credit cash, 99_00
|
|
76
|
+
entry.credit "fees:revenue", 1_00
|
|
77
|
+
end
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Accounts are referenced by record or by code.
|
|
81
|
+
|
|
82
|
+
Under the hood every entry is stored as **paired legs**: each journal row moves one
|
|
83
|
+
amount from a debit account to a credit account in one currency, so the books sum to
|
|
84
|
+
zero by construction. Builder legs are netted per account and zipped into pairs
|
|
85
|
+
deterministically — the payout above stores two rows (`wallet→cash 99_00`,
|
|
86
|
+
`wallet→fees:revenue 1_00`). Read them via `entry.lines` (`debit_account` /
|
|
87
|
+
`credit_account` / `currency` / `amount`), or per account via
|
|
88
|
+
`account.debit_lines` / `account.credit_lines`.
|
|
89
|
+
|
|
90
|
+
## Idempotency
|
|
91
|
+
|
|
92
|
+
Every write accepts an `idempotency_key`:
|
|
93
|
+
|
|
94
|
+
- same key, same payload → returns the original entry, posts nothing
|
|
95
|
+
- same key, different payload → raises `PgLedger::IdempotencyConflict`
|
|
96
|
+
|
|
97
|
+
The guarantee is a unique index, not application logic. Safe under retries, job re-runs, and double-clicks.
|
|
98
|
+
|
|
99
|
+
## Concurrency
|
|
100
|
+
|
|
101
|
+
Balance updates lock balance rows in account order (`SELECT ... FOR UPDATE`), so concurrent postings serialize per account and never deadlock against each other.
|
|
102
|
+
|
|
103
|
+
Accounts with `min_balance: 0` (the default for owned accounts) cannot go negative — a concurrent overdraft attempt raises `PgLedger::InsufficientBalance` instead of losing money.
|
|
104
|
+
|
|
105
|
+
## Multiple ledgers
|
|
106
|
+
|
|
107
|
+
Isolated ledgers in one database — brands, legal entities, platform tenants:
|
|
108
|
+
|
|
109
|
+
```ruby
|
|
110
|
+
billing = PgLedger.ledger("billing")
|
|
111
|
+
billing.create_account!(code: "cash", currency: "USD", normal_balance: :debit)
|
|
112
|
+
billing.transfer!(from: "cash", to: "fees", amount: 100, idempotency_key: "b:1")
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Account codes and idempotency keys are unique per ledger; period close and balances are per ledger; an entry whose legs span two ledgers is rejected by Postgres (`CrossLedger`). Everything defaults to the built-in `main` ledger — single-ledger apps never see this layer.
|
|
116
|
+
|
|
117
|
+
## Multiple currencies
|
|
118
|
+
|
|
119
|
+
Currency is an attribute of the account — a multi-currency wallet is one account per currency, and every entry must balance within each currency (Postgres enforces it).
|
|
120
|
+
|
|
121
|
+
Cross-currency transfers route through per-currency trading accounts automatically:
|
|
122
|
+
|
|
123
|
+
```ruby
|
|
124
|
+
PgLedger.transfer!(
|
|
125
|
+
from: usd_wallet, to: eur_wallet,
|
|
126
|
+
amount: 100_00,
|
|
127
|
+
rate: "0.92", # String or Rational — Floats are rejected
|
|
128
|
+
idempotency_key: "fx:#{order.id}"
|
|
129
|
+
)
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
One atomic entry, four legs, both currencies balanced. The conversion is recorded in the entry metadata; `trading:USD` / `trading:EUR` balances show your open FX position. Pass `to_amount:` instead of `rate:` for exact control over rounding (with `rate:`, banker's rounding applies).
|
|
133
|
+
|
|
134
|
+
```ruby
|
|
135
|
+
PgLedger.balances(owner: user) # => { "USD" => 90000, "EUR" => 9200 }
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## Reporting
|
|
139
|
+
|
|
140
|
+
Chart-ready primitives, all computed from the immutable lines — exact for sharded accounts too:
|
|
141
|
+
|
|
142
|
+
```ruby
|
|
143
|
+
wallet.balance # current, O(shards)
|
|
144
|
+
wallet.balance(at: 1.month.ago) # point-in-time
|
|
145
|
+
wallet.balance_series(from: 30.days.ago) # [[time, balance]] per day
|
|
146
|
+
wallet.balance_series(from: 1.day.ago, interval: "hour")
|
|
147
|
+
wallet.turnover(from: 30.days.ago) # { debits:, credits: }
|
|
148
|
+
wallet.turnover(from: 30.days.ago, interval: "day") # per-day inflow/outflow
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Intervals: `hour`, `day`, `week`, `month`, or any fixed step like `"30 minutes"`.
|
|
152
|
+
Empty buckets carry the running balance forward, so lines draw without gaps.
|
|
153
|
+
|
|
154
|
+
Query accounts BY balance — the aggregate is a SQL column you can filter and sort on:
|
|
155
|
+
|
|
156
|
+
```ruby
|
|
157
|
+
PgLedger::Account.with_balance.where("current_balance > ?", 100_00)
|
|
158
|
+
PgLedger::Account.with_balance.order(Arel.sql("current_balance DESC")).limit(10)
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
## Batching
|
|
162
|
+
|
|
163
|
+
For high-throughput ingestion, post many entries in one database roundtrip and one fsync:
|
|
164
|
+
|
|
165
|
+
```ruby
|
|
166
|
+
PgLedger.batch do |batch|
|
|
167
|
+
batch.transfer!(from: a, to: b, amount: 100, idempotency_key: "t:1")
|
|
168
|
+
batch.post!(idempotency_key: "t:2") do |entry|
|
|
169
|
+
entry.debit cash, 50
|
|
170
|
+
entry.credit b, 50
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
A batch is atomic: if any posting fails, the whole batch rolls back. Idempotency is
|
|
176
|
+
per posting — replayed keys return their original entries, new ones post. `min_balance`
|
|
177
|
+
is enforced on the batch's final state, so a spend funded by a later posting in the
|
|
178
|
+
same batch is legal.
|
|
179
|
+
|
|
180
|
+
Batches of 500 sustain 60k entries/s on a laptop (10 cores); a single `post!` runs ~1.3ms.
|
|
181
|
+
|
|
182
|
+
## Hot accounts
|
|
183
|
+
|
|
184
|
+
A busy account serializes on its balance row. Shard it:
|
|
185
|
+
|
|
186
|
+
```ruby
|
|
187
|
+
PgLedger.create_account!(code: "cash:main", currency: "USD", normal_balance: :debit,
|
|
188
|
+
min_balance: nil, balance_shards: 16)
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Each posting updates one random shard; the balance is the sum. Sharding requires `min_balance: nil` — overdraft checks need a single row.
|
|
192
|
+
|
|
193
|
+
## Corrections
|
|
194
|
+
|
|
195
|
+
Entries and lines are immutable — `UPDATE`/`DELETE` raise in Postgres. To undo:
|
|
196
|
+
|
|
197
|
+
```ruby
|
|
198
|
+
PgLedger.reverse!(entry, idempotency_key: "refund:#{refund.id}") # full reversal
|
|
199
|
+
PgLedger.reverse!(entry, amount: 30_00) # partial (two-leg entries)
|
|
200
|
+
|
|
201
|
+
PgLedger.post!(reverses: entry) do |e| # partial with explicit legs, e.g. keep the fee
|
|
202
|
+
e.credit cash, 99_00
|
|
203
|
+
e.debit wallet, 99_00
|
|
204
|
+
end
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
Postgres enforces the reversal invariant: per account, the sum of all reversals of an entry can never exceed the original — a double refund is impossible, including under concurrency.
|
|
208
|
+
|
|
209
|
+
## Controls
|
|
210
|
+
|
|
211
|
+
```ruby
|
|
212
|
+
PgLedger.freeze_account!(wallet) # any posting touching it raises FrozenAccount
|
|
213
|
+
PgLedger.unfreeze_account!(wallet)
|
|
214
|
+
PgLedger.close_period!(before: Date.new(2026, 8, 1))
|
|
215
|
+
# postings with posted_at before the closed date raise PeriodClosed
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
Every posting emits an `ActiveSupport::Notifications` event (`post.pg_ledger`) for APM integration.
|
|
219
|
+
|
|
220
|
+
## Errors
|
|
221
|
+
|
|
222
|
+
All errors inherit from `PgLedger::Error`:
|
|
223
|
+
|
|
224
|
+
- `PgLedger::Unbalanced`
|
|
225
|
+
- `PgLedger::InsufficientBalance`
|
|
226
|
+
- `PgLedger::IdempotencyConflict`
|
|
227
|
+
- `PgLedger::ImmutableRecord`
|
|
228
|
+
- `PgLedger::UnknownAccount`
|
|
229
|
+
|
|
230
|
+
## History
|
|
231
|
+
|
|
232
|
+
View the [changelog](CHANGELOG.md).
|
|
233
|
+
|
|
234
|
+
## Contributing
|
|
235
|
+
|
|
236
|
+
Everyone is encouraged to help improve this project:
|
|
237
|
+
|
|
238
|
+
- [Report bugs](https://github.com/hrakovich/pg_ledger/issues)
|
|
239
|
+
- Fix bugs and [submit pull requests](https://github.com/hrakovich/pg_ledger/pulls)
|
|
240
|
+
- Suggest or add new features
|
|
241
|
+
|
|
242
|
+
Read [STYLE.md](STYLE.md) first — the invariant rules are strict on purpose.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators/active_record"
|
|
4
|
+
|
|
5
|
+
module PgLedger
|
|
6
|
+
module Generators
|
|
7
|
+
class InstallGenerator < Rails::Generators::Base
|
|
8
|
+
include ActiveRecord::Generators::Migration
|
|
9
|
+
source_root File.expand_path("templates", __dir__)
|
|
10
|
+
|
|
11
|
+
def copy_migration
|
|
12
|
+
migration_template "create_pg_ledger_tables.rb", "db/migrate/create_pg_ledger_tables.rb"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def migration_version
|
|
16
|
+
"[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|