EU special territories
EU special territories
A country code is not always enough to know which tax applies. Eleven territories sit inside an EU Member State and outside its VAT rules, and they fail in two completely different ways.
Outside the VAT area
The Canary Islands, Ceuta, Melilla, Åland, Livigno, Campione d'Italia, Büsingen, Heligoland and Mount Athos are in the European Union but not in its VAT territory. A supply into one of them from a Member State is an export.
This is not a rate error. Charging 21% Spanish VAT on a delivery to Tenerife invents a liability that does not exist, while the customer separately owes IGIC that nobody collected. No rate table can express "this is not our tax" — only a place can, which is why these are modelled as territory rather than as a rate override.
$assessment = $tax->assess(new TaxQuery(
// ...
place: $geo->find(new CountryCode('ES')),
postalCode: '38001', // Santa Cruz de Tenerife
));
$assessment->treatment; // TaxTreatment::ZeroRated
$assessment->reason; // "…Canary Islands lies outside the EU VAT area…IGIC applies there…"
The reason names the tax that does apply there. A bare zero would leave the caller to work out whether nothing is owed or something is owed to somebody else.
What this does not cover. A supply into the territory is an export and zero-rated — the overwhelming majority of what an EU seller does. A supply by a business established in the territory to a customer there is not EU VAT at all; it is IGIC or IPSI, and this engine does not compute either. The two cannot be told apart from a country code and a postcode, so the reason states the local tax rather than implying the answer covers both.
Inside the VAT area, with their own rates
The Azores charge 16% and Madeira 22%, where mainland Portugal charges 23%. They are in the VAT area; they simply set their own rates.
$tax->assess(/* … postalCode: '9500-001' */)->rate->percentage; // "16"
Every level is substituted, not just the standard rate. Portugal sets three
levels and the regional assemblies set their own value for each, so the engine
looks up what the territory charges where the mainland charges what the source
returned: Madeira is 22/12/4 against the mainland's 23/13/6 (the reduced
rate was 5% before 2024-10-01, and the substitution is dated), the Azores
16/9/4. The figures come from the Portuguese tax authority's Ofício Circulado
n.º 25045 (2024-12-06); the substituted rate carries Confidence::Derived
rather than Authoritative to say where it came from.
A supply at a level the territory's figures do not cover keeps the mainland band, with the shortfall named in the reason — over-charging is recoverable where refusing the line would lose the sale.
Two territory families are absent on purpose: Corsica's special rates are per-operation rather than per-level, so the substitution mechanism cannot carry them (Corsica), and the Greek islands' 30% reduction and Austria's Jungholz/Mittelberg 19% follow the seller's establishment, so for the cross-border supplies this map prices the national rate is already correct (Seller-scoped territories).
Why postal codes
Because nothing else is available. The addressing reference data carries no subdivisions at all for Portugal, Finland, France or Greece, so the Azores, Madeira, Åland and Corsica cannot be named that way. Every one of these territories has had its own postal range for decades.
postalCode on TaxQuery is optional and null is common — most callers have none
at hand, and the national rules are right for the overwhelming majority of
addresses. What the engine must not do is treat a missing postcode as proof of
mainland, and EuTerritories::for() returns null for "cannot place" rather than
"ordinary".
France's overseas départements need no postcode: Guadeloupe, Martinique, French Guiana, Réunion and Mayotte each carry their own ISO 3166-1 country code, and the geo repository already resolves them as outside the EU.
Replacing the map
EuTerritories is a contract like any other. The shipped StaticEuTerritories is
a snapshot of Article 6 of the VAT Directive plus the postal ranges; bind your own
to override it.
$this->app->singleton(EuTerritories::class, MyTerritories::class);