September 2026 · 13 min read

Extracting the Lumo ledger, and the race it had been hiding

tenant-ledger is the accounting core of Lumo, taken out of the application and put on npm as its own package. Charges, payments, allocations, standing credit, credit notes and their reversal on one side, cash in and cash out on the other, one append-only event log under both. It exists because a studio desk does not pay the way a form expects: a parent pays for two children in one transfer, a payment arrives before the invoice does, a card bounces three weeks later, an owner types 200 instead of 20 and needs to fix it without losing the record that they did. None of that is hard to write down once. It is hard to keep one answer to "what does this account owe" that every screen and every month-end report agree on. I did not extract it because anyone needed a package. I extracted it because the core had been written under one rule and I wanted to know whether the rule was real, and then, once the ledger stood on its own, reviewing it found a race that had been in production the whole time, kept from happening only by the way one person at a desk uses the product.

Role
Design, the extraction brief, and the review. A coding agent did the mechanical work against the brief
Package
TypeScript · no runtime dependencies · ES modules and CommonJS · in-memory and Postgres stores · one behavioural contract suite
Status
1.0.0 on npm. 287 tests, five of them against a real Postgres on two connections. CI on Postgres 16 and 18
Source
github.com/robertciudica/tenant-ledger, MIT. npm install tenant-ledger

This is what a payment looks like from the outside. It is the first four of the nine steps of npm run demo, which drives the package against Postgres compiled to WebAssembly and reads every figure back out of the database after the step that produced it.

1. Bill the customer for January and February
   2026-01     €50.00  paid     €0.00  OVERDUE         (stored says PENDING)
   2026-02     €50.00  paid     €0.00  PENDING
   Today is 3 February. January is late and February is not, and no code
   path ever wrote OVERDUE: it falls out of the due date when you read it.

2. They say they will send €70. Where would it land?
   2026-01     €50.00 of    €50.00 outstanding  -> PAID
   2026-02     €20.00 of    €50.00 outstanding  -> PARTIALLY_PAID
   left over as credit:     €0.00   (nothing was written)

3. The €70 arrives
   allocated    €70.00, credit     €0.00
   2026-01     €50.00  paid    €50.00  PAID
   2026-02     €50.00  paid    €20.00  PARTIALLY_PAID

4. The bank webhook fires again with the same key
   IdempotencyError: Operation already processed: bank-2026-02-03-a1b2
   payments recorded: 1
Host applicationLumo: pricing, enrolment, billing periods, UIBillingServiceLedgerServicereceivables: charges, payments, allocations, creditcash: money in, money outa payment writes its own cash rowLedgerStore port30 methods, tenant id on every read and writeevent_logappend onlyone row per write,same transactionIn-memory storePostgres storetests, contract suiteschema.sql, contract suite, the race on two connections
The whole package. Two services, one storage port they share, two stores that implement it. The host hands in amounts and permissions and gets back what is owed.

The rest of this is about the decisions behind those steps, what was wrong with them when the ledger came out of Lumo, and how I know they hold now.

The boundary survived extraction

The rule the codebase calls the golden rule says that inside the core there are no ORM imports, no HTTP, no framework types and no file I/O, that storage goes through one interface injected into the constructor, and that every read and write on that interface takes the studio id as an explicit argument. I wrote it for testability and so that tenant identity stays visible at every call site. Extraction was never the plan. But a rule like that is a hypothesis with a cheap test, because if the boundary was real then taking the ledger out should be a rename and not a rewrite, so before any code moved I had an estimate written down: eight source files, about 135 identifier renames, 131 existing tests of which about 110 would port with renames only and six would be dropped along with a product feature, no adapters, no stubs.

That is what happened, and the estimate sits in docs/extraction.md next to the outcome. studentId became accountId and the entity behind it kept an id and a tenant and nothing else; a fixed six-role matrix became a permission set the caller supplies; the studio's chart of accounts became a constructor argument; five foreign keys into product tables became one optional reference string that nothing in the ledger reads; and the storage port shrank from the whole product to the 29 methods the ledger actually used. What stayed behind is everything that decides an amount: pricing, enrolment, billing periods, and the monthly summary that replays the event log. The ledger takes an amount and does not ask where it came from.

