Skip to main content
The POS tax calculator mirrors Odoo 12 POS behavior exactly, supporting percent/fixed taxes, price-included taxes, tax-on-tax stacking, discounts, fiscal position remapping, and per-line or global rounding.

Tax data model

src/domain/taxes/types.ts:
Taxes are normalized from the bootstrap payload (payload.taxes) and wired to products via product.tax_ids. Fiscal positions and their tax mappings come from payload.fiscal_positions and payload.fiscal_position_taxes.

Core calculator

src/domain/taxes/calc.ts — pure functions, no DOM, no stores:

Calculation rules (matching Odoo POS)

  1. Apply discount first: discountedUnit = unitPrice × (1 − discount/100)
  2. Sort taxes by sequence then id
  3. price_include: true — back-solve base from included price
  4. price_include: false — add tax on top of base
  5. include_base_amount: true — next tax’s base = previous base + previous tax amount (tax-on-tax)
  6. Fixed taxesamount × qty (excluded) or backed out from included price
  7. Rounding:
    • line: round each tax amount to currency.rounding, then sum
    • global: keep full precision per line, round only at order totals

Fiscal position tax remapping

Certain taxes may not apply to takeout or delivery orders (e.g., a service charge that only applies to dine-in). The frontend mirrors Odoo’s account.fiscal.position.map_tax() to remap taxes in real time as the order type or customer changes.

Resolution chain

src/domain/taxes/fiscalPosition.tsresolveFiscalPositionId():
Step 4 is the fallback for when no customer is selected and no config-level default FP is set. Without it, takeout orders would have no fiscal position and the Propina Legal tax would remain.

Tax mapping

mapTaxIds() applies the fiscal position’s tax mappings to each line’s tax IDs before calculation:

Frontend reactivity

The effective fiscal position is computed as a derived value inside the usePOS computed memo:
When a user switches the order type (dine-in to takeout) or assigns a customer, activeOrder changes, the memo recomputes, and the tax breakdown updates immediately — no server round-trip required.

Backend mirror

Both _resolve_fiscal_position_id (in api_orders.py) and _hub_resolve_fiscal_position_id (in pos_order.py) implement the same 4-step chain server-side. The frontend resolution exists to provide instant feedback; the backend is the authority at finalization.

Configuration

The default_takeout_fiscal_position_id field lives on nu.pos.config (the shared configuration model). Set it in the Odoo backend under the “Fiscal” tab of the shared POS config form.
The takeout FP and any para_llevar_fp_id variants must be included in the POS config’s fiscal_position_ids list, or they will be auto-included by the bootstrap loader. The bootstrap expands the fiscal position set to include all referenced FPs so their tax mappings are available to the frontend.

Order totals integration

src/domain/orders/services/orderMath.ts: calculateTotals(lines, pricingContext, fiscalPositionId?) accepts an optional fiscal position ID. When provided, each line’s tax IDs are remapped through mapTaxIds() before computing taxes. The original taxIds on lines are never mutated.

Test scenarios

The test suite in src/domain/taxes/calc.test.ts covers 9 scenarios aligned with Odoo 12: Fiscal position tests in src/domain/taxes/fiscalPosition.test.ts cover 19 scenarios: Order math FP integration tests in orderMath.test.ts cover tax removal, replacement, and passthrough via fiscal position mappings.
These expected totals should be verified against Odoo 12 native POS for the same product/tax setup. The calculator is designed to produce identical results to Odoo’s built-in tax engine.

Data wiring

Do not compute taxes inside React components. All tax logic must go through src/domain/taxes/calc.ts and orderMath.calculateTotals. Hardcoded TAX_RATE constants in UI files (usePOS, order-summary, etc.) should be replaced with domain-computed totals.