ledgerService

ledger-service

A double-entry payments ledger with a REST API, in Java 21 / Spring Boot 3 / PostgreSQL 16.

It does the thing every payment system needs a correct answer for: record money moving between accounts so that it is impossible to lose track of a cent, impossible to pay a merchant twice because a request timed out, and impossible to quietly edit history when something goes wrong. About 1,000 lines of Java plus 950 of tests, no clever abstractions, and a suite that asserts on database state rather than just on status codes.

docker compose up -d          # PostgreSQL 16
./mvnw spring-boot:run        # the service, on :8080
./demo.sh                     # a guided tour, in another terminal

Why this problem

Storing a balance is easy. Storing it correctly is where payment systems actually break, and the failure modes are all boring, all well-known, and all things this service has an explicit answer for:

Failure What this service does
A client retries after a timeout and the merchant gets paid twice Idempotency-Key with a unique constraint behind it — 200 with the original transaction, not a second one
A rounding error opens a half-cent gap that compounds over millions of rows integer minor units; no float or double touches money anywhere
Half a transaction is written and the other half fails one @Transactional boundary; either every entry lands or none does
A cached balance drifts away from the entries that produced it there is no cached balance — it is derived in SQL every time
Someone “fixes” a bad posting by editing it, and the audit trail is gone no update, no delete; corrections are reversals that leave both records

The three decisions worth defending

Money is a long of minor units

amount_minor holds cents. Not double, which cannot represent 0.10 exactly and will eventually disagree with itself about whether 0.1 + 0.2 == 0.3. Not BigDecimal, which is correct but drags an object allocation and a scale question onto every arithmetic operation for a precision this domain never needs — currencies have a fixed minor unit, so an integer count of them is exact by construction.

A long holds about 92 quadrillion cents. Canada’s annual GDP is around 2 quadrillion. There is room.

Amounts are always positive. What makes an entry add or subtract is its direction, not its sign, so there is exactly one way to represent “$500 out of cash” and no possibility of a -(-500) bug.

Balances are derived, never stored

There is no balance column. GET /accounts/{id}/balance runs one SQL aggregate:

SELECT COALESCE(SUM(amount_minor) FILTER (WHERE direction = 'DEBIT'),  0),
       COALESCE(SUM(amount_minor) FILTER (WHERE direction = 'CREDIT'), 0)
FROM entry WHERE account_id = ?

and applies the account type’s sign convention to the two totals in Java.

A stored balance is a cache, and every cache can go stale. A stored balance also makes every write to an account contend on the same row, which is exactly what you don’t want when settlement volume spikes. Deriving it means reads never block writes, and the number can never be wrong — it is the entries.

The cost is real and worth naming: this is O(entries) per balance read. At the volume this service is built for, the index on entry(account_id) covers it. At a volume where it did not, the fix is a periodically-snapshotted running balance — a materialised view or a balance_snapshot(account_id, as_of, balance_minor) table that reads sum forward from — and not a mutable column that writes race each other to update.

The ledger is append-only

There is no PUT /transactions/{id} and no DELETE. A mistake is corrected by posting a reversal: a new transaction whose entries mirror the original with every direction flipped, linked back by reverses_transaction_id.

After a reversal the account balances return to where they started, but both transactions are still on the books — which is the point. “This payment was refunded on the 3rd” and “this payment never happened” are different facts, and a system that cannot tell them apart cannot be audited, reconciled against a processor’s statement, or trusted in a dispute.

Because reversal is “flip every direction”, a reversal of a balanced transaction is always itself balanced. That is not a coincidence to be tested for; it is a property of the operation.


Schema

Three tables. ledger_transaction rather than transaction because that word is reserved in PostgreSQL.

  account                      ledger_transaction                    entry
  ───────────                  ──────────────────────                ────────────
  id           uuid  PK   ┌──  id                 uuid  PK   ──┐     id           uuid PK
  name         text       │    idempotency_key    text  UQ     │     transaction_id uuid FK ─┘
  type         text       │    description        text         │     account_id   uuid FK ───┐
  currency     varchar(3) │    posted_at          timestamptz  │     direction    text       │
  created_at   timestamptz│    reverses_txn_id    uuid FK UQ ──┘     amount_minor bigint     │
                          └───────────────────────────────────────────────────────────────────┘

