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ář):
- Prodejce = předaný, jinak portálový
7/invoice.default.sellerId. - 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). - Limit je pojistka podle IP (
buildAutoCheckLimit()), ne kvóta tlačítka uživatele. - Nikdy nevyhodí výjimku: uložení, které ověření spustilo, je už commitnuté. Chyba jde do logu
taxa 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í:
- Mezivýsledky řádků a poplatku se sčítají nezaokrouhlené na 12 míst (
WORK_SCALE). - Součet se zaokrouhlí jednou, rovnou na přesnost měny (
cms_system_currencies.precision), metodouroundToPrecision(). Výsledek jde dopayment.total_pricea 1:1 do brány. - 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ě#
- Sestav
TaxContext:sellerId,taxDate= datum plnění, údaje zákazníka (země, země doručení, DIČ, IČ,customerIsBusiness), případněpaymentRegionId. - Zvol politiku:
CacheOnlyvšude, kde se nic závazně neodesílá;RefreshStalejen při odeslání objednávky nebo platby, s limitemVatIdCheckManager::buildAutoCheckLimit($ip, $privileged). - Zavolej
resolve()s řádkyTaxLine(druh plnění, částka,grossPrice). - Ověř použitelnost: hlavička,
hasErrors()(NO_LINES,SUPPLY_KIND_MISSING,RATE_MISSING) a příznakSELLER_STATUS_MISSING. Nepoužitelný výsledek nezapisuj. - Dopravu a příplatky rozpočítej
allocateAncillary(), ne vlastním dělením. - 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 |