# The Dunning Ladder Engine — the ladder that climbs itself

Part of **The Engine Room** on simranjaiswal.in: five automation engines that stop money going
missing between systems. This one is the six-rung reminder ladder from the collections case,
written down as a per-invoice state machine and raced against two other ways of chasing the same
book: not chasing at all, and shouting.

Live case, with the engine running in the browser: https://simranjaiswal.in/work/dunning-engine

## What it does

Every open invoice climbs the same ladder unless it is paid. Offsets are days from the due date.

| Rung | When     | What                                   |
|------|----------|----------------------------------------|
| R1   | due − 3  | friendly nudge                         |
| R2   | due + 3  | statement, every open invoice attached |
| R3   | due + 10 | call                                   |
| R4   | due + 20 | escalation to the account owner        |
| R5   | —        | promise-to-pay hold (entered from R3/R4) |
| R6   | due + 35 | final notice                           |
| —    | due + 50 | handover list; in-house contact stops  |

Rules, exactly as coded:

1. **No contact on weekends.** Day 0 is a Monday; a rung that lands on day 5 or 6 (mod 7) slides to
   the next weekday.
2. **One message per customer per day.** All of a customer's rungs due that day are batched into one
   contact; the engine counts contacts, not rungs. (With batching off it is one message per invoice.)
3. **Promise to pay.** At a contact that lands on R3 or R4, with probability 0.35 the customer
   promises to pay within the promise window (7 days). The ladder pauses; the invoice's hazard doubles
   for the window. Paid in time → kept. Not paid → broken, and the invoice resumes at R6 that day.
4. **Disputes.** A disputed invoice is off the ladder from issue until its resolution day, then
   rejoins at the rung its age implies.
5. **Handover.** At due + 50 an unpaid invoice goes on the handover list and in-house contact stops.
   The agency keeps chasing at the base hazard; what it collects is counted at face value (fees are not
   modelled — printed on the page).

Three policies run on the same book from the same simulation seed:

- **LADDER** — the rules above.
- **NO_LADDER** — hazards only: no contacts, no uplifts, no handover list (there is no process to hand
  over from).
- **SHOUTING** — a contact every 2 days from due + 1, same uplifts, but each contact after the fourth
  to the same customer halves the uplift (compounding fatigue). Handover at due + 50 as for the ladder.

Outputs per policy: cash collected by day (150-point cumulative series), % collected by day 150,
adherence (share of invoices paid by due + 7), DSO-ish (mean days from issue to payment), contacts
in total and per customer per month, handover count and value, promises made / kept / broken (LADDER),
rung reach counts, contacts per customer, the four invoice states on every day, and a segment table.
Tie-out, asserted on every one of the 150 days: invoices = paid + open + handed over + disputed-open.

## The synthetic world

There is no client data here. `build_book()` generates 50 customers × 8 invoices, issued over days
0–60 with 30-day terms and lognormal amounts (exp(normal(10.0, 0.7)), clipped ₹3,000–₹2,50,000).
Each customer is one of four segments — PROMPT 40% / NUDGE 35% / CHRONIC 20% / DISPUTED 5% — and pays
by a daily hazard once the clock starts: PROMPT 0.20/day from due − 2; NUDGE 0.03/day, CHRONIC 0.012/day
and DISPUTED 0.06/day from due (or from the dispute's resolution). A contact adds an uplift for 5 days:
NUDGE +0.18 on R1/R2, +0.10 from R3; CHRONIC +0.02 on R1/R2, +0.12 on R3/R4, +0.20 on R6; PROMPT +0.02
on any rung; DISPUTED (once resolved) as NUDGE. Half of a disputed customer's invoices are disputed,
resolving at due + 10 + int(rng·20).

The order of random draws is documented at the top of `engine.py`, because the whole thing hangs on it:
one draw per unpaid invoice per day in invoice order, before any contact logic; one draw per R3/R4
contact for the promise. Randomness is mulberry32, a 32-bit generator the browser port implements
bit-for-bit, so the page's reference run reproduces `results.json` exactly (the status line says so).

## Run it

```
python3 engine.py                                  # reference: seed 42, tempo 0, promise 7 d, weekend rule on, batching on
python3 engine.py --tempo 5                        # every rung 5 days later
python3 engine.py --promise 14 --no-weekend-rule   # a longer promise window, contacts on weekends
python3 engine.py --no-batching                    # one message per invoice: watch contacts per customer
node parity.mjs                                    # proves the browser port matches results.json
```

Stdlib only. `results.json` carries the parameters, the book, the three policies, the daily series,
the comparison and one customer's whole journey on the ladder.

## Reference run (seed 42)

See `results.json`. Headline: 400 invoices, ₹1.15 crore billed. LADDER collects 99.9% by day 150
with 571 contacts (2.28 per customer per month), adherence 74.3%, 2 handovers, 34 of 39 promises
kept. NO_LADDER collects 90.9% with adherence 47.8%. SHOUTING collects 94.4% with 1,061 contacts and
48 handovers. The ladder's extra cash over no ladder: ₹10,39,030, 9.1 points of billed.

## Where the shape comes from

The six-rung ladder Simran built for a logistics firm's fifty-odd client accounts
(https://simranjaiswal.in/work/shine-receivables), where adherence went to 95% and
receivables fell 20%. Outcome figures in that case are as reported there; nothing in this repository
is a client's data and the hazards above are chosen, not measured.

MIT.
