pg_ledger
Double-entry ledger for Rails, backed by Postgres invariants.
- Correct under concurrency — row-level locks with strict ordering, no lost updates, no negative balances
- Idempotent by design — retry-safe money movement with idempotency keys
- Append-only — entries are immutable at the database level; corrections are reversals
- Balanced by construction — a journal row is a debit→credit pair in one currency; an unbalanced entry is unrepresentable, no trigger needed
Amounts are integers in minor units. Floats are rejected.
Installation
Add to your Gemfile:
gem "pg_ledger"
Then run:
bundle install
rails generate pg_ledger:install
rails db:migrate
Postgres 13+ required.
Quick start
Create accounts:
cash = PgLedger.create_account!(code: "cash:main", currency: "USD", normal_balance: :debit, min_balance: nil)
wallet = PgLedger.create_account!(code: "wallet", currency: "USD", normal_balance: :credit, owner: user)
Record a deposit (both accounts grow — debit the asset, credit the liability):
PgLedger.post!(idempotency_key: "deposit:#{payment.id}") do |entry|
entry.debit cash, 10_00
entry.credit wallet, 10_00
end
Move money between accounts of the same polarity — from decreases, to increases:
PgLedger.transfer!(
from: wallet,
to: other_wallet,
amount: 10_00,
idempotency_key: "p2p:#{payment.id}"
)
Mixed-polarity movements need explicit post! — transfer! raises PgLedger::Unbalanced instead of guessing directions.
Check balances:
wallet.balance # => 1000
wallet.balance(at: 1.day.ago)
Multi-leg entries
An entry can touch any number of accounts. It must balance per currency:
PgLedger.post!(idempotency_key: "payout:#{payout.id}", metadata: { order_id: order.id }) do |entry|
entry.debit wallet, 100_00
entry.credit cash, 99_00
entry.credit "fees:revenue", 1_00
end
Accounts are referenced by record or by code.
Under the hood every entry is stored as paired legs: each journal row moves one
amount from a debit account to a credit account in one currency, so the books sum to
zero by construction. Builder legs are netted per account and zipped into pairs
deterministically — the payout above stores two rows (wallet→cash 99_00,
wallet→fees:revenue 1_00). Read them via entry.lines (debit_account /
credit_account / currency / amount), or per account via
account.debit_lines / account.credit_lines.
Idempotency
Every write accepts an idempotency_key:
- same key, same payload → returns the original entry, posts nothing
- same key, different payload → raises
PgLedger::IdempotencyConflict
The guarantee is a unique index, not application logic. Safe under retries, job re-runs, and double-clicks.
Concurrency
Balance updates lock balance rows in account order (SELECT ... FOR UPDATE), so concurrent postings serialize per account and never deadlock against each other.
Accounts with min_balance: 0 (the default for owned accounts) cannot go negative — a concurrent overdraft attempt raises PgLedger::InsufficientBalance instead of losing money.
Multiple ledgers
Isolated ledgers in one database — brands, legal entities, platform tenants:
billing = PgLedger.ledger("billing")
billing.create_account!(code: "cash", currency: "USD", normal_balance: :debit)
billing.transfer!(from: "cash", to: "fees", amount: 100, idempotency_key: "b:1")
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.
Multiple currencies
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).
Cross-currency transfers route through per-currency trading accounts automatically:
PgLedger.transfer!(
from: usd_wallet, to: eur_wallet,
amount: 100_00,
rate: "0.92", # String or Rational — Floats are rejected
idempotency_key: "fx:#{order.id}"
)
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).
PgLedger.balances(owner: user) # => { "USD" => 90000, "EUR" => 9200 }
Reporting
Chart-ready primitives, all computed from the immutable lines — exact for sharded accounts too:
wallet.balance # current, O(shards)
wallet.balance(at: 1.month.ago) # point-in-time
wallet.balance_series(from: 30.days.ago) # [[time, balance]] per day
wallet.balance_series(from: 1.day.ago, interval: "hour")
wallet.turnover(from: 30.days.ago) # { debits:, credits: }
wallet.turnover(from: 30.days.ago, interval: "day") # per-day inflow/outflow
Intervals: hour, day, week, month, or any fixed step like "30 minutes".
Empty buckets carry the running balance forward, so lines draw without gaps.
Query accounts BY balance — the aggregate is a SQL column you can filter and sort on:
PgLedger::Account.with_balance.where("current_balance > ?", 100_00)
PgLedger::Account.with_balance.order(Arel.sql("current_balance DESC")).limit(10)
Batching
For high-throughput ingestion, post many entries in one database roundtrip and one fsync:
PgLedger.batch do |batch|
batch.transfer!(from: a, to: b, amount: 100, idempotency_key: "t:1")
batch.post!(idempotency_key: "t:2") do |entry|
entry.debit cash, 50
entry.credit b, 50
end
end
A batch is atomic: if any posting fails, the whole batch rolls back. Idempotency is
per posting — replayed keys return their original entries, new ones post. min_balance
is enforced on the batch's final state, so a spend funded by a later posting in the
same batch is legal.
Batches of 500 sustain 60k entries/s on a laptop (10 cores); a single post! runs ~1.3ms.
Hot accounts
A busy account serializes on its balance row. Shard it:
PgLedger.create_account!(code: "cash:main", currency: "USD", normal_balance: :debit,
min_balance: nil, balance_shards: 16)
Each posting updates one random shard; the balance is the sum. Sharding requires min_balance: nil — overdraft checks need a single row.
Corrections
Entries and lines are immutable — UPDATE/DELETE raise in Postgres. To undo:
PgLedger.reverse!(entry, idempotency_key: "refund:#{refund.id}") # full reversal
PgLedger.reverse!(entry, amount: 30_00) # partial (two-leg entries)
PgLedger.post!(reverses: entry) do |e| # partial with explicit legs, e.g. keep the fee
e.credit cash, 99_00
e.debit wallet, 99_00
end
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.
Controls
PgLedger.freeze_account!(wallet) # any posting touching it raises FrozenAccount
PgLedger.unfreeze_account!(wallet)
PgLedger.close_period!(before: Date.new(2026, 8, 1))
# postings with posted_at before the closed date raise PeriodClosed
Every posting emits an ActiveSupport::Notifications event (post.pg_ledger) for APM integration.
Errors
All errors inherit from PgLedger::Error:
PgLedger::UnbalancedPgLedger::InsufficientBalancePgLedger::IdempotencyConflictPgLedger::ImmutableRecordPgLedger::UnknownAccount
History
View the changelog.
Contributing
Everyone is encouraged to help improve this project:
- Report bugs
- Fix bugs and submit pull requests
- Suggest or add new features
Read STYLE.md first — the invariant rules are strict on purpose.