A double-entry payment ledger with the correctness properties you want behind a real fintech: ACID-safe balance math under concurrency, idempotent writes, an immutable audit trail, multi-currency FX, and a built-in web dashboard so you can see it working.
FastAPI · PostgreSQL 16 · Redis 7 · SQLAlchemy 2.0 async · Alembic · Docker Compose
git clone https://github.com/gottostartsomewhere/ledger-api.git
cd ledger-api && cp .env.example .env
docker compose up --buildThen open:
| URL | |
|---|---|
| http://localhost:8000/ | Dashboard: register, create accounts, deposit / transfer / withdraw |
| http://localhost:8000/docs | Swagger UI |
| http://localhost:8000/metrics | Prometheus metrics |
| http://localhost:8000/health | Liveness |
For production, replace JWT_SECRET in .env with:
python -c "import secrets; print(secrets.token_urlsafe(64))"
Dashboard: per-currency accounts with tabular-aligned balances
Ledger history: every money movement is a balanced debit / credit pair
Write path: every action carries an auto-generated Idempotency-Key, and overdrafts are rejected atomically (422, no ledger rows written)
Interactive OpenAPI docs at /docs, with full request/response schemas for every endpoint
This is the ledger layer of a payment product: the part that remembers whose money is whose. It is not a payment gateway. It doesn't talk to Visa, ACH, SEPA, or UPI. If you "deposit $100" here, nothing moves in the real world; a ledger row is written.
Every real fintech has two layers:
┌────────────────────────────────────────────────┐
│ LEDGER - "who owns what, right now" │ <- this repo
│ user balances · internal transfers · splits │
│ escrow · fees · refunds · reversals · audit │
├────────────────────────────────────────────────┤
│ RAILS - "move real money between banks" │ <- Stripe, Plaid, ACH, Visa
│ card networks · bank wires · on-chain │
└────────────────────────────────────────────────┘
Venmo balances, Uber driver earnings, Airbnb escrow, airline miles, and Wise book transfers are all ledgers. The rails only fire when money enters or leaves the platform, and this repo is the top half. Wire the write endpoints to Stripe webhooks and you have a real product; swap "dollars" for "loyalty points" and the math doesn't change.
Most payment tutorials model a transfer as two independent UPDATEs:
UPDATE accounts SET balance = balance - 100 WHERE id = :from;
-- crash here -> money vanishes
UPDATE accounts SET balance = balance + 100 WHERE id = :to;That has three distinct ways to lose customer money:
- A crash between the statements leaves a half-applied transfer.
- Concurrent transfers race on the same row and one update is lost.
- A client retries a timed-out request and gets double-charged.
This API handles all three:
-
Double-entry bookkeeping. Every transfer writes two immutable rows into
ledger_entries(oneDEBIT, oneCREDIT) plus the balance updates, all inside one DB transaction. Either all of it commits or none of it does. -
Deterministic row locking. Both sides of a transfer are locked with
SELECT … FOR UPDATEin sorted UUID order, so opposing transfers between the same pair of accounts can't deadlock. -
Idempotency keys. Every write endpoint requires an
Idempotency-Keyheader. The(user, key)pair is stored with the request hash and the cached response body, and retries return the cached response instead of re-executing. The insert race is handled explicitly: the losing request catchesIntegrityError, rolls back, re-reads the winner's response, and serves it. -
A per-transfer, per-currency balance invariant that is provable from raw SQL at any time:
SELECT transfer_id, SUM(CASE WHEN entry_type='DEBIT' THEN amount ELSE 0 END) AS dr, SUM(CASE WHEN entry_type='CREDIT' THEN amount ELSE 0 END) AS cr FROM ledger_entries GROUP BY transfer_id HAVING SUM(CASE WHEN entry_type='DEBIT' THEN amount ELSE 0 END) <> SUM(CASE WHEN entry_type='CREDIT' THEN amount ELSE 0 END); -- must always return zero rows
- Accounts: multi-currency (any ISO-4217), per-user, with
ACTIVE / FROZEN / CLOSEDstatus. - Money movement: deposit, withdraw, same-currency transfer, and cross-currency transfer (4-leg, routed through per-currency system cash accounts with admin-set FX rates).
- Immutable audit trail:
ledger_entriesis append-only; reversals post compensating entries and never mutate history. - JWT auth: access tokens (HS256, short-lived) plus rotating refresh tokens stored in Redis and individually revocable.
- Redis rate limiting: fixed-window per-minute, per-user when authenticated else per-IP, with per-route overrides for auth endpoints.
- Transactional outbox and webhook dispatcher: writes that produce side effects (transfer posted, account frozen, and so on) append a row to
outbox_eventsin the same transaction; a background sweeper usesFOR UPDATE SKIP LOCKEDfor multi-replica safety and retries with exponential backoff. Webhooks are HMAC-SHA256 signed. - Prometheus
/metrics: request counter and latency histogram keyed by route template, plus ledger counters and outbox gauges. - Structured JSON logs:
request_idanduser_idpropagated viacontextvars. - Alembic migrations: async-compatible, run automatically on container start.
- Admin endpoints: freeze / close accounts, upsert FX rates, reverse transfers via compensation.
- Integration tests against real Postgres and Redis via
testcontainers, including a concurrency test that hammers the transfer endpoint to verify the row-lock ordering. - Built-in dashboard: a single static HTML file, vanilla JS, no build step, served at
/.
A single-page app served at / directly by FastAPI. No npm, no build step, no separate frontend to run. Roughly 550 lines of vanilla JS and hand-written CSS (Inter / JetBrains Mono via Google Fonts).
It lets you:
- Register, sign in, sign out (JWT stored in
localStorage). - Create accounts in any currency.
- Deposit / withdraw / transfer through modal forms. Every write auto-generates a fresh
Idempotency-Key, so clicking twice is safe. - Browse ledger entries for any account: signed amounts,
DEBIT/CREDITdirection, kind, transfer ID. - Poll balances every 4 seconds.
Source lives in app/static/index.html. Tweaks are plain HTML/CSS/JS edits, no compilation.
Client ─HTTP─▶ FastAPI (uvicorn, 3 workers)
│
├── RequestContext MW → contextvars: request_id, user_id
├── Metrics MW → Prometheus request + latency
├── RateLimit MW → Redis INCR per minute bucket
└── Router
├── /auth → register, login, refresh, logout
├── /accounts → CRUD
├── /transactions → deposit, withdraw, transfer, history
│ └─ IdempotencyService (hash + cache)
│ └─ LedgerService (double-entry + FOR UPDATE)
├── /webhooks → register/list/rotate endpoints
└── /admin → FX rates, account status, reversals
PostgreSQL (async SQLAlchemy + asyncpg)
users · accounts (USER | SYSTEM) · transfers · ledger_entries (immutable)
idempotency_keys · fx_rates · webhook_endpoints · outbox_events
Redis
rate-limit buckets · refresh token jti index
Background
OutboxSweeper → FOR UPDATE SKIP LOCKED, exponential backoff, HMAC-signed POST
ledger-api/
├── app/
│ ├── main.py FastAPI app, middleware, exception handlers, static mount
│ ├── config.py pydantic-settings
│ ├── database.py async engine + session
│ ├── dependencies.py JWT auth dep, Idempotency-Key dep
│ ├── dependencies_admin.py admin-email gate
│ ├── core/
│ │ ├── logging.py JSON structlog w/ contextvars
│ │ ├── metrics.py Prometheus collectors
│ │ ├── redis.py async Redis client
│ │ └── security.py bcrypt + PyJWT
│ ├── middleware/
│ │ ├── metrics.py per-route-template labels
│ │ ├── rate_limit.py Redis fixed-window
│ │ └── request_context.py request_id, user_id contextvars
│ ├── models/ SQLAlchemy 2.0 declarative
│ │ ├── user.py
│ │ ├── account.py USER / SYSTEM, ACTIVE / FROZEN / CLOSED
│ │ ├── transaction.py Transfer, LedgerEntry, IdempotencyKey
│ │ ├── fx.py FXRate
│ │ ├── outbox.py OutboxEvent
│ │ └── webhook.py WebhookEndpoint
│ ├── schemas/ Pydantic v2
│ ├── services/
│ │ ├── auth.py
│ │ ├── account.py
│ │ ├── ledger.py deposit / withdraw / transfer / reverse / history
│ │ ├── fx.py rate lookup for cross-currency
│ │ ├── idempotency.py hash, lookup, store
│ │ ├── outbox.py OutboxSweeper (SKIP LOCKED, backoff)
│ │ ├── webhooks.py HMAC-SHA256 signer + dispatcher
│ │ ├── tokens.py refresh token rotation
│ │ └── exceptions.py LedgerError hierarchy -> HTTP codes
│ ├── routers/ auth · accounts · transactions · webhooks · admin
│ └── static/
│ └── index.html built-in web dashboard
├── alembic/
│ └── versions/
│ ├── 0001_initial.py
│ ├── 0002_outbox.py
│ └── 0003_fx_freeze_webhooks.py
├── tests/ pytest + testcontainers
│ ├── conftest.py
│ ├── test_auth.py
│ ├── test_accounts.py
│ ├── test_ledger.py
│ ├── test_idempotency.py
│ ├── test_concurrency.py
│ └── test_fx_and_admin.py
├── docker-compose.yml
├── Dockerfile
├── requirements.txt
├── requirements-dev.txt
├── pytest.ini
├── Makefile
├── .env.example
└── README.md
users(id, email, password_hash, created_at)
accounts(id, user_id?, account_type[USER|SYSTEM], currency, balance,
status[ACTIVE|FROZEN|CLOSED], name, created_at)
transfers(id, kind[DEPOSIT|WITHDRAWAL|TRANSFER|REVERSAL], status,
initiator_user_id, amount, currency, reverses_transfer_id?,
description, created_at)
ledger_entries(id, transfer_id→transfers, account_id→accounts,
entry_type[DEBIT|CREDIT], amount, currency, created_at)
-- append-only; CHECK (amount > 0)
idempotency_keys(id, user_id, key, request_hash, response_status,
response_body jsonb, transfer_id?, created_at)
UNIQUE(user_id, key)
fx_rates(id, from_currency, to_currency, rate, created_at)
UNIQUE(from_currency, to_currency)
webhook_endpoints(id, user_id, url, secret, events[], active, created_at)
outbox_events(id, event_type, payload jsonb, status[PENDING|SENT|FAILED],
attempts, next_attempt_at, created_at, delivered_at?)
BASE=http://localhost:8000
# 1. Register
curl -X POST $BASE/auth/register \
-H 'Content-Type: application/json' \
-d '{"email":"alice@example.com","password":"correct-horse-battery-staple"}'
# 2. Login -> JWT
TOKEN=$(curl -s -X POST $BASE/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"alice@example.com","password":"correct-horse-battery-staple"}' \
| python -c 'import sys,json;print(json.load(sys.stdin)["access_token"])')
# 3. Create a USD account
ACCT=$(curl -s -X POST $BASE/accounts \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"currency":"USD","name":"Main checking"}' \
| python -c 'import sys,json;print(json.load(sys.stdin)["id"])')
# 4. Deposit $200 (idempotent: retry the same header and nothing happens)
curl -X POST $BASE/transactions/deposit \
-H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: $(uuidgen)" \
-H 'Content-Type: application/json' \
-d "{\"account_id\":\"$ACCT\",\"amount\":\"200.00\"}"
# 5. Transfer to another user (Bob's account UUID)
curl -X POST $BASE/transactions/transfer \
-H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: $(uuidgen)" \
-H 'Content-Type: application/json' \
-d "{\"from_account_id\":\"$ACCT\",\"to_account_id\":\"$BOB_ACCT\",\"amount\":\"42.00\"}"
# 6. Paginated history for an account
curl "$BASE/transactions/history/$ACCT?limit=25&offset=0" \
-H "Authorization: Bearer $TOKEN"pip install -r requirements-dev.txt
pytestIntegration tests spin up real Postgres and Redis in Docker via testcontainers, with no SQLite or in-memory substitutes. test_concurrency.py fires many concurrent transfers at the same pair of accounts and asserts the per-currency invariant still holds.
All errors have a consistent body:
{ "error": "<machine_code>", "detail": "<human_string>" }| Status | error |
When |
|---|---|---|
| 401 | invalid_credentials |
Wrong email/password on /auth/login |
| 403 | account_forbidden |
Touching an account that isn't yours |
| 403 | admin_only |
Non-admin hitting /admin/* |
| 404 | account_not_found |
Unknown account_id |
| 409 | email_already_registered |
Duplicate /auth/register |
| 409 | idempotency_key_conflict |
Same Idempotency-Key replayed with a different payload |
| 422 | insufficient_funds |
Withdrawal or transfer exceeds balance |
| 422 | currency_mismatch |
Same-currency transfer between differing currencies |
| 422 | same_account_transfer |
from_account_id == to_account_id |
| 422 | fx_rate_missing |
Cross-currency transfer with no FX rate configured |
| 423 | account_frozen |
Debiting a FROZEN account |
| 422 | validation_error |
Pydantic validation failure |
| 429 | rate_limited |
RATE_LIMIT_PER_MINUTE exceeded (includes Retry-After) |
See .env.example for the full list. The keys that matter:
| Variable | Default | Purpose |
|---|---|---|
DATABASE_URL |
(none) | postgresql+asyncpg://… |
REDIS_URL |
(none) | redis://… |
JWT_SECRET |
(none) | HMAC secret for access + refresh tokens |
JWT_ACCESS_TTL_MINUTES |
60 |
Access token lifetime |
JWT_REFRESH_TTL_DAYS |
30 |
Refresh token lifetime |
RATE_LIMIT_PER_MINUTE |
60 |
General per-user / per-IP cap |
RATE_LIMIT_AUTH_PER_MINUTE |
10 |
Tighter cap on /auth/* |
ADMIN_EMAILS |
"" |
Comma-separated emails granted /admin/* |
CORS_ORIGINS |
* |
Comma-separated allowlist |
WEBHOOK_MAX_ATTEMPTS |
8 |
Outbox retries before marking FAILED |
WEBHOOK_TIMEOUT_SECONDS |
5 |
Per-delivery HTTP timeout |
.env is for local dev only and is gitignored. In production:
- Generate a real
JWT_SECRET:python -c "import secrets; print(secrets.token_urlsafe(64))". - Pull
DATABASE_URL,REDIS_URL, andJWT_SECRETfrom a secrets manager (AWS Secrets Manager / SSM, GCP Secret Manager, Vault) rather than.env, and inject them at container boot instead of baking them into the image. - Rotate
JWT_SECRETby deploying alongside the old pod. Access tokens issued before the flip will 401, and/auth/refreshgets clients a clean path back in. - Refresh tokens live in Redis and are individually revocable (
/auth/logout) or en masse (revoke_all_for_user).
docker compose exec postgres psql -U ledger -d ledger-- stored balances match the sum of ledger entries
SELECT a.id, a.balance,
(SELECT COALESCE(SUM(CASE WHEN entry_type='CREDIT' THEN amount ELSE -amount END), 0)
FROM ledger_entries WHERE account_id = a.id) AS computed
FROM accounts a;
-- every transfer is internally balanced
SELECT transfer_id,
SUM(CASE WHEN entry_type='DEBIT' THEN amount END) AS dr,
SUM(CASE WHEN entry_type='CREDIT' THEN amount END) AS cr
FROM ledger_entries GROUP BY transfer_id
HAVING SUM(CASE WHEN entry_type='DEBIT' THEN amount END)
<> SUM(CASE WHEN entry_type='CREDIT' THEN amount END);
-- expected: 0 rowsMIT. See LICENSE.