A customer's AP portal does not argue; it rejects. A missing PO number, a GSTIN from the wrong state, an invoice dated eight days after delivery — each one bounces the invoice ten days later, and the payment clock starts again from zero. This engine reads every customer's own rules, runs all ten checks before the invoice leaves, and holds anything that would have been rejected with the fix list attached. It is running below, in your browser, on 800 invoices to 40 customers whose rules you can read.
Large customers bill by portal now. The portal has rules — a PO that must exist and still have value on it, a GSTIN whose state matches the bill-to, a delivery note number, HSN codes on every line, a date within a week of delivery — and it enforces them without reading the covering email. In the collections case the invoices that sat longest were the ones that had bounced: re-issued, re-numbered, re-submitted, and paid on terms counted from the second attempt. The fix is cheap on the day the invoice is drafted and expensive ten days later. The Invoice Validator is the SEAL that moves the check to the cheap side: a requirements profile per customer, ten checks that run in order and report every failure, and a hold that lists exactly what to fix.
The engine knows nothing clever. It does not guess what a portal wants; it is told, once, per customer, and it prints what it was told. A check that a customer does not require is not run for that customer — a missing HSN code is a defect only where HSN is a rule — which is why the profile matters more than the checks. The output is not a score. It is a list: this invoice, these failures, fix these before it goes.
| CHECK | RULE | REQUIRED BY |
|---|---|---|
| C1 · PO_MISSING | The invoice carries a PO number | 70% of customers |
| C2 · PO_EXHAUSTED | That PO is open and has remaining value (closed or fully billed → fail) | The same 70% |
| C3 · GSTIN_INVALID | GSTIN present and well-formed: ^[0-9]{2}[A-Z0-9]{13}$ with a state code 01–37. Format only — no checksum | Everyone |
| C4 · STATE_MISMATCH | The GSTIN's state code equals the bill-to state (place of supply). Only run when C3 passed | Everyone |
| C5 · ADDRESS_DRIFT | Bill-to address equals the customer master | Everyone |
| C6 · HSN_MISSING | Every line carries an HSN code | 50% of customers |
| C7 · DATE_WINDOW | Invoice date within 7 days of delivery | 40% |
| C8 · DN_MISSING | The delivery note number is on the invoice | 35% |
| C9 · AMOUNT_VS_PO | Invoice value not more than 0.5% over the PO line | 30% |
| C10 · DUPLICATE_NUMBER | The invoice number has never been used before | Everyone |
| Working days | 25% of portals accept submissions on working days only; a weekend send lands on Monday. A scheduling rule, not a defect — applied in both worlds | 25% |
| HOLD | Any failure holds the invoice with the full list; the fix takes 1–2 days and it goes out clean. Without the validator the same invoice goes out as drafted; the portal rejects it 4–13 days later with p = 0.85 per applicable failure (the rest slip through), it is re-issued after the same fix, and the customer pays 30-day terms plus their habitual lag (normal(8, 6) days) from the re-issue | The clock restarts |
# the validator — every check the customer requires, in order, all failures listed (engine.py) def validate(inv, C, seen): fails = [] if C["needs_po"] and inv["po"] is None: fails.append("PO_MISSING") if C["needs_po"] and inv["po"] is not None and inv["po"]["remaining"] <= 0: fails.append("PO_EXHAUSTED") g = inv["gstin"] gstin_ok = bool(GSTIN_RE.match(g)) and 1 <= int(g[:2]) <= 37 # ^[0-9]{2}[A-Z0-9]{13}$ if not gstin_ok: fails.append("GSTIN_INVALID") if gstin_ok and int(g[:2]) != C["state"]: fails.append("STATE_MISMATCH") # place of supply if inv["bill_to"] != C["address"]: fails.append("ADDRESS_DRIFT") if C["hsn"] and not inv["hsn"]: fails.append("HSN_MISSING") if C["date_win"] and inv["issue"] - inv["delivery"] > DATE_WINDOW: fails.append("DATE_WINDOW") if C["dn"] and not inv["dn"]: fails.append("DN_MISSING") if C["po_cap"] and inv["po"] is not None and inv["amount"] > inv["quote"] * (1 + AMOUNT_TOL): fails.append("AMOUNT_VS_PO") if inv["number"] in seen: fails.append("DUPLICATE_NUMBER") return fails # any failure → HELD, with this list
Run the book to see what the validator holds back.
Two things to try. Switch the validator off and the big number becomes the delay you are already paying for: the same 800 invoices, the same portals, nobody checking first. Then drag PO defects to ×3: held invoices climb, but only at the 70% of customers who require a PO — the other 30% never notice, which is the whole argument for a profile per customer rather than one rulebook for all.
| WHEN | WHAT HAPPENS | WHO SEES IT |
|---|---|---|
| At draft | Every invoice the ERP creates is validated against its customer's profile before it gets a number. Any failure → held, with the list. Nothing held is sent. | Billing, in the same screen they draft in. The fix is minutes, not a re-issue. |
| 09:00 daily | The hold list: one row per held invoice, customer, value, the failures, how long it has been held. Tie-out: drafted = sent + held. Any gap → no send run. | The billing lead. A hold older than two days is a conversation, not a queue. |
| On any rejection | A portal rejection that still arrives is a rule the profile did not know. It becomes a line in that customer's profile the same day — the engine learns the customer, not the other way round. | Finance ops; the profile diff is reviewed, not guessed. |
| Weekly | Two numbers: rejections avoided and days-to-cash by customer, with and without. Profiles reviewed only when a customer's rejections move. | Leadership — the WATCH stage of the Leak Ledger. |
The customer's rules are the customer's rules; the only choice is when you find out. Ten days after sending, a rejection costs a re-issue, a re-number and a restarted clock. Ten minutes before sending, it costs a PO lookup. Write the profile down, run every check, list every failure, and the portal has nothing left to say.
Invoices bouncing off a portal? A validator is a two-to-three-week SEAL: your customers' profiles, written down; every check run at draft; a hold list with the fix on every line — and a clock that starts once.
Start with a TRACE → engine.py README results.json The case it came from →