Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

payments-ledger-mcp

A double-entry payments ledger whose correctness guarantees are enforced by the database, and a read-only MCP server that lets an AI agent investigate it without being able to damage or leak it.

Most ledger examples store a mutable balance column and call it done. This one treats the ledger as append-only and derives balances from entries, which is how real payment systems work and why they can be audited. The MCP layer then demonstrates the harder half: giving an agent genuine analytical access without handing it the database.


The five invariants

These are the product. Everything else is scaffolding.

# Invariant Enforced by Where
1 Every transaction's legs sum to zero DEFERRABLE INITIALLY DEFERRED constraint trigger 002-zero-sum-invariant.sql
2 An idempotency key maps to at most one transaction, ever UNIQUE constraint, plus insert-and-catch in the service 001-core-tables.sql, PostingService
3 Entries are append-only; corrections are reversing entries BEFORE UPDATE OR DELETE trigger that always raises 003-append-only-invariant.sql
4 A balance is always derived, never stored There is no balance column to store one in 004-derived-balance-views.sql
5 A transaction commits all legs or none One database transaction, checked at commit TransactionWriter

Note the "Enforced by" column. Not one of these says "application code". That is the whole argument: a rule that lives only in a service method binds the service method. A rule that lives in the schema binds the service, the migration script, the bulk importer, the intern with a psql prompt at 2am, and every future component nobody has written yet.


Architecture

flowchart LR
    subgraph agent["AI agent"]
        A["Claude / any MCP client"]
    end

    subgraph mcp["MCP server  ·  Python"]
        T1["get_balance"]
        T2["list_transactions"]
        T3["trace_transaction"]
        T4["find_imbalances"]
    end

    subgraph svc["Ledger service  ·  Java 21 / Spring Boot 3"]
        W["POST /transactions<br/>POST /accounts"]
        R["GET /balance<br/>GET /transactions<br/>GET /audit/integrity"]
    end

    subgraph db["PostgreSQL"]
        direction TB
        TBL["account · ledger_transaction · ledger_entry"]
        INV["zero-sum trigger · unique idempotency key<br/>append-only triggers · derived views"]
        TBL --- INV
    end

    A -- "stdio, MCP" --> mcp
    mcp -- "HTTPS, GET only" --> R
    W --> TBL
    R --> TBL

    style INV fill:#fff3cd,stroke:#d39e00
    style mcp fill:#e7f1ff,stroke:#2a6fb0
Loading

The dashed boundary worth noticing is the one the MCP server does not cross. It has no database credentials and no write path; it reaches the ledger through the same public REST API any other consumer would use. The most an agent can do with it is read things it was already allowed to read.


Running it

Requires Docker (for Postgres and Testcontainers), JDK 21, and Python 3.10+. The Maven wrapper (./mvnw) is checked in, so a separate Maven install isn't required.

# 1. Database
docker compose up -d

# 2. Ledger service - Liquibase creates the schema, triggers and seed accounts on boot
cd ledger-service
./mvnw spring-boot:run

# 3. MCP server, in a second terminal
cd mcp-server
python3 -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
LEDGER_API_BASE_URL=http://localhost:8080 ledger-mcp

./mvnw spring-boot:run blocks the terminal it runs in — that's the running server, not a hang. Check it came up before moving on:

curl -s http://localhost:8080/actuator/health
# {"status":"UP"}

To wire the MCP server into Claude Desktop instead of running it standalone, see mcp-server/README.md.

To seed a realistic scenario instead of posting by hand, see Worked example below.


Testing

Both services are tested against the real thing, not a stand-in. This matters more here than in most projects — see Tests run against real PostgreSQL, never H2 below for why an in-memory database would test a different system.

Ledger service

cd ledger-service
./mvnw verify

This runs two JUnit 5 classes end to end, each spinning up its own disposable PostgreSQL 16 container via Testcontainers, with Liquibase applying the full changelog on boot:

  • InvariantContractTest attacks each invariant through raw SQL, going around the service entirely — because "the application prevents it" and "the system prevents it" are different claims, and only the second one is worth putting on a README.
  • ConcurrentPostingTest fires 32 simultaneous duplicate requests through a CyclicBarrier and asserts exactly one 201, thirty-one 200s, one row, two legs, and one movement of money — the idempotency race, forced to actually happen instead of being taken on faith.

