A small double-entry transaction ledger: Spring Boot backend, React frontend. Every transaction is recorded as a set of legs (debits and credits) that must balance, so the books can never drift out of sync by construction.
Backend interviews keep asking what happens when two requests hit the same row at the same moment, and I would rather have written the thing than talk about it in the abstract. Double-entry bookkeeping turned out to be a good excuse, because the invariant is simple enough to state in one line and unforgiving enough that getting concurrency wrong shows up as money appearing out of nowhere.
Most of what is here is ordinary CRUD. The three parts I actually care about are in the design notes at the bottom: the balance invariant, idempotent posting, and what happens when two postings race for the same key.
Requires Java 21 and Maven. The default profile uses an in-memory H2 database, so no setup is needed:
cd backend
mvn spring-boot:run
The API is served on http://localhost:8080. Schema is managed by Flyway
(src/main/resources/db/migration).
To run against Postgres instead:
docker compose up -d # from the repo root, starts postgres:16
cd backend
mvn spring-boot:run -Dspring-boot.run.profiles=postgres
Tests:
cd backend
mvn test
Requires Node 18+.
cd frontend
npm install
npm run dev
Open http://localhost:5173. The Vite dev server proxies /api to the backend
on port 8080, so start the backend first.
| Method | Path | Description |
|---|---|---|
| POST | /api/accounts | Create an account (name, type) |
| GET | /api/accounts | List accounts with cached balances |
| GET | /api/accounts/{id}/balance | Balance recomputed from legs |
| GET | /api/accounts/{id}/statement?from=&to= | Legs in [from, to) with running balance (ISO 8601) |
| POST | /api/transactions | Post a transaction (2+ balanced legs, optional category) |
| GET | /api/transactions | List transactions with legs |
| POST | /api/imports/csv | Multipart CSV import (field name file) |
Error mapping: validation failures are 400, unknown resources are 404, transactions that violate the double-entry invariant are 422, and a write that loses a race against a concurrent one is 409 (retry it).
Validation runs on the service, not only on the controller, because the CSV importer builds requests in code and posts them directly.
CSV format for imports (header row optional):
date,description,amount,debitAccountId,creditAccountId
2026-07-01,July pay,2500.00,1,2
Fields are split on commas with no quoting support, so a description containing a comma is rejected rather than silently mis-parsed. A bad row fails the whole file, since a partial import is worse than none.
Double entry. Money is never created or destroyed, only moved. Each
transaction has at least two legs, and the sum of debit amounts must equal the
sum of credit amounts; the service rejects anything else with a 422 before it
touches the database. An account's balance is defined as debits minus credits
over its legs, so asset and expense accounts carry positive balances while
liability and income accounts naturally go negative. Amounts are BigDecimal
end to end and compared with compareTo, because 0.1 + 0.2 is not money.
Idempotency. POSTs are retried in the real world: timeouts, flaky wifi,
double clicks. A transaction can carry an idempotencyKey, backed by a unique
column. Replaying a known key returns the original transaction with a 200
instead of creating a duplicate, so a client can safely retry until it gets an
answer. The CSV import builds its keys by hashing each raw row, which makes
re-uploading the same file (or overlapping exports) a no-op rather than a
double-booking.
Two postings racing on the same key is the one case the lookup cannot catch: both miss the select, and the unique constraint decides the winner. The loser gets a 409 rather than a 500, and its retry finds the committed transaction and gets the ordinary 200 replay.
Reconciliation. Each account keeps a cachedBalance that is updated inside
the same database transaction as the posting, with the affected account rows
locked in id order to keep concurrent postings from interleaving. The legs
remain the source of truth. An hourly scheduled job recomputes every account's
balance from its legs and logs a warning for any account where the cache has
drifted, which would indicate a bug or an out-of-band data edit. Cheap to run,
and it turns a silent corruption into a loud log line.