Taxes Overview
Why taxes live in the Sales module
The Sales module owns tax definitions so every downstream surface (catalog, orders, quotes, invoices) reuses a single source of truth. Tax rates are tenant + organization scoped and stored as sales_tax_rates, which are administered from Sales → Configuration → Tax classes in the backend UI. Each tax class captures:
- A label and code (used in dropdowns + APIs).
- A percentage rate (4 decimal precision) and optional scoping filters (country, region, channel, customer group).
- Default flag – one tax class per organization can be marked as the fallback when no variant-level override is supplied.
Assigning taxes to products and variants
- Open Catalog → Products → Create (or edit any existing product).
- Pick the product-level tax class. This value is automatically inherited by all variants that do not override their own class.
- Inside the variants builder, each SKU row exposes a “Tax class” column. Use it when specific option combinations (e.g., kids’ clothing) need a different rate than the base product.
- When entering prices, choose whether the price kind is including tax or excluding tax. The UI only asks for a single number—the system derives the complementary value (net vs. gross) by invoking the shared tax calculator described below.
The REST API mirrors this behaviour:
POST /api/catalog/productsacceptstaxRateIdfor the product default.POST /api/catalog/variantsacceptstaxRateIdper variant. When omitted, the product value is used.POST /api/catalog/pricesaccepts eithertaxRateId(preferred) ortaxRate(raw percentage). Only submit a raw percentage if you are synchronizing historical rates; otherwise use the id so scope validation kicks in.
How prices store tax data
Every price row persists:
unitPriceNet— amount excluding tax.unitPriceGross— amount including tax.taxRate— the resolved percentage (copied from the tax class at the moment the price is created/updated).taxAmount— the tax delta between gross and net (precision 4).
The admin UI and product APIs expose all four components so storefronts can choose the appropriate value to display or to pass into downstream ERP systems.
Shared tax calculation service
All price commands, importers, and backend forms call the DI token taxCalculationService. The default implementation (in packages/core/src/modules/sales/services/taxCalculationService.ts) runs this flow:
- Resolve the tax class from
taxRateId, ensuring it belongs to the same org/tenant as the price row. - If
taxRateIdis omitted, fall back to the explicittaxRateor default to zero. - Normalize the entered amount (non-negative, finite).
- Derive the missing side of the price (net → gross or vice versa) and compute
taxAmount. - Emit before and after events so custom modules can intervene (see next section).
Hooking custom logic
The Event Bus publishes:
sales.tax.calculate.before— receives{ input, setInput(next), setResult(result) }. CallsetInput()to swap the tax class based on geography or customer metadata. CallsetResult()to short-circuit the default math (e.g., when you use an external tax engine such as Avalara).sales.tax.calculate.after— receives{ input, result, setResult(next) }. Use it to post-process rounding, add recoverable VAT breakdowns, or log telemetry.
Example module hook:
import { createRequestContainer } from '@/lib/di/container'
const container = await createRequestContainer()
const eventBus = container.resolve('eventBus')
eventBus?.on('sales.tax.calculate.before', ({ input, setInput }) => {
if (!input.taxRateId && input.organizationId === 'org-brazil') {
setInput({ taxRateId: 'default-brazil' })
}
})
eventBus?.on('sales.tax.calculate.after', ({ result, setResult }) => {
if (result) {
const rounded = Math.ceil(result.taxAmount * 100) / 100
setResult({ ...result, taxAmount: rounded })
}
})
Because this is a DI service, you can also replace it entirely by registering your own implementation in a module di.ts (e.g., plug in a headless tax SaaS) as long as you keep the same TaxCalculationService interface.
Price display modes
Price kinds determine whether backend inputs and frontend displays treat values as tax-inclusive (including-tax) or tax-exclusive (excluding-tax). Best practices:
- Create separate price kinds for B2C (gross) vs. B2B (net) contexts.
- Ensure your storefront labels match the selected display mode to avoid confusion.
- When switching an existing price kind from net → gross (or vice versa), re-run the importer/API so the stored data matches the expected interpretation.
Testing and migrations
- New installations automatically include the
tax_amountcolumn oncatalog_product_prices. If you upgraded from earlier commits, runnpm run db:migrateto applyMigration20251117173713. - Use the
sales.tax.calculate.*events during integration tests to assert your overrides are firing. The Sales module exposessalesCalculationServicefor order totals—use that service for document totals so tax logic continues to be centralized.
Troubleshooting
- “Tax class not found for this organization.” — The provided
taxRateIdbelongs to another organization/tenant. Query/api/sales/tax-ratesscoped to the current org to fetch valid ids. - “Unsupported tax calculation mode.” — Only
netandgrossare accepted. Ensure price kinds declare the intended display mode and that API callers send eitherunitPriceNetorunitPriceGross. - Rounding differences between storefront and admin UI. — Confirm the storefront is using the same precision (4 decimals) and is reading both net + gross from the API. Do not recompute tax in the browser; consume the stored values.