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

DPH engine#

Kdo, komu a s jakou sazbou DPH účtuje, rozhoduje jedno místo v Api vrstvě systému. Kapitola je pro toho, kdo počítá DPH na novém místě (objednávka, doklad, platba, náhled ceny) nebo hledá, proč zákazník s DIČ dostal cenu s daní. Jak se DPH promítne do dokladu, popisuje Fakturace, ceny v e-shopu E-shop.

Z čeho se skládá#

Část Úkol
TaxManager orchestrátor: načte profil prodejce k datu plnění, země, povahu druhů plnění, sazby a ověření DIČ, zařadí zákazníka a zeptá se tabulky pravidel. Nic neukládá kromě evidence ověření DIČ
TaxRuleTable (Models/Helpers/) jediné místo s pravidly zacházení (kód, účtovat DPH, země sazby, doložka)
TaxAmountCalculator (Models/Helpers/) jediná práce s částkami: rozpad ceny s DPH a rozpočet vedlejších nákladů (doprava, příplatek) po sazbách
SellerTaxProfileManager jediné místo, odkud engine ví, kdo je prodejce pro daňové účely: status k datu, země, DIČ
VatIdCheckManager evidence ověření DIČ (vies / manual) a pravidla, kdy DIČ platí
VatIdAutoCheckManager automatické ověření DIČ po uložení registrace, profilu a kontaktu adresáře

Všechno je v app/UI/Api/System/Models/.

Metoda TaxManager Kdy
resolve(TaxContext, $lines) závazný výpočet: zacházení, základ a daň každého řádku, hlavička, příznaky a chyby
resolveDisplay($sellerId, $paymentRegionId, $supplyKindIds, $at) katalog a detail: anonymní spotřebitel v zemi zobrazovacího regionu; region bez země = země prodejce a příznak COUNTRY_ASSUMED. Nikdy nevolá VIES
allocateAncillary($resolution, $amount, $grossPrice) rozpočte dopravu nebo příplatek na skupiny sazeb poměrně podle základů; zbytek po zaokrouhlení dostane největší skupina
rate($supplyKindId, $countryCode, $at) holá sazba druhu plnění v zemi k datu
findNoteKey($code, $sellerId, $at) klíč překladu doložky k zacházení
verifyVatId(…) ověření DIČ mimo výpočet (uložení profilu, tlačítko v administraci)
resetCache() zahodí mezipaměti požadavku včetně profilů prodejců

Kdo engine dnes volá:

Volající Co volá
OrderTaxManager (e-shop) resolve, rate, resolveDisplay
ProductSetManager (e-shop, zobrazení ceny sady) resolveDisplay
InvoiceTaxManager (ručně vystavený doklad, rozvrh) resolve, resolveDisplay, findNoteKey
InvoiceGenerator (doklad z platby bez snímku) resolve, rate, findNoteKey
bridge api/system/tax/resolve a .../resolve-display (TaxResolveTrait, formulář platby na webu) resolve, allocateAncillary, resolveDisplay

Čtení je raw DBAL

Engine nečte přes ORM a nic nehydratuje, takže ho jde volat uprostřed stavby objednávky nebo platby. Sazby (druh × země × den), povahy druhů plnění, země regionů a profily prodejců si drží v paměti po dobu požadavku.

Prodejce k datu plnění#

SellerTaxProfileManager::findProfile($sellerId, $at) vrátí status, zemi a DIČ prodejce platné k datu:

Údaj Odkud
status (payer, identified, non_payer) řádek cms_mod_invoicer_seller_tax_statuses s nejnovějším valid_from <= datum
status prodejce bez jediného řádku historie plochý sellers.use_vat (payer / non_payer)
status, když historie začíná až po datu žádný — profil je null
země sellers.country_code; prázdná = CZ (DEFAULT_COUNTRY_CODE)
DIČ snímek prodejce (SellerManager::getSellerSnapshot())

Bez profilu engine nespadne: přidá globální příznak SELLER_STATUS_MISSING, počítá jako neplátce a rozhodnutí nechá volajícímu.

Výsledek bez statusu prodejce nepoužívejte

Všichni dnešní volající berou výsledek jen tehdy, když má hlavičku (header !== null), nemá chyby (hasErrors()) a nemá příznak SELLER_STATUS_MISSING; jinak nechají sazby, které měli. Kdo příznak přehlédne, vystaví doklad nebo objednávku jako neplátce, i když prodejce DPH účtuje.

Dlouhý CLI proces po změně prodejce nebo sazeb

Profil se cachuje po prodejci a dni, sazby po druhu, zemi a dni. Cron nebo import, který v jednom procesu mění status prodejce či sazby a hned počítá, musí zavolat TaxManager::resetCache(). Jinak počítá se stavem z prvního dotazu.

setStatusOverride($sellerId, $status) vnutí prodejci status jen v tomto procesu. Je to šev pro testy a harnessy; žádný parametr požadavku ani bridge k němu nevede a nesmí vést.

Ověření DIČ zákazníka#

Zákazník se zařazuje po řádcích podle země, kam řádek míří (zboží podle země doručení, ostatní podle země zákazníka):

