Skip to main content

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

  1. Open Catalog → Products → Create (or edit any existing product).
  2. Pick the product-level tax class. This value is automatically inherited by all variants that do not override their own class.
  3. 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.
  4. 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/products accepts taxRateId for the product default.
  • POST /api/catalog/variants accepts taxRateId per variant. When omitted, the product value is used.
  • POST /api/catalog/prices accepts either taxRateId (preferred) or taxRate (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:

  1. Resolve the tax class from taxRateId, ensuring it belongs to the same org/tenant as the price row.
  2. If taxRateId is omitted, fall back to the explicit taxRate or default to zero.
  3. Normalize the entered amount (non-negative, finite).
  4. Derive the missing side of the price (net → gross or vice versa) and compute taxAmount.
  5. 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) }. Call setInput() to swap the tax class based on geography or customer metadata. Call setResult() 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_amount column on catalog_product_prices. If you upgraded from earlier commits, run npm run db:migrate to apply Migration20251117173713.
  • Use the sales.tax.calculate.* events during integration tests to assert your overrides are firing. The Sales module exposes salesCalculationService for 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 taxRateId belongs to another organization/tenant. Query /api/sales/tax-rates scoped to the current org to fetch valid ids.
  • “Unsupported tax calculation mode.” — Only net and gross are accepted. Ensure price kinds declare the intended display mode and that API callers send either unitPriceNet or unitPriceGross.
  • 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.