Constraints that are in the database and not only in Java, because the API is not the only way rows could ever arrive:

Flyway owns the schema; Hibernate runs ddl-auto: validate and refuses to start if the entities and the migrated tables have drifted apart. ddl-auto: update would let a careless entity change silently alter a production table, which is a fine way to lose a column of financial history.

The sign convention

ASSET, EXPENSE       (debit-normal)   balance = debits − credits
LIABILITY, REVENUE   (credit-normal)  balance = credits − debits

This lives in exactly one place — AccountType.balanceOf — because it is the rule most likely to be got subtly wrong in a second copy.

A debit is not “money in”. Cash (ASSET) grows when you debit it; the merchant you owe (LIABILITY) grows when you credit them. Same word, opposite effect, which is why the enum knows and the callers do not.


API

Base path /api. Interactive docs at /swagger-ui.html.

     
POST /accounts Open an account
GET /accounts List them
GET /accounts/{id} One account
GET /accounts/{id}/balance Derived balance, with the two sums it came from
GET /accounts/{id}/entries?page=0&size=20 Paginated, newest first
POST /transactions Post a transaction (Idempotency-Key required)
GET /transactions/{id} One transaction and its entries
POST /transactions/{id}/reversal Post the mirror image (Idempotency-Key required)
GET /trial-balance Prove total debits equal total credits

Open an account

curl -X POST localhost:8080/api/accounts \
  -H 'Content-Type: application/json' \
  -d '{"name":"Settlement Cash","type":"ASSET","currency":"CAD"}'
{
  "id": "b1c4e7a2-3f8d-4c91-a5e6-7d20f8b3c914",
  "name": "Settlement Cash",
  "type": "ASSET",
  "currency": "CAD",
  "createdAt": "2026-01-12T09:14:22.481Z"
}

Post a settlement

A $500 card batch: we hold the cash, we owe the merchant $485, we keep $15 in fees. Three entries, one transaction, and it balances.

curl -X POST localhost:8080/api/transactions \
  -H 'Content-Type: application/json' \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "description": "Morning card settlement",
    "entries": [
      {"accountId":"b1c4e7a2-...","direction":"DEBIT", "amountMinor":50000},
      {"accountId":"9e2f1a05-...","direction":"CREDIT","amountMinor":48500},
      {"accountId":"4a7c3d18-...","direction":"CREDIT","amountMinor":1500}
    ]
  }'
{
  "id": "7f3e2b91-4d6a-4e08-b2c7-1a5f9e0d3846",
  "description": "Morning card settlement",
  "postedAt": "2026-01-12T09:15:03.117Z",
  "reversesTransactionId": null,
  "entries": [
    {"id":"...","transactionId":"7f3e2b91-...","accountId":"b1c4e7a2-...","direction":"DEBIT","amountMinor":50000},
    {"id":"...","transactionId":"7f3e2b91-...","accountId":"9e2f1a05-...","direction":"CREDIT","amountMinor":48500},
    {"id":"...","transactionId":"7f3e2b91-...","accountId":"4a7c3d18-...","direction":"CREDIT","amountMinor":1500}
  ]
}

Send the exact same request again — same Idempotency-Key — and you get 200 OK with that same id. Not a second transaction, not an error. The merchant is paid once.

Read a balance

curl localhost:8080/api/accounts/b1c4e7a2-3f8d-4c91-a5e6-7d20f8b3c914/balance
{
  "accountId": "b1c4e7a2-3f8d-4c91-a5e6-7d20f8b3c914",
  "currency": "CAD",
  "balanceMinor": 50000,
  "totalDebitsMinor": 50000,
  "totalCreditsMinor": 0
}

The two totals come back alongside the answer so the arithmetic is checkable without reading the source.

Reverse it

curl -X POST localhost:8080/api/transactions/7f3e2b91-.../reversal \
  -H "Idempotency-Key: $(uuidgen)"
{
  "id": "c8d1f4e6-2a93-4b07-9e5c-3f81a2d70b45",
  "description": "Reversal of Morning card settlement",
  "postedAt": "2026-01-12T16:42:09.882Z",
  "reversesTransactionId": "7f3e2b91-4d6a-4e08-b2c7-1a5f9e0d3846",
  "entries": [
    {"id":"...","accountId":"b1c4e7a2-...","direction":"CREDIT","amountMinor":50000},
    {"id":"...","accountId":"9e2f1a05-...","direction":"DEBIT", "amountMinor":48500},
    {"id":"...","accountId":"4a7c3d18-...","direction":"DEBIT", "amountMinor":1500}
  ]
}

