PayLedger

A double-entry payments API that stays correct when transfers race, requests are retried, and callers try wallets that aren't theirs.

Role
Solo: design, build, deploy
Stack
Python · FastAPI · PostgreSQL · Alembic · Docker
Status
Live · API docs · Repo
Tests
67 pytest on real PostgreSQL
How it was built
AI-assisted development (Claude Code). I set the design and direction, reviewed each change, and verified it with the tests below.
Sequence of a PayLedger transferThe client sends POST /transfers with an Idempotency-Key. Inside one database transaction the API claims the key, checks the caller owns the source wallet, locks both wallet rows in ascending id order, checks the balance, inserts one transaction and two ledger entries that sum to zero, updates both balances and saves the response on the key, then commits once and answers 201.clientapi · transfer servicepostgresqlPOST /transfers Idempotency-Keyclaim key INSERT … ON CONFLICT DO NOTHINGsource wallet owned by caller? (404 if not)SELECT … FOR UPDATE both wallets, ascending idbalance ≥ amountINSERT transaction + entries −100 / +100 Σ = 0UPDATE balances · save response on the keyCOMMIT (key and transfer together)201 CreatedSequence of a PayLedger transferThe client sends POST /transfers with an Idempotency-Key. Inside one database transaction the API claims the key, checks the caller owns the source wallet, locks both wallet rows in ascending id order, checks the balance, inserts one transaction and two ledger entries that sum to zero, updates both balances and saves the response on the key, then commits once and answers 201.POST /transfersIdempotency-Keyclaim keyINSERT … ON CONFLICT DO NOTHINGsource wallet owned by caller?(404 if not)SELECT … FOR UPDATEboth wallets, ascending idbalance ≥ amountINSERT transaction + entries−100 / +100 Σ = 0UPDATE balancessave response on the keyCOMMIT(key and transfer together)201 Created
Every transfer is one database transaction, key included. Bright lines are where correctness is decided.

The problem

Moving money between two wallets looks like one UPDATE. It isn't. Two transfers at the same moment can both spend the same balance. A client that times out and retries can pay twice. And an API that trusts the wallet id in a request lets anyone spend anyone else's money. PayLedger is a small payments API built to get those three things right. Users hold wallets in several currencies and move money between them, and every movement is written as balanced double-entry ledger lines, the way a bank books it.

Constraints

  • Money is an integer. Amounts are stored in minor units (pence, cents); floats never touch a balance.
  • Every transaction balances. Its ledger entries sum to zero per currency, checked before anything is written.
  • No overdraft, even under concurrent load.
  • Retries are safe. The same idempotency key and body return the same result. The same key with a different body is a conflict, never a second transfer.
  • Only the owner moves money out of a wallet, and the API doesn't confirm that other people's wallets exist.

Architecture

A FastAPI app with thin routers over a service layer, on PostgreSQL, with the schema managed by Alembic migrations. Seven tables: users, accounts, wallets (each holding a cached balance), transactions, ledger entries, idempotency keys and events. A transfer is one database transaction, from claiming the idempotency key to writing the stored response. The diagram above is the whole path. There is never a moment where money has left one wallet and not arrived in the other, or where the key is saved without the transfer it describes.

The live demo runs as a Vercel Function in London against Neon's serverless PostgreSQL. Each invocation opens its own connection through Neon's pooler, because a connection pool inside a short-lived function would hand out connections the database had already dropped.

Hard problems

Two transfers, one balance

The failure here is a lost update: two requests read a balance of 100, both decide 100 is available, and both spend it. PostgreSQL's default isolation doesn't prevent that on its own. Both wallet rows are read with SELECT … FOR UPDATE, and the balance is checked only after the locks are held. Locking two rows invites a deadlock when two opposite transfers each grab one first, so the wallets are always locked in ascending id order.

# Lock wallet rows in a deterministic order to avoid deadlocks.
first, second = sorted([from_wallet_id, to_wallet_id])
locked = {first: _lock_wallet(db, first), second: _lock_wallet(db, second)}
src, dst = locked[from_wallet_id], locked[to_wallet_id]

if src.currency != currency or dst.currency != currency:
    raise CurrencyMismatch()
if src.balance_minor < amount_minor:
    raise InsufficientFunds()

A test fires 20 threads at a wallet holding exactly enough for ten transfers: ten succeed, the source never goes below zero, and the total is conserved.

A lock that read a stale balance

Adding ownership checks and demo deposits exposed a subtler bug. An ownership check loads the wallet, and the lock then runs SELECT … FOR UPDATE on the same row. SQLAlchemy already held that wallet in its session, so it kept the old attribute values instead of the ones read under the lock, and the transfer was checked against a balance someone else had already spent. In a reproduction, a wallet that should have ended at 600 ended at 900: money created from nothing. The lock now forces a fresh read.

A test loads the wallet before locking it, on purpose, and proves the balance used is the current one.

A retry that pays twice

The first version looked the key up, ran the transfer and committed, then stored the key in a second commit. One retry at a time was fine. Two at once both missed the lookup, both transferred, and the second then crashed with a 500 on the key's primary key. Now the key is claimed first, inside the transfer's own transaction, with INSERT … ON CONFLICT DO NOTHING. A duplicate waits on the unique index. If the first request commits, the duplicate returns its stored response; if the first rolls back, the duplicate does the work itself. Keys are scoped per user.

Ten parallel retries with one key used to make ten transfers and nine errors; now they make exactly one transfer.

A wallet that isn't yours

An audit I ran with AI review while preparing this write-up found that a transfer never checked who owned the source wallet: any signed-in user could move money out of anyone's. Every wallet, statement and transaction route now checks ownership and answers with the same 404 body as a missing id. Transfers can still go to anyone's wallet, since that's what a payment is. That means a transfer response can reveal whether a destination exists and whether its currency matches, and response times aren't constant. The README says so plainly.

The ownership tests act as a second user against transfers, wallets, statements and transactions, and expect the same 404 as a missing id.

How it's tested

67 tests. The suite runs against a real PostgreSQL, with the schema recreated before each test, because the bugs that matter here don't exist in a mock or in SQLite. Unit tests cover the money and ledger rules, service tests cover transfers and idempotency, and API tests go through HTTP. The concurrency tests use real threads with separate database sessions:

  • 20 transfers racing for one balance;
  • 10 parallel retries sharing one key;
  • demo deposits racing transfers to the treasury, which caught a deadlock where one path locked the treasury out of order;
  • 20 first-ever deposits racing to create the treasury, which caught a 500 on a unique constraint.

The last two were found by an independent review of the fixes above.

CI runs the linter and the full suite against PostgreSQL 16 on every push and pull request. Before this round of work the suite had 31 tests.

What I'd change

  • Enforce the balance rule in the database too. It lives only in Python today; a deferred constraint trigger would make an unbalanced transaction impossible rather than unlikely.
  • Reconcile each wallet's cached balance against its ledger entries on a schedule, and give the treasury's opening balance its own ledger entry.
  • Opening a second wallet in the same currency returns a 500 rather than a clear error, and a wallet can transfer to itself.
  • Events are recorded but never delivered. My Webhook-Dispatcher project is the missing half.