Země řádku Zařazení
země prodejce tuzemsko; firma podle customerIsBusiness nebo vyplněného DIČ
jiný stát EU, bez DIČ spotřebitel, i když zákazník uvedl, že je firma
jiný stát EU, s DIČ firma jen s ověřeným DIČ, jinak spotřebitel
mimo EU firma podle prohlášení zákazníka, příznak BUSINESS_STATUS_DECLARED

DIČ se ověřuje líně: nejvýš jednou za výpočet a jen tehdy, když některý řádek míří do jiného členského státu EU. U firmy mimo EU se ruční doklad („Doložit firmu“, pod DIČ nebo IČ) k výsledku jen připojí. Liší-li se prefix DIČ od země adresy, B2B status jde podle prefixu, místo plnění podle adresy a výsledek nese VAT_ID_COUNTRY_MISMATCH.

Jestli DIČ platí, rozhoduje poslední rozhodující záznam evidence pro dvojici country_code + vat_number:

Záznam / politika Výsledek
manual + platné ověřeno, bez expirace (platí, dokud se DIČ nezmění; nová dvojice záznam nemá)
manual + neplatné neověřeno; online se nepřebíjí, rozhodl správce
CacheOnly ověřeno jen vies + platné a checked_at nejvýš FRESH_DAYS (30) dní před datem; jinak VAT_ID_NOT_VERIFIED
RefreshStale VIES online nejvýš jednou denně pro DIČ; rozhodující odpověď vies z téhož dne se vezme z evidence
výpadek VIES (valid = NULL) uloží se jako doklad, ale sám nerozhoduje: platné ověření do 30 dní dál drží zákazníka jako firmu s příznakem VIES_UNAVAILABLE, bez něj je neověřený
vyčerpaný limit online volání VIES se nevolá, rozhodne uložený záznam; příznak VAT_ID_CHECK_LIMIT_REACHED
Politika (VatIdPolicy) Pro
CacheOnly (výchozí v TaxContext) zobrazení, náhled, vykreslení formuláře, pozdější spárování platby
RefreshStale odeslání objednávky nebo platby, tedy den plnění

Do limitu (config/Shared/ratelimit.neon → vatIdCheck, viz Rate limit) se počítá jen skutečné volání VIES; odpověď z evidence ne. Limit null = bez omezení (privilegovaná role, kód administrace a CLI). Selhaný zápis do evidence se zaloguje a online odpověď se použije dál, jen bez checkId.

Automatické ověření po uložení#

VatIdAutoCheckManager::verifyAfterSave() volá registrace (UserManager::completedSave() s meta.verifyVatId) a akce api/system/vat-id-check/verify s automatic = true (profil, adresář):

  1. Prodejce = předaný, jinak portálový 7/invoice.default.sellerId.
  2. Online (RefreshStale) jen u DIČ jiného členského státu EU než země prodejce. Tuzemské DIČ nemění zacházení a automaticky se neověřuje; DIČ mimo EU VIES nemá. Ostatní případy odpoví z evidence (CacheOnly).
  3. Limit je pojistka podle IP (buildAutoCheckLimit()), ne kvóta tlačítka uživatele.
  4. Nikdy nevyhodí výjimku: uložení, které ověření spustilo, je už commitnuté. Chyba jde do logu tax a vrátí se jako nedostupné VIES.

Bridge pro web#

api/system/tax/resolve a api/system/tax/resolve-display jsou jen interní — nemají action*() a nesmí ho dostat: resolve s vatIdPolicy=refresh_stale volá VIES a z internetu spustit nesmí. Klíče :Api:System:Tax:resolve a :Api:System:Tax:resolveDisplay mají guest, registrated a admin. sellerId = null znamená portálového prodejce 7/invoice.default.sellerId.

Prázdná odpověď bridge není „bez DPH“

Bez grantu vrátí guard na interní cestě tichou prázdnou obálku a front traity skončí u „žádné informace o DPH“. Volající ji musí brát jako selhání, ne jako nulovou daň — jinak plátce ukáže a naúčtuje ceny bez DPH.

Formulář platby na webu#

Platební formulář (app/UI/Front/Base/Components/PaymentForm/, peníze v Traits/PriceTrait.php) účtuje tarify a kredity Inzerce, Katalogu firem, Diskuze a uživatelských účtů. Ceny tarifů (TariffPrice.price) jsou základ bez DPH, kreditní balíčky také. Co formulář naúčtuje, to se objeví na dokladu.

Režim volí brána a měna ceníku (resolveTaxMarker(), isFinalPriceGate()):

Brána / ceník Engine Cena Značka platby Doklad zákazníkovi
brána s payment_gates.intermediary_supply (SMS přes agregátora) nevolá se hladina 1:1 INTERMEDIARY nevzniká
ceník v nepeněžní měně (currencies.monetary = 0, kredity) nevolá se 1:1 CREDIT_REDEMPTION nevzniká
brána s payment_gates.final_prices resolve, všechny řádky jako grossPrice hladina 1:1, DPH se jen rozpadne shora — ano
ostatní resolve nad základy základ + DPH — ano

