Delinquency ladder for self storage. TypeScript, MySQL, no framework.
Live: https://arrears.levelbrook.com
A tenant who stops paying moves through a fixed sequence: courtesy reminder, late fee, overlock, statutory lien notice, advertised sale, auction. That looks like a cron job with a switch statement until you look at what makes it hard.
Lien timelines are rows, not constants. The day thresholds, and whether a step is required at all, are set by state lien law and they differ. In the seeded data Texas allows a lien notice at 30 days past due and California does not until 45, and California does not require overlock at all. Two tenants both 34 days past due sit at different rungs because their states say different things. Encoding this in code means every statutory amendment is a deploy, and an operator running facilities in four states runs one state's rules everywhere.
A state with no rules on file raises rather than falling back to a default. Guessing here means applying some other state's lien timeline to a real tenant.
One step at a time. A ledger ignored for ninety days does not get six notices fired at it in one batch. Several of those steps carry statutory waiting periods between them, and sending them together is legally the same as not sending them.
Steps cannot be skipped or reordered. Every earlier required step is a precondition. The deployed console has a "Send straight to auction" button on every ledger and it is refused every time, with the reason. Skipping a rung is how a unit gets sold out from under someone.
A step is written at most once. Enforced by UNIQUE (ledger_id, step, cycle), not by
a preceding "have we already sent this" query, because that check is a race the moment the
scheduler runs on two workers. A duplicate lien notice restarts a statutory clock, a
duplicate sale advertisement costs money, and a duplicate late fee is how you get a class
action.
Only a payment that clears the balance cures the delinquency. Ten dollars against a four hundred dollar balance has not cured anything, and treating it as a reset leaves the unit in limbo while the operator believes it is handled. Curing starts a new ladder cycle rather than deleting the old steps: the notice history is evidence, and if the tenant falls behind again next quarter the operator still has to show what went out the first time.
-
Send straight to auction on any ledger. Refused, with which precondition is missing or which threshold has not passed.
-
Click it 24 times at once fires 24 simultaneous sends of the due step:
24 workers tried to send courtesy_reminder notices written : 1 refused : 23 errors : none -
Pay $10 against a $425 balance. Posted, still delinquent, ladder does not reset.
-
Compare Rosa (TX) and Priya (CA), both 34 days past due, at different rungs.
mysql -u"$USER" -e "CREATE DATABASE arrears_test;"
npm install
npm test # 29 tests
npm run typecheckThe database tests skip cleanly when no MySQL is reachable, rather than failing and hiding a real regression behind an environment problem.
completed() originally ordered the step history by taken_at alone. MySQL DATETIME is
second-resolution by default, so two steps taken in the same second tied and came back in
whatever order the engine chose, which made the sequence assertion flap. That sequence is
the audit trail of what was sent to a tenant, so it has to be stable: the insertion id is
now the tiebreaker.
src/ladder.ts which step is due, and whether a given step may be taken
src/store.ts persistence, the unique index, payments under a row lock
src/server.ts the operations console
test/ 29 tests, including a 24-way concurrent send