A clean run looks like:

Tests run: 8, Failures: 0, Errors: 0, Skipped: 0 -- in com.ledgerlab.InvariantContractTest
Tests run: 3, Failures: 0, Errors: 0, Skipped: 0 -- in com.ledgerlab.ConcurrentPostingTest
BUILD SUCCESS

macOS + Docker Desktop: if ./mvnw verify fails immediately with Could not find a valid Docker environment, it's almost always one of two Docker Desktop quirks, not a real problem with the project:

  1. Socket path. Docker Desktop on macOS doesn't put its socket at /var/run/docker.sock unless you've explicitly enabled "Allow the default Docker socket to be used" in Settings → Advanced. Point Testcontainers at the real one instead:
    export DOCKER_HOST="unix://$HOME/.docker/run/docker.sock"
  2. API version. Recent Docker Desktop releases raised their minimum supported Docker API version. The docker-java client version pinned by Spring Boot's Testcontainers BOM can predate that floor and skip version negotiation, so every request comes back 400 Bad Request with an all-blank body. Force a version above the floor:
    ./mvnw verify -Dapi.version=1.41

Both are read-only environment flags — nothing about the project or the test code changes. Run docker version first if you want to confirm your daemon's own MinAPIVersion before picking a number.

MCP server

cd mcp-server
source .venv/bin/activate      # from the Running it setup above
pytest -q
ruff check .

pytest covers the read-only guarantee (no tool the server exposes can reach a write endpoint) and the request/response shape of each tool without needing the ledger service running — HTTP calls are mocked with respx. A clean run:

7 passed in 0.02s
All checks passed!

Manual smoke test

For an end-to-end check that exercises both services together against a real ledger service (not mocks), start everything per Running it, then in a third terminal:

./docs/scenario-off-by-250.sh

This posts four real transactions over HTTP and prints the resulting balance. See Worked example for what it sets up and why.


Workflows and responses

Every response below is real output from this project, captured by running the commands shown against a freshly seeded database — not hand-written examples. Amounts are in minor units (paise); LIABILITY:MERCHANT:M001 starts this section already seeded by docs/scenario-off-by-250.sh.

Create an account

curl -X POST localhost:8080/api/v1/accounts \
  -H 'Content-Type: application/json' \
  -d '{"code": "LIABILITY:MERCHANT:M998", "name": "Merchant M998 payable", "type": "LIABILITY", "currency": "INR"}'
HTTP/1.1 201
Location: /api/v1/accounts/LIABILITY:MERCHANT:M998

{"id":"ead1e36d-36b0-4e08-b6d6-886eb63e76ee","code":"LIABILITY:MERCHANT:M998","name":"Merchant M998 payable","type":"LIABILITY","currency":"INR","createdAt":"2026-09-13T11:39:54.020629Z"}

created_at is insertable = false — the database fills it in from its own DEFAULT now(), not the application — and Account is @Immutable, so AccountService.create can't just re-read the row it just saved inside the same transaction: Hibernate's session would hand back the same cached, pre-insert object rather than the one the database actually wrote. create is deliberately not @Transactional; save() and the follow-up findByCode each get their own transaction and their own Hibernate session, so the second one reads the real row — the same two-phase shape PostingService.post already uses for the same reason.

Create an account whose code lies about its type

curl -X POST localhost:8080/api/v1/accounts \
  -H 'Content-Type: application/json' \
  -d '{"code": "ASSET:BAD:MISMATCH", "name": "Mismatched type test", "type": "LIABILITY", "currency": "INR"}'
HTTP/1.1 422