That proves one narrow thing: the boundary the rule described was where the dependencies actually were. It does not prove the ledger was correct, and it was not. The defects inside the boundary survived extraction exactly as well as the boundary did. What extraction gave me was a place where I could review the subsystem without the application around it making its assumptions safe.

The question the review kept asking

The extraction asked one question of the code: can this behaviour be preserved outside Lumo. The review asked a different one, and it turned out to be the same question every time: which layer can actually make this guarantee true? None of the failures were algorithmic. The waterfall allocated correctly, the status derivation computed the right state, the idempotency check caught the ordinary duplicate. Each failure was a guarantee sitting in a layer that could not own it, and every correction moved it down to the lowest layer that could, leaving the layers above to express the meaning rather than imitate the guarantee.

Authority in the wrong place

Problem

A charge has a state: pending, partially paid, paid, overdue or void. Every screen filters on it and the allocation waterfall selects open charges by it.

Obvious answer

Store the status on the charge row and keep it in sync.

Why it breaks

The original Lumo did that. One path wrote the column, five read it, and I found out it had drifted from a studio owner, who asked why the invoice list said a parent owed €50 while the parent's own page said nothing; both were reading the same rows. The fix at the time introduced one derivation function, but it kept one line from the old way of thinking: after deriving paid, partially paid and overdue it fell back to the stored value, so a row stamped paid with nothing allocated to it still read as paid, and the waterfall selected open charges by the column, so that charge could never be paid. Both were true in production and both came across in the extraction.

What I built

Authority moved back to the facts. VOID is the only value taken from storage, because it is the one state a person sets and the rows cannot derive. Everything else is computed from the amount, the allocations and the due date, and the column is kept for filtering only.

Why

A projection stored as truth drifts, whatever synchronises it. A projection computed at the read cannot.
export function computeEffectiveStatus(dbStatus, amount, paidAmount, dueDate, now) {
  if (dbStatus === 'VOID') return 'VOID'
  const balance = computeBalance(amount, paidAmount)
  if (balance <= 0) return 'PAID'
  if (paidAmount > 0) return 'PARTIALLY_PAID'
  if (dueDate.getTime() < now.getTime()) return 'OVERDUE'
  return 'PENDING'
}

Overdue had the same shape. Nothing in Lumo ever wrote it, so an account three weeks late looked identical to one due at month end and four features that filtered on overdue matched nothing. It is a function of a date the caller has already loaded, so now it is computed. And a method called calculateBalance returned standing credit, not a balance; inside Lumo its one caller knew that, so nobody else had to. It is calculateStandingCredit now with the formula untouched.

One consequence of facts being immutable needs stating precisely, because it is the part a financial systems engineer will push on. When a payment is reversed, its allocations are deleted rather than flagged, because the authority for "is this charge paid" is the sum of allocations for one charge id at about twenty read sites, and a flag would need all twenty, and every site written later, to remember an exclusion. Deleting makes every site correct without being touched, and the reversal writes one event whose payload snapshots every allocation it removed, so the history is kept in the log rather than in the table. That is a choice about where a fact lives, not a claim that it never was one.

Money follows the same principle. Amounts are integers in minor units, and until the review the guards only checked that an amount was an integer, which is the wrong check, because above 2^53 a JavaScript integer stops being exact and two different amounts compare equal. Every entry point now requires a safe integer and every sum goes through one function that throws rather than rounds, so the check sits where the arithmetic happens.

Idempotency: the read is a fast path, the constraint is the guarantee

Problem

Every mutating call takes a caller-supplied idempotency key. A retried webhook, a double-clicked button and two callers racing must all produce one payment and one error type.

Obvious answer

Receive the key, read the event log to see whether it has been seen, do the mutation, store the key.

Why it breaks

Two callers can both win the read. A read followed by a write with nothing holding them together guarantees nothing, and the only case it catches is the ordinary retry where the first attempt has already committed.

What I built

A unique constraint on (organization_id, idempotency_key) on the event log, written in the same transaction as the payment, the allocations and the cash row, so a rejected key rolls everything back together and a failed transaction rolls the key back with it. The pre-flight read stays as the fast path. The store throws DuplicateIdempotencyKeyError, and the services translate that into the one IdempotencyError a caller ever sees.

