A payment-date model on 48,839 real B2B invoices: for every open invoice, how many days after due it will clear — with a band the model has to earn — which ones will slip past a week, and a $323M order book rolled into a weekly cash forecast. Built with a no-lookahead protocol, judged against the number a good collector already has, exported to plain JavaScript and scoring live on this page.
Every ledger with terms on it has an open book: invoices posted, not yet paid. Model 06 ranks which accounts to chase. This one answers the question that comes after: for each open invoice, when will it actually clear — not the due date, the real date — and, added up, what does the next quarter's cash look like? A date without a band is a guess dressed up; a forecast without a comparison to the due-date schedule hides how much is slipping. This page gives both, and it is judged against the number a good collector already has in their head: this customer's usual lateness.
The HighRadius B2B invoice dataset, public and anonymised: invoices posted December 2018 to May 2020, in USD and CAD, across a handful of business units. 39,158 have a clearing date; 9,681 are still open — $323.1M, the order book this page forecasts. Terms are mostly net 15; 39% of invoices clear late, 6% more than a week late. It is used here because it is exactly the shape of a receivables ledger: posting_date, due_in_date, amount, customer, clear_date.
| FIELD | WHAT IT IS | USED AS |
|---|---|---|
| posting_date · baseline_create_date · due_in_date | When the invoice was raised, when its terms started, when it fell due | Terms length (due − baseline), calendar features; the posting day is the model's "today" |
| clear_date | When it was actually paid (blank for open invoices) | The target: days_late = clear − due, negative when early |
| cust_number · name_customer | Customer id and a masked name (4,197 name variants for 1,099 ids) | The id, never the name — history is built per customer |
| total_open_amount · invoice_currency | Invoice value, USD or CAD | Amount (log) and the money in the forecast |
| business_code · cust_payment_terms | Business unit; a terms code (NAA8, NAH4, CA10…) | Unit one-hots; terms as a number of days |
| area_business · document type | Entirely empty; all but six rows are "RV" | Dropped |
Hygiene. 1,161 rows were exact duplicates and went. Dates arrive as ISO strings in one column and YYYYMMDD integers in the next — both parsed, and checked against each other. Customer names are masked into variants ("WAL-MAR corp", "WAL-MAR trust", "WAL-MAR llc"…), so the customer id is the key, not the name. One customer accounts for 43% of invoices and $211M — and pays a median four days early, which is why the aggregate cash schedule on this book barely slips. A widely mirrored "new" copy of this dataset has scrambled due dates, some in the year 2157; this page uses the original file, and the README says which.
No lookahead. The strongest features are the customer's history: how late were they last time, how often past a week. Built carelessly, that history leaks — an invoice posted on 3 March must not "know" that an invoice posted on 1 March was paid late on 20 March. So every history feature is computed from invoices cleared before the posting day of the invoice being scored, and the count of that customer's invoices still open on that day is a feature in its own right.
The protocol. A time-based split, not a random one: train on invoices posted before 1 December 2019 (32,395), test on December to February (6,763). Random splits flatter payment models because the same customer's neighbouring invoices land on both sides; a time split is the honest version of "would this have worked last quarter".
# history strictly before the posting day (train.py) — per customer, per invoice done = (clear < posting_day) & ~isnat(clear) # only invoices already paid by "today" f["prior_share_late"] = (days_late[done] > 0).mean() f["prior_share_late7"] = (days_late[done] > 7).mean() f["last3_mean_late"] = days_late[done][argsort(clear[done])[-3:]].mean() f["open_count_at_posting"] = (~done & (posting < posting_day)).sum() # pressure: how much is already outstanding train = cleared & (posting_date < "2019-12-01"); test = cleared & ~train # time split, touched once
| BUSINESS UNIT | INVOICES | MEDIAN DAYS LATE | OVER 7 DAYS LATE |
|---|
| PREDICTOR OF DAYS LATE | MAE, DAYS · HELD-OUT DEC–FEB | WHAT IT IS |
|---|
| "MORE THAN 7 DAYS LATE" | AUC | PR-AUC | BRIER |
|---|
| SEGMENT | INVOICES | MAE · MODEL | MAE · CUSTOMER MEDIAN | SLIP RATE |
|---|
A first invoice from a new customer is a seven-day guess for anyone; with twenty invoices of history the error is two and a half days. Large invoices are the most predictable — they belong to the customers with the longest records. This is the honest shape of the problem: the customer's own history is most of the signal; the model's job is to combine it with terms, calendar and pressure, and to put a band on it.
Each open invoice gets a p10, a median and a p90 for days late. Four hundred Monte-Carlo draws through that band turn the book into a weekly schedule with its own band, set against the schedule the due dates promise. On this ledger the two lines nearly coincide — the dominant customer pays early and most terms are net 15 — which is itself the finding: the aggregate is healthy; the risk is concentrated in a tail of ~382 invoices worth $11.3M, listed below.
| # | AMOUNT | DUE | P(SLIP > 7 D) | EXPECTED DAYS LATE · BAND | UNIT · HISTORY |
|---|
The first row is the kind of thing a ranked list exists for: a $654k invoice from a customer with two prior invoices and a one-in-four chance of slipping — not the latest, not the most likely, but the most expensive to be wrong about. Rows two and three are the opposite: modest amounts, a long record, and a 97% probability of clearing a month late. Different calls, same list.
| CARD | |
|---|---|
| Intended use | A cash forecast with bands for treasury, and a chase list ranked by expected late value for collections. Decision support for humans; exclusions and disputes run before any score, as in case 04. |
| Training data | HighRadius B2B invoices, one company, posted Dec 2018 – Nov 2019 (32,395 invoices); tested on Dec 2019 – Feb 2020 (6,763); open book Feb – May 2020 (9,681). |
| Not valid for | Any other ledger, and this one after a change of terms policy. Mostly net-15, one dominant early payer, USD and CAD mixed without conversion (CAD is 8% of invoices). The method transfers; the model does not. |
| Known failure modes | New customers (seven-day error, nothing to learn from yet). Big slips: the model is late to call them, not early. The p10–p90 band covers 73%, not 80% — widen by the measured factor before use. The Monte-Carlo forecast treats invoices as independent; a customer who slips, slips on every invoice at once, so real bands are wider. December's dip in lateness (1.4% vs 7%) is a year-end effect the model has seen once. |
| Monitoring | Weekly: forecast vs actual cash by week; MAE and band coverage on invoices cleared that week; slip-flag calibration by decile; population-stability index on prior_share_late and term_days. Re-fit monthly, and immediately after any terms change. |
| Explainability | Per-invoice path contributions in days (shown above), summing exactly to the prediction; global importance by split gain; the baseline comparison published alongside every metric. |
A cash forecast is a payment-date model with a band, added up. Most of what there is to know about when an invoice clears is already in the customer's own history — the model's honest gain over that is seven percent on the date and seven AUC points on the slip. What the model adds that no median can is the band, the probability, and the roll-up: a weekly schedule the treasury can plan on, and a short list of invoices where being wrong is expensive. On this book the aggregate barely slips; the $11.3M tail is the whole story, and it fits on one page.
Have an open book and a cash plan that keeps missing? A payment-date model with bands on your own ledger is a Seal-sized piece of work: two to four weeks, fixed fee, forecast and chase list included. It starts with a TRACE of where the dates come from.
Start with a TRACE → train.py README model.json