{"code":"ACCOUNT_CODE_TYPE_MISMATCH","message":"The database refused this account: Batch entry 0 insert into account (code,currency,name,type,id) values (('ASSET:BAD:MISMATCH'),('INR'),('Mismatched type test'),('LIABILITY'),('357cdbcc-1a33-4821-b26f-fe27153b7c46'::uuid)) was aborted: ERROR: new row for relation \"account\" violates check constraint \"account_code_prefix_matches_type\"\n  Detail: Failing row contains (357cdbcc-1a33-4821-b26f-fe27153b7c46, ASSET:BAD:MISMATCH, Mismatched type test, LIABILITY, INR, 2026-09-13 18:13:30.13883+05:30).  Call getNextException to see other errors in the batch.","at":"2026-09-13T12:43:30.154791Z","detail":null}

A malformed code (wrong characters, wrong length) never reaches the database — @Pattern on the request DTO catches that as a plain 400. This one can't be caught the same way: it's a relationship between two fields, code and type together, which a single-field validator can't see. So it surfaces exactly like Post something unbalanced does — the database's own refusal, translated to a 422 rather than left to fall through as a bare 500. See Account codes carry their own type.

The money lifecycle, and why there are two asset accounts

A payment is not one movement, it is three, and the chart of accounts is shaped to record each one separately.

Step What actually happens Posting
Capture The card is charged. The money is real, but the card network still holds it. DEBIT ASSET:RECEIVABLE:NETWORK · CREDIT LIABILITY:MERCHANT:M001 · CREDIT REVENUE:FEES:PROCESSING
Settlement A day later the network pays. The promise becomes cash. DEBIT ASSET:BANK:SETTLEMENT · CREDIT ASSET:RECEIVABLE:NETWORK
Payout We pay the merchant what we owe them. DEBIT LIABILITY:MERCHANT:M001 · CREDIT ASSET:BANK:SETTLEMENT

ASSET:RECEIVABLE:NETWORK exists for the gap between the first two rows. For those hours the money is ours and not in our hands, and a ledger that cannot express that has to either pretend the cash arrived early or pretend the sale had not happened yet. Both lie.

Notice what each step does not touch. Settlement moves value between two asset accounts and never touches the merchant payable or revenue: nothing was earned or owed there, money only changed form. That is the sort of distinction a single mutable balance column cannot make at all.

The obligation and the fee are recognised at capture, not at settlement — the merchant made the sale then, and we did the work then. If the network never settles, ASSET:RECEIVABLE:NETWORK sits there not shrinking, which is exactly the operational alarm you want.

Account codes carry their own type, and the database checks that they do

Codes are TYPE:CATEGORY:IDENTIFIER - LIABILITY:MERCHANT:M001, REVENUE:FEES:PROCESSING. The first segment repeats the type column, which is redundant on purpose: it makes a code readable wherever it appears alone, with no lookup.

INVARIANT_VIOLATION: transaction abc-123 legs net to 5000 minor units, expected 0
  DEBIT   25000  ASSET:BANK:SETTLEMENT
  CREDIT  20000  LIABILITY:MERCHANT:M001

At 2am that readability is worth the duplication - but only if the duplicate cannot drift. Until 006-account-code-conventions.sql nothing stopped an account coded ASSET:... carrying type = 'LIABILITY', and a duplicated fact that can disagree with itself is worse than no duplication at all. So the convention became a constraint:

CONSTRAINT account_code_prefix_matches_type CHECK (code LIKE type || ':%')

The same file also renames ASSET:PSP:RECEIVABLE to ASSET:RECEIVABLE:NETWORK. Every account in this ledger belongs to the PSP, so that segment said nothing, while the party the account is actually about - the card network that owes us the money - went unnamed. It is a new changeset rather than an edit to 005, because 005 has already run and Liquibase checksums exist precisely so that rewriting applied history is noisy rather than silent.

A CHECK constraint refusing a row is a DataIntegrityViolationException by default, which AccountService.create was not translating — it fell through to a bare 500, the one place in the API where the database's refusal was not yet a decent HTTP response. It is now: CHECK_VIOLATION becomes a 422 ACCOUNT_CODE_TYPE_MISMATCH, the same shape PostingService.post already used for the zero-sum trigger. See Create an account whose code lies about its type for the real response.

