When you run a B2B auto parts e-shop, invoicing isn’t “send a PDF.” It’s a state machine with transitions that must make sense — and when one is missing or wrong, the accountant will notice before you finish typing the fix.
I recently went through the entire billing module of a white-label auto parts e-shop and cleaned it up properly. Here’s what I found and how I fixed it.
State Machines: No Going Back
Delivery notes and invoices each have their own lifecycle. Once a document is issued, it shouldn’t go back to draft. Once it’s paid, it shouldn’t be cancelled — only via a credit note.
Delivery note: DRAFT → ISSUED → DELIVERED
→ CANCELLED
Invoice: DRAFT → ISSUED → PAID
→ OVERDUE → PAID
→ CANCELLED
No more status === "CANCELLED" || status === "DRAFT" scattered across five files. One type-safe transition dictionary, one canTransition() function. When you add a new status, TypeScript forces you to write transitions everywhere.
VAT: Gross Price Is Sacred
This was the trap. Prices in the e-shop are VAT-inclusive — the customer pays 1,210 CZK, not 1,000 CZK + 210 CZK VAT. But when you need to split an invoice into base and tax, you must derive from the gross price and calculate tax as the remainder.
function splitVat(totalGross: number, rate: number): VatSplit {
const baseGross = Math.round(totalGross / (1 + rate));
return { baseGross, taxGross: totalGross - baseGross };
}
Why not totalGross * rate / (1 + rate)? Rounding. With 1,210 CZK at 21%, the correct base is 1,000 CZK and tax is 210 CZK. If you multiplied, some amounts would break base + tax !== total. This way the base is derived from gross and tax is what remains — it always adds up.
Outstanding Balance: One Definition, Two Places
The admin saw a different debt amount than the customer on their account page. Admin was counting cancelled invoices, customer wasn’t. Result: arguments about who’s right.
Fix: one outstandingGross() function that takes issued and overdue invoices, subtracts partial payments, and returns the number. Admin and customer call the same logic.
export function outstandingGross(invoices: OutstandingInvoice[]): number {
return invoices
.filter(i => OUTSTANDING_INVOICE_STATUSES.includes(i.status))
.reduce((sum, i) => sum + invoiceBalance(i.totalGross, sumPayments(i.payments)), 0);
}
Visibility: Customers Don’t See Drafts
Sounds obvious, but it’s easy to forget in practice. A document in DRAFT or CANCELLED status doesn’t belong on the customer account. One customerVisibleDocumentWhere() filter, used in every customer-facing query. Admin sees everything.
What Else Needed Fixing
- Duplicate documents: you could create two delivery notes for one order and invoice both. Now the system checks whether an issued document already exists for the order.
- Zero tax on invoices:
taxGrosssometimes came through as 0 because it was calculated from the wrong base. The fix is above —splitVat(). - Due dates: overdue tolerance was read from an env variable with no fallback or validation. Now it’s stored in shop settings and configurable from the admin panel.
- Email notifications: emails were dividing gross amounts by a hundred, even though they were whole crowns. Customers received numbers a hundred times lower. The bug was baked into the test too.
Result
Billing isn’t sexy until it starts calculating wrong. Now we have one source of truth for state transitions, one formula for VAT, one outstanding balance calculation, and one visibility filter. No copied logic, no discrepancies between admin and customer.
And the accountant? She didn’t notice a thing — which is the best sign that it works.