# Taxation FloCafe has one tax calculation engine and data-only country tax packs. A pack describes a jurisdiction's categories and rules; the engine decides what a transaction owes. There is no per-jurisdiction code path, and the host never executes pack content. The reasoning behind that split is in [0004: data-only tax packs](../decisions/0004-data-only-tax-packs.md). The pack schema, the trust model, and the install mechanics are in the [tax pack reference](../reference/tax-packs.md). How to author one is in [adding a tax pack](../guides/adding-a-tax-pack.md). ## One calculation path `TaxEngine.calculate()` in [`main/services/tax-engine.ts`](../../main/services/tax-engine.ts) is the only authoritative calculation. Every caller that owes a tax amount goes through it, whether the result lands on a bill, a receipt, a refund, or a report. `previewCategoryRate()` in [`main/services/tax.ts`](../../main/services/tax.ts) is the one deliberate exception, and the code says so at the definition: > Computes a display-only preview tax rate for UI category pickers. Authoritative calculation > always goes through TaxEngine.calculate. It returns a `number` for a category picker and is never used to decide what is owed. Money is arithmetic in `decimal.js`, never a JavaScript `number`. The engine clones the decimal constructor once, at module load, with 40 significant digits and `ROUND_HALF_UP` as the default, and every amount enters and leaves the engine as a string. A preview rate being a `number` is the documented display exception, not a general licence. ## Category resolution `resolveTaxCategory()` in `main/services/tax-engine.ts` picks the tax category for a line by walking seven precedence steps and returning at the first that applies. `CategoryResolution` names each step in its `source` field. | Step | `source` | Condition | Result | | --- | --- | --- | --- | | 1 | `transaction_exemption` | The line is marked exempt on this transaction. | No category, no tax. | | 2 | `transaction_override` | The line carries `transactionCategoryId`. | That category. | | 3 | `merchant_override` | The line carries `merchantCategoryId`. | That category. | | 4 | `explicit` | The line carries `taxCategoryId` or `productCategoryId`. | That category. | | 5 | `parent` | The line is an `addon` with `inheritParentCategory` set and a `parentProductCategoryId`. | The parent product's category. | | 6 | `charge_default` | The pack declares a default category for the line's `kind`. | The kind's default. | | 7 | `unclassified` | None of the above. | The pack's `unclassifiedCategoryId`. | The order is load-bearing in two places. A transaction exemption outranks everything, so a line marked exempt cannot be taxed by a later step. The `parent` step is narrow on purpose: an add-on inherits its parent product's category only when it says so, so a pack can treat add-ons separately where a jurisdiction does. A resolved category id that the pack does not define is an error, not a silent zero. The engine throws rather than falling through to `unclassified`. ## Rounding Two independent policies decide how amounts round, and they are set separately on the pack. **Tax rounding** is `taxRounding`, applied to the tax components themselves. It has a `scope`, a `method`, a `decimalPlaces`, and a `remainderAllocation` of `largest_remainder`. | `scope` | What is rounded | | --- | --- | | `unit` | Each component is divided by the line quantity, rounded, then multiplied back. A `fixed` rule with `appliesPer: 'line'` is the exception and rounds per line regardless. | | `line` | Each component on each line is rounded on its own. | | `document` | Every component across the whole document is floored, and the units needed to reach the document total are handed out by largest remainder. | `method` is one of `half_up`, `half_even`, `floor`, or `ceiling`. In `document` scope the tie-break order is fixed and deterministic: remainder descending, then `ruleId` ascending, then `lineId` ascending. The same transaction therefore allocates the same extra units on every machine and on every run. Document-scoped packs evaluate all transaction lines (items and configured charges) together across the complete document, distributing remainders across both rather than rounding items or charges in isolation. **Payable rounding** is `payableRounding`, which has its own `increment` and its own `method`. It decides the smallest amount a customer can actually be charged, which is a cash-handling question rather than a tax-arithmetic one, so it is never inferred from `taxRounding`. A pack that declares `currency: 'XXX'` and a configured currency whose fraction digits differ from the pack's increment resolves its increment from the configured currency instead, so a placeholder-currency pack still produces a payable amount in real minor units. `decimalPlaces` is bounded at install: an integer from 0 to 6. The payable increment must be finite, positive, and at most 1000. ## Input shape `TaxEngineInput` is the whole contract: ```ts interface TaxEngineInput { pack: CountryPack; currency?: string; country: string; jurisdiction?: string; businessType?: string; storeStateCode?: string; transactionDate: string; customer?: TaxCustomer | null; lines: TaxEngineLine[]; } ``` `TaxEngineInput` has no separate `charges` array. Its `lines` cover products, add-ons, and the standard `packaging`, `delivery`, and `service_charge` kinds. Applied custom-fee snapshots in `charges_breakdown` contribute to order and bill totals but are not converted into tax-engine lines. A custom charge's `tax_category_id`, when present, is stored metadata and does not affect tax calculation. During initial transaction pricing (tax preview and order creation), `calculateDocumentTaxes()` evaluates items and configured standard charge columns (`packaging`, `delivery`, `service_charge`) together in a single `TaxEngine.calculate()` call with stable line identities. `calculateConfiguredChargeTaxes()` delegates to `calculateDocumentTaxes()` with an empty items array when charges are evaluated in isolation. Step 6 of category resolution looks up `defaultCategories[line.kind]`, which is why the pack declares a default for each supported tax-line kind. **There is no `activePackVersion` field.** The version being applied is `pack.version`, and the version's identity string is the caller's to track, not the engine's. A `TaxEngineLine` carries its own identity (`lineId`), amounts as strings, and a set of narrowing category hints. The engine rejects a non-positive quantity, a negative price or discount, and a discount larger than the gross amount. A discount exceeding the gross amount is clamped to zero with a warning rather than rejected, because a discount row that is temporarily larger than the price is a data-entry state, not a tax problem. ## Fixed and percent rules together A `percent` rule may name `baseRuleIds`, making it compound: the base rules are solved first and the compounding rule applies to the result. The dependency graph is resolved at calculation time and must be acyclic; a cycle throws. A `baseRuleIds` entry may not contain `:` or `/`, and must name a rule on the same tax line. A `fixed` rule may not declare `baseRuleIds` at all. A fixed amount is a flat charge, and letting it compound would make the result depend on evaluation order in a way no jurisdiction means. For inclusive pricing, the engine removes the total of all fixed components from the line gross first, then solves the percent rules against what remains, and divides by the resulting divisor to recover the pre-tax base. A fixed inclusive total larger than the line gross, or inclusive taxes that drive the taxable base negative, are errors rather than clamped values. ## Where the amount came from Three layers, in precedence order: the versioned pack, then a merchant override, then the immutable snapshot written onto the transaction. A merchant override is keyed to an entity (product, add-on, packaging, delivery, or service charge) and must resolve against the pack version being evaluated. Activation check 20 refuses a version that any live merchant override would not resolve against, so an override can never silently point at a category the new version dropped. Once a transaction is written, its tax is a snapshot, not a live calculation. `tax_snapshot` is stored on `orders`, `order_items`, and `bills`, holding the item-level line amounts as strings. During active order mutations (such as item edits, cancellations, discounts, or charge updates), `recomputeOrderTotals()` preserves existing items' saved `tax_snapshot` rules without re-evaluating them against the active catalog, evaluates configured charge taxes against the active pack via `calculateConfiguredChargeTaxes()`, and calls `allocateDocumentTaxRounding()` to reallocate undiscounted document tax rounding across active item and charge snapshots before persisting those un-order-discounted line allocations to `order_items`, so subsequent discount edits never compound on a prior order discount. For the order, bill, and tax-preview (`calculateTaxPreview()`) rollup, `rollupOrderTaxes()` in `main/services/tax.ts` applies any order-discount ratio (`computeDiscountTaxRatio(discountedSubtotal, subtotal)`) before combining item and charge taxes: on `document`-scoped lines, `allocateDocumentTaxRounding()` scales raw unrounded item tax components (`amount + roundingRemainder`) by the 40-digit `Decimal` ratio before performing document-scope `largest_remainder` allocation across the combined discounted-item and unchanged-charge components, while non-document lines scale via `scaleTaxBreakdowns()` and `scaleTaxSnapshots()` before `combineItemAndChargeTaxes()` folds the item and charge snapshots into an order or bill level array with `aggregateTaxSnapshots()`. Bill item projection (`projectOrderItems()` in `main/routes/bills.ts`) reads each item's saved discounted document allocation from `order.tax_snapshot` (falling back to `allocateDocumentTaxRounding()` when no saved order-level item snapshots are present) so projected line taxes and component reports match both new and historical bill rollups. Once bills are settled or paid, transactions preserve the recorded snapshots, and `invertTaxSnapshot()` negates the line amounts for a void or refund adjustment row, so an adjustment reverses the recorded numbers rather than recomputing them. A split bill produces child snapshots carrying `splitAllocation: 'minor-unit-v1'`, recorded by `SPLIT_TAX_SNAPSHOT_VERSION` in `main/routes/bills.ts` and read back in `main/services/tax-components.ts`. That discriminator exists so a reader can tell a split allocation apart from an ordinary snapshot, and so the split's minor-unit arithmetic is not reinterpreted as a per-line amount. Refunds read the snapshot. Recomputing tax at refund time would produce a different answer if the pack had been updated in between, which is the exact failure the snapshot exists to prevent. The reporting counterparts are `GET /api/reports/tax-components` and X/Z cash-closure reporting in [`main/routes/cash-closures.ts`](../../main/routes/cash-closures.ts), both backed by [`main/services/tax-components.ts`](../../main/services/tax-components.ts). `resolveTaxComponents()` derives components item by item from stored snapshots and breakdowns so a bill mixing categorized and uncategorized lines cannot double-count the categorized portion against the bill-level `tax_breakdown`, and never recomputes historical tax from live tax settings. Whole-day X/Z reports aggregate paid bills in the day window through `aggregateTaxComponents()`. Session-scoped X/Z reports (`computeSessionAggregates()`) include settled bills and partially paid bills on non-cancelled orders: a bill settled wholly within one shift keeps `resolveTaxComponents()` as-is, while partial or cross-shift bills allocate resolved components sequentially across payment lines against the remaining bill total in minor units (`allocateSignedMinorUnits()`, `reconcileSnapshotAllocations()`, and `mergeDisplayTaxComponents()`), so a shift closed before settlement keeps its collected tax share in its immutable Z snapshot and subsequent shifts receive the exact remaining tax. ## Bundled packs `BUNDLED_COUNTRY_PACKS` in [`main/tax-packs/bundled.ts`](../../main/tax-packs/bundled.ts) contains exactly one entry: `main/tax-packs/generic.json`. It is a placeholder pack with a hidden zero-rate `unclassified` category, not a country profile. `getBundledCountryPack()` falls back to it for any country without a bundled match. Fourteen community packs also ship in the repository under `main/tax-packs/community-*.json`. They are reference data in the source tree, not bundled installables: `BUNDLED_COUNTRY_PACKS` does not include them. First-run setup never contacts the catalog. The catalog is fetched by the best-effort startup update check in `main/index.ts` and by the tax-pack routes, so a store with no network completes setup and starts with the generic pack. ## Trust model Four paths can make a pack version valid at activation. Check 6 in [`main/routes/tax-packs.ts`](../../main/routes/tax-packs.ts) decides between them: 1. **Bundled.** The artifact is byte-identical to a bundled definition. 2. **Local.** The pack declares `publisher: 'local'` and has no signature. This is the path manual tax configuration takes, and the catalog path refuses it, so a pack cannot claim local publisher status to bypass signing. 3. **Signed.** A valid Ed25519 signature against `TRUSTED_TAX_PACK_SIGNING_PUBLIC_KEY`, verified over the signed artifact JSON. 4. **Legacy digest.** An unsigned artifact whose SHA-256 matches a pinned entry in `LEGACY_TRUSTED_PACK_DIGESTS`. The legacy digest path is a deliberate, test-pinned compatibility exception, and it is the one part of the trust model worth calling out as a security-relevant decision. `LEGACY_TRUSTED_PACK_DIGESTS` in `main/routes/tax-packs.ts` pins four digests, for `official-india`, `official-thailand`, `official-in`, and `official-th`, of pre-signing artifacts. An unsigned artifact that hashes exactly to one of them stays trusted. `tests/legacy-tax-pack-digest.test.ts` pins the behaviour so it cannot be removed by accident. Any new pack must go through signing; the digest list is not an extension point. There is one trusted Ed25519 public key, in `main/tax-packs/trusted-signing-key.ts`. There is no root key, no delegated key, and no rotation mechanism. `publisher: 'local'` is reserved for synthetic packs the app builds itself, and check 26 refuses a local pack that also declares `sourceType: 'community'`, so the community disclaimer can never be skipped by manufacturing a local pack. A pack with `sourceType: 'community'` is public-information material and requires the merchant to acknowledge the disclaimer. ## Activation checks `validationChecklist()` in `main/routes/tax-packs.ts` runs **26** checks. All 26 must pass; `valid` is `checks.every(passed)`. If the stored pack JSON will not parse, the function returns early with a single failed check and stops. | # | Check | | --- | --- | | 1 | Supported manifest and schema version | | 2 | Valid pack identity, publisher, country, and jurisdiction scope | | 3 | Valid, internally consistent version and effective-date range | | 4 | FloCafe satisfies the pack's minimum compatible version | | 5 | Stored artifact digest matches | | 6 | Artifact is bundled, or local and unsigned, or has a valid trusted Ed25519 signature, or matches a pinned legacy digest | | 7 | Pack version is not `revoked` or `incompatible` | | 8 | Category and rule ids are unique | | 9 | Required default categories and the unclassified category exist | | 10 | All category, rule, and dependency references resolve | | 11 | The rule dependency graph is acyclic | | 12 | All dependencies reference rules on the same tax line | | 13 | Rates, fixed amounts, precision, and payable increment are within bounds | | 14 | Fixed rules have no tax-rule dependencies | | 15 | Inclusive fixed-tax combinations produce a non-negative net amount | | 16 | Every tax behavior and jurisdiction selector is recognized | | 17 | Tax and payable rounding policies are complete | | 18 | Currency code and decimal settings are valid | | 19 | Existing category and rule ids remain available, so override aliases are not required | | 20 | Every current merchant override resolves against this version, or the pack is local | | 21 | Default-language labels are present on every category and rule | | 22 | Artifact is data-only and contains no executable or unsafe path values | | 23 | Mandatory component, total, interstate, and rounding vectors are self-consistent | | 24 | Activation uses one SQLite transaction and does not modify transactions | | 25 | A declared registration-number format is a well-formed, non-catastrophic pattern | | 26 | A local or manual pack never declares a community `sourceType` | Check 22 is the one that enforces the data-only rule at the door. `containsUnsafeData()` rejects an artifact carrying anything executable or a path value that could escape the expected shape. Check 23 runs the pack's own mandatory vectors through the engine before it can be activated, so a pack whose arithmetic does not round-trip against its declared expectations never reaches a store. Check 19 requires a new version to keep the category and rule ids the active version exposed, so a merchant override can never end up aliasing an id that no longer exists. A local pack is exempt, because manual configuration remaps its own overrides as it saves. Check 20 fails when a `tax_overrides` row on the active version points at a category the candidate version does not define, unless the candidate is local. A local pack remaps stale overrides inside the activation transaction instead. Check 25 rejects nested-quantifier regular expressions, so a merchant-facing registration-number pattern cannot introduce catastrophic backtracking. Check 7 reads a status stored on the local version row. There is no remote revocation feed: nothing is fetched that could mark a version revoked. ## Manual tax configuration Manual entry in settings is not a second calculation path. `buildManualPack()` in `main/routes/tax-packs.ts` synthesizes a `CountryPack` with `publisher: 'local'`, installs it through the same path a catalog entry takes, and it is then calculated by the same `TaxEngine.calculate()`. The synthesized pack id is `manual-`, its jurisdiction is `*`, it adds a hidden zero-rate `unclassified` category, and it uses line-scope `half_up` rounding at 2 decimal places with a `0.01` payable increment. `POST /manual-config` is owner-only and requires a store country to be configured first. ## Out of scope The following are not implemented. None of them should be documented as if they were, and a pack author cannot rely on any of them. - **Executable pack plugins.** There is no `utilityProcess` plugin layer; `grep -rn utilityProcess main/` returns nothing. A pack is data. See [0004: data-only tax packs](../decisions/0004-data-only-tax-packs.md). - **A remote revocation feed.** `LEGACY_TRUSTED_PACK_DIGESTS` is a pinned compatibility list, not a revocation mechanism. Check 7 reads a local row status. - **Root and delegated signing keys.** One Ed25519 public key is trusted, and there is no delegation or rotation. - **`taxPoint` behaviour.** The field is required on `CountryPack` and every shipped pack sets it to `finalized_at`, but nothing in the engine reads it. Setting `order_created` has no effect. It is a declared field, not a supported value. - **`storeId` on a pack.** No pack schema field references a store. - **Customer tax fields.** A customer carries a registration number, state code, and exempt flag through `TaxCustomer`, and nothing tax-address-shaped beyond that. - **Reporting by anything other than tax component.** Tax reporting surfaces (such as `GET /api/reports/tax-components` and X/Z cash-closure reports) group only by resolved tax component; there is no reporting by rule, by category, or by jurisdiction. ## Verification ```sh npm run test:tax-engine npm run test:tax-components npm run test:integration-tax npm run test:order-totals-recompute npm run test:tax-pack-catalog npm run test:tax-pack-management npm run test:manual-tax-config npm run test:legacy-tax-pack-digest npm run test:community-tax-packs ```