LedgerLoop

Inspiration

I split expenses with people across three time zones regularly. Most apps handle the easy case fine: one person pays, everyone splits evenly, done. But the moment two people add expenses from different cities at the same second, or someone partially settles a messy web of debts, things break quietly. Balances drift by a cent here, a naira there. Nobody notices until someone gets asked to pay more than they actually owe.

That's not a UI problem. It's a math problem.

In any closed group, the sum of all net balances must equal zero. Always. If Alice owes Bob ₦500 and Bob owes Carol ₦300, the system must track those flows without allowing a concurrent write to silently corrupt the total. Most apps use optimistic locking at the application layer, or just accept the occasional wrong number and hope nobody checks. I wanted the database itself to refuse the inconsistency, not paper over it.

When I saw the H0 Hackathon's AWS + Vercel track, the fit was obvious. Aurora PostgreSQL with serializable isolation means that when two transactions conflict, the database doesn't silently merge them. It aborts one with SQLSTATE 40001 and forces a retry from a clean read. That's exactly what a ledger needs.


What I learned

Correctness can be stated precisely, and then tested

The biggest shift in how I think about code came from property-based testing. I stopped writing examples and hoped they covered the edge cases. Instead, I wrote down what "correct" actually means as a mathematical property, and let fast-check generate hundreds of random inputs to try to break it.

The split calculator's core rule looks obvious:

$$\sum_{i=1}^{n} \text{share}_i = \text{amount}$$

Obvious. And easy to break. ₦1,000 split three ways should be 334 + 333 + 333 = 1,000. Naive rounding gives 333 + 333 + 333 = 999, one kobo gone, every single time. My first implementation did exactly that. The property test caught it on its second run. The fix was two lines: compute the remainder explicitly, distribute it one unit at a time to the first N participants. I'd written dozens of unit tests that all passed. The property test found the case I hadn't thought of.

The debt simplification property is harder to state, but even more satisfying to test:

$$\forall \text{ member } m: \text{netPosition}(m, \text{original}) = \text{netPosition}(m, \text{simplified})$$

The algorithm takes 12 tangled debts and reduces them to 4 payments. But if anyone's net position shifts by even one minor unit, the test fails. Writing that property forced me to think about what the algorithm actually guarantees, not just what it does in normal cases.

OCC changes what you build, not just how

Serializable isolation changed my mental model for database writes in a way I didn't fully anticipate. Traditional systems: lock the row, update it, release the lock. With OCC, you read a snapshot, compute your change, and commit. If someone else changed the same data while you were thinking, you get 40001 and start over from a fresh read.

That sounds like a different retry strategy. It isn't, really.

Once that model clicks, several things follow. You stop storing derived values like running balances, because any mutable row you try to update is a conflict surface just waiting to bite someone. You make the ledger append-only because two inserts with different primary keys rarely collide. You write the retry wrapper once and then stop thinking about locking anywhere else in the codebase.

The architecture didn't lead me to the concurrency model. The concurrency model told me what the architecture had to look like.

Floating-point money bites quietly

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 at all. ₦1,000 is stored as 100000 kobo. The database column is BIGINT. The TypeScript type is number (safe for integers up to ±2⁵³ − 1). Formatting back to major units happens exactly once, at the display layer, and nowhere else.

I ran into this with percentage splits. A 33% + 33% + 34% split looks fine until you compute Math.round(amount * pct / 100) per person and get a total off by 2 minor units on certain inputs. The actual fix: floor every share, sum the floors, then hand out the shortfall one unit at a time to whoever had the largest fractional part after flooring. The property test for this has run 100 iterations of random amounts and random percentage distributions. It hasn't failed once.


How I built it

Architecture

One Next.js deployment on Vercel, one Aurora PostgreSQL database. That's the whole thing.

Browser
    │
    ▼
Vercel —> Next.js App Router
    ├── Server Components (page render, data fetch)
    ├── Client Components (forms, live split preview)
    └── Server Actions (write operations)
            │
            ▼
        Ledger Service
            ├── Auth Guard
            ├── Split Calculator
            ├── Balance Engine
            ├── Debt Simplifier
            ├── Settlement Validator
            └── withOccRetry
                    │
                    ▼
            Aurora PostgreSQL
                Append-only: expenses, splits, settlements
                Reference state: users, groups, memberships
                Serializable isolation (SQLSTATE 40001 on conflict)

