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
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 |
long of minor unitsamount_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.
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.
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.
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:
idempotency_key UNIQUE — the actual mechanism behind idempotency, not just a lookupreverses_transaction_id UNIQUE — a transaction can be reversed at most once, even
under two concurrent reversal requestsamount_minor > 0 — magnitude only; direction carries the signdirection IN ('DEBIT','CREDIT'), type IN (...), currency ~ '^[A-Z]{3}$'reverses_transaction_id <> id — nothing reverses itselfentry(account_id) — the balance query depends on itFlyway 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.
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.
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 |
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"
}
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.
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.
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.
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.
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:
Idempotency-Key, unparseable body,
"direction": "SIDEWAYS", a field that failed bean validation.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 |
POST /transactions applies these in a fixed order so that a request breaking several at
once gets a predictable answer:
Idempotency-Key missing or blank → 400amountMinor ≤ 0 → 422accountId unknown → 404Rules 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.
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.
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.
./mvnw test # needs Docker running
AccountTypeTest, TransactionValidatorTest. No Spring context, no
database, no HTTP. They cover the sign convention and every posting rule, and finish in
well under a second.LedgerApiIT, TrialBalanceIT. Full @SpringBootTest with
MockMvc against a real PostgreSQL in Testcontainers. One container starts for the whole
suite; each test truncates first, so no test depends on what ran before it.ConcurrentPostingIT. Two threads released by a shared latch,
driving the service directly because MockMvc is single-threaded. Covers the duplicate-key
race and the double-reversal race.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.
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:
docs/ledger.js ports the rules from
src/main/java: same ordering, same status codes, same error messages copied verbatim.
This is what a static host can offer, since it cannot run Java or PostgreSQL.docs/backends.js drives the real service over HTTP.
Start it locally, paste http://localhost:8080, and every request on the page becomes a
real one. Reset disables itself in this mode: the ledger is append-only, and that
absence is the lesson.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}
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.
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.
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.