Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

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::Unbalanced
  • PgLedger::InsufficientBalance
  • PgLedger::IdempotencyConflict
  • PgLedger::ImmutableRecord
  • PgLedger::UnknownAccount

History

View the changelog.

Contributing

Everyone is encouraged to help improve this project:

Read STYLE.md first — the invariant rules are strict on purpose.

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages