Skip to content

Powered by Grav CMS + Helios Theme

Tax

This page shows a store administrator how to charge tax, using either one flat rate or tax zones matched against the customer's address.

Flat rate, or zones

KahunaCart picks between the two for you. There is no switch to set.

Zones configured Calculation What it does
None Flat rate One percentage, applied to every order.
One or more Zone Address matching, per-class rates, one adjustment per applicable rate.

Creating your first zone disables the flat rate. Both engines write identical adjustment rows of type tax, which receipts, refunds and the admin process in the same way.

A third option is to hand the whole calculation for a region to an outside service. That is a tax driver, bound per zone rather than per store. KahunaCart ships the contract without shipping a driver.

Set a flat tax rate

  1. Open KahunaCart → Settings and the Commerce tab.
  2. Under Tax, set Flat Tax Rate (%) to the percentage (tax.rate).
  3. Set Flat Tax Label to the name that appears on receipts (tax.label).
  4. Turn on Flat Tax on Shipping if shipping belongs in the taxable base (tax.apply_to_shipping).
  5. Click Save.
YAML
tax:
  rate: '20'                # percent, as a string
  label: 'VAT'
  apply_to_shipping: true
  included: false

Every order is now taxed at that rate.

Config key Default What it does
tax.rate '0' Flat percent, as a string. 0 emits nothing.
tax.label 'Tax' The label on the receipt.
tax.apply_to_shipping false Whether shipping is part of the taxable base.
tax.included false Whether catalog prices already contain tax. The bottom of the resolution order.
tax.basis shipping Which address decides the zone.
tax.adjust_price_when_disqualified false Whether baked-in tax is taken back off for a customer no zone covers. See Strip baked-in tax.
tax.engine '' The store-level default tax driver every unbound zone falls back to. Empty means your own rate table.
tax.price_note false Print "incl. VAT" or "excl. VAT" under product prices. See Saying so under the price.
tax.price_note_shipping false Add "plus shipping" to that note for products that ship.

Note

Write rates as strings. Finland's 25.5% has to reach the cents intact, and a float does not do that reliably.

Zones

A zone is a jurisdiction. Zones are ordered by priority then id, and the first zone whose criteria all match the address wins. One zone applies per order, never a union.

A zone matches when every non-empty criterion matches.

Criterion Matching
countries ISO 3166-1 alpha-2, compared case-insensitively.
regions State or province name, compared case-insensitively after trimming. A name match, not a code match.
postcodes Patterns, matched after uppercasing and stripping spaces and hyphens from both sides.
cities The trimmed text, compared case-insensitively. A city on a zone narrows the match. Only tax zones carry one; shipping zones share the matcher and leave it empty.

Postcode patterns take three forms:

TXT
CB21AB          exact
CB2*            wildcard — '*' matches any run of characters
1000...1999     inclusive range

Ranges compare numerically when both ends and the candidate are all digits, and lexicographically otherwise. Spaces and hyphens are stripped first, so cb2 1ab, CB2-1AB and CB21AB are one postcode.

An empty or absent criterion means any, so a zone with no criteria at all is a catch-all for the end of the list. A missing address field is treated as an empty string, which matches only zones leaving that criterion empty.

Shipping zones use the same matcher and the same rules. The ordering differs: tax zones sort by priority, shipping zones by position.

Create a tax zone

  1. In the admin sidebar, click KahunaCart, then Tax.
  2. Click New zone.
  3. Enter a Name, then set Priority. Lower numbers are matched first.
  4. Fill in Countries, Regions and Postcodes. Values are comma separated, and an empty field matches anything.
  5. Under Rates, click Add rate, then fill in Label, Tax class, Rate %, Shipping and Priority.
  6. Click Create zone.

The zone engine is now active for the whole store, and the flat rate is no longer read.

Important

Saving a zone replaces that zone's rates with whatever the form holds. Add every rate the zone needs before you save.

Rates and tax classes

