Tax
Tax resolution is a swappable driver. The default database driver is a configurable engine over two editable tables, so you control which jurisdictions you charge in and at what rate. The other drivers are simpler fallbacks for EU or for tests.
How the database driver decides
Tax is charged only where the merchant is registered. The logic runs in this order for a given amount and customer context:
- The buyer's own exemption → no tax, labelled with the ground for it.
- EU cross-border B2B with a verified VAT id → reverse charge, no tax.
- No registration covering the customer's country → out of scope, no tax.
- Otherwise → the rate from the rate table for that country, date, and product category.
Two tables drive it:
meteric_tax_registrations, the jurisdictions you are VAT-registered in. A direct country row, or aneu_ossrow that covers all EU destinations. No registration for the customer's country means no tax is charged.meteric_tax_rates, date-versioned rates per country and product category. EU rows are refreshed from ibericode; non-EU jurisdictions are added by hand.
Switzerland example
Register for Swiss VAT and add its rates:
use Meteric\Models\{TaxRegistration, TaxRate};
TaxRegistration::create([
'country' => 'CH',
'scheme' => 'ch_vat',
'number' => 'CHE-123.456.789 MWST',
]);
TaxRate::create([
'country' => 'CH',
'category' => 'standard',
'rate' => '0.081000', // 8.1%, stored as a fraction string
'effective_from' => '2024-01-01',
]);
TaxRate::create([
'country' => 'CH',
'category' => 'lodging',
'rate' => '0.038000', // 3.8% reduced rate
'effective_from' => '2024-01-01',
]);Swiss customers are charged 8.1% (3.8% for lodging products), EU customers go through OSS, and customers elsewhere are untaxed until you register there. The category matches the product's tax class, set it on the TaxContext to bill a reduced rate. TaxContext::withCategory() returns the same context under a different class, which is how a single invoice carries lines at two rates; ManualLine::$taxCategory is the caller-facing way in (invoicing.md). The ibericode driver reads the same category as its rate level and falls back to standard where a destination defines no such level, so the two live drivers answer a category the same way.
rate is a numeric(8,6) fraction stored as a string. Rates are date-versioned: superseding a rate means closing the old row with effective_to and inserting a new one, which the rate table's activeOn scope reads back correctly.
An exempt buyer
A buyer can hold an exemption their tax authority granted them: a diplomatic mission, a public body, a charity with a ruling. None of it follows from where they are, what they bought or whether they hold a VAT id, so it is its own input and every resolver settles it before anything else.
$context = new \Meteric\Tax\TaxContext(
countryCode: 'DE',
taxExempt: true,
exemptReason: 'Diplomatic mission', // becomes the result's label
);TaxContext::fromProfile() reads the same two off a BillingAccount's tax_profile, as tax_exempt and tax_exempt_reason, so an account marked exempt resolves that way everywhere without the caller building a context by hand. The result is zero tax with exempt true and the reason as its label, falling back to Tax exempt where the host recorded none.
It is never labelled reverse charge, and it is settled ahead of the reverse-charge branch precisely so a cross-border business with a VAT id cannot come back wearing Art. 196 wording. The two are different in law: reverse charge moves the liability to the buyer, an exemption means nobody owes it, and a document stating the wrong one states a legal basis that does not apply. The host decides what its documents print; what the engine guarantees is that the label it writes beside the zero says which of the two happened.
EU rates and VIES
EU rates come from ibericode/vat. Cross-border B2B reverse charge is confirmed against VIES when a validator is available, so a business customer in another EU country with a valid VAT id is reverse-charged rather than taxed. Turn VIES verification off with METERIC_VERIFY_VAT_ID=false, which then trusts the mere presence of a VAT id.
Qualified VIES check
The reverse-charge decision above only needs a valid/invalid answer. For a checkout form that warns when the entered company details do not match the VAT registration, run a qualified check. It returns VIES's registered name and address plus per-field match flags, and a consultation number you can keep as an audit record.
$result = Meteric::viesCheck('DE', '123456789', [
'name' => 'ACME GmbH',
'street' => 'Strasse 1',
'city' => 'Berlin',
], requester: ['countryCode' => 'DE', 'vatNumber' => '999999999']);
$result->valid; // bool, the VAT id is registered
$result->detailsMatch(); // bool, valid and no supplied detail came back as a mismatch
$result->mismatches(); // ['name'] when the entered name does not match
$result->name; // VIES's registered name
$result->consultationNumber; // VIES request identifier, for your audit recordTrader fields are optional: omit them to get a plain valid/invalid result. The requester defaults to config('meteric.tax.vies_requester') (METERIC_VIES_REQUESTER_COUNTRY / METERIC_VIES_REQUESTER_VAT), so you set your own VAT id once and omit it per call; a per-call requester overrides it. The endpoint is config('meteric.tax.vies_base_url') (the EU VIES REST API by default). Tax computation does not depend on this call; it is for the warning and the record.
Keeping EU rates current
meteric:vat-sync refreshes the EU rows of meteric_tax_rates from ibericode. It only touches rows with source = 'ibericode', your manual jurisdictions (CH, UK, anything else) are never modified. When a rate changes, the old row is closed with effective_to and a new current row is inserted, so history is kept.
php artisan meteric:vat-sync # standard + reduced
php artisan meteric:vat-sync --category=standard # one categoryRun it on a schedule so EU rates stay fresh:
use Illuminate\Support\Facades\Schedule;
Schedule::command('meteric:vat-sync')->weekly();Other drivers
METERIC_TAX_DRIVER | Behaviour |
|---|---|
database (default) | Multi-jurisdiction registrations + rate table, EU via ibericode + VIES. |
ibericode | Live EU-only rates plus VIES, no rate table. |
eu_vat | Static offline EU rates. Good for tests with no network. |
flat | One flat rate (METERIC_TAX_FLAT_RATE). |
null | No tax. |
Bind your own resolver by implementing Meteric\Contracts\TaxResolver and adding it to the tax.drivers map. Keeping rates legally correct is the host's responsibility; the engine makes it manageable.
Passing tax context
The resolver needs to know where the customer is. A BillingAccount carries a tax_profile, and taxContext() turns it into a TaxContext:
$context = $account->taxContext();
// or build one directly for a quote:
$context = new \Meteric\Tax\TaxContext(
countryCode: 'CH',
isBusiness: true,
vatId: 'CHE-123.456.789',
category: 'standard',
);Pass it to Meteric::quote()->tax(...) to render tax-correct totals on a checkout page.
The profile an invoice was priced under
An account's tax_profile is current. It follows the customer as they move country, register for VAT or stop being a business, and it has to: the next quote and the next invoice must be priced on where they are now.
An issued invoice is the opposite. It is a document that was already sent, and anything derived from the account afterwards restates it. So the profile is snapshotted onto the invoice as meteric_invoices.tax_profile and read back with $invoice->taxProfile() or $invoice->taxContext():
$invoice->taxContext()->countryCode; // where the buyer was when it was priced
$account->taxContext()->countryCode; // where the buyer is nowThe snapshot is written every time meteric prices a line onto the draft, and stamped at finalizeInvoice() for a draft that never had one priced. The trigger that freezes an issued invoice's totals freezes it too, so it cannot move afterwards even by a direct update. copyInvoice() carries it onto the copy, whose lines are clones and therefore carry the source's rates.
It is null on an invoice issued before the column existed, and stays null: assembling one today would state a country nobody recorded. taxProfile() falls back to the account for those, so nothing breaks; a caller that must not attribute history to a current address has to test tax_profile for null itself and say what it does with the answer.
A host key survives the round trip. The profile is stored as it was given, so keys meteric does not read (a legal status, a customer classification) come back out of the snapshot. That is what makes it usable as the one record of what the buyer was at issue rather than only of what the rate was resolved from.