docs/scenario-off-by-250.sh posts the full capture → settlement → refund sequence and leaves the books at ₹500.00 cash, ₹485.00 owed and ₹15.00 earned — assets equalling liabilities plus equity, which falls out of the zero-sum rule rather than being asserted anywhere. The inline example below is the shortened one-step version, crediting the bank directly, to keep the request readable.

Post a transaction

A capture of ₹250.00 with a ₹5.00 fee, three legs:

curl -X POST localhost:8080/api/v1/transactions \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: capture_ord_88410' \
  -d '{
    "description": "Card capture 250.00, fee 5.00",
    "legs": [
      {"accountCode": "ASSET:BANK:SETTLEMENT",   "direction": "DEBIT",  "amountMinor": 25000},
      {"accountCode": "LIABILITY:MERCHANT:M001", "direction": "CREDIT", "amountMinor": 24500},
      {"accountCode": "REVENUE:FEES:PROCESSING", "direction": "CREDIT", "amountMinor": 500}
    ]
  }'
HTTP/1.1 201
Location: /api/v1/transactions/9d27868d-178c-43c0-9bee-8b84218447c6

{"id":"9d27868d-178c-43c0-9bee-8b84218447c6","idempotencyKey":"capture_ord_88410","description":"Card capture 250.00 for order 88410, fee 5.00","postedAt":"2026-03-01T09:12:00Z","createdAt":"2026-09-13T11:32:25.972996Z","reversesTransactionId":null,"reversedByTransactionId":null,"netMinor":0,"legs":[{"entryId":"2046d921-0ef3-42f2-aa3d-954928637fde","accountCode":"ASSET:BANK:SETTLEMENT","accountName":"Settlement bank account","accountType":"ASSET","direction":"DEBIT","amountMinor":25000,"signedAmountMinor":25000,"currency":"INR","amountDisplay":"INR 250.00"},{"entryId":"51715139-832c-4d47-a97e-0a377d8c81d4","accountCode":"REVENUE:FEES:PROCESSING","accountName":"Processing fee revenue","accountType":"REVENUE","direction":"CREDIT","amountMinor":500,"signedAmountMinor":-500,"currency":"INR","amountDisplay":"INR 5.00"},{"entryId":"a121658d-73c2-40c9-a288-3810fc35a132","accountCode":"LIABILITY:MERCHANT:M001","accountName":"Merchant M001 payable","accountType":"LIABILITY","direction":"CREDIT","amountMinor":24500,"signedAmountMinor":-24500,"currency":"INR","amountDisplay":"INR 245.00"}]}

netMinor is 0 — that's invariant 1, visible in the response, not just enforced invisibly.

Send it again: idempotent replay

Same Idempotency-Key, same body:

HTTP/1.1 200
Idempotent-Replay: true

{"id":"9d27868d-178c-43c0-9bee-8b84218447c6", ...same transaction...}

200, not 201 — same transaction id, same legs, no second posting. The Idempotent-Replay header is how a caller distinguishes "this succeeded just now" from "this succeeded when you called it the first time."

Reuse the key with a different body

curl -X POST localhost:8080/api/v1/transactions \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: capture_ord_88410' \
  -d '{"description": "Card capture 500.00 for order 88410", "legs": [
        {"accountCode": "ASSET:BANK:SETTLEMENT",   "direction": "DEBIT",  "amountMinor": 50000},
        {"accountCode": "LIABILITY:MERCHANT:M001", "direction": "CREDIT", "amountMinor": 50000}
      ]}'
HTTP/1.1 409

{"code":"IDEMPOTENCY_KEY_REUSED","message":"Idempotency key capture_ord_88410 was already used for a different request","at":"2026-09-13T11:32:44.821771Z","detail":{"existingDescription":"Card capture 250.00 for order 88410, fee 5.00","existingTransactionId":"9d27868d-178c-43c0-9bee-8b84218447c6"}}

409, not a silent replay of the original ₹250 posting — see A reused idempotency key with a different body is a conflict.

Post something unbalanced

curl -X POST localhost:8080/api/v1/transactions \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: bad-posting-1' \
  -d '{"description": "Debit 250, credit 200 - 50 short", "legs": [
        {"accountCode": "ASSET:BANK:SETTLEMENT",   "direction": "DEBIT",  "amountMinor": 25000},
        {"accountCode": "LIABILITY:MERCHANT:M001", "direction": "CREDIT", "amountMinor": 20000}
      ]}'