Field Notes
tax_class Defaults to standard. Matched against each product's tax_class.
rate Percent, as a string.
label What appears on the receipt, for example VAT, Sales Tax, GST.
apply_to_shipping 0 or 1. Set per rate, not per zone. It governs the line_price subject only, because the other five name their own base.
priority Application order within the zone. It is also the order compound rates stack in.
taxable_subject What this rate taxes, and where its adjustment lands. Defaults to line_price. See The six taxable subjects.
compound 0 or 1. A compound rate is charged on the running total including the rates already applied. See Compound rates.

A zone can carry one rate per tax class (standard, reduced, zero), and several rates on the same class where a jurisdiction stacks them, such as a state rate plus a county rate. Each rate produces its own adjustment row.

How the zone engine applies rates

  1. No country on the address, no tax. Nothing is emitted.
  2. No matching zone. Nothing is emitted.
  3. An item's tax class comes from the line's snapshot, never the live product row, so reclassifying a product does not change what a completed order was charged.
  4. Rates whose class has no items in the cart do not apply.
  5. Tax is charged on the discounted base. A €100 cart with a 20% coupon at 19% VAT is taxed on €80. A discount carrying an item id reduces that item's class; an order-level discount is apportioned across the classes by their share of the goods.
  6. Shipping is only taxed by rates with apply_to_shipping = 1. When several such rates apply, the shipping total is apportioned between them by their classes' share of the goods, so shipping is taxed exactly once.

Discount before tax

Every discount, whether a sale, a coupon or an order-level adjustment an add-on wrote, is taken off before tax is worked out. A 20% rate on a $100 cart with a $20 coupon on it charges $16 of tax, not $20. VAT is due on what the customer paid, not what the shelf said.

The one exception is an adjustment marked included, which is already inside the price a line is holding and is never taken off it twice.

A coupon writes its discount onto the lines it came off rather than as one order-level row, so a per-line tax engine sees a line that has already been discounted. See Coupons.

A payment method fee

A payment method can carry a fee, written as a fixed amount plus a percentage. The fee lands on the order as its own adjustment and is taxed at the tax class set on the method, not the class of the goods.

  • The class is read off the adjustment's frozen snapshot rather than off the live method row. Moving a method to the reduced rate next month does not restate last month's orders.
  • A fee whose class no other line in the cart uses still gets taxed.
  • The fee's base is the taxable base (goods, less discounts, plus shipping), never the order total.

Choose which address decides the zone

tax.basis selects the address that both the tax engine and the shipping engine match against. Set it to billing to match on the billing address instead.

Value Meaning
shipping (default) The destination principle, used by nearly every VAT and sales-tax regime.
billing Suits stores selling services.

The other address is the fallback when the chosen one has no usable country. A digital order has no shipping address, and a customer who filled in only one of the two still lands in a zone.

This setting governs shipping-zone matching too, not just tax.

A collected order is taxed at the counter

A collected order is delivered over your own counter, so it is taxed there. A shop in Ireland selling to a customer in France charges Irish VAT on an order collected in Dublin. The zone is matched on the collection point's country, state and postcode, and the customer's address is not consulted for tax at all.

The collection charge, when the location has one, is a shipping adjustment and is taxed like every shipping line, by the rates with apply_to_shipping set. The location's tax class is recorded on the adjustment's snapshot and decides which rate taxes it: a location on the services class is taxed by the services rate, not the goods' rate.

Inclusive vs exclusive pricing

Setting Catalog prices On the receipt
false (default) Pre-tax VAT (20%), added to the total.
true Already contain tax VAT (20% incl.), marked as included, does not move the total.

With tax.included set to true, the contained tax is back-calculated and recorded as an adjustment with included = 1. The customer pays the sticker price and the receipt tells them how much of it was tax.

The inclusivity resolution order

tax.included is the bottom of a four-level order. A store selling to the United Kingdom and to the United States needs both answers at once: British shoppers expect the shelf price to contain VAT, American shoppers expect sales tax added at the till.

  1. The product's own price_includes_tax, when it is set. Three states, not two. Unset means "whatever the rest of this list says". 0 means "this one is priced net whatever the store says".
  2. The region preference, a price preference naming the tax zone that matched.
  3. The currency preference, a price preference naming the order's currency.
  4. tax.included, the store-wide setting.

A store that configures none of 1 to 3 gets 4 for every line.