Why

The application can recognise the common case. Only storage can make the duplicate unrepresentable.

That translation is where my first correction was wrong. At the extraction commit a lost race surfaced as the driver's own error, and my first fix had the services recognise it by comparing the Postgres constraint name, which put the relational schema on the wrong side of the interface whose whole purpose was to hide it. The translation lives in the adapter now. And one of the seven mutating methods, applyCreditNote, had no pre-flight read at all; the constraint still stopped the duplicate, only the error type was wrong, and it had never mattered because the constraint had never fired.

The race

Two payments arrive for the same account at about the same time. Each, in its own transaction, reads the account's open charges and how much has landed on each, plans where its money goes, and writes allocations.

A: read outstanding on the charge   100
B: read outstanding on the charge   100
A: allocate 100, commit
B: allocate 100, commit
charge holds 200 against 100 owed, and no error anywhere

Each transaction is locally valid: it read a charge that was open and covered it. The final state is globally wrong. This was the behaviour in Lumo and in the extraction commit, and nothing had shown it, because the unit tests run one operation at a time against a store where every await resolves in order, so there is no second caller to interleave. The idempotency key does not help, because the two payments are genuinely different. READ COMMITTED does not help either: the allocation inserts do not conflict, and the two status updates on the same charge row do, but the second simply waits for the first to commit and then proceeds, by which point both transactions have already decided what to allocate from the same stale reading. The conflict the database sees arrives after the decision it would have needed to prevent.

The obvious reading is that the review found a concurrency bug. The truer one is that the model had an invariant nobody had written down, allocation for one account must be serialised, and the only thing enforcing it had ever been that in a studio one person records payments by hand, so the window was milliseconds wide and there was never a second caller in it. That is absence of failure, not prevention of it, and I had been treating the two as the same thing.

The waterfall was correct given a stable read, so the fix went around the read and the write and not inside the algorithm: a row lock on the account, taken as the first statement of every transaction that allocates and held until commit. B now waits for A and then reads the state A left, and nothing in the waterfall changed. The lock is one method on the storage interface, lockAccount, with a documented obligation that a real store holds it to the end of the transaction, so the invariant is part of the port rather than an assumption about callers. I wrote the test that shows the overpayment before the fix, because I wanted to see it happen rather than reason that it could. It runs against a real Postgres on two connections, with a counting latch rather than a timed pause, so the interleaving is a certainty and not a probability.

20,000

Allocated against a 10,000 charge with the lock degraded to a plain read and each transaction held after its read until the other had read too

test/postgres-concurrency.test.ts, Postgres 16 and 18 in CI

10,000 and 0

What the two payments allocate with the lock in place: the second waits, then reads the state the first left

same test file

The same lock has since gone back into Lumo, where account-level serialisation now runs in production. The extraction was meant to produce a package. What it also produced was a place where a production assumption could be challenged without the application around to make it safe.

One operation serving two events

The ledger had one correction for a payment: reverse every payment that landed on a charge, entered from the charge's side. That is right when a transfer bounces. It is wrong when the charge itself should not have been raised, because then the money did arrive, and clearing the charge declared it nonexistent and reopened every other charge it had covered. Nothing could even set a charge to void. Inside Lumo the two situations were rare enough, and handled by the same person, that the distinction never surfaced. They are two operations now because they are two accounting events. reversePayment says the money never arrived: it voids the payment whole, removes every allocation it funded and re-projects every charge it touched. voidInvoice says the charge was wrong: it marks it void, releases its allocations and leaves the payments alone, so their money becomes standing credit. Both take the account lock, because both change what is allocated.

Contracts that were only prose

The storage interface describes shape. The ledger depends on behaviour that shape cannot express, and at the extraction commit those rules were comments: voided payments must not come back from the account's payment list, or a reversed payment reappears as credit; the event log must reject a duplicate key, or every idempotency test is theatre; rows with no tenant column of their own must be reached through a parent that has one. The comments were correct, and with one implementation written by the person who wrote them that was tolerable. With two it was not.