Reverse it a second time and you get 409 Conflict. So does trying to reverse a reversal.

Trial balance

The report an accountant reads to check that a ledger holds together — every account, its two totals, and whether the whole thing nets to zero. Grouped by currency, because this service stores currency but never converts it, so adding a CAD total to a USD total would produce a meaningless number.

curl localhost:8080/api/trial-balance
{
  "generatedAt": "2026-01-12T16:45:11.204Z",
  "currencies": [
    {
      "currency": "CAD",
      "rows": [
        {"name":"Settlement Cash",  "type":"ASSET",     "debitsMinor":50000,"creditsMinor":50000,"balanceMinor":0},
        {"name":"Merchant Payable", "type":"LIABILITY", "debitsMinor":48500,"creditsMinor":48500,"balanceMinor":0},
        {"name":"Processing Fees",  "type":"REVENUE",   "debitsMinor":1500, "creditsMinor":1500, "balanceMinor":0}
      ],
      "totalDebitsMinor": 100000,
      "totalCreditsMinor": 100000,
      "balanced": true
    }
  ],
  "balanced": true
}

Note the totals after a reversal: $1000 of activity for a $500 payment that ended at zero. Nothing was erased — the history of both the settlement and its reversal is still there.

balanced: false should be unreachable. Exposing it anyway turns an invariant the code asserts about itself into something a human or a monitoring check can verify from outside.


Errors

RFC 7807 ProblemDetail on every failure, from one @RestControllerAdvice.

{
  "type": "about:blank",
  "title": "Unprocessable Entity",
  "status": 422,
  "detail": "Transaction does not balance: debits 50000, credits 40000",
  "instance": "/api/transactions"
}

Every detail names the ids or amounts involved. “Invalid request” is not a useful thing to read at 3am.

The distinction that matters is 400 vs 422:

A client retries those differently, so conflating them is a real defect and not a formatting preference. It also means the entry-count and amount checks deliberately do not use bean validation, since bean validation can only produce 400.

Exception Status
AccountNotFoundException, TransactionNotFoundException 404
MissingIdempotencyKeyException, MethodArgumentNotValidException, HttpMessageNotReadableException 400
UnbalancedTransactionException, CurrencyMismatchException, InvalidEntryException 422
AlreadyReversedException 409
anything else 500, logged server-side, generic detail to the client

Posting rules, in order

POST /transactions applies these in a fixed order so that a request breaking several at once gets a predictable answer:

  1. Idempotency-Key missing or blank → 400
  2. Key already used → 200 with the original transaction; nothing created
  3. Fewer than 2 entries → 422
  4. Any amountMinor ≤ 0 → 422
  5. Any accountId unknown → 404
  6. Entries reference accounts with differing currencies → 422
  7. Debits ≠ credits → 422
  8. Otherwise → persist atomically → 201

Rules 3, 4, 6 and 7 live in TransactionValidator, which takes plain arguments and has no Spring or JDBC anywhere in it — so the rules can be tested in milliseconds instead of behind a container start.

Two requests, one key, same instant

Rule 2 above is a lookup, and a lookup cannot make this safe on its own. Two simultaneous requests both check, both find nothing, and both insert.

What actually arbitrates is the unique constraint on idempotency_key. One insert wins. The loser’s transaction raises a constraint violation and rolls back having written nothing — then re-reads the winner’s row and returns it, so both callers get the same transaction and the merchant is paid once.

The subtlety is where that recovery runs. It cannot happen inside the transaction that collided: once a constraint fires, that transaction is marked rollback-only and any further query on it fails at commit. So the write sits behind its own boundary in TransactionWriter, and TransactionService catches the violation after that boundary has closed. They are two beans rather than two methods on one class because @Transactional works through a proxy — a method calling another method on this never crosses it, so the inner annotation would be silently ignored.

This is not theoretical. ConcurrentPostingIT fires two real requests at one latch and asserts that exactly one is created, that the loser leaves no orphaned entries, and that two concurrent reversals of the same transaction still produce exactly one — that last one guarded by the unique constraint on reverses_transaction_id, since the two requests carry different idempotency keys and the replay check cannot help. Putting @Transactional back on the outer method makes those tests fail, which is the check that the split is load-bearing rather than decorative.