HTTP/1.1 422

{"code":"UNBALANCED_TRANSACTION","message":"The database refused this posting: ERROR: INVARIANT_VIOLATION: transaction 63bfe0fc-2117-4444-98e4-9888630c647c legs net to 5000 minor units, expected 0\n  Where: PL/pgSQL function assert_transaction_balanced() line 31 at RAISE","at":"2026-09-13T11:32:44.856268Z","detail":null}

The message is the database trigger's own text, surfaced rather than paraphrased — that's what SET CONSTRAINTS ... IMMEDIATE in TransactionWriter buys, explained in The zero-sum rule is checked by the database.

Get a balance

curl localhost:8080/api/v1/accounts/LIABILITY:MERCHANT:M001/balance
{
  "accountId": "11111111-1111-4111-8111-111111111103",
  "code": "LIABILITY:MERCHANT:M001",
  "name": "Merchant M001 payable",
  "type": "LIABILITY",
  "currency": "INR",
  "balanceMinor": -48500,
  "normalBalanceMinor": 48500,
  "balanceDisplay": "INR 485.00",
  "entryCount": 4,
  "lastEntryAt": "2026-09-13T11:32:26.081320Z",
  "derivedAt": "2026-09-13T11:32:26.103799Z"
}

balanceMinor is signed (credits negative, per accounting convention); normalBalanceMinor flips it to the account's own normal side so a caller doesn't have to know the sign convention to read it. derivedAt is not lastEntryAt — it's recomputed on this exact request, which is the whole point of invariant 4.

List transactions for an account

curl "localhost:8080/api/v1/transactions?accountCode=LIABILITY:MERCHANT:M001&limit=2"

Returns an array, most recent first, each with every leg inline — the counterparty of any movement is visible without a second call:

[
  {
    "id": "48ec212d-b420-4e99-8768-a0e3d8fe69eb",
    "idempotencyKey": "refund_ord_88412",
    "description": "Refund 250.00 for order 88412 - customer disputed",
    "postedAt": "2026-03-03T14:22:00Z",
    "netMinor": 0,
    "legs": [
      {"accountCode": "LIABILITY:MERCHANT:M001", "direction": "DEBIT",  "amountMinor": 25000, "amountDisplay": "INR 250.00"},
      {"accountCode": "ASSET:BANK:SETTLEMENT",   "direction": "CREDIT", "amountMinor": 25000, "amountDisplay": "INR 250.00"}
    ]
  }
]

Reverse a posting

curl -X POST localhost:8080/api/v1/transactions/6352a699-8805-4999-a902-528d97647526/reversal \
  -H 'Content-Type: application/json' \
  -d '{"reason": "Captured against the wrong merchant"}'
HTTP/1.1 201

{"id":"ad6fe9c7-9f45-4bc8-8760-b3f2e3595ecc","idempotencyKey":"reversal:6352a699-8805-4999-a902-528d97647526","description":"Reversal of 6352a699-8805-4999-a902-528d97647526: Captured against the wrong merchant","reversesTransactionId":"6352a699-8805-4999-a902-528d97647526","reversedByTransactionId":null,"netMinor":0,"legs":[{"accountCode":"REVENUE:FEES:PROCESSING","direction":"DEBIT","amountMinor":500,"amountDisplay":"INR 5.00"},{"accountCode":"LIABILITY:MERCHANT:M001","direction":"DEBIT","amountMinor":24500,"amountDisplay":"INR 245.00"},{"accountCode":"ASSET:BANK:SETTLEMENT","direction":"CREDIT","amountMinor":25000,"amountDisplay":"INR 250.00"}]}

Every direction is flipped from the original, and reversesTransactionId points back at it. Both the mistake and the fix are now on the record, permanently — there is no PATCH or DELETE on either endpoint; the database would refuse it if there were.

Check the ledger's own integrity

