Skip to content
V
For developers
Architecture, conventions, the core and security
Core / VAT engine

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):

  1. Seller = the one passed, otherwise the portal seller 7/invoice.default.sellerId.
  2. 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).
  3. The limit is a per-IP safety cap (buildAutoCheckLimit()), not the user's button quota.
  4. It never throws: the save that triggered it is already committed. The error goes to the tax log 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:

  1. Line and fee intermediates are summed unrounded at 12 places (WORK_SCALE).
  2. The sum is rounded once, straight to the currency precision (cms_system_currencies.precision), with roundToPrecision(). The result goes to payment.total_price and 1:1 to the gate.
  3. 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#

  1. Build a TaxContext: sellerId, taxDate = supply date, customer data (country, delivery country, VAT id, registration number, customerIsBusiness), optionally paymentRegionId.
  2. Pick the policy: CacheOnly wherever nothing is submitted bindingly; RefreshStale only when an order or a payment is submitted, with the limit VatIdCheckManager::buildAutoCheckLimit($ip, $privileged).
  3. Call resolve() with TaxLine rows (supply kind, amount, grossPrice).
  4. Check usability: the header, hasErrors() (NO_LINES, SUPPLY_KIND_MISSING, RATE_MISSING) and the SELLER_STATUS_MISSING flag. Do not write an unusable result.
  5. Allocate shipping and surcharges with allocateAncillary(), not by your own division.
  6. 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

Cookie settings