The level that answered is recorded on the adjustment's snapshot as inclusivity_level, so a receipt six months old can still say why it was inclusive.

Price preferences

A preference is an attribute of region or currency, the value it names, and is_tax_inclusive.

Field What it holds
attribute region or currency. Nothing else is accepted.
value A tax zone id for region, an ISO 4217 code for currency.
is_tax_inclusive The answer for everything the row covers.
currency The currency the row is for. Empty means every currency. See Prices that contain tax in one currency.

One answer per region per currency. A unique key on (attribute, value, currency) makes two rows that disagree impossible.

Prices that contain tax in one currency

On a store that sells in more than one currency the currency column scopes a row to orders in that currency, and empty means every currency. Two rules cover every row:

  • A region row applies to an order when its currency is the order's currency or empty. A row for the order's currency beats an empty one for the same region.
  • A currency row is "prices in EUR contain tax". It matches on its value, whatever its currency column says, because it is a statement about euro and not about the store's own currency.

Rows written before a store could sell in a second currency carry the store's currency in the column, including a currency row for EUR, and they keep their meaning: the region rows still cover the base currency, which is the only currency such a store had orders in, and the EUR row now applies to euro orders. Adding a currency changes no answer for an order in the base currency.

To add "prices in EUR contain tax", open Tax → Price preferences → Add a preference, choose A currency, pick EUR (a store with several currencies gets a list of its own) and tick Prices contain tax. Through the API it is POST /kahunacart/tax/price-preferences with {"attribute": "currency", "value": "EUR", "is_tax_inclusive": true}, and a currency row is never given the store's currency by default. A region row with no currency covers every currency on a store that sells in more than one, and carries the store's currency on a store that sells in one, as before. The resolution order does not change, so a region preference still beats a currency preference. A preference is read once per order currency in a request, and a store that has none asks the database one cheap question and nothing more.

TXT
GET    /kahunacart/tax/price-preferences
POST   /kahunacart/tax/price-preferences
PUT    /kahunacart/tax/price-preferences/{id}
DELETE /kahunacart/tax/price-preferences/{id}

When one tax class holds two answers

A cart where one product is overridden to net pricing and the rest follow an inclusive region is a single tax class with two answers in it. The rate writes two adjustments, one included and one not, and the class's base is split between them by the lines' share of it, cent-exact. A store with no overrides and no preferences writes the one row it always wrote.

Strip baked-in tax for an out-of-zone customer

A store pricing inclusive of 20% VAT sells a £120 jumper. A customer in a country outside every tax zone is charged no tax, correctly, and still pays £120, of which £20 is VAT the store now owes nobody. tax.adjust_price_when_disqualified takes it off. It is false by default, because turning it on changes what a customer is shown at the total, and a plugin upgrade must not move a price.

The removal is a negative adjustment of type tax. It is never a discount. Writing it as a discount would corrupt every report, rule, refund allocation and coupon cap that reads discounts. The row carries subtype: disqualified_inclusive in its snapshot, which is the key a reader should look for.

Three things have to be true before a line is stripped: the setting is on, the line's resolved inclusivity says its price contains tax, and no included tax adjustment covers it. A customer inside a zone is never disqualified, because the tax was charged and is on the receipt.

Which rate comes off is the store's home rate. On a store with tax zones that is the rate for that line's tax class on the first zone by priority, id; on a store with none it is the flat tax.rate. The rate baked into a gross price is not recorded on the product, so it is a fact about the store. A store whose first zone is not the one its prices were set for should give that zone the lowest priority.

A payment method's surcharge is never stripped. It is not a catalog price and never had tax baked into it.

Orders entered with "Don't charge tax"

A business customer in another EU country buys without VAT under reverse charge: they account for the VAT in their own country, and the seller's invoice carries none. Until a tax add-on checks VAT numbers on VIES for you, you enter an order like that by hand on Orders → New order, with Don't charge tax ticked and the customer's VAT number typed in. See Selling without tax under reverse charge. Check the number on VIES first. The store does not check it, and you are responsible for it.

