ConceptsTax Classes

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. A rate with appliesToShipping: false is left off the delivery cost (the items still pay it) without moving delivery onto a less specific rate; it defaults to true.

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, …), unique slug per store, with one optional isDefault class. Store-scoped.
  • A TaxRate may target one class via taxClassId. A rate with taxClassId: null is 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%, not 0.0725).
  • Every rate names a country; there is no global fallback, so a buyer whose country has no rate pays zero tax (taxBreakdown.taxCoverage: 'no_rate'). region takes a code or a name (normalized: Quebec → QC), and postalCode is one exact code, never a range or prefix.

Per-line resolution order

For each cart line, checkout resolves the tax class in this order and stops at the first hit:

  1. Variant taxClassId
  2. Product taxClassId
  3. Product's category taxClassId
  4. Store default tax class (the isDefault one, if any)
  5. 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 — `name` up to 100 characters; `slug` is lowercase letters, digits and
// hyphens, unique per store (409 on a clash); `description` and `isDefault` are optional
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 (at most 200 ids in each of the three lists per call; an id that does not belong to this store is a 400):

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, and the response carries the dependents counts). Deleting the default class is a 400, and so is clearing isDefault with updateTaxClass(id, { isDefault: false }): call setDefaultTaxClass on another class instead (setting isDefault: true on a class takes the flag off the previous default). 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);

A class cannot be merged into itself, and the default class cannot be the source of a merge (400): call setDefaultTaxClass on another class first. The rates that move are recorded in the tax rate history with the reason class_merged, so the dashboard's Activity tab can explain why a rate's class changed.

Storefront (public, no API key)

A storefront can list the store's tax classes without an API key, in either storeId mode or sales-channel 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 sales-channel storefront —
// const store = new BrainerceClient({ salesChannelId: 'vc_abc123' });
const { data: classes } = await store.getStoreTaxClasses();

Endpoints

MethodPathSDK
GET/api/stores/:storeId/tax-classesgetStoreTaxClasses() (public)
GET/api/vc/:connectionId/tax-classesgetStoreTaxClasses() (public)
GET/api/v1/tax-classesgetTaxClasses()
GET/api/v1/tax-classes/:idgetTaxClass(id)
POST/api/v1/tax-classescreateTaxClass(dto)
PATCH/api/v1/tax-classes/:idupdateTaxClass(id, dto)
DELETE/api/v1/tax-classes/:iddeleteTaxClass(id)
PATCH/api/v1/tax-classes/:id/set-defaultsetDefaultTaxClass(id)
POST/api/v1/tax-classes/:id/assignassignTaxClass(id, dto)
POST/api/v1/tax-classes/:id/merge-into/:targetmergeTaxClasses(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.