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 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
@@ -0,0 +1,3 @@
1
+ # 0.1.0 (unreleased)
2
+
3
+ - First release
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