Such an order is priced as if the customer were outside every tax zone. The tax stage (zones, the flat rate or a tax engine) charges it nothing, so a price that excludes tax gets no tax added. A price that includes tax has the included tax taken back off, as described above, whether or not tax.adjust_price_when_disqualified is on: you asked for the net price, and a reverse-charge invoice with VAT still inside its prices would be wrong. The removal is the same negative tax row with subtype: disqualified_inclusive.

Nothing the storefront sends can set it. It is tax_exempt: true in the order's data_json, written only by POST /kahunacart/orders, and the VAT number sits beside it as vat_id. Once the order is placed, its tax is fixed like any other order's. An order change carries the recorded tax rows forward and update_tax writes the amount you give it, and a refund and its credit note read the recorded tax, so none of them add the tax back.

The receipt, the invoice, the invoice PDF and the credit note PDF print the customer's VAT number under the totals, and on an exempt order a reverse-charge line: Reverse Charge Wording, under Invoices on the Accounts tab of KahunaCart → Settings (invoice.reverse_charge_text), or "VAT reverse charge: the customer accounts for the VAT." when that is empty. The four templates are stamped @version 1.3.4 and print it through partials/kahunacart-customer-tax.html.twig, which a theme overriding one of them includes with ignore missing. The Kahuna theme's receipt prints them since Kahuna 1.0.8.

Saying so under the price

Shoppers in much of the EU expect to be told whether a price includes VAT, and in some countries the shop has to say so. Turn on Say whether prices include tax, under Tax on the Commerce tab of KahunaCart → Settings (tax.price_note), and every product price on the storefront gets a small line under it: "incl. VAT" or "excl. VAT". It is off by default, so a store that has not asked for it looks the way it always did.

The note never works anything out for itself. It asks the inclusivity resolution order above, the one checkout uses, so it follows the product's own price_includes_tax, then the region preference, then the currency preference, then tax.included. A store on the flat rate reads tax.included alone, because that is all the flat rate reads.

The region is the zone the shopper's address matches, and on a product page the store knows less about the shopper than checkout does. It uses, in order, the cart's address once checkout has one, the country typed into the cart's estimate box, and otherwise the store's default country (checkout.default_country). A store with a region preference for Germany and its default country set to DE therefore tells a first-time visitor "incl. MwSt.", and tells a shopper who has put an American address into checkout "excl. Sales Tax". A visitor with no cart sees the same page as every other visitor with no cart, so a catalog page stays cacheable.

The name of the tax is the store's own. With zones it is the label of the matched zone's rate for the product's tax class, translated as it is on the receipt, falling back to the standard rate's label. While the shopper's country is not known yet, it comes from the first zone by priority, the zone a store's prices are set for. On the flat rate it is tax.label, and the shipped default Tax reads as "incl. tax". The words themselves are translatable (PRICE_TAX_INCLUDED, PRICE_TAX_EXCLUDED).

Nothing is printed where checkout charges no tax: on a store with no zones and a flat rate of 0, for a shopper whose country no zone covers, and for a product whose tax class has no rate above zero in the shopper's zone (a zero-rated book in a zone with only a standard rate).

"Plus shipping" is a second switch, tax.price_note_shipping, for stores that want the "incl. VAT, plus shipping" line some countries expect. It is added only for a product that ships, never for a download or a service. It is words only and does not link to a shipping page.

Themes print it with kahunacart_tax_note() or the partials/kahunacart-tax-note.html.twig partial, which the shipped buy button, product grid and hero cards already include.

The six taxable subjects

A rate's taxable_subject decides what it taxes and where the adjustment it writes lands.

Subject Taxed amount Adjustment lands on
unit_price The line's unit price × quantity, before any discount The line
line_price The line's discounted total The order
line_shipping The shipping apportioned to that line The line
line_price_and_shipping Both of the above The line
order_shipping The order's shipping total The order
order_taxable_total The order's taxable total, so goods, less discounts, plus shipping The order

line_price is the default and is what every rate on every existing store carries after the upgrade. It is the one line subject whose adjustment stays on the order, so existing receipts, credit notes and reports do not change. The other five are opt-in per rate.

apply_to_shipping governs line_price and nothing else. The other five name their own base.

An unrecognised value in the column falls back to line_price rather than being refused, so a typo cannot silently drop a jurisdiction's tax. The CSV import refuses a bad row instead, with the line number.

