Přeskočit obsah
V
Pro vývojáře
Architektura, konvence, jádro systému a bezpečnost
Moduly / Fakturace

Fakturace#

Modul Invoicer vystavuje daňové doklady — ručně z administrace, z objednávky, automaticky z platby a podle rozvrhu. Kód: app/UI/Api/Invoicer/, administrace app/UI/Admin/Invoicer/.

Z čeho se modul skládá#

Skupina Entity K čemu
doklad Invoice + řádky InvoiceItem, InvoiceShipper, InvoicePaymentMethod, InvoiceVoucher, InvoiceGift samotná faktura; každý druh řádku má vlastní tabulku, ale stejnou sadu peněžních polí
šablona InvoiceTemplate + zrcadlová sada InvoiceTemplate* předloha pro opakované doklady
strany Seller, SellerContact, SellerBankAccount, Customer, CustomerContact prodejce a odběratel
číselníky InvoiceType, BillingLine, PaymentMethod, ProductType typ dokladu, číselná řada, způsob platby, druh plnění
automatika Schedule, PaymentStatusRule rozvrh opakování, pravidlo „stav platby → typ dokladu"

K tomu tři generátory a jeden počítadlo-free helper:

Třída Co dělá Kde se volá
InvoiceCalculator přepočte všechna peněžní pole InvoiceManager::beforeSave(), před flush
InvoiceNumberGenerator přidělí order, periodKey a number tamtéž, jen při vložení
InvoiceGenerator postaví celý doklad z platby (nebo dobropis ze zdroje) cron cron/invoicer/payment/generate
InvoicePdfGenerator vyrobí PDF a QR platbu InvoiceManager::completedSave(), po commitu
CreditNoteLedger dopočítá, kolik z faktury už je dobropisováno kontrola stropu dílčích dobropisů

Doklad je otisk, ne odkaz#

Faktura si při vystavení opíše adresu prodejce i odběratele, bankovní spojení, formát čísel i sazby. Není to výpadek normalizace — daňový doklad musí navždy vypadat tak, jak byl vystaven.

Změna karty prodejce nezmění už vystavené doklady — a naopak

Oprava jen na jednom místě je nejčastější chyba v tomhle modulu. Když opravíte IČO na kartě prodejce, staré faktury dál nesou to původní; když opravíte kartu faktury, nová faktura zase vyjde se starým údajem. Ptejte se pokaždé, jestli opravujete budoucí doklady, nebo jeden konkrétní.

Postup: vystavuji doklad z kódu#

  1. Sestav Invoice — strany, měnu, datum vystavení a řádky.
  2. Peněžní pole neplň. Cokoli tam dáte, InvoiceCalculator v beforeSave() přepíše. Vstupy jsou jen cena, množství, sazba DPH, sleva a přepínače useVat / useDiscount.
  3. Číslo nastavuj jen při importu. Běžně nechte pole number prázdné a naplňte billingLineId — číslo přidělí řada.
  4. Ulož přes InvoiceManager::save(). Přepočet, číslování i PDF se navěsí samy.
  5. Ověř isSuccess() — neúspěch nese konkrétní kód (chybějící řada, duplicitní číslo, porušené XOR).

Ruční číslo a řada se vylučují (XOR)

Poslat obojí naráz skončí chybou VALIDATION_FAILED. Doklad s ručním číslem nese order = 0 a prázdný periodKey; druhý takový ve stejné řadě by porušil unikátní index uq_invoice_sequence. Ruční číslo je vyhrazené pro import a migraci, admin formulář ho nezadává vůbec.

Číslování#

Řada (BillingLine) nese masku, periodu resetu a počet číslic. Generátor z ní odvodí:

Pole Jak vznikne
periodKey klíč období podle billing_period řady (rok, měsíc, …)
order MAX(order) + 1 v rámci dvojice (řada, období)
number maska s náhradami %Y, %m, %d (z data vystavení) a %number (doplněné nulami na number_length)
variableSymbol jen číslice z čísla, pokud nebyl zadaný ručně

Kritická sekce je serializovaná SELECT … FOR UPDATE na řádku řady a drží se do commitu; pojistkou na úrovni schématu je UNIQUE(billing_line_id, order, period_key).