curl localhost:8080/api/v1/audit/integrity
{
  "checkedAt": "2026-09-13T11:32:49.632911Z",
  "healthy": true,
  "imbalancedTransactions": [],
  "trialBalance": [
    {"currency": "INR", "totalDebitsMinor": 125000, "totalCreditsMinor": 125000, "netMinor": 0, "transactionCount": 5, "entryCount": 14}
  ],
  "note": "Every transaction's legs net to zero and total debits equal total credits in every currency."
}

imbalancedTransactions should always be empty — the zero-sum trigger makes it unreachable through the API. It's exposed anyway, because a guarantee nobody can check is a guarantee nobody should believe.

MCP tool calls

These are the exact JSON strings the four tools return to an MCP client, captured by calling them directly against a running ledger service. Every result shares one envelope: source (the exact HTTP request that produced data), data, and — where there's something worth saying in one line — summary.

get_balance(account_code="LIABILITY:MERCHANT:M001")

{
  "source": "GET http://localhost:8080/api/v1/accounts/LIABILITY:MERCHANT:M001/balance",
  "data": { "code": "LIABILITY:MERCHANT:M001", "balanceMinor": -24000, "balanceDisplay": "INR 240.00", "entryCount": 5, "...": "..." },
  "summary": "LIABILITY:MERCHANT:M001 (LIABILITY) holds INR 240.00 on its normal credit side, derived from 5 entries."
}

trace_transaction(transaction_id=...) — on a reversal, data gains a followUps array pointing back at the original posting, and summary renders every leg as a readable line:

{
  "source": "GET http://localhost:8080/api/v1/transactions/ad6fe9c7-...",
  "data": {
    "reversesTransactionId": "6352a699-8805-4999-a902-528d97647526",
    "followUps": ["This posting IS a reversal; trace 6352a699-... to see what it undid."],
    "...": "..."
  },
  "summary": "2026-09-13T11:32:49Z  Reversal of 6352a699-...: Captured against the wrong merchant\n  REVERSAL of 6352a699-...\n  DEBIT        INR 5.00  REVENUE:FEES:PROCESSING  (REVENUE)\n  DEBIT      INR 245.00  LIABILITY:MERCHANT:M001  (LIABILITY)\n  CREDIT     INR 250.00  ASSET:BANK:SETTLEMENT  (ASSET)\n  net = 0 minor units"
}

find_imbalances()

{
  "source": "GET http://localhost:8080/api/v1/audit/integrity",
  "data": { "healthy": true, "imbalancedTransactions": [], "trialBalance": [ { "currency": "INR", "netMinor": 0, "...": "..." } ] },
  "summary": "Ledger is internally consistent: every transaction nets to zero and debits equal credits in every currency."
}

A failed callget_balance on an account code that doesn't exist. There is no exception for the agent to catch; the error comes back as readable content it can act on:

{
  "error": "Not found",
  "status": 404,
  "source": "GET http://localhost:8080/api/v1/accounts/LIABILITY:MERCHANT:DOES_NOT_EXIST/balance",
  "hint": "Check the account code or transaction id, or call find_imbalances to confirm the ledger is reachable."
}

The API

Method Path Purpose
POST /api/v1/accounts Create an account
GET /api/v1/accounts List the chart of accounts
GET /api/v1/accounts/{code}/balance Balance, derived at read time
POST /api/v1/transactions Post a transaction (Idempotency-Key header honoured)
GET /api/v1/transactions Recent postings, optionally filtered by account
GET /api/v1/transactions/{id} One posting with every leg
POST /api/v1/transactions/{id}/reversal Correct a posting by mirroring it
GET /api/v1/balances Every balance
GET /api/v1/audit/integrity Imbalance check and trial balance

See Workflows and responses above for a worked example of each.


Design decisions, and what they cost

Amounts are integer minor units, never decimals

amount_minor BIGINT, with a CHECK (amount_minor > 0) and the sign carried by a separate direction column. signed_amount_minor is a generated column, so the sign can never disagree with the direction even if someone writes rows by hand.

Cost: every consumer has to know the currency's exponent. The API sends both amountMinor and a formatted string, and the MCP layer is explicitly instructed never to parse money out of the string.

