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

# Tax calculation

> Odoo-compatible POS tax calculation — data model, pure function calculator, fiscal position remapping, and order totals integration.

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`:

```typescript theme={null}
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:

```typescript theme={null}
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 taxes** — `amount × 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.ts` — `resolveFiscalPositionId()`:

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

<Note>
  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.
</Note>

### Tax mapping

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

| Mapping result          | Behavior                                                    |
| ----------------------- | ----------------------------------------------------------- |
| `taxDestId` is a number | Replace the source tax with the destination tax             |
| `taxDestId` is `false`  | Remove the tax entirely (e.g., a service charge on takeout) |
| No mapping for a tax    | Pass through unchanged                                      |

### Frontend reactivity

The effective fiscal position is computed as a derived value inside the `usePOS` `computed` memo:

```typescript theme={null}
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.

<Warning>
  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.
</Warning>

## Order totals integration

`src/domain/orders/services/orderMath.ts`:

| Field            | Calculation                      |
| ---------------- | -------------------------------- |
| `amount_untaxed` | Sum of per-line `total_excluded` |
| `amount_tax`     | Sum of per-line tax totals       |
| `amount_total`   | Sum 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:

| # | Scenario                                | Expected                                                               |
| - | --------------------------------------- | ---------------------------------------------------------------------- |
| 1 | Single excluded % tax (18%)             | unit=100 → excl=100, tax=18, incl=118                                  |
| 2 | Single included % tax (18%)             | unit=118 → excl=100, tax=18, incl=118                                  |
| 3 | Discount + excluded % tax               | unit=100, qty=2, disc=10%, tax=18% → base=180, tax=32.40, total=212.40 |
| 4 | Two excluded taxes, no tax-on-tax       | taxA 10% + taxB 18% → tax=28, total=128                                |
| 5 | Tax-on-tax (`include_base_amount=true`) | taxA 10% then taxB 18% on (base+taxA)                                  |
| 6 | Fixed excluded tax                      | fixed=5, qty=3, unit=20 → base=60, tax=15, total=75                    |
| 7 | Mixed included + excluded               | taxA 18% incl + taxB 10% excl                                          |
| 8 | Rounding edge                           | unit=0.99, qty=3, tax=18% → exact 0.01 rounding                        |
| 9 | Global vs line rounding                 | Different totals for `'line'` vs `'global'`                            |

Fiscal position tests in `src/domain/taxes/fiscalPosition.test.ts` cover 19 scenarios:

| Area                      | Scenarios                                                                            |
| ------------------------- | ------------------------------------------------------------------------------------ |
| `parseFiscalPositionData` | Bootstrap parsing, empty payload                                                     |
| `resolveFiscalPositionId` | Config default, customer override, para\_llevar, takeout fallback, delivery, no data |
| `mapTaxIds`               | Remove 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.

```bash theme={null}
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
```

<Info>
  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.
</Info>

## 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 }
```

<Warning>
  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.
</Warning>
