Fifty customers, four hundred invoices, and a finance executive chasing from memory. This engine is the six-rung reminder ladder from the collections case written down as a machine: every open invoice climbs the same rungs on the same days, one message per customer per day, never on a weekend, paused for a promise or a dispute, handed over at fifty days. It runs below, in your browser, against two other ways of chasing the same book — not chasing, and shouting.
A reminder ladder is easy to design and hard to keep. Rung one is a courtesy three days before due; rung six is a final notice at thirty-five days past; in between are a statement, a call and an escalation, each on its own day with its own tone. Written down, it took adherence at Shine Logistics to 95% and receivables down 20%. Kept by hand, it drifts: the Friday rungs slip to Monday, two invoices to one customer become two emails on one morning, a promise to pay is remembered by whoever took the call. The engine below is the ladder as a state machine — every invoice knows its rung, every rung knows its day, and the only human decision left is what the tone of each message should be. It is the SEAL of the Leak Ledger for the biggest leak of all: money that is owed, undisputed, and simply not asked for.
The engine has no opinion about tone and no memory of the customer's excuses. It answers one question per invoice per morning — which rung is due today? — then applies three rules before anything is sent: not on a weekend, one message per customer, nothing while a promise or a dispute is open. What makes it trustworthy is what it refuses to do: it will not send a statement on Saturday, will not send the same customer three emails, and will not chase an invoice the customer has said is wrong.
| RUNG | WHEN | WHAT HAPPENS |
|---|---|---|
| R1 · nudge | due − 3 | A friendly note that the invoice falls due this week, statement attached. |
| R2 · statement | due + 3 | Every open invoice for the customer in one statement. Half the "we never got it" replies are true. |
| R3 · call | due + 10 | A phone call. With probability 0.35 the customer promises to pay within the promise window. |
| R4 · escalation | due + 20 | To the account owner on our side. Same promise chance. |
| R5 · promise hold | from R3 / R4 | The ladder pauses for the window (7 days). Paid in time → kept. Not paid → broken, resume at R6 that day. |
| R6 · final notice | due + 35 | The last message from us. |
| Handover | due + 50 | On to the handover list; in-house contact stops. The agency chases at the base rate; its fees are not modelled. |
| Weekend rule | day mod 7 ∈ {5, 6} | No contact. The rung slides to the next weekday (day 0 is a Monday). |
| Batching | every day | One message per customer per day: all rungs due for that customer go in one contact. Contacts are counted, not rungs. |
| Disputes | issue → resolution | Off the ladder. On resolution the invoice rejoins at the rung its age implies. |
| Paying | every day | One draw per open invoice: pays if the draw is under its hazard — the segment's base rate, plus an uplift for five days after a contact, doubled during a promise. |
# the morning, for one customer (engine.py) — which rungs are due, then one message for i in by_cust[c]: if paid or handed or promise_open or dispute_open: continue # sched = due + offset, slid off weekends due_rungs = [no for no in RUNG_NO if no > s["last_sent"] and s["sched"][no] <= t] if due_rungs: s["last_sent"] = due_rungs[-1]; batch.append((i, due_rungs[-1])) if batch: contacts += 1 # ONE message per customer per day # the promise draw, only if a call actually happens if max(r for _, r in batch) in (3, 4) and rng.random() < 0.35: for i, r in batch: if r in (3, 4): S[i]["promise_until"] = t + promise_days
Run the ladder to see what it collects.
Three things to try. Press NO LADDER: the same customers, the same book, nobody asks — watch the cash curve sag and the unpaid pile up at day 150. Press SHOUTING: a contact every two days from the day after due. It collects more than silence and less than the ladder, at twice the messages, because after the fourth contact the customer stops reading — and it hands far more to the agency. Then drag the tempo to +10 and see what a ladder that starts a week late costs: the promise rate holds, the adherence does not. The rung days are a business decision, so they are a slider, not a constant.
| WHEN | WHAT HAPPENS | WHO SEES IT |
|---|---|---|
| 07:00 daily | Pull open invoices, payments to midnight, disputes and promises. For each invoice: which rung is due today? Refuse to run if the payment feed is older than 26 hours — a stale feed chases people who have paid. | A failed run pages the owner. Nobody is chased on a stale feed. |
| 07:02 | Batch: one message per customer, all rungs due, statement attached. Weekend and holiday check. Tie out: open invoices = on the ladder + on hold + disputed + handed over. Any gap → no send. | Finance sees the day's send list before it goes; the tie-out failure is the engine finding a bug in itself. |
| 07:05 | Send. Log the rung, the day and the channel against the invoice — the memory the business never had. | The customer gets one message, on a weekday, with everything on it. |
| Promise + window | The day a promise expires unpaid, the invoice resumes at R6 that morning — no reminder to remind. | The account owner, with the promise date on the line. |
| Weekly | Two numbers on one chart: adherence this week, contacts per customer. Rung days and tone reviewed only when that chart moves. | Leadership — the WATCH stage of the Leak Ledger. |
Chasing is not a mood; it is a calendar. The ladder works because it is boring — the same rung on the same day in the same tone, whoever is on leave. The engine adds the three refusals a person cannot keep up: not on a weekend, one message per customer, nothing while a promise is open. Shouting collects less at twice the cost. Silence collects least of all, and calls it patience.
Invoices that go out on time and come back late? A dunning engine is a two-to-four-week SEAL: your rungs, your tone, your batching rules — running every morning with a log of who was asked, when, and what they promised.
Start with a TRACE → engine.py README results.json The case it came from →