Compound rates

Quebec charges QST on the GST-inclusive amount, so a 5% federal rate and a 9.975% provincial one on a $100 sale are not 14.975% of $100.

A rate with compound set is charged on the running total including the non-compound rates already applied, in priority, id order.

TXT
Goods                                          100.00
GST 5%          5% of 100.00                     5.00
QST 9.975%      9.975% of 105.00                10.47
                                               ------
Total                                          115.47

Reordering the rates changes the total, and it changes it downwards. Put the compound rate first and there is nothing in front of it to compound on, so it charges its plain percentage:

TXT
Goods                                          100.00
QST 9.975%      9.975% of 100.00                 9.98
GST 5%          5% of 100.00                     5.00
                                               ------
Total                                          114.98

A compound rate with nothing in front of it behaves as a plain rate, so turning the flag on for a single rate is harmless.

A compound rate on one of the line subjects spreads the running total across the lines it charges, weighted by their own bases, so it charges the same total as one landing on the order and no cent is created or lost.

Warning

Compounding applies to exclusive pricing only. Under inclusive pricing every rate is back-calculated out of its own base independently and the flag does nothing, because a gross price already contains both taxes. The adjustment's snapshot records compound_base_minor: 0 when that happened.

The rate table as a CSV

The column list is WooCommerce's, so a merchant arriving from Woo can import the file they already have:

Column Notes
country_code ISO 3166-1 alpha-2. Empty matches every country.
state_code State or province.
postcode One pattern.
city Compared case-insensitively, and it narrows the match.
rate Percent, as a decimal string.
tax_rate_name What prints on the receipt. Falls back to the zone's name.
priority Application order, and the order compound rates stack in.
compound 0 or 1.
shipping 0 or 1, the rate's apply_to_shipping.
tax_class Defaults to standard.
TXT
country_code,state_code,postcode,city,rate,tax_rate_name,priority,compound,shipping,tax_class
GB,,,,20,VAT,0,0,1,standard
CA,QC,,,5,GST,0,0,1,standard
CA,QC,,,9.975,QST,1,1,1,standard

A sample file ships at data/tax-rates-sample.csv.

Write the most specific geography first. Imported zones are created in file order and all at priority 0, and KahunaCart matches one zone per order. A US,CO row written before a US,CO,80202,Denver row would win for Denver and the city rate would never apply. A jurisdiction that stacks a state rate and a city rate is one zone carrying both rates: two rows sharing a geography, exactly as GST and QST are.

One row is a zone and one rate on it. The import folds rows sharing a geography into one zone. The two Quebec rows above become one CA / QC zone with GST and QST on it.

Import is idempotent on (country_code, state_code, postcode, city, tax_class, priority). Import the same file twice and the second run updates the rows the first created.

A round trip changes nothing. Export 200 rates, import the file back, export again, and the two files are byte for byte identical. taxable_subject is the one thing the ten columns cannot carry, so a new rate gets the default and an existing one keeps whatever it has.

A malformed row fails alone. A country that is not two letters, an unparseable percentage or a flag that is neither 0 nor 1 is refused by itself, with its line number, and every other row still imports.

A header row is matched by name rather than by position, so a file whose columns were moved in a spreadsheet still imports. A file with no header at all is read positionally. A zone naming several countries exports as its first, so keep multi-country zones in the zone editor.

TXT
POST /kahunacart/tax/rates/import     { "csv": "…" }
GET  /kahunacart/tax/rates/export

The export serves a text/csv file rather than JSON.

Quote tax through an outside service

A tax driver quotes tax for a region through its own service instead of the rate table above, and it can replace the zone and rate editors while it does. Binding is per tax zone, so one install can quote a connector for the United States and its own EU VAT rates for Europe.

KahunaCart ships the contract and no driver. The connectors, VAT-number validation and reverse charge belong to the tax and VAT add-on. See Tax drivers for the interface, the registration events, what a driver may and may not do, and what happens when one is down.

TXT
GET /kahunacart/tax/engines
PUT /kahunacart/tax/engines/bind      { "zone_id": 4, "engine": "my-tax-service" }