Running it

Prerequisites: JDK 21 and Docker.

docker compose up -d          # PostgreSQL 16 on :5432 (db/user/pass all "ledger")
./mvnw spring-boot:run

Then either poke at http://localhost:8080/swagger-ui.html, or:

./demo.sh                     # needs jq: brew install jq

demo.sh walks through a day at a coffee shop: opening a chart of accounts, settling a card batch, retrying it after a simulated network drop, fat-fingering an unbalanced posting, reversing a disputed payment, and printing the trial balance. It is the fastest way to see what the service actually does.

Testing

./mvnw test                   # needs Docker running

The rejection tests (unbalanced, unknown account, mixed currency) assert on row counts as well as status codes. A 422 with a half-written transaction still in the table would be a much worse bug than a wrong status code, and only the counts can catch it.

If Testcontainers says “Could not find a valid Docker environment” On Docker Engine 29+ that message is a red herring — Docker is running and reachable, it just refused the API version. Testcontainers probes with `GET /v1.32/info`, and Docker 29 dropped every API version below 1.44. The `docker.api.version` property in `pom.xml` pins it to 1.44, which is what makes `./mvnw test` work out of the box. On an older daemon: ```bash ./mvnw test -Ddocker.api.version=1.41 ``` Upgrading Testcontainers does not fix this on its own — 1.21.3 still probes with v1.32.

The project page

docs/ is a self-contained static site: a project page with a working ledger you can post to in the browser. Open docs/index.html directly, or serve it:

python3 -m http.server 8099 --directory docs

The demo has two modes, behind one interface, and the UI cannot tell them apart:

If the simulation and the real service ever disagree, the page is where you would notice.

Browser calls need CORS, which CorsConfig provides — origins are configuration, never a wildcard, since * would let any page on the internet drive this API from a visitor’s browser. Idempotency-Key is a custom header and so must be named explicitly, or preflight drops it.

ledger:
  cors:
    allowed-origins: ${LEDGER_CORS_ALLOWED_ORIGINS:http://localhost:8099,http://127.0.0.1:8099}

Deploying

The page is a docs/ folder on any static host — GitHub Pages (Settings → Pages → source main, folder /docs), Netlify, Vercel, Cloudflare Pages.

The service is a container. The Dockerfile is a two-stage build shipping a JRE and a jar rather than Maven and a ~/.m2 cache, and it reads PORT, DB_URL, DB_USER, DB_PASSWORD and LEDGER_CORS_ALLOWED_ORIGINS from the environment — enough for Render, Railway, Fly.io or Koyeb with a Postgres add-on.

docker build -t ledger-service .
docker run -p 8080:8080 --env DB_URL=jdbc:postgresql://host.docker.internal:5432/ledger ledger-service

One catch worth knowing: a page served over https cannot call an http backend — browsers block it as mixed content. A deployed page needs a deployed service on https, or both on localhost.

Layout

com.leisingha.ledger
├── account       Account, AccountType (owns the sign convention), balance queries
├── transaction   LedgerTransaction, Entry, Direction, posting and reversal
├── reporting     Trial balance
├── common        Domain exceptions and the ProblemDetail advice
└── LedgerApplication

Stack: Java 21 · Spring Boot 3.3 · Spring Data JPA · PostgreSQL 16 · Flyway · Jakarta Bean Validation · springdoc-openapi · JUnit 5 · AssertJ · Testcontainers · Maven.

No Lombok, no MapStruct, no ModelMapper — DTOs are records and the mapping is written by hand, so there is no annotation processor between the source and what runs.


Things I decided not to build

Multi-currency conversion (currency is stored and compared, never converted), auth, soft deletes, a caching layer, and a stored running balance. Each of those is a real feature with a real cost, and none of them is needed to demonstrate that the ledger is correct — which is the only thing this project is trying to prove.

The one I would build next is currency conversion, and it would not be a column change: a cross-currency transaction needs a rate, the rate’s timestamp and source, and a decision about which side of the trade absorbs the rounding remainder — which is a fourth entry, to an FX gain/loss account, so that the transaction still balances in the ledger’s own terms. That is a design conversation, not a schema tweak, which is why it is out of scope rather than half-done.