← SIMRANJAISWAL.INCASE · STUCK MONEY

Where the money got stuck

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%.

TRACEMap every point money should move and doesn't.
AGECohort the stuck money by age, segment and cause.
RANKScore each leak by recoverable value against effort.
SEALAutomate the top leaks, reconciliation built in.
WATCHA standing dashboard and a monthly read, so it stays fixed.
THE LEAK LEDGER · THE METHOD THIS CASE FOLLOWS, STAGE BY STAGE
01 · THE BUSINESS QUESTION

Orders were fulfilled, deliveries were on time, and finance still spent the last week of every month asking "who hasn't paid us?"

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?

02 · THE DATA

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?
03 ·TRACEMAP WHERE MONEY SHOULD MOVE AND DOESN'T

Cash has a path. Walk it, and the leaks announce themselves.

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:

LEAKWHAT WAS ACTUALLY HAPPENINGWHY NOBODY SAW IT
Delivered, never invoicedInvoicing 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 ageingInvoice 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.
04 ·AGECOHORT THE STUCK MONEY

Once every invoice had a real date and a real term, ageing was arithmetic — and the arithmetic was ugly.

# 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()
BEFOREAFTER · MONTH 5
60% 45% 30% 15% 0% 1–30 days · before: 26% of overdue value 1–30 days · after: 58% of overdue value 26%58% 1–30 DAYS 31–60 days · before: 24% 31–60 days · after: 27% 24%27% 31–60 61–90 days · before: 31% — the biggest bucket 61–90 days · after: 11% 31%11% 61–90 90+ days · before: 19% 90+ days · after: 4% 19%4% 90+
SHARE OF OVERDUE VALUE BY AGEING BUCKET · BEFORE THE LEDGER VS MONTH 5 · HOVER A BAR. HALF THE OVERDUE MONEY WAS OLDER THAN 60 DAYS; AFTERWARDS, 85% OF IT WAS UNDER 60.

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.

05 ·RANKRECOVERABLE VALUE AGAINST EFFORT

Not every leak is worth sealing first. Score them, then argue.

LEAKSHARE OF STUCK VALUEEFFORT TO SEALVERDICT
Delivered, never invoiced≈ 35%Low — a daily join and a listSeal first. Pure process; the money was never even asked for.
31–90 days, never chased≈ 40%Low — reminders by owner, by bucketSeal second. Largest pool; a phone call recovers most of it.
Disputed quantities / short deliveries≈ 15%Medium — needs delivery-vs-invoice reconciliationSeal third, with the inventory fix.
90+ days, silent customers≈ 10%High — escalation, credit holdsFounder call list. Don't automate what needs a human.
06 ·SEALAUTOMATE THE TOP LEAKS, RECONCILIATION BUILT IN

The fix was not a dashboard. It was a scheduled job that made the ledger and the warehouse agree every morning.

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.

07 ·WATCHA STANDING DASHBOARD AND A MONTHLY READ

A leak you stop watching reopens. The dashboard's job is to make that boring to prevent.

100% 80% 60% 40% Month 0 · 62% on time (baseline) Month 1 · 65% — un-invoiced backlog raised Month 2 · 74% — chase list live Month 3 · 84% Month 4 · 88% Month 5 · 90% on time 62% 90% M0M1 M2M3 M4M5
ON-TIME COLLECTION RATE, MONTHLY · INVOICES PAID WITHIN TERMS ÷ INVOICES DUE · HOVER A POINT FOR WHAT CHANGED THAT MONTH.
BEFOREAFTER (MONTH 5)
On-time collections≈ 62%90%
Overdue value older than 60 days≈ 50% of overdue≈ 15%
Delivered-but-uninvoiced ordersUnknown (nobody could count them)A daily list, cleared same day
Inventory discrepanciesBaselineDown 30% · accuracy to 95%
"Who hasn't paid us?"A week of month-endA sheet, refreshed before standup
08 · THE TAKEAWAY

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.

STARTUP WORK (ZOPLAR ERA, 2023–25) — OUTCOME FIGURES AS REPORTED: ON-TIME COLLECTIONS TO 90%, DISCREPANCIES DOWN 30%, INVENTORY ACCURACY TO 95%. AGEING DISTRIBUTION, BASELINE RATE AND LEAK SHARES ARE REPRESENTATIVE RECONSTRUCTIONS; EMPLOYER DATA CONFIDENTIAL. CODE IS THE REAL SHAPE OF THE PIPELINE, SIMPLIFIED.

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 →