Konečné ceny jsou datové: rozhodují vlajky brány a měna, v kódu není žádný identifikátor brány. Poznámka u konečných cen je překlad price.vatNoteFinal.<script_name>, použije se jen tehdy, když existuje.

Vykreslení a překreslení čtou ověření DIČ jen z evidence (cache_only), odeslání formuláře je den plnění a jde přes refresh_stale. Poplatek brány je vedlejší plnění: jeho základ se pošle jako ancillary a engine DPH rozpočte poměrně podle skupin sazeb vybraných položek.

Zaokrouhlení:

  1. Mezivýsledky řádků a poplatku se sčítají nezaokrouhlené na 12 míst (WORK_SCALE).
  2. Součet se zaokrouhlí jednou, rovnou na přesnost měny (cms_system_currencies.precision), metodou roundToPrecision(). Výsledek jde do payment.total_price a 1:1 do brány.
  3. Brány s konečnými cenami (SMS, kredity) se na přesnost měny nezaokrouhlují, jen na 3 místa (money()). Posunutá hladina (1,619 → 1,62 €) by se v bráně SMS nespárovala s číslem.
Zápis 494,9045 na 2 místa
roundToPrecision($součet, 2) 494,90
roundToPrecision(money($součet), 2) 494,91, protože money() už jednou zaokrouhlil na 494,905

Selhání bridge není „bez DPH“

Když engine u brány bez značky nevrátí použitelný výsledek (chyby, chybějící sazba, zamítnutí RBAC), formulář ukáže ceny jako základ a odeslání odmítne (isTaxUnavailable()). Brát prázdnou odpověď jako nulovou daň by znamenalo naúčtovat plátci ceny bez DPH.

money(), applyVat() a vatMultiplier() jsou kopie z InvoiceCalculator

Front nesmí importovat třídu z Api, proto má formulář vlastní kopie (mezivýpočty na 12 míst, peníze half-up na 3). Kdo změní jednu stranu a druhou ne, rozejde se částka platby s dokladem. Sazba z engine jde do výpočtu jako DECIMAL(5,2) řetězec ('21.00', '5.50'), stejně jako InvoiceItem.vat. Zaokrouhlit ji na celé číslo by špatně nacenilo položku se sazbou 5,5 %.

Snímek položek se ukládá v transakci platby

Formulář posílá řádky cms_system_payment_items v meta['snapshotItems'] spolu s platbou. Api je uloží v PaymentManager::afterSave() uvnitř transakce uložení platby, stejně jako saveWithSnapshotItems() u expresních SMS. Selže-li jediný řádek, neuloží se ani platba a formulář ukáže chybu price.paymentNotCreated. Platba s částí snímku proto nevznikne. Klíč snapshotItems není v externím allowlistu meta, posílá ho jen interní formulář.

Postup: počítám DPH na novém místě#

  1. Sestav TaxContext: sellerId, taxDate = datum plnění, údaje zákazníka (země, země doručení, DIČ, IČ, customerIsBusiness), případně paymentRegionId.
  2. Zvol politiku: CacheOnly všude, kde se nic závazně neodesílá; RefreshStale jen při odeslání objednávky nebo platby, s limitem VatIdCheckManager::buildAutoCheckLimit($ip, $privileged).
  3. Zavolej resolve() s řádky TaxLine (druh plnění, částka, grossPrice).
  4. Ověř použitelnost: hlavička, hasErrors() (NO_LINES, SUPPLY_KIND_MISSING, RATE_MISSING) a příznak SELLER_STATUS_MISSING. Nepoužitelný výsledek nezapisuj.
  5. Dopravu a příplatky rozpočítej allocateAncillary(), ne vlastním dělením.
  6. Běží-li kód v dlouhém CLI procesu, po změně prodejce nebo sazeb zavolej resetCache().

Sazbu ani zacházení nepočítejte mimo engine

Pravidla jsou jen v TaxRuleTable. Vlastní podmínka „zákazník má DIČ → bez DPH“ obejde ověření ve VIES, status prodejce k datu i místo plnění podle povahy zboží a služeb.

Kam sáhnout#

Chci Kde
výpočet app/UI/Api/System/Models/Managers/TaxManager.php
pravidla zacházení app/UI/Api/System/Models/Helpers/TaxRuleTable.php
profil prodejce app/UI/Api/System/Models/Managers/SellerTaxProfileManager.php
ověření DIČ a evidenci app/UI/Api/System/Models/Managers/VatIdCheckManager.php, VatIdAutoCheckManager.php
vstup a výstup app/UI/Api/System/Models/DTO/TaxContext.php, TaxResolution.php; příznaky Enums/TaxFlag.php, chyby Enums/TaxError.php
bridge pro web app/UI/Api/System/Presenters/Traits/Actions/TaxResolveTrait.php, VatIdCheckVerifyTrait.php
peníze formuláře platby na webu app/UI/Front/Base/Components/PaymentForm/Traits/PriceTrait.php, snímek Traits/FormTrait.php
testy tests/Unit/System/TaxManagerTest.php

Nastavení cookies