A B2B startup was delivering on time and getting paid late. The Leak Ledger found the leak was not the customers — it was the ledger. On-time collections went from 62% to 90%.
A procurement-platform startup selling to businesses on credit terms. Sales closed, operations delivered, and cash arrived whenever it arrived. Nobody could say how much was overdue, by how many days, or with whom — so nobody could chase it with any confidence. The question from the founders was blunt: where is our money, and why is it there?
Three systems that had never met properly. Zoho Books as the accounting ledger (invoices, line items, credit terms in days). A MySQL operations database (orders, quantities, delivery status, committed dates). And Google Sheets — including a tab called, in earnest, Collection Dashboard — where the team typed invoice dates and payment status by hand.
-- the join that did not exist yet: order → invoice → terms SELECT o.order_id, o.delivery_date, i.invoice_id, i.invoice_date, i.payment_terms AS payment_terms_in_days, i.status FROM ops.orders o LEFT JOIN zoho_db_new.invoices i ON i.reference_number = o.order_id WHERE o.delivery_status = 'Delivered'; -- delivered, but was it ever invoiced?
Order confirmed → goods delivered → signed delivery receipt uploaded → invoice raised in Zoho → terms start → payment lands. Tracing every order through those six steps produced three findings in the first week:
| LEAK | WHAT WAS ACTUALLY HAPPENING | WHY NOBODY SAW IT |
|---|---|---|
| Delivered, never invoiced | Invoicing waited for the signed receiving form to be uploaded. Field teams uploaded late or not at all. Orders sat "delivered" in ops and simply did not exist in the ledger. | Two systems, no join. Each looked healthy alone. |
| Invisible ageing | Invoice dates were typed into the sheet as dd-Mon-yy. A mistyped date became a blank when parsed — and a blank has no age. | errors="coerce" is silent by design. The oldest invoices vanished first. |
| Status by memory | "Overdue" was a status someone had to set. Payment status was free text: paid, Paid, partial?, chq recd. | No rule computed it, so the number depended on who last opened the sheet. |
# ageing from the ledger, not from anyone's memory (pandas) inv["invoice_date"] = pd.to_datetime(inv["invoice_date"], format="%d-%b-%y", errors="coerce") bad = inv["invoice_date"].isna() & inv["invoice_id"].notna() assert bad.sum() == 0, f"{bad.sum()} invoices lost their date — fix the sheet, not the code" inv["due_date"] = inv["invoice_date"] + pd.to_timedelta(inv["payment_terms_in_days"], unit="D") inv["days_overdue"] = (today - inv["due_date"]).dt.days.clip(lower=0) inv["bucket"] = pd.cut(inv["days_overdue"], [-1, 0, 30, 60, 90, 10_000], labels=["current", "1–30", "31–60", "61–90", "90+"]) ageing = inv.groupby(["bucket", "customer_segment"])["balance_due"].sum()
The shape said everything. Half of all overdue value was more than sixty days old — not because customers were slow, but because nobody had asked. Cut by segment, the worst cohort was the one everyone assumed was safest: repeat customers on 45-day terms, where familiarity had replaced follow-up.
| LEAK | SHARE OF STUCK VALUE | EFFORT TO SEAL | VERDICT |
|---|---|---|---|
| Delivered, never invoiced | ≈ 35% | Low — a daily join and a list | Seal first. Pure process; the money was never even asked for. |
| 31–90 days, never chased | ≈ 40% | Low — reminders by owner, by bucket | Seal second. Largest pool; a phone call recovers most of it. |
| Disputed quantities / short deliveries | ≈ 15% | Medium — needs delivery-vs-invoice reconciliation | Seal third, with the inventory fix. |
| 90+ days, silent customers | ≈ 10% | High — escalation, credit holds | Founder call list. Don't automate what needs a human. |
A Python job on a small EC2 box ran before the day started: pull orders and deliveries from MySQL, pull invoices and terms from Zoho, join them, compute every flag from rules instead of memory, and publish the result to the one place the team already lived — the Collection Dashboard sheet. Humans kept the phone calls. The machine kept the list.
# flags computed from rules, never typed (the core of the daily job) tracker["invoiced_status"] = np.select( [tracker.qty_invoiced == tracker.qty_ordered - tracker.qty_cancelled, tracker.qty_invoiced > 0], ["Invoiced", "Partially Invoiced"], default="Not Invoiced") leak_1 = tracker[(tracker.delivery_status == "Delivered") & (tracker.invoiced_status != "Invoiced")] leak_2 = inv[(inv.days_overdue.between(31, 90)) & (inv.last_chased.isna())] # one reconciliation check that has to pass before anything publishes assert abs(inv.balance_due.sum() - zoho_ar_total) < 1, "ledger and tracker disagree — stop" publish(ws_collections, leak_2.sort_values("balance_due", ascending=False)) # chase list, largest first publish(ws_uninvoiced, leak_1) # raise these today
Two smaller seals travelled with it. Payment status became a computed column, so chq recd stopped being a state. And because a third of "disputes" were really short deliveries the customer was right about, inventory got barcode scanning and audit cycles — the receivables problem was partly a warehouse problem wearing a finance costume.
| BEFORE | AFTER (MONTH 5) | |
|---|---|---|
| On-time collections | ≈ 62% | 90% |
| Overdue value older than 60 days | ≈ 50% of overdue | ≈ 15% |
| Delivered-but-uninvoiced orders | Unknown (nobody could count them) | A daily list, cleared same day |
| Inventory discrepancies | Baseline | Down 30% · accuracy to 95% |
| "Who hasn't paid us?" | A week of month-end | A sheet, refreshed before standup |
The customers were never the leak. The ledger was. Money got stuck at the seams between systems — an invoice that was never raised, a date that quietly became a blank, a status that lived in someone's head. Every one of those is a rule waiting to be written. Trace the path, age what's stuck, rank it by what it's worth, seal the top of the list with something that runs every morning, and watch it — because the moment you stop, it reopens.
Have money stuck somewhere in billing, collections or pricing? The Leak Ledger starts with a two-week TRACE — a map of where cash should move in your business and doesn't, with the leaks ranked. You'll know what it's worth before deciding anything else.
Start with a TRACE →