Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.laportenard.com/llms.txt

Use this file to discover all available pages before exploring further.

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:
interface Tax {
  id: string;
  name: string;
  amount: number;              // Percent (e.g., 18) or fixed amount
  amount_type: 'percent' | 'fixed';
  price_include: boolean;      // Is tax already included in the unit price?
  include_base_amount: boolean; // Does this tax compound onto the next?
  sequence: number;
  company_id?: string;
}

interface FiscalPosition {
  id: number;
  name: string;
  paraLlevarFpId?: number | false; // Takeout variant of this FP
}

interface FiscalPositionTaxMapping {
  id: number;
  positionId: number;
  taxSrcId: number;
  taxDestId: number | false; // false = remove the tax entirely
}
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:
function computeAllTaxes(params: {
  unitPrice: number;
  qty: number;
  discount: number;           // 0–100
  taxes: Tax[];
  currency: { rounding: number };
  roundingMethod?: 'line' | 'global';
}): {
  total_excluded: number;
  total_included: number;
  taxes: Array<{ tax_id: string; name: string; amount: number; base: number }>;
}

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():
1. Start with config's default_fiscal_position_id
2. Override with customer's property_account_position_id (if set)
3. If takeout/delivery AND resolved FP has para_llevar_fp_id → use it
4. If takeout/delivery AND no FP resolved → use nu.pos.config.default_takeout_fiscal_position_id
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:
Mapping resultBehavior
taxDestId is a numberReplace the source tax with the destination tax
taxDestId is falseRemove the tax entirely (e.g., a service charge on takeout)
No mapping for a taxPass through unchanged

Frontend reactivity

The effective fiscal position is computed as a derived value inside the usePOS computed memo:
const effectiveFpId = resolveFiscalPositionId({
  orderType: activeOrder.orderType,
  customerFpId: pricing.partnerFpMap?.[activeOrder.partnerId ?? ""],
  defaultFpId: pricing.defaultFiscalPositionId,
  defaultTakeoutFpId: pricing.defaultTakeoutFiscalPositionId,
  fiscalPositions: pricing.fiscalPositions,
});

const { totals, taxLines } = calculateTotals(lines, pricing, effectiveFpId);
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:
FieldCalculation
amount_untaxedSum of per-line total_excluded
amount_taxSum of per-line tax totals
amount_totalSum of per-line total_included
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:
#ScenarioExpected
1Single excluded % tax (18%)unit=100 → excl=100, tax=18, incl=118
2Single included % tax (18%)unit=118 → excl=100, tax=18, incl=118
3Discount + excluded % taxunit=100, qty=2, disc=10%, tax=18% → base=180, tax=32.40, total=212.40
4Two excluded taxes, no tax-on-taxtaxA 10% + taxB 18% → tax=28, total=128
5Tax-on-tax (include_base_amount=true)taxA 10% then taxB 18% on (base+taxA)
6Fixed excluded taxfixed=5, qty=3, unit=20 → base=60, tax=15, total=75
7Mixed included + excludedtaxA 18% incl + taxB 10% excl
8Rounding edgeunit=0.99, qty=3, tax=18% → exact 0.01 rounding
9Global vs line roundingDifferent totals for 'line' vs 'global'
Fiscal position tests in src/domain/taxes/fiscalPosition.test.ts cover 19 scenarios:
AreaScenarios
parseFiscalPositionDataBootstrap parsing, empty payload
resolveFiscalPositionIdConfig default, customer override, para_llevar, takeout fallback, delivery, no data
mapTaxIdsRemove tax, replace tax, passthrough, no FP, no map, empty IDs
Order math FP integration tests in orderMath.test.ts cover tax removal, replacement, and passthrough via fiscal position mappings.
cd nu_pos_react
npm test -- src/domain/taxes/calc.test.ts
npm test -- src/domain/taxes/fiscalPosition.test.ts
npm test -- src/domain/orders/services/orderMath.test.ts
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

bootstrap payload.taxes → Tax[] (typed, normalized)
bootstrap payload.fiscal_positions → FiscalPosition[] (typed)
bootstrap payload.fiscal_position_taxes → FiscalPositionTaxMapping[] (typed)
bootstrap payload.config.default_fiscal_position_id → default FP
bootstrap payload.config.nu_default_takeout_fiscal_position_id → takeout fallback FP
bootstrap payload.partners[].property_account_position_id → per-customer FP

parseFiscalPositionData(payload) → { fiscalPositions, fiscalPositionTaxMap, partnerFpMap, ... }

PricingContext (taxesById + fiscal position data)

resolveFiscalPositionId({ orderType, customerFpId, ... }) → effectiveFpId

calculateTotals(lines, pricingContext, effectiveFpId)

mapTaxIds(line.taxIds, effectiveFpId, taxMap) → remapped tax IDs

computeAllTaxes({ unitPrice, qty, discount, taxes, currency })

order.totals { amount_untaxed, amount_tax, amount_total }
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.