In the review the rules became a suite that takes a factory for any store and runs 29 cases: tenant isolation per read, allocations reached only through a parent, voided rows excluded from the reads that spend them, the event log as idempotency anchor, locking and transactions, and round trips of amounts, instants and nulls. Run against the two stores that ship with the package, it found four defects. My in-memory store filtered allocations by id alone, ignoring the tenant, which is the exact failure the interface header warns about, in the reference implementation written by the person who wrote the header, and every service test had passed because every service test uses one tenant. The Postgres schema made account ids globally unique, so two tenants could not both have a customer-1. Charges created in one transaction got the same timestamp, because now() is transaction time, so oldest first had nothing to order by. And the Postgres store's runTransaction constructed the class by name, so a subclass's overrides were silently dropped inside transactions. The interface compiled in every case and the implementation was wrong in every case. The same review found that a payment could settle a charge in another currency at face value, through four entry points, because each of Lumo's studios runs one currency and nothing had ever needed to check. Preview and commit now take the currency and refuse a mismatch.

Tenant identity is where explicitness and enforcement divide. Every read and write on the port takes organizationId, with no ambient state, and what that repetition across thirty signatures buys is inspectability: when a query is wrong, the wrong id is in the arguments of the call that made it. It does not buy correctness, and the in-memory store proved that, because the type checker guarantees the argument is present and not that it is right. Explicit identity made the contract inspectable, the suite made it enforceable, and neither alone would have found the leak. The rule came from an incident, when Lumo leaked data across tenants through a table with no tenant column of its own. In this package that table is allocations, and the absence is deliberate: an allocation joins a payment and a charge that each carry the tenant, so a column would be a second copy of the same fact, and copies can disagree.

Permissions are the same division at the host boundary. The ledger checks membership in a set of four permissions the caller supplies, and at the extraction commit one method had no check at all because its check had lived in a Lumo server action. It has one now. But who a person is and what they may do belongs to the host, and the ledger's checks are only as good as what it is handed.

How to check it

Nothing above is estimated. Each claim has a command in the repository.

The rules hold
npm test: 287 tests, each named for the rule it covers, on Postgres compiled to WebAssembly
The lock holds under contention
DATABASE_URL=... npm test: two connections, the same race, 20,000 without the lock and 10,000 with it
A store is checked, not trusted
The contract suite runs against both shipped stores inside the same npm test
Cost does not grow with the account
npm run bench: nine queries per payment whether one charge is open or a hundred
The estimate held
docs/extraction.md, written before the work, next to what happened

What I did not build

A neutral role model. Owner, manager, clerk, so the package has authorization out of the box. The matrix would be a product decision the ledger has no business making, every host would map its real roles onto an invented set anyway, and it would imply a security boundary the ledger cannot provide. Four permissions, supplied by the host, keep the guarantee where it can be made.

ORM types, an HTTP layer or an ambient tenant in the core. Each removes repetition: the mapping layer, the need for a host, thirty organizationId parameters. Each would have made the extraction a rewrite, and the ambient tenant would have removed the property that made the leak findable.

What I would do differently

The concurrency test before the extraction, not after. It is one test on two connections, and it did not exist because I had never written the invariant down, so there was nothing to test. And the contract suite on the day the second store appeared, because the in-memory store was wrong from its first commit and every green run since had been a run with one tenant.

A coding agent did the mechanical extraction against a brief I wrote, and later the implementation work of the review, and I reviewed every diff, every test and every change to the shape of the thing; the README says so. The extraction was mechanical because the boundary was real, since a core that imports nothing but its own storage interface gives a literal reader nothing to untangle, and that is one example supporting one narrow claim.

The pattern across every finding is the same. Each assumption had survived because a fact about the host covered for it: one caller, one currency, one operator, one implementer. Each is now an invariant in the code, a case in the contract, or a responsibility stated as the host's. So I argue a subsystem's correctness without reference to how its current callers behave, and if the argument needs "but nobody calls it that way", the guarantee that sentence protects has not been placed anywhere yet. That sentence is the thing I look for when I review now, because every time I have found it, it was standing where a guarantee should have been.