VAT engine#
Who charges VAT to whom and at what rate is decided in one place in the system Api layer. This chapter is for anyone computing VAT in a new place (order, document, payment, price preview) or looking for why a customer with a VAT id got a price including tax. How VAT ends up on a document is described in Invoicing, shop prices in E-shop.
What it consists of#
| Part | Job |
|---|---|
TaxManager |
orchestrator: loads the seller profile on the supply date, countries, the nature of supply kinds, rates and the VAT id verification, classifies the customer and asks the rule table. It stores nothing except VAT id verification evidence |
TaxRuleTable (Models/Helpers/) |
the only place with treatment rules (code, charge VAT, rate country, legal note) |
TaxAmountCalculator (Models/Helpers/) |
the only work with amounts: splitting a gross price and allocating ancillary costs (shipping, surcharge) over rates |
SellerTaxProfileManager |
the only place the engine learns who the seller is for tax purposes: status on a date, country, VAT id |
VatIdCheckManager |
VAT id verification evidence (vies / manual) and the rules for when a VAT id counts |
VatIdAutoCheckManager |
automatic VAT id check after saving a registration, profile or address book contact |
Everything lives in app/UI/Api/System/Models/.
TaxManager method |
When |
|---|---|
resolve(TaxContext, $lines) |
binding computation: treatment, base and tax of every line, header, flags and errors |
resolveDisplay($sellerId, $paymentRegionId, $supplyKindIds, $at) |
catalog and detail: anonymous consumer in the country of the display region; a region without a country = the seller country and the COUNTRY_ASSUMED flag. Never calls VIES |
allocateAncillary($resolution, $amount, $grossPrice) |
allocates shipping or a surcharge to rate groups proportionally to their bases; the rounding remainder goes to the largest group |
rate($supplyKindId, $countryCode, $at) |
plain rate of a supply kind in a country on a date |
findNoteKey($code, $sellerId, $at) |
translation key of the legal note for a treatment |
verifyVatId(…) |
VAT id verification outside a computation (profile save, admin button) |
resetCache() |
drops the request caches including seller profiles |
Who calls the engine today:
| Caller | What it calls |
|---|---|
OrderTaxManager (e-shop) |
resolve, rate, resolveDisplay |
ProductSetManager (e-shop, product set price display) |
resolveDisplay |
InvoiceTaxManager (manually issued document, schedule) |
resolve, resolveDisplay, findNoteKey |
InvoiceGenerator (document from a payment without a snapshot) |
resolve, rate, findNoteKey |
bridge api/system/tax/resolve and .../resolve-display (TaxResolveTrait, payment form on the website) |
resolve, allocateAncillary, resolveDisplay |
Reads are raw DBAL
The engine does not read through the ORM and hydrates nothing, so it can be called in the middle of building an order or a payment. Rates (kind × country × day), supply kind natures, region countries and seller profiles are kept in memory for the request.
Seller on the supply date#
SellerTaxProfileManager::findProfile($sellerId, $at) returns the status, country and VAT id of
the seller valid on the date:
| Item | Source |
|---|---|
status (payer, identified, non_payer) |
the cms_mod_invoicer_seller_tax_statuses row with the newest valid_from <= date |
| status of a seller without any history row | the flat sellers.use_vat (payer / non_payer) |
| status when the history starts after the date | none — the profile is null |
| country | sellers.country_code; empty = CZ (DEFAULT_COUNTRY_CODE) |
| VAT id | the seller snapshot (SellerManager::getSellerSnapshot()) |
Without a profile the engine does not fail: it adds the global flag SELLER_STATUS_MISSING,
computes as a non-payer and leaves the decision to the caller.
Do not use a result without the seller status
All current callers use the result only when it has a header (header !== null), has no
errors (hasErrors()) and has no SELLER_STATUS_MISSING flag; otherwise they keep the rates
they had. Whoever misses the flag issues a document or an order as a non-payer even though the
seller charges VAT.
Long CLI process after a seller or rate change
The profile is cached per seller and day, rates per kind, country and day. A cron or import that
changes a seller status or rates in one process and computes right away must call
TaxManager::resetCache(). Otherwise it computes with the state from the first query.
setStatusOverride($sellerId, $status) forces a seller status in this process only. It is a seam
for tests and harnesses; no request or bridge parameter leads to it, and none may.
Customer VAT id verification#
The customer is classified per line by the country the line goes to (goods by the delivery country, everything else by the customer country):
| Line country | Classification |
|---|---|
| the seller country | domestic; a business by customerIsBusiness or a filled VAT id |
| another EU state, no VAT id | consumer, even if the customer declared a business |
| another EU state, with a VAT id | business only with a verified VAT id, otherwise consumer |
| outside the EU | business by the customer's declaration, flag BUSINESS_STATUS_DECLARED |
The VAT id is verified lazily: at most once per computation and only when some line goes to
another EU member state. For a business outside the EU the manual evidence ("Prove the company",
under the VAT id or the registration number) is only attached to the result. When the VAT id prefix
differs from the address country, the B2B status follows the prefix, the place of supply the
address, and the result carries VAT_ID_COUNTRY_MISMATCH.
Whether a VAT id counts is decided by the latest decisive evidence row for the pair
country_code + vat_number:
| Row / policy | Result |
|---|---|
manual + valid |
verified, no expiry (valid until the VAT id changes; a new pair has no row) |
manual + invalid |
not verified; never overridden online, the admin decided |
CacheOnly |
verified only by vies + valid with checked_at at most FRESH_DAYS (30) days before the date; otherwise VAT_ID_NOT_VERIFIED |
RefreshStale |
VIES online at most once a day per VAT id; a decisive vies answer from the same day is taken from the evidence |
VIES outage (valid = NULL) |
stored as proof but never decides on its own: a valid check within 30 days still keeps the customer a business with the VIES_UNAVAILABLE flag, without one the customer is not verified |
| online call limit exhausted | VIES is not called, the stored row decides; flag VAT_ID_CHECK_LIMIT_REACHED |
Policy (VatIdPolicy) |
For |
|---|---|
CacheOnly (default in TaxContext) |
display, preview, form rendering, later pairing of a payment |
RefreshStale |
submitting an order or a payment, i.e. the supply date |
The limit (config/Shared/ratelimit.neon → vatIdCheck, see
Rate limit) counts only real VIES calls; an answer from the
evidence does not. A null limit = unlimited (privileged role, admin and CLI code). A failed evidence
insert is logged and the online answer is still used, only without checkId.
Automatic check after a save#
VatIdAutoCheckManager::verifyAfterSave() is called by the registration
(UserManager::completedSave() with meta.verifyVatId) and by the action
api/system/vat-id-check/verify with automatic = true (profile, address book):
- Seller = the one passed, otherwise the portal seller
7/invoice.default.sellerId. - Online (
RefreshStale) only for a VAT id of another EU member state than the seller country. A domestic VAT id does not change the treatment and is not checked automatically; a non-EU VAT id has no VIES. Other cases answer from the evidence (CacheOnly). - The limit is a per-IP safety cap (
buildAutoCheckLimit()), not the user's button quota. - It never throws: the save that triggered it is already committed. The error goes to the
taxlog and comes back as an unavailable VIES.
Bridge for the website#
api/system/tax/resolve and api/system/tax/resolve-display are internal only — they have no
action*() and must not get one: resolve with vatIdPolicy=refresh_stale calls VIES and must not
be triggerable from the internet. The keys :Api:System:Tax:resolve and
:Api:System:Tax:resolveDisplay are held by guest, registrated and admin. sellerId = null
means the portal seller 7/invoice.default.sellerId.
An empty bridge answer is not \"no VAT\"
Without the grant the guard returns a silent empty envelope on the internal path and the front traits end at "no VAT information". Callers must treat it as a failure, not as zero tax — otherwise a payer shows and charges prices without VAT.
The payment form on the site#
The payment form (app/UI/Front/Base/Components/PaymentForm/, money in Traits/PriceTrait.php)
charges the tariffs and credits of Adverts, the Company catalogue, Discussion and user accounts.
Tariff prices (TariffPrice.price) are a base without VAT, credit packages too. What the form
charges is what appears on the invoice.
The mode is chosen by the gate and the price-list currency (resolveTaxMarker(),
isFinalPriceGate()):
| Gate / price list | Engine | Price | Payment marker | Invoice for the customer |
|---|---|---|---|---|
gate with payment_gates.intermediary_supply (SMS via an aggregator) |
not called | level 1:1 | INTERMEDIARY |
none |
price list in a non-monetary currency (currencies.monetary = 0, credits) |
not called | 1:1 | CREDIT_REDEMPTION |
none |
gate with payment_gates.final_prices |
resolve, all lines as grossPrice |
level 1:1, VAT is only split from the top | — | yes |
| others | resolve over the bases |
base + VAT | — | yes |
Final prices are data-driven: the gate flags and the currency decide, there is no gate
identifier in code. The note for final prices is the translation price.vatNoteFinal.<script_name>,
used only when it exists.
Rendering and redraws read the VAT id check from the records only (cache_only); submitting the
form is the tax point and goes through refresh_stale. The gate fee is an ancillary supply: its
base is sent as ancillary and the engine splits the VAT proportionally by the rate groups of the
selected items.
Rounding:
- Line and fee intermediates are summed unrounded at 12 places (
WORK_SCALE). - The sum is rounded once, straight to the currency precision
(
cms_system_currencies.precision), withroundToPrecision(). The result goes topayment.total_priceand 1:1 to the gate. - Gates with final prices (SMS, credits) are not rounded to the currency precision, only to
3 places (
money()). A shifted level (1.619 → €1.62) would not be matched to a number by the SMS gate.
| Call | 494.9045 to 2 places |
|---|---|
roundToPrecision($sum, 2) |
494.90 |
roundToPrecision(money($sum), 2) |
494.91, because money() already rounded once to 494.905 |
A bridge failure is not \"no VAT\"
When the engine returns no usable result for a gate without a marker (errors, missing rate, RBAC
denial), the form shows the prices as a base and rejects the submit (isTaxUnavailable()).
Reading an empty response as zero tax would charge a VAT payer's prices without VAT.
money(), applyVat() and vatMultiplier() are copies from InvoiceCalculator
The front end must not import an Api class, so the form has its own copies (intermediates at
12 places, money half-up to 3). Changing one side and not the other makes the payment amount
drift from the invoice. The engine rate enters the computation as a DECIMAL(5,2) string
('21.00', '5.50'), like InvoiceItem.vat. Rounding it to a whole number would misprice an
item at 5.5 %.
The item snapshot is saved in the payment transaction
The form sends the cms_system_payment_items rows in meta['snapshotItems'] together with the
payment. The Api saves them in PaymentManager::afterSave() inside the payment save
transaction, like saveWithSnapshotItems() for express SMS. If a single row fails, the payment
is not saved either and the form shows the price.paymentNotCreated error. A payment with a
partial snapshot therefore cannot arise. The snapshotItems key is not in the external meta
allowlist; only the internal form sends it.
Procedure: computing VAT in a new place#
- Build a
TaxContext:sellerId,taxDate= supply date, customer data (country, delivery country, VAT id, registration number,customerIsBusiness), optionallypaymentRegionId. - Pick the policy:
CacheOnlywherever nothing is submitted bindingly;RefreshStaleonly when an order or a payment is submitted, with the limitVatIdCheckManager::buildAutoCheckLimit($ip, $privileged). - Call
resolve()withTaxLinerows (supply kind, amount,grossPrice). - Check usability: the header,
hasErrors()(NO_LINES,SUPPLY_KIND_MISSING,RATE_MISSING) and theSELLER_STATUS_MISSINGflag. Do not write an unusable result. - Allocate shipping and surcharges with
allocateAncillary(), not by your own division. - In a long CLI process, call
resetCache()after a seller or rate change.
Do not compute the rate or treatment outside the engine
The rules live only in TaxRuleTable. A custom condition "customer has a VAT id → no VAT"
bypasses the VIES check, the seller status on the date and the place of supply by the nature of
goods and services.
Where to look#
| I want | Where |
|---|---|
| the computation | app/UI/Api/System/Models/Managers/TaxManager.php |
| treatment rules | app/UI/Api/System/Models/Helpers/TaxRuleTable.php |
| the seller profile | app/UI/Api/System/Models/Managers/SellerTaxProfileManager.php |
| VAT id verification and evidence | app/UI/Api/System/Models/Managers/VatIdCheckManager.php, VatIdAutoCheckManager.php |
| input and output | app/UI/Api/System/Models/DTO/TaxContext.php, TaxResolution.php; flags Enums/TaxFlag.php, errors Enums/TaxError.php |
| the website bridge | app/UI/Api/System/Presenters/Traits/Actions/TaxResolveTrait.php, VatIdCheckVerifyTrait.php |
| money of the payment form on the site | app/UI/Front/Base/Components/PaymentForm/Traits/PriceTrait.php, snapshot Traits/FormTrait.php |
| tests | tests/Unit/System/TaxManagerTest.php |