Tax Classes
Charge different tax rates for different product types (Standard, Reduced, Zero-rated, Food) with a deterministic per-line resolution order.
A tax class groups products that should be taxed differently from the
default. Groceries might be zero-rated, books reduced-rate, everything else
Standard. You create the classes once, assign them to products / variants /
categories, then attach class-specific TaxRate rows. At checkout each line
resolves to exactly one tax class, and normally to one rate within it. The
exception is stackable rates: when the winning rate is
stackable, every other stackable rate in the same class is charged with it, on
the same pre-tax base. That is how Canada's GST and a province's PST or QST are
both charged on one line.
If you only ever charge one rate, you can ignore tax classes entirely, because every
line falls back to the Standard rate (a TaxRate with no class).
The model
TaxClass: a named bucket (Food,Reduced, …), uniqueslugper store, with one optionalisDefaultclass. Store-scoped.- A
TaxRatemay target one class viataxClassId. A rate withtaxClassId: nullis the Standard fallback, and it applies to any line whose class has no class-specific rate (and to lines with no class at all). - Tax rates remain whole percentages (
7.25= 7.25%, not0.0725).
Per-line resolution order
For each cart line, checkout resolves the tax class in this order and stops at the first hit:
- Variant
taxClassId - Product
taxClassId - Product's category
taxClassId - Store default tax class (the
isDefaultone, if any) null, meaning no class
It then picks the matching TaxRate for the buyer's country/region: the rate
whose taxClassId equals the resolved class, falling back to the Standard
(null-class) rate when no class-specific rate exists.
Shipping is taxed at the Standard rate and never resolves to a class, unless
the selected ShippingRate carries taxStatus: 'NONE', in which case the
delivery charge is not taxed at all. TAXABLE and an unset taxStatus both
mean "taxed at Standard". Live carrier rates have no taxStatus and are always
taxed.
Resolution is fully batched, so the whole cart resolves in a fixed number of queries regardless of line count (no N+1).
Managing classes (admin SDK)
storeId is derived from the API key. Requires the tax-classes:read /
tax-classes:write scopes.
import { BrainerceClient } from 'brainerce';
const admin = new BrainerceClient({ apiKey: process.env.BRAINERCE_API_KEY });
// List + inspect
const { data: classes } = await admin.getTaxClasses();
const detail = await admin.getTaxClass(classId);
detail.dependents; // { productCount, variantCount, categoryCount, taxRateCount }
// Create — slug is kebab-case, unique per store
const food = await admin.createTaxClass({
name: 'Food',
slug: 'food',
description: 'Zero / reduced-rate groceries',
});
await admin.updateTaxClass(food.id, { description: 'Reduced VAT' });
// Optional: one default class auto-applied to products without an explicit class
await admin.setDefaultTaxClass(food.id);Assigning a class
A class can be attached to products, variants, and categories. Bulk-assign:
const { updated } = await admin.assignTaxClass(food.id, {
productIds: ['prod_1', 'prod_2'],
variantIds: ['var_9'],
categoryIds: ['cat_groceries'],
});Or set it on a single entity through the normal update endpoints (taxClassId
on updateProduct, createVariant / updateVariant).
Attaching rates
A class only changes tax once a TaxRate targets it. Create the Standard rate
(no class) plus any class-specific rates:
// Standard — applies to everything without a class-specific rate
await admin.createTaxRate({ name: 'VAT', rate: 20, country: 'GB' });
// Food is zero-rated in GB
await admin.createTaxRate({
name: 'VAT (Food)',
rate: 0,
country: 'GB',
taxClassId: food.id,
});You cannot create two rates with the same (country, region, postalCode, taxClassId) tuple and the same priority; the API returns 409 Conflict.
Give the second rate a different priority and both are allowed; rates that
differ only by priority are valid, and the lower priority number wins when
they are equally specific.
Merge and delete
Deleting a class with dependents is blocked (409). Merge it into another
class first, which moves every product / variant / category / rate FK onto the
target, then deletes the source:
await admin.mergeTaxClasses(food.id, standardClassId); // moves FKs, deletes `food`
// or, once it has no dependents:
await admin.deleteTaxClass(food.id);Storefront (public, no API key)
A storefront can list the store's tax classes without an API key, in either
storeId mode or vibe-coded mode (salesChannelId: 'vc_*', gated on the
products:read scope every connection already has). Storefront-safe fields only
(id, name, slug, description, isDefault), useful for a transparency
badge like "9% VAT":
const store = new BrainerceClient({ storeId: 'store_123' });
// — or in a vibe-coded storefront —
// const store = new BrainerceClient({ salesChannelId: 'vc_abc123' });
const { data: classes } = await store.getStoreTaxClasses();Endpoints
| Method | Path | SDK |
|---|---|---|
| GET | /api/stores/:storeId/tax-classes | getStoreTaxClasses() (public) |
| GET | /api/vc/:connectionId/tax-classes | getStoreTaxClasses() (public) |
| GET | /api/v1/tax-classes | getTaxClasses() |
| GET | /api/v1/tax-classes/:id | getTaxClass(id) |
| POST | /api/v1/tax-classes | createTaxClass(dto) |
| PATCH | /api/v1/tax-classes/:id | updateTaxClass(id, dto) |
| DELETE | /api/v1/tax-classes/:id | deleteTaxClass(id) |
| PATCH | /api/v1/tax-classes/:id/set-default | setDefaultTaxClass(id) |
| POST | /api/v1/tax-classes/:id/assign | assignTaxClass(id, dto) |
| POST | /api/v1/tax-classes/:id/merge-into/:target | mergeTaxClasses(id, target) |
See also Regions for currency + payment-provider scoping, and Tax Configuration for rates.
Stacking never crosses a class
A class-specific rate replaces the Standard rates for that line rather than
adding to them. That is the pre-existing resolution rule and stacking does not
change it: a line resolving to Reduced sees only the Reduced rates, never the
Standard ones.
The practical consequence in a country that charges two taxes: a Reduced class
that should be charged GST plus a reduced QST needs both rows created inside
that class. It does not inherit the Standard GST row. If it did, a reduced-rate
book in Quebec would be charged its reduced QST plus the Standard GST plus the
Standard QST.
Regions
Bind countries to a currency, tax-display mode, and payment providers, managed from the dashboard or the admin SDK.
FX & Analytics Currency
Roll multi-currency revenue into one reporting currency for analytics, using a daily FX rate snapshotted onto each order, without ever changing what a customer is charged.