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#
- Sestav
Invoice— strany, měnu, datum vystavení a řádky. - Peněžní pole neplň. Cokoli tam dáte,
InvoiceCalculatorvbeforeSave()přepíše. Vstupy jsou jen cena, množství, sazba DPH, sleva a přepínačeuseVat/useDiscount. - Číslo nastavuj jen při importu. Běžně nechte pole
numberprázdné a naplňtebillingLineId— číslo přidělí řada. - Ulož přes
InvoiceManager::save(). Přepočet, číslování i PDF se navěsí samy. - 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