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