Rate sources
Rate sources
The engine owns the calculation; the rate number is the one thing it sources.
Bind your own Contracts\TaxRateSource to replace the default static rates:
use Cbox\Tax\Contracts\TaxRateSource;
$this->app->singleton(TaxRateSource::class, fn () => new TedbRateSource(/* ... */));
A source returns a TaxRate (percentage, band, provenance, confidence) for a
jurisdiction and category, or null when it has no rate — in which case the
engine raises UnresolvedTaxRate rather than assuming 0%.
Recommended defaults per region:
| Region | Source |
|---|---|
| EU | the EU Commission's TEDB, called live (shipped adapter, no API key), or the MIT-licensed ibericode/vat-rates dataset |
| US (SST states) | the SST Rate & Boundary files |
| US (non-SST / home-rule), Canada provinces | a commercial adapter |
Rates are data that changes — treat them as versioned/refreshable, never
hard-coded. Record the source and confidence on each assessment so a coarse
fallback is never mistaken for an authoritative rate.
Category-aware rates (reduced / zero bands)
rateFor() receives the supply's TaxCategory, and the shipped sources honour
it: a source may carry per-(jurisdiction, category) reduced or zero bands and
return one instead of the standard rate. Pass bands to StaticTaxRateSource keyed
by "<jurisdiction>:<category>":
use Cbox\Tax\Enums\RateKind;
use Cbox\Tax\RateSource\StaticTaxRateSource;
use Cbox\Tax\ValueObjects\RateBand;
new StaticTaxRateSource(rates: null, bands: [
'FR:digital_service' => new RateBand('5.5', RateKind::Reduced),
'DK:digital_service' => new RateBand('0', RateKind::Zero),
]);
No national reduced-rate table ships. The default snapshot carries only standard rates — the package will not fabricate reduced bands, which are DATA that must come from an authoritative feed. Enable the live TEDB source for EU bands, supply your own, or bind a TEDB export whose entries carry a
bandsmap. A category with no band resolves the standard rate.
The EU TEDB service (TedbSoapRateSource)
The Commission's Taxes in Europe Database is the authoritative EU rate source,
and this adapter calls it directly. There is nothing to download — TEDB
publishes no CSV/JSON export, and its VatRetrievalService SOAP endpoint plus the
web UI are the only ways to get the data. The service needs no API key and no
registration:
// config/tax.php (or .env: TAX_TEDB_LIVE=true)
'tedb' => [
'live' => env('TAX_TEDB_LIVE', false),
'ttl' => (int) env('TAX_TEDB_TTL', 86400),
],
Enabled, the engine composes ChainTaxRateSource(TEDB → static snapshot) and
caches each member state's parsed rate table for ttl seconds — one request per
country per TTL, not one per assessment.
What it resolves, and what it refuses
- Standard rates for all 27 member states, from the single
DEFAULTentry. - Reduced and zero bands for
grocery,prepared_food,books,newspapers,magazines,medical_devicesandprescription_drugs— but only where TEDB resolves that category to one rate for that country.
That last condition carries the weight. TEDB routinely carries a category at several rates at once because the sub-scopes differ: French pharmaceuticals sit at 2.1%, 5.5% and 10%, and Irish books are zero-rated in print while their electronic form is 9%. Nothing in the response says which applies to a given supply, so the band is dropped and the standard rate applies. Over-charging is recoverable; silently applying the wrong reduced rate is not.
Where a state splits a category itself, its own split wins: Poland rates newspapers separately at 8%, so that survives, while Sweden files newspapers under the broader "books, newspapers and periodicals" heading and resolves from there.
Determinations for the splits TEDB resolves in prose
Some splits are only apparent. Ireland's 9% "foodstuffs" rate is restaurant, canteen and takeaway food; its 13.5% "medical equipment" rate is repairs to equipment. The competing rate belongs to a different product class, and TEDB's own scope note says so — so the band is determined, with that note as the basis:
| Country | Category | Rate | Because TEDB's note says |
|---|---|---|---|
| IE | grocery |
0% | the competing 9% is "Restaurant food, food served in canteens, and take away food" |
| IE | books |
0% | "The Zero Rate applies to newspapers, printed books, e-books, audiobooks"; 9% is brochures and catalogues |
| IE | newspapers |
0% | the zero rate names newspapers explicitly |
| IE | magazines |
9% | "9% applies to periodicals (in printed form or electronically supplied)" — unlike books and newspapers |
| IE | medical_devices |
0% | the competing 13.5% is "Repairs to medical equipment" — a service |
| IE | prescription_drugs |
0% | 0% is "Human Oral Medicine…"; 13.5% is non-oral contraceptives |
| FR | prescription_drugs |
2.1% | "For reimbursed pharmaceutical products"; 10% is non-reimbursed, 5.5% sanitary protection |
| HR | prescription_drugs |
5% | "medicines which have the approval of the competent authority"; 13% is menstrual products |
| BE | prescription_drugs |
6% | "medicinal products registered as medicines"; the 0% is human organs and blood |
| EL | prescription_drugs |
6% | "medicaments … of tariff heading 3003 and 3004 and vaccines"; the 0% is Covid-19 vaccines only |
| BE | books newspapers magazines |
6% | "Newspapers, periodicals and books (digital and on paper)"; the 0% entries are library loans by non-profits |
Two properties keep these honest. A determination is consulted only when TEDB is ambiguous — a state reporting one rate is never overridden. And it is applied only while the rate it names is still one TEDB returns: if a member state changes the split, the determination stops matching and the band is refused rather than shipped stale. It self-invalidates instead of quietly going wrong.
The splits that stay open
The rest are not curatable at this granularity, because the category genuinely spans several rates by product type. Hungary rates meat, fish, milk and eggs at 5% and dairy desserts, flavoured milk and cereals at 18% — both are groceries, and no single number is right:
grocery in AT BE EL HU IT PL PT SK · prepared_food in SK ·
medical_devices in CY EL IT · prescription_drugs in IT MT PL ·
newspapers in HR · books and magazines in PL
These resolve to the standard rate from the category alone — and this is what a commodity code closes.
Commodity codes: CN and CPA
TEDB scopes its own rates by CN codes (goods) and CPA codes (services) — 92% of its reduced and exempt entries carry them, most at full 8-digit depth. So the finer product model is not something to invent: it is the classification the authority already speaks, published free and machine-readable by the Commission.
Pass one on the query and the split resolves:
new TaxQuery(
amount: Money::of('100.00', 'EUR'),
pricing: Pricing::Exclusive,
place: $geo->find(new CountryCode('PL')),
customer: CustomerType::Consumer,
seller: new SellerRegistrations(new CountryCode('PL')),
category: TaxCategory::Grocery,
commodityCode: '0201', // beef, fresh or chilled
);
| Country | Category | Category alone | With a CN code |
|---|---|---|---|
| PL | grocery |
23% (split 5/8) | 5% for 0201 beef |
| HU | grocery |
27% (split 5/18) | 5% for 0403 yoghurt |
| AT | grocery |
20% (split 0/4.9/10/13) | 10% for 0302 fresh fish |
Measured across the nineteen open splits, disjoint CN scopes resolve seven of them outright — Austrian, Belgian and Hungarian groceries, Italian medical devices, Italian and Polish pharmaceuticals, Polish books — and narrow the rest to a handful of overlapping codes.
Four rules keep it safe:
- The code refines, never restricts. Absent or unrecognised, the category alone decides, exactly as before.
- A code TEDB itself lists at several rates within a category is dropped — it is no more decisive than the category.
- Spacing is irrelevant:
0504 00 00,05040000and0504.00.00are the same code. - A code is tried at successively shallower depths (8 → 6 → 4 → 2), since a member state may scope a rate to a whole heading rather than one subheading.
Sources opt in by implementing Contracts\CommodityRateSource; a source that cannot
use codes is called exactly as before, so existing implementations are untouched.
Categories with no confident equivalent — digital services and e-publications above all, which several states fold into other headings — are not mapped at all rather than guessed.
Two quirks worth knowing
- TEDB spells Greece
EL, not the ISOGR, and rejects the entire request withTEDB-ERR-2if any code is unknown. The adapter translates before calling. - A SOAP fault answers HTTP 500. Any fault, timeout or unparseable body yields
null, so a composed chain falls through instead of guessing.
US rooftop by polygon (ArcGisRateSource)
Two states publish rooftop geography as polygons rather than as a boundary file, and both services are official and unauthenticated:
| State | Service | A point returns |
|---|---|---|
| California | CDTFA's California_Sales_and_Use_Tax_Rates |
the jurisdiction and its all-in RATE |
| New Mexico | the TRD gross-receipts service — the one the compiled dataset already reads for rates, queried with geometry | the location code and its combined grt_rate |
It is bound automatically when us_tax_data.rooftop is enabled, ahead of the
dataset source, and returns null for every other state so the chain falls through.
Verified against both services: Los Angeles City Hall resolves 9.75%, San
Francisco 8.625%, Albuquerque 7.625%, Santa Fe 8.1875%.
This is finer than the ZIP+4 index — a polygon is real geography where a ZIP+4 is a postal proxy for it — and it is a live query per point rather than a shipped file, cached like the TEDB source. Misses are cached too, so an address in the sea does not re-query on every assessment.
Three things it does not do. It carries no category-specific rates, because neither service publishes any, so a reduced band must come from elsewhere in the chain. It stacks nothing: the rate returned is already all-in for the point. And it needs coordinates, which is why the geocoder attaches a point rather than a ZIP+4 for these two states.
The EU VAT feed (IbericodeVatRateSource)
IbericodeVatRateSource binds a real, public, MIT-licensed EU VAT-rate dataset
— the community-maintained
ibericode/vat-rates feed
(https://raw.githubusercontent.com/ibericode/vat-rates/master/vat-rates.json). Its
source, license, shape and honest-provenance notes are documented in
EU VAT rate feed.
Enable it and the provider composes ChainTaxRateSource(EU feed → static snapshot):
TAX_EU_VAT_FEED=true
# Optional: pin to a mirror or a TEDB export.
# TAX_EU_VAT_URL=https://your-mirror.example/vat-rates.json
It reads the real dataset shape (items keyed by country → date-effective rate
periods) and selects the period in force at the assessment date. The dataset's
reduced tiers are not category-labelled, so it resolves the standard rate by
default; pass an authoritative TaxCategory → tier map to surface a reduced tier:
use Cbox\Tax\RateSource\IbericodeVatRateSource;
new IbericodeVatRateSource(
$app->make(\Illuminate\Http\Client\Factory::class),
config('tax.eu_vat.url'),
categoryTiers: ['digital_service' => 'reduced1'], // operator-asserted mapping
);
The TEDB adapter
TedbRateSource reads a TEDB-derived dataset — the EU Commission's Taxes in
Europe Database (VatRetrievalService), transformed to the JSON shape below. Its
location is config-driven (tax.tedb.url), an http(s) URL or a local file
path; the package ships no endpoint, so you must point it at a real export.
Set tax.tedb.url (env TAX_TEDB_URL) and the provider composes
ChainTaxRateSource(TEDB → static snapshot) automatically — TEDB is authoritative,
the static snapshot is the fallback. Unconfigured, the plain static snapshot stays
the zero-config default.
Documented dataset shape:
{
"version": "2026-07-01",
"rates": {
"DK": { "standard": "25" },
"FR": { "standard": "20", "bands": { "digital_service": { "rate": "5.5", "kind": "reduced" } } }
}
}
Each country entry's standard is the standard rate; an optional bands map keys
reduced/zero rates by TaxCategory value (kind ∈ reduced | zero). A missing
country, an unreadable source, or malformed JSON yields null so the engine denies
(and the chain falls back to the static snapshot) rather than guessing. For a URL
source, wrap it in CachingTaxRateSource to avoid a request per lookup.
Composing sources
The package ships composable sources so you can assemble a live feed with a safe fallback:
StaticTaxRateSource— the built-in map (default binding); accepts optional reduced/zerobands.IbericodeVatRateSource— reads the real MIT-licensedibericode/vat-ratesEU dataset (URL or file), date-effective; auto-wired to aChainTaxRateSourcefallback whentax.eu_vat.enabledis true.TedbRateSource— reads a normalised TEDB-derived dataset (URL or file); auto-wired to aChainTaxRateSourcefallback whentax.tedb.urlis set.RemoteRateSource— fetches a generic JSON country→rate feed (number,{standard}, or{standard, bands}); one request per lookup, so wrap it in caching.CachingTaxRateSource— caches the current rate from an inner source; a date-specific lookup bypasses the cache.ChainTaxRateSource— tries sources in order, first hit wins.
use Cbox\Tax\Contracts\TaxRateSource;
use Cbox\Tax\RateSource\{ChainTaxRateSource, CachingTaxRateSource, TedbRateSource, StaticTaxRateSource};
$this->app->singleton(TaxRateSource::class, fn ($app) => new ChainTaxRateSource([
new CachingTaxRateSource(
new TedbRateSource($app->make(\Illuminate\Http\Client\Factory::class), config('tax.tedb.url')),
$app->make(\Illuminate\Contracts\Cache\Repository::class),
),
new StaticTaxRateSource, // fallback
]));
The adapters implement the documented feed shape; point them at a source you trust (the EU TEDB feed, the SST files transformed to JSON, a commercial adapter) and verify the data before relying on it in production.