[ BLOG // 2026-07-14 // 3 MIN ]

Fakturace, stavové stroje a DPH: jak vypadá pořádný billing v B2B e-shopu

Proč nestačí uložit pár čísel do databáze — stavové přechody dokladů, správné dělení DPH z hrubé ceny a jednotný výpočet pohledávek pro admina i zákazníka.

PROJEKTY nextjseshopbillingautodilycase-study

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: taxGross obč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.

// ALL_POSTS
ZPĚT NA BLOG