tax.engine is the store-level default every unbound zone falls back to, and it is empty ('') by default.

Apply the EU VAT preset

KahunaCart ships the standard VAT rate for all 27 EU member states. The import creates one zone per country, scoped to that country's ISO code, with one standard-class rate that also applies to shipping.

  1. In the admin sidebar, click KahunaCart, then Tax.
  2. Click Import EU VAT rates.

The admin reports how many zones were inserted. Zone names take the form VAT — Germany. The same import is available over the API:

TXT
POST /kahunacart/tax/presets/eu-vat

The import is idempotent by zone name. A country whose zone already exists is skipped entirely, rate and all. Rename a zone and a re-import creates a fresh zone for that country.

Warning

The preset is a starting point, not tax advice. Rates change and nothing in KahunaCart tracks them. When a member state moves its rate, edit the zone yourself: a plugin update will not do it, and neither will a re-import.

Two things the preset does not cover:

  • Reduced rates. A store selling books, food, children's clothing or anything else on a reduced schedule has to add its own reduced-class rates, per country.
  • Thresholds and OSS registration obligations. Whether you must charge that VAT, and where you remit it, is between you and your accountant.
Where the shipped rates came from

The rates were verified on 2026-08-18 against the Tax Foundation's EU VAT table, VATupdate's global table and Eurofiscalis, cross-checked against national tax authorities for the four states that had moved recently: FI 24 → 25.5 in 2024, SK 20 → 23 in 2025, EE 22 → 24 in 2025, RO 19 → 21 in 2025. No member state changed its standard rate during 2026. The authoritative source is the Taxes in Europe Database.

Merchant-of-record providers

A merchant of record sells to your customer on your behalf, and it computes and remits sales tax, VAT and GST itself. Polar is the first-party example.

A provider declaring the MerchantOfRecord capability is charged a total with no local tax in it, whichever engine the store runs and whether or not prices are inclusive. There is nothing to configure.

The customer picks a payment provider at the end of checkout, after the cart was priced. The choice is stored on the cart, and the cart is recalculated before anything is charged, so switching between providers always ends with the total that matches the one about to charge.

Important

The cart page still shows the local tax estimate, because no payment provider has been chosen at that point. A shopper heading for Polar sees a tax line on the cart, no tax on the completed order, and Polar's own tax on their receipt.

Inclusive pricing with a merchant of record

An inclusive tax row never moves the total, so suppressing it changes no charged amount. What it stops is a receipt reporting tax the store did not collect and will not remit.

Your inclusive prices still contain the tax you built into them, and a merchant-of-record provider adds its own jurisdiction's tax to that gross number. KahunaCart does not re-derive net prices from your catalog.

Tip

A store selling mainly through a merchant of record should price net. Set tax.included to false.

The merchant of record has a matching setting of its own. Polar calls it Default tax behavior, under Payments in the organization's settings, and it should be Exclusive: KahunaCart hands over the pre-tax total and expects the tax to be added on top, not carved out of it. See Tax with Polar.

API reference

All tax endpoints require the kahunacart.settings permission.

TXT
GET    /kahunacart/tax/zones
POST   /kahunacart/tax/zones
PATCH  /kahunacart/tax/zones/{id}
DELETE /kahunacart/tax/zones/{id}
POST   /kahunacart/tax/presets/eu-vat
GET    /kahunacart/tax/engines
PUT    /kahunacart/tax/engines/bind
GET    /kahunacart/tax/price-preferences
POST   /kahunacart/tax/price-preferences
PUT    /kahunacart/tax/price-preferences/{id}
DELETE /kahunacart/tax/price-preferences/{id}
POST   /kahunacart/tax/rates/import
GET    /kahunacart/tax/rates/export

Zone fields are name, countries, regions, postcodes, cities, priority and engine. The four criteria accept PHP arrays or JSON arrays, and are always stored as JSON, never null.

When a bound engine has taken the built-in rate administration away, the four zone writes and the CSV import answer 409 with a message naming the engine. PUT /kahunacart/tax/engines/bind is left out of that list, because it is the route that undoes a binding. See Tax drivers → Replacing the admin.

See Tax in the REST API for the full request and response bodies.