A double-entry bookkeeping REST API built with Node.js, TypeScript, Express, and PostgreSQL.
Every financial movement is recorded as a pair of entries — one debit, one credit — that must always balance. The service enforces this invariant at both the application and database level.
- Runtime: Node.js + TypeScript
- Framework: Express 5
- Database: PostgreSQL 16
- Metrics: Prometheus (
prom-client) - Logging: Winston (structured JSON)
- Validation: Zod
- Container: Docker + Docker Compose
src/
├── config/ # Environment config
├── controllers/ # Request handlers
├── db/ # Migrations, seed
│ └── migrations/
├── errors/ # AppError, error handler middleware
├── lib/ # DB pool, logger, metrics
├── middleware/ # auth, requestId, requestLogger, metrics, validate
├── models/ # DB query functions
├── routes/ # Express routers
└── services/ # Business logic (ledger, balance, audit)
Prerequisites: Docker and Docker Compose
# Clone and start
git clone <repo>
cd ledger_service_ts
docker compose upThe service starts on http://localhost:3001.
On boot it:
- Verifies the database connection
- Runs all migrations (idempotent)
- Seeds the system account (
00000000-0000-0000-0000-000000000000)
All endpoints except /health and /metrics require the X-Actor-ID header.
| Method | Path | Description |
|---|---|---|
GET |
/health |
Health check |
GET |
/metrics |
Prometheus metrics |
POST |
/accounts |
Create an account |
GET |
/accounts/:id |
Get an account |
GET |
/accounts/:id/balance |
Get account balance |
GET |
/accounts/:id/entries |
List entries (cursor-paginated) |
POST |
/transactions |
Post a transaction |
GET |
/transactions/:id |
Get a transaction |
GET |
/audit |
Query audit log |
All transaction writes execute under SERIALIZABLE isolation.
REPEATABLE READ prevents dirty reads and non-repeatable reads but does not protect against write skew. Write skew is the exact failure mode that matters here: two concurrent requests both read a balance as sufficient, both pass the balance check, and both proceed to debit — leaving the account overdrawn. SERIALIZABLE detects this cycle and aborts one of the transactions with a serialization failure, which the service surfaces as a retryable error. The performance cost over REPEATABLE READ is minimal for a write-heavy ledger workload where transactions are short and operate on a small number of rows.
Enforced at two layers:
- Application: debits and credits are summed and compared before any DB write. Unbalanced transactions are rejected immediately with a
422before a database transaction is even opened. - Database: a
DEFERRABLE INITIALLY DEFERREDconstraint trigger on theentriestable fires atCOMMITtime, rejecting any transaction where debits ≠ credits. The deferred firing is intentional — it allows all entries for a transaction to be inserted in a single batch before the check runs, rather than requiring them to be pre-balanced at each individual insert.
POST /transactions requires an Idempotency-Key header. Duplicate requests with the same key return the original result (200 OK) without creating a new transaction. Uniqueness is enforced by a UNIQUE constraint on transactions.idempotency_key — application-level deduplication alone is insufficient under concurrent requests.
Balance is always derived by summing committed entries (SELECT SUM(amount) ... WHERE direction = 'CREDIT' minus debits). No cached balance field exists. Because the balance check inside a transaction runs under SERIALIZABLE isolation, it sees a consistent snapshot and cannot observe a concurrent uncommitted debit. Balance reads outside a transaction (GET /accounts/:id/balance) reflect only committed entries and are therefore always correct, though they may lag by milliseconds under very high concurrency — acceptable for a query endpoint.
Two cases apply:
Successful transactions — the audit record is written inside the same database transaction as the ledger entries. If the audit insert fails, the whole transaction rolls back. The ledger and audit log stay consistent by construction — no separate decision is needed.
Rejected transactions (e.g. insufficient funds) — the ledger transaction rolls back by definition, so the audit record must be written in a separate connection after the rollback. If that write fails, the rejection response is still returned to the caller. No money moved, so this is a missing record problem, not a correctness problem. Blocking the caller on a secondary audit write would make the audit system a liveness dependency over an operation that already failed.
In a production system this would be supplemented by an async audit queue with a dead-letter path, so no audit record is silently dropped.
A BEFORE UPDATE/DELETE trigger on audit_log raises an exception for any modification attempt. This is enforced at the database level independently of the application.
The system account (00000000-0000-0000-0000-000000000000) is the external source of funds. To credit a user account, debit the system account. Its balance is allowed to go negative — it represents an external funding source, not a user wallet.
All amounts are stored as integers in the smallest currency unit (e.g. cents for USD). 10000 = $100.00. Floating-point types are never used for money.
| Variable | Description |
|---|---|
DATABASE_URL |
PostgreSQL connection string |
PORT |
Server port (default 3000) |
Configured via .env file.
accounts — id, currency, created_at
transactions — id, idempotency_key, status, currency, created_at
entries — id, transaction_id, account_id, direction, amount, created_at
audit_log — id, actor, action, entity_type, entity_id, payload, created_at
PostgreSQL data is stored in a named Docker volume (pgdata). Data survives container restarts and docker compose down. To reset all data:
docker compose down -vThe schema already stores currency on both transactions and accounts. Cross-currency support would require:
-
Exchange rate source — a
fx_ratestable (or external oracle) storing rates with a timestamp. Rates must be snapshotted into the transaction payload at execution time so the audit record is self-contained and not affected by later rate changes. -
Conversion entries — introduce a system-owned FX account per currency pair. A USD→EUR transfer becomes four entries: debit user USD account, credit USD-FX account, debit EUR-FX account, credit recipient EUR account. The double-entry invariant still holds within each currency leg.
-
Invariant relaxation — the current trigger checks that
∑ debits = ∑ creditsglobally per transaction. For multi-currency transactions this check would need to be scoped per currency, or the FX accounts absorb the spread as a fee entry. -
Precision — different currencies have different decimal places (JPY has 0, KWD has 3). The integer representation already handles this correctly as long as amounts are stored in the currency's smallest unit consistently.