Když děláte B2B e-shop s autodíly, fakturace není „pošleme PDF”. Je to stavový stroj s přechody, které musí dávat smysl — a když některý chybí nebo je špatně, účetní to pozná dřív, než stihnete commitnout fix.
Nedávno jsem prošel celý billing modul v jednom white-label e-shopu s autodíly a udělal jsem pořádný úklid. Tohle je, co jsem našel a jak jsem to spravil.
Stavové stroje: žádná cesta zpět
Dodací listy a faktury mají každý svůj životní cyklus. Když je doklad jednou vystavený, neměl by se dát vrátit do konceptu. Když je zaplacený, neměl by se dát stornovat — leda přes dobropis.
Dodací list: DRAFT → ISSUED → DELIVERED
→ CANCELLED
Faktura: DRAFT → ISSUED → PAID
→ OVERDUE → PAID
→ CANCELLED
Žádné status === "CANCELLED" || status === "DRAFT" na pěti místech. Jeden typově bezpečný přechodový slovník, jedna funkce canTransition(), která řekne ano/ne. Když přidáváte nový stav, TypeScript vás donutí dopsat přechody všude.
DPH: hrubá cena je svatá
Tohle byla past. Ceny v e-shopu jsou včetně DPH — zákazník platí 1 210 Kč, ne 1 000 Kč + 210 Kč DPH. Když ale potřebujete rozdělit fakturu na základ a daň, musíte vycházet z hrubé ceny a daň dopočítat jako zbytek.
function splitVat(totalGross: number, rate: number): VatSplit {
const baseGross = Math.round(totalGross / (1 + rate));
return { baseGross, taxGross: totalGross - baseGross };
}
Proč to není totalGross * rate / (1 + rate)? Protože zaokrouhlování. Když máte 1 210 Kč a sazbu 21 %, správný základ je 1 000 Kč a daň 210 Kč. Kdybyste násobili, při některých částkách by base + tax !== total. Takhle je základ odvozený z hrubé a daň je to, co zbývá — vždycky to sedí.
Pohledávky: jedna definice, dvě místa
Admin viděl dluh zákazníka jinak než zákazník na svém účtu. Admin počítal i se zrušenými fakturami, zákazník ne. Výsledek: dohady, kdo má vlastně pravdu.
Oprava: jedna funkce outstandingGross(), která bere issued a overdue faktury, odečte částečné platby a vrátí číslo. Admin i zákazník volají stejnou logiku.
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);
}
Viditelnost: zákazník nevidí koncepty
Zní to jako samozřejmost, ale v praxi se na to zapomíná. Doklad ve stavu DRAFT nebo CANCELLED nepatří na zákaznický účet. Přidal jsem jeden filtr customerVisibleDocumentWhere() a používám ho ve všech customer-facing dotazech. Admin vidí všechno.
Co ještě bylo potřeba spravit
- Duplicitní doklady: šlo vytvořit dva dodací listy k jedné objednávce a oba zafakturovat. Teď se hlídá, jestli už k objednávce existuje vystavený doklad.
- Nulová daň na faktuře:
taxGrossobčas prolétlo jako 0, protože se počítalo z nesprávného základu. Fix je výše —splitVat(). - Splatnost: tolerance pro overdue se brala z env proměnné, ale chyběla fallback hodnota a validace. Teď se čte z nastavení e-shopu, které jde měnit v adminu.
- E-mailové notifikace: maily dělily hrubé částky stem, přestože šlo o celé koruny. Zákazník dostával stokrát nižší čísla. Bug byl zabetonovaný i v testu.
Výsledek
Billing není sexy, dokud nezačne počítat špatně. Teď máme jeden zdroj pravdy pro stavové přechody, jeden vzorec pro DPH, jeden výpočet pohledávek a jeden filtr viditelnosti. Žádné kopírování logiky, žádné rozpory mezi adminem a zákazníkem.
A účetní? Ta si ničeho nevšimla — a to je ten nejlepší signál, že to funguje.