Maska bez %number shodí druhou fakturu v období

První doklad se vystaví, druhý složí totéž číslo. Generátor zkusí zvednout pořadí, ale bez %number se výsledek nezmění — smyčku ukončí a backstop v manageru vrátí DB_DUPLICATE_ENTRY. Vypadá to jako chyba ukládání, ale je to chybně nastavená řada.

Datum vystavení řídí číslo, takže zpětné datování vyrobí jinou řadu čísel

%Y/%m/%d se berou z data vystavení a podle něj se počítá i klíč období. Změna data u nevystaveného dokladu tedy mění, do které sekvence spadne — a to není vidět, dokud se doklad neuloží.

V sekvenci vznikají díry a je to správně

Ručně importovaná faktura má číslo, ale nezvedá MAX(order) v řadě (nemá řadu). Generátor kolizi obejde posunem pořadí — obsazený slot zůstane jako mezera. Kdo tu díru „opraví“, vyrobí duplicitu.

Přepočet částek#

Server je autorita: invoice-calc.js tutéž matematiku jen zrcadlí kvůli UX. Všechny tři druhy řádků (položka, doprava, platební metoda) sdílejí identickou sadu polí.

per řádek
  vatPercent      = useVat      ? (vat ?? 0)      : 0
  discountPercent = useDiscount ? (discount ?? 0) : 0
  priceAfterDiscount = price − price · discountPercent/100
  totalPrice         = priceAfterDiscount · quantity
  totalPriceWithVat  = priceAfterDiscount · (1 + vatPercent/100) · quantity
  vatInMoney         = totalPriceWithVat − totalPrice

hlavička
  base    = Σ řádek.totalPrice          (položky + doprava + platby)
  baseVat = Σ řádek.totalPriceWithVat
  totalPrice        = base    − Σ sleva voucheru
  totalPriceWithVat = baseVat − Σ sleva voucheru s DPH

Hlavičkové useVat dominuje nad sazbami řádků

Když je vypnuté, sazba každého řádku se počítá jako nula — v PDF nebude rozpis DPH, i když hodnoty v datech jsou. Neplátce DPH tak dostane správný doklad, ale kdo přepínač přehlédne, vidí „chybí DPH“ a hledá to v šabloně.

Klientským číslům se nevěří

item.totalPrice i header.totalPrice poslané formulářem se neukládají — přepíše je dopočet. Nemá smysl je v Api volání posílat a nemá smysl ladit, proč „se neuložila“ hodnota, kterou počítá server.

Zaokrouhlení je zatím vypnuté

roundedTotal* = total* a rounding* = 0. Pole existují a PDF šablona je čte, ale celokorunové zaokrouhlení se zatím nedělá.

Dobropis#

Dobropis není příznak na faktuře. Je to typ dokladu s příznakem isCreditNote(); výsledný doklad je zrcadlo zdroje se zápornými částkami a odkazem na něj (relatedInvoiceId).

Strop dílčích dobropisů hlídá CreditNoteLedger a drží jedinou invariantu:

pro každý řádek zdrojové faktury platí Σ dobropisovaných kusů ≤ fakturovaných kusů

Věc Jak se řeší
zůstatek dopočítává se z existujících dobropisů, nedrží se jako čítač
klíč id řádku zdrojové faktury, nikdy order_item_id (ten není unikátní)
voucher neklíčuje se kusy, ale částkou — pevná sleva se rozpouští po částech
souběh ledger sám zámek nemá; kontrola i zápis musí proběhnout uvnitř zámku zdroje

Čítač zůstatku by se rozešel při prvním smazaném dokladu

Proto se dopočítává. Kdo to „zoptimalizuje“ na uložené číslo, dostane doklad, který jde dobropisovat víc než jednou.

Dobropisový řádek bez vazby na zdroj se nesmí ignorovat

Starý doklad, který se nepodařilo spárovat, znamená, že nevíme, který zdrojový řádek spotřeboval. Stropu u takové faktury nelze věřit — volající to musí odmítnout, ne přeskočit.

Jedna platba smí mít víc dokladů

Řádnou fakturu i dobropis. Na Invoice.paymentId proto není unikátní index a idempotenci drží kontrola existujícího dokladu daného typu plus posun Payment.invoicedStatus.

