Built for the H0 Hackathon AWS + Vercel track.
Live: ledgerloop-delta.vercel.app
Repo: github.com/suletete/LedgerLoop
Three friends in three time zones split a hotel bill. Two of them add expenses from different cities at the same time. Most apps let one of those writes quietly overwrite the other. Balances drift by a cent. Nobody notices until someone gets asked to pay more than they actually owe.
That's not a UI problem. It's a math problem.
The invariant that started everything
In a closed expense group, the sum of all net balances must equal zero. Always. If Alice owes Bob $50 and Bob owes Carol $30, those flows have to cancel out across the whole group. That's not a business requirement someone wrote in a spec. It's arithmetic.
Most apps treat this as an application concern; they lock a row, update a running total, and release the lock. The problem: if two writes race, application-layer optimistic locking can fail silently. You get a wrong number and no error.
I wanted the database to refuse the inconsistency. Don't paper over it. Refuse it.
Aurora PostgreSQL runs with serializable isolation. When two transactions touch the same data at the same instant, one of them gets SQLSTATE 40001, a serialization failure, and the database aborts it. No silent merge. No wrong number. A clean error you can retry from a fresh read.
The rest of the architecture follows from that.
What I built
LedgerLoop is a group expense ledger. Friends split shared costs, rent, trips, dinners, and the app figures out exactly what each person owes, reduced to the fewest possible payments.
The core flow: add an expense, split it (equal, by percentage, or exact amounts), and watch the balances update. If someone tries to settle $600 when they only owe $500, the system rejects it before anything hits the database. Twelve tangled debts collapse to four payments.
Stack: Next.js 15 (App Router) on Vercel, TypeScript strict mode, Tailwind CSS, Aurora PostgreSQL Serverless v2, Vitest + fast-check.
The concurrency model changed my architecture
Traditional systems: lock the row, update it, release the lock. With OCC, you read a snapshot, compute your change, commit if someone else changed the same data while you were computing, you get 40001, and start over from a fresh read.
That sounds like a retry strategy. It's more than that.
Once the model clicked, I stopped storing derived values. A running balance column is a conflict surface. Two writes that touch it will race. So I didn't store it. Balance is computed fresh on every read, from the raw ledger expenses, splits, and settlements. Nothing to corrupt. Nothing to get out of sync.
The ledger is append-only. Expenses and settlements are inserted. Corrections are new reversing rows, not edits. Two inserts with different UUIDs rarely collide. The conflict window gets narrow enough that retries are rare in practice.
Every write goes through withOccRetry: on 40001, exponential backoff with jitter, up to 4 attempts. If all 4 exhaust, a clean error comes back. The ledger is exactly as it was before the first attempt.
The architecture didn't lead me to the concurrency model. The concurrency model told me what the architecture had to look like.
The remainder bug property testing found
My first equal-split implementation passed every unit test I wrote.
// Naive: $10.00 / 3 = 333 + 333 + 333 = 999 cents. One cent gone, every time.
const perPerson = Math.floor(amount / n);
The property test caught it on the second run. fast-check threw random amounts and random participant counts at the invariant sum(shares) == amount and found a specific combination I hadn't thought to try.
The fix was two lines:
const base = Math.floor(amount / n);
const remainder = amount - base * n;
// First `remainder` participants get base + 1, the rest get base.
For $10.00 / 3: 334 + 333 + 333 = 1000 cents. Always.
I wrote dozens of unit tests before this. None of them found it. The property test found it on run 2 because it generated the exact amount and participant count combination where naive flooring fails.
That's property testing. You write down what must always be true and let the library go looking for trouble.
Six invariants, each one trying to break itself
| # | What must hold | Enforced by |
|---|---|---|
| INV-1 | sum(splits) == expense amount |
Split Calculator + atomic transaction |
| INV-2 |
sum(balances) == 0 across a group |
Balance Engine derivation |
| INV-3 | No double-counting under concurrency | Aurora SERIALIZABLE + withOccRetry |
| INV-4 | Money is always integer minor units | BIGINT storage, no floats |
| INV-5 | Settlement <= what's owed | Settlement Validator against derived ledger |
| INV-6 | Every row references a real entity | Auth Guard + DB foreign keys |
27 property-based tests total. Each one runs 100+ random inputs via fast-check.
INV-2 is the most satisfying. The balance engine reads every expense, split, and settlement for a group and computes each member's net. Positive means the group owes you. Negative means you owe. The sum across the whole group is always zero, not something verified after the fact, but a consequence of how the formula adds up.
Here's the schema those invariants protect. Groups and membership:
The append-only ledger records expenses, splits, and settlements. No UPDATE ever runs on these tables. Corrections are new reversing rows:
Auth tables persisted to Aurora, so sessions survive Vercel cold starts:
Why integers everywhere
IEEE 754 can't represent 0.1 exactly. $10.50 stored as a float might come back as 10.4999...97. The fix isn't smarter, rounding it's never using floats.
$10.00 is stored as 1000 cents. The database column is BIGINT. The TypeScript type is number (safe for integers up to 2^53 - 1). Formatting back to major units happens exactly once, at the display layer, and nowhere else. Any field with money in it ends in Minor: amountMinor, shareMinor.
The percentage split problem is subtler. Math.round(amount * pct / 100) per person produces totals that are off by 2 minor units on certain inputs. The actual fix: floor every share, sum the floors, hand out the shortfall one unit at a time to whoever had the largest fractional part. The property test for this ran 100 iterations. It hasn't failed once.
Testing OCC without a live database
138 tests. 24 files. Under 25 seconds. No database, no network.
The in-memory fake has an injectOccConflict(n) method. Pass n = 2 and the next two writes throw 40001 before touching the state. That's how the retry path gets exercised, including the full backoff sequence without a live database.
The persistence layer sits behind an interface. The entire test suite runs against the fake. The real Aurora adapter and the fake both implement the same interface, so swapping them is one line:
// src/lib/persistence-factory.ts
export function getPersistence(): Persistence {
return process.env.AURORA_HOST
? new AuroraPersistence()
: new InMemoryPersistence();
}
The architecture
One Next.js deployment. One Aurora database. No cross-service coordination. Concurrency correctness is Aurora's job, not the application's.
Every write goes through four steps left to right: auth check → split calculation → OCC retry wrapper → Aurora atomic transaction. The red dashed arrow is the SQLSTATE 40001 retry loop. Aurora fires it on a conflict, withOccRetry backs off and retries, the second attempt lands cleanly.
Registration: how a new account gets created and persisted to Aurora:
Sign-in and session lookup warm path hits in-memory, cold start falls back to Aurora:
Try it yourself
git clone https://github.com/suletete/LedgerLoop
cd LedgerLoop
npm install
npm test # 138 tests, all pass, no database needed
npm run dev # http://localhost:3000
No AWS account. No Docker. The in-memory fake handles everything locally.
Or go straight to ledgerloop-delta.vercel.app and register. The first request may take 5-10 seconds for an Aurora Serverless cold start. The second is fast.
What I'd do differently
The settle-up flow is half-wired. The UI renders, and the server action exists. I ran out of time connecting them before the deadline. That's the most obvious gap.
The OCC demo page shows two concurrent writes and lets you watch one retry. It works, but real-time feedback instead of a page reload would be better. That's a WebSocket problem I decided not to introduce mid-hackathon.
The thing that stuck with me
I came into this thinking the concurrency problem was a detail I'd handle at the end. It turned out to be the thing everything else bent around. Append-only inserts, no stored balances, no mutable totals to race on, none of those were the original plan. The OCC model required them.
The database refusing a conflicting write isn't a retry strategy. It's a contract. The architecture is just what you have to build to honor it.
Built for the H0 Hackathon (AWS + Vercel track). #H0Hackathon







Top comments (0)