Double-entry payments ledger with a read-only MCP server
payments-ledger-mcp ↗Constraint
A double-entry ledger is only useful if it cannot go out of balance. Application-layer checks bind only the code paths that remember to call them — a migration script, a bulk importer, or a psql session at 2am walks straight past all of them.
Separately: an AI agent investigating a discrepancy needs to read the ledger, and must never be able to write to it.
Decisions
- Five invariants pushed into PostgreSQL, not the service — so the database is what refuses a bad write, whoever issues it.
- Amounts as
BIGINTminor units with a separate direction column. No decimal type, no rounding drift, no currency-scale guessing. - Corrections by reversal only. Never
UPDATEorDELETE— post the mirror transaction, so the mistake and the fix both stay in the audit trail. - Idempotency by request fingerprint. SHA-256 of the canonicalized body: replay the same key with a different payload and you get a
409, not a silent success returning someone else's transaction. - The MCP server holds no database credentials. It calls read-only HTTP endpoints — the same boundary any external consumer faces — and exposes four tools:
get_balance,list_transactions,trace_transaction,find_imbalances. Every response carries the exact request it made, so an answer can be checked. - Tested against real PostgreSQL via Testcontainers, never H2. An invariant contract test attacks each rule through raw SQL, bypassing the service entirely, to prove it's the database doing the refusing.
| Invariant | Enforcement |
|---|---|
| Zero-sum | Debits equal credits per transaction — DEFERRABLE INITIALLY DEFERRED constraint trigger, checked at commit |
| Idempotency | Unique constraint on the key, plus insert-and-catch; the conflict loser re-reads the winner in a fresh transaction |
| Append-only | BEFORE UPDATE OR DELETE trigger on ledger entries — there is no path that mutates history |
| Derived balances | No stored balance column; balances compute from entries on every read via SQL views |
| Atomicity | All legs of a transaction commit together or not at all, verified at commit time |