Balance is never stored; it's computed from the raw ledger on every read. The domain logic is pure: no I/O, no database calls, testable in isolation. The persistence layer is behind an interface, so the entire test suite runs against an in-memory fake.

The domain core

The split calculator's one job is to turn an expense amount into per-member shares that sum exactly to that amount. For equal splits, it distributes the remainder deterministically (first N participants get one extra unit). For percentage splits, it uses the Hamilton method to floor each share, then hand out leftover units by the largest fractional part. The guarantee holds for any input, which is why it's tested as a property rather than a list of examples.

The balance engine reads every expense, split, and settlement for a group and computes each member's net. Positive: the group owes you. Negative: you owe. The sum across the whole group is always zero, not something we verify after the fact, but a consequence of how the formula adds up.

Debt simplification works by pairing the largest debtor with the largest creditor, transferring the smaller of the two outstanding amounts, and repeating. 12 debts, 4 payments.

The settlement validator checks one thing: whether the amount you're trying to record exceeds what you actually owe right now. If it does, the write is rejected before anything hits the database.

Concurrency

Every write goes through withOccRetry. On SQLSTATE 40001, it backs off (jittered exponential), then retries up to 4 attempts. If all 4 exhaust, a clean error comes back, and the ledger is exactly as it was before the first attempt.

In practice, conflicts are rare. Append-only inserts with different UUIDs just don't collide often. The conflict window is narrow: two writes to the same group, within the same snapshot window. When it happens, withOccRetry catches it. The caller never knows.

Testing

138 tests, 24 files, under 25 seconds, no database, no network.

27 of those are property-based via fast-check; each correctness invariant gets hundreds of random inputs. The rest cover service logic, the auth guard, OCC retry, accessibility (axe-core, zero violations), contrast ratios, keyboard navigation, and touch target sizing.

The in-memory fake has an injectOccConflict(n) method. Call it with n = 2 and the next two writes throw 40001 before touching the state. That's how the retry path gets exercised without a live database, including the full backoff sequence.

Frontend

Server components fetch data and render the page skeleton. Client components handle interactivity: the add-expense form (with live split preview as you type), the settle-up flow, and balance display.

Tailwind CSS with shared design tokens. Radix UI for form primitives. Accessibility wasn't an afterthought; every control has a label, every error message is wired to its field via aria-describedby, and balance status uses text and icons, not color alone.


The hard parts

The remainder bug

My first equal-split implementation passed every unit test I wrote. The property test found a counterexample on its second run: a specific amount that didn't divide evenly, where the "assign leftover to last person" logic was off by exactly one.

remainder = amount - (perPerson * n). That's the fix. Distribute one extra unit to each of the first remainder participants. Two lines of code. But I never would have found the failing case by writing examples; it only showed up at a particular combination of amount and participant count that I hadn't thought to try.

That's what property testing is for. You describe what must always be true, and the library finds the counterexample for you.

Authorization is separate from referential integrity

Aurora PostgreSQL enforces foreign keys, and the schema uses them. But a foreign key constraint tells you a row exists; it doesn't tell you whether the caller is allowed to touch it.

The auth guard checks three things in order before every write: does the group exist, is the caller a member of it, and are all the participants in the expense members too? If any check fails, nothing is written, and the error message doesn't say which check failed. That last part matters; you don't want to leak whether a group exists to someone who isn't in it.

Testing OCC without a live database

Retry logic is hard to test in a single-threaded context. The in-memory fake makes it possible by letting you say "the next N writes should throw 40001 without touching state." The retry wrapper backs off, and retries against a clean state, and the eventual result is identical to a first-attempt success.

The property test for this has one claim: if the persistence layer eventually succeeds within the retry budget, the ledger ends up consistent. If all retries are exhausted, the error is clean, and nothing has changed. That has held across thousands of fast-check iterations. I'm reasonably confident in it.


Stack

Next.js 15, TypeScript strict mode, Tailwind CSS, Aurora PostgreSQL with serializable isolation, Vitest + fast-check for 27 property-based correctness tests, axe-core (zero violations), Vercel.

Built With

Share this project:

Updates