The zero-sum rule is checked by the database, and the service does not duplicate it

There is no if (sum != 0) throw anywhere in PostingService. On purpose. The service's job is to translate the database's refusal into a decent 422, not to re-implement the rule and create a second place where it can be wrong.

The trigger is DEFERRABLE INITIALLY DEFERRED, because a per-row check would reject every balanced posting on its first leg. That leaves one ergonomic problem: a deferred constraint fires during COMMIT, outside the service method, where Spring surfaces it as an opaque TransactionSystemException. So TransactionWriter issues SET CONSTRAINTS ledger_entry_zero_sum IMMEDIATE once every leg is inserted, forcing the check to happen where the original SQLSTATE and the trigger's own message are still intact.

Cost: one extra round trip per posting, and an error path that depends on reading SQLSTATE rather than catching a typed exception.

Idempotency is insert-and-catch, not select-then-insert

The obvious implementation — look up the key, and insert if nothing is there — has a race between the lookup and the insert wide enough for two concurrent retries of the same payment to both find nothing and both post. Under a retry storm that is not a theoretical window; it is Tuesday.

So every posting attempts the insert, and a unique-constraint violation is the answer: the database picks a winner, and the loser re-reads the winner's row in a fresh transaction. TransactionWriter is a separate bean precisely so that recovery can run in a new transaction — once a constraint fires, the current one is poisoned and cannot read anything back. Self-invocation would go around the proxy and quietly break that.

ConcurrentPostingTest fires 32 simultaneous duplicate requests through a CyclicBarrier and asserts exactly one 201, thirty-one 200s, one row, two legs, and one movement of money.

A reused idempotency key with a different body is a conflict

Each transaction stores request_fingerprint, the SHA-256 of the canonicalised request. A key replayed with different content returns 409, not the original transaction. Answering it with the original would tell a caller that their ₹500 payment succeeded when a ₹250 one did — a bug that stays invisible until reconciliation, which is the worst possible time to find it.

Cost: legs are sorted before hashing, so leg order is not semantically meaningful; if it ever needs to be, the fingerprint has to change.

There is no stored balance, and no cache

account_balance is a view that sums entries on every read. No column, no materialised view, no Redis.

Cost: this is O(entries) per account and will not survive a hot account with millions of entries. The honest fix is a periodic snapshot table — (account_id, as_of, balance_minor, last_entry_id) — with reads computing snapshot + entries since snapshot. That keeps invariant 4 intact, because the snapshot is a cache that can be rebuilt from entries rather than a source of truth. It is out of scope here because adding it before it is needed would obscure the property the project exists to demonstrate.

Corrections are reversals, and the database refuses to allow anything else

UPDATE and DELETE on ledger_entry and ledger_transaction raise APPEND_ONLY_VIOLATION, and TRUNCATE has its own statement-level guard because it bypasses row triggers entirely. POST /transactions/{id}/reversal posts the mirror image, so both the mistake and the fix stay on the record — which is exactly the property that makes the ledger auditable.

Cost: the test suite has to disable those triggers explicitly to clean up between tests, and do so on a connection with autocommit forced on — the app's own pool runs with hikari.auto-commit: false, which is correct for request handling (Spring's transaction manager commits explicitly) but means an un-managed DISABLE TRIGGER from a test never actually takes effect before the next statement unless the test says so explicitly. That's visible in AbstractLedgerIntegrationTest rather than hidden by weakening the trigger for everyone.

Multi-currency is out of scope — and refused, not merely undocumented

A cross-currency posting would net to zero in raw minor units while being economically meaningless. The zero-sum trigger therefore also rejects any transaction whose legs span more than one currency, and a declarative FK on (account_id, currency) keeps an entry's currency locked to its account's.

An un-enforced scope boundary is just a bug waiting for a Friday.

Tests run against real PostgreSQL, never H2

Every invariant in this project is a constraint trigger, a deferred constraint, or a generated column — features an in-memory stand-in either lacks or fakes. Testing them anywhere but the engine that enforces them would be testing a different system. InvariantContractTest attacks each invariant through raw SQL, going around the service entirely, because "the application prevents it" and "the system prevents it" are different claims.