PDF a QR platba#

Vznikají v completedSave() — tedy po commitu, mimo save transakci. Sloupce pdf a qrCode jsou ve defaultSkipFields, takže je běžné uložení objektového grafu vynechá; jediný jejich zapisovatel je přímý updateColumns() po commitu.

Generátor je best-effort: nikdy nevyhazuje výjimku, dílčí selhání jen zaloguje a vrátí null.

Bez PDF varianty se soubor vůbec nevytvoří a nikdo se to nedozví

Variantu vybírá matice typ plnění × platební region × typ uživatele. Když pro kombinaci nic není, faktura se uloží v pořádku, jen bez souboru. Chyba nikde není — přijde se na to, až si někdo řekne o PDF.

Přegenerování nejdřív promaže složku dokladu

Ručně přidané soubory ve složce faktury zmizí. Proto se do pdf/qrCode zapisuje i null — stará cesta by po neúspěšné regeneraci ukazovala na smazaný soubor.

Automatika: rozvrhy a platby#

Úloha Endpoint Jak často Vypínač
rozvrhy /cron/invoicer/schedule/generate denně
doklady z plateb /cron/invoicer/payment/generate často (např. à 15 min) 7/enabledForPayments

Obě jsou dávkové (7/scheduleBatchSize, 7/paymentBatchSize, výchozí 10).

Rozvrh generuje ze šablony nebo klonem faktury a datum dalšího spuštění posune až po úspěšném uložení — proto je opakované spuštění bezpečné.

Doklady z plateb jdou podle pravidel PaymentStatusRule (stav platby → jeden typ dokladu). Generace je záměrně odpojená od platebního callbacku, aby nezdržovala odpověď bráně. Chyby se dělí na dva druhy:

Druh chyby Co se stane Proč
přechodná (konfigurace, selhání uložení) invoicedStatus se neposune, platba se zkusí příště příště to může vyjít
trvalá (DB_NOT_FOUND — osiřelé pravidlo, chybí zdroj dobropisu) platba se odbaví bez dokladu a zapíše se do chyb retry by nikdy neuspěl a platba by trvale žrala dávku

Trvalá chyba znamená platbu bez faktury — a systém ji pustí dál

Je to schválně: jedna nevyřešitelná platba by jinak vyhladověla celou dávku a nevystavily by se ani ty ostatní. Důsledek ale je, že přehled chyb se musí kontrolovat — jinak zůstane zaplacená objednávka bez dokladu a nikdo se to nedozví.

Měna s vypnutým vystavováním se odbaví bez dokladu a bez chyby

Kredity a podobné interní měny doklad nedostávají. Platba se označí za odbavenou, aby se nevracela do fronty každý běh — v přehledu chyb proto nebude.

Rozvrh bez řady se tiše přeskočí

Doplněním řady se to samo spraví, ale zmeškaný termín se nevrátí.

Uložení kolekcí je nahrazení#

Pole, která formulář nevykresluje, se musí přenést explicitně

Uložení objektového grafu chybějící řádky maže — viz Hydrátory. U faktury to má navíc peněžní důsledek: zmizelé řádky posunou strop dílčích dobropisů, takže se pak dá dobropisovat víc, než bylo fakturováno.

Kam sáhnout#

Chci Kde
přepočet částek app/UI/Api/Invoicer/Models/Helpers/InvoiceCalculator.php
číslování app/UI/Api/Invoicer/Models/Generators/InvoiceNumberGenerator.php
stavbu dokladu z platby app/UI/Api/Invoicer/Models/Generators/InvoiceGenerator.php
PDF a QR app/UI/Api/Invoicer/Models/Generators/InvoicePdfGenerator.php
strop dobropisů app/UI/Api/Invoicer/Models/Managers/CreditNoteLedger.php
hooky uložení app/UI/Api/Invoicer/Models/Managers/InvoiceManager.php
cron úlohy app/UI/Cron/Invoicer/Presenters/
administraci app/UI/Admin/Invoicer/

Navazující kapitoly: Životní cyklus manageru · Hydrátory · E-shop · Naplánované úlohy · Fakturace v administraci