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.

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).

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 — 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

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.