The MCP layer, and the tool that is missing

Four tools: get_balance, list_transactions, trace_transaction, find_imbalances. Real output from each is in Workflows and responses above; every result comes back in the same envelope:

{
  "source": "GET http://localhost:8080/api/v1/accounts/LIABILITY:MERCHANT:M001/balance",
  "summary": "LIABILITY:MERCHANT:M001 (LIABILITY) holds INR 485.00 on its normal credit side, derived from 7 entries.",
  "data": { "...": "..." }
}

source is the exact request that produced data. It is there so an agent asked to justify a conclusion can quote the call rather than reconstruct it from memory — the difference between an audit trail and a plausible story.

Why there is no run_sql tool

This is the most important decision in the project, and it is an argument rather than an omission.

A raw SQL tool is genuinely tempting. It is one function, it answers every question the four tools answer and every question they don't, and it makes the demo look powerful. It is also wrong here, for four separate reasons, any one of which would be sufficient:

  1. It defeats the read-only guarantee at the only layer that can hold it. "Read-only" would become a string check on the query text, and query-text parsing is a losing game: CTEs with INSERT ... RETURNING, SELECT pg_read_file(...), COPY ... TO PROGRAM, a function with a side effect. A read-only role would close the write hole and leave the others.
  2. It makes the ledger's invariants irrelevant to the agent. The four tools return direction, amountMinor and the account's normal side, because that is the vocabulary in which the ledger is correct. An agent writing its own SUM(amount) will eventually sum debits and credits together and produce a confident, wrong number.
  3. It has no natural blast radius. list_transactions caps its rows. SELECT * FROM ledger_entry does not, and one careless question fills the context window — or the database's memory.
  4. It moves the trust boundary to the wrong place. With four tools, the dangerous input is a tool name and typed arguments. With run_sql, the dangerous input is an arbitrary program written by a language model that read the ledger's own data, and that data may contain text written by someone hostile. Prompt injection stops being a content problem and becomes a code-execution problem.

The right escape hatch, when the four tools genuinely aren't enough, is a fifth named tool with typed parameters — not a general-purpose one. Every new tool is then a deliberate widening of the boundary, reviewed on its own merits.

Cost, stated plainly: some questions need a code change rather than a clever prompt. That is the trade, and it is the right one for a system that moves money.


Worked example: "this account is off by ₹250, what happened?"

./docs/scenario-off-by-250.sh

Three card captures of ₹250.00 each with a ₹5.00 processing fee, then one refund of ₹250.00 that operations didn't know about. Merchant M001 is expected to be owed ₹735.00 and actually shows ₹485.00 — the ₹250.00 discrepancy an agent is asked to explain, using only the four MCP tools above.

docs/agent-transcript.md walks the investigation tool call by tool call, with every query cited, so the transcript is reproducible rather than illustrative.


Explicitly out of scope

Authentication and user management. Multi-currency and FX. Any UI. Real payment rails or settlement. A raw SQL tool on the MCP side.

The out-of-scope list matters as much as the feature list. A small ledger with airtight guarantees is worth more than a sprawling one with soft ones.

Known limits

  • Balance reads are O(entries). The snapshot design above is the fix; it is not implemented.
  • No authentication anywhere. Both services assume a trusted network. In production the MCP server would carry a scoped read token, not ambient access.
  • Two-decimal assumption. Money reads the currency's exponent from the JDK where it can, but the MCP formatter assumes two places. Correct for INR, wrong for JPY and KWD.
  • transaction_imbalance should always be empty. It is exposed anyway, because a guarantee nobody can check is a guarantee nobody should believe.

Layout

ledger-service/     Java 21, Spring Boot 3, PostgreSQL, Liquibase
  src/main/resources/db/changelog/   the invariants, as DDL
  src/test/java/                     the invariants, as tests
mcp-server/         Python, official MCP SDK, read-only
docs/               worked example and seed script

Licence

MIT.

About

A double-entry payments ledger with PostgreSQL-enforced invariants and a read-only MCP server for AI agent investigation.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages