tax — Module Spec

1. Purpose

tax calculates and durably decomposes tax owed — module #17, the first module in this build sequence with zero v1 precedent. v1's architecture assumed tax was 100% outsourced to Stripe Tax with no local schema at all (confirmed: find docs/old -iname "*tax*" returns nothing; Payments' MODULE_INDEX row says "tax → Stripe Tax"; Admin's says "tax config (Stripe Tax)"). This module exists because that assumption proved insufficient: pos's own 19-to-9 audit found sale_line.tax_amount_cents/tax_rate collapse to a flat aggregate with no per-jurisdiction breakdown — an unrecoverable loss for stacked-tax remittance, logged as a PRE-CUSTOMER OPEN_ITEMS decision. tax closes that gap. This module never computes a rate — Stripe Tax (external) does; tax persists the result, per jurisdiction, as a legal record.

2. Ownership

Owns — 3 tables, 47 columns (Remediation Phase 4 added jurisdiction_level_catalog and 1 column each to the other 2 tables):

Table Cols Role
tax_calculation 31 Header: one row per calculation event, the nexus/rate-anomaly-flag surface
tax_calculation_jurisdiction 10 Append-only per-jurisdiction breakdown — the restored decomposition
jurisdiction_level_catalog 6 Global reference: catalog of valid jurisdiction levels, additive-interim alongside the pre-existing CHECK-enum — Remediation Phase 4, Item 17c
Total 47

Does NOT own: tax RATES or jurisdiction rule definitions (Stripe Tax's exclusive domain); nexus/registration configuration (Admin's scope, per v1's own placement, not duplicated); the invoice/receivable amount (Billing's — tax only optionally gets referenced by billing.ar_charge for traceability, never the reverse); the actual Stripe Tax API integration (a future TaxService/Payments concern).

3. Layer & Dependencies

Cross-cutting calculation layer consumed by pos (finalizes tax, and — since Remediation Phase 3 — reverses it on refund) and orders (estimates tax), reading pricing's tax_treatment field and crm's exemption certificates. Depends on: platform, identity (actor), multi_loc (site), shared (currency), crm (customer, customer_tax_certificate), pos (sale_line — read via polymorphic source_ref, no reciprocal column; sale_refund_line — same pattern, Remediation Phase 3), orders (order_line — same pattern), platform (legal_entity — Remediation Phase 4, Item 15, tax_calculation.entity_id). Depended on by: billing (ar_charge.tax_calculation_id, module #18, built same day — see PROJECT_DECISIONS #32).

4. Capabilities — honest Part D framing

Tax calculation is fully deterministic — recording what an external, authoritative provider (Stripe Tax) returned is not a judgment call, ever. There is no "agent computes a rate" surface and there never will be; rates are exclusively Stripe Tax's domain. The only legitimate agent surface is monitoring and flagging: a nexus- or rate-anomaly-detection agent can flag a tax_calculation for human review (e.g., "this jurisdiction's rate dropped to 0% — possible misconfiguration," or "transaction volume in state X may indicate new, unregistered nexus"). No agent ever overrides a calculation or computes a rate itself.

automation_source defaults 'system', not 'human' — the first justified deviation in the entire codebase (verified live: every other module's automation_source column defaults 'human', zero exceptions before this). This is deliberate, not an oversight: a tax calculation is a deterministic, externally-computed result, never a human action recorded after the fact. See DR-B.

No TaxService exists yet — schema-only build.

5. Service Contract — TaxService

Not built this pass. Binding future requirement: TaxService.calculate() calls Stripe Tax, writes one tax_calculation header + N tax_calculation_jurisdiction rows per calculation, and never writes to pos.sale_line's own tax_amount_cents/tax_rate columns except as a service-layer sync of the aggregate (documented, not DB-enforced).

6. Build Requirements (binding)

DR-1 — never compute a rate; always call Stripe Tax (or record manual/exempt)

TaxService MUST call the external Stripe Tax API (or, for the manual/exempt provider values, record an explicit human/system override) — never derive a rate from any local table, because none exists.

DR-2 — total_tax_amount_cents must reconcile to the jurisdiction sum

Every tax_calculation write MUST ensure total_tax_amount_cents = SUM(tax_calculation_jurisdiction.tax_amount_cents) for that calculation, in the same transaction. Not DB-enforced (cross-row aggregate); live-tested as a build requirement (test E1).

DR-3 — corrections supersede same-source_ref only; never mutate a jurisdiction row

A miscalculated tax is corrected by inserting a NEW tax_calculation row (same source_ref) with supersedes_calculation_id set — never by mutating the original header, and tax_calculation_jurisdiction rows are never mutated or deleted at all (append-only, enforced by the table shape). The same-source_ref scoping is DB-enforced by trg_tax_calculation_validate_supersession (BEFORE INSERT OR UPDATE OF supersedes_calculation_id, mirroring pricing.trg_price_rule_validate_supersession) — added after an independent post-lock verification pass found the scoping was, until then, only a documented convention with no CHECK/trigger behind it; live-tested (test F3) that a cross-source_ref supersede attempt is rejected.

DR-4 — orders-estimate and pos-final calculations are never auto-linked

Per resolved decision 1 (option b): TaxService MUST NOT attempt to set supersedes_calculation_id across an is_estimate=true (orders) row and its later is_estimate=false (pos) counterpart — no line-level seam exists to do this correctly (see DR-A below). Reporting reconciles them via the existing header-level orders.order_header.fulfilled_sale_id → pos.sale join.

7. Design Rationale (DR-A…H)

  • DR-A — the orders↔pos reconciliation gap, disclosed not hidden. An adversarial design-phase pass caught that an earlier draft asserted the orders-estimate → pos-final relationship as "a service-layer reconciliation, not a schema gap." It structurally cannot be: supersedes_calculation_id only works when both rows share the same source_ref, and no column anywhere (not even order_header.fulfilled_sale_id, which is header-level only) maps a specific order_line to the specific sale_line it becomes. Resolved (decision 1, option b): the two rows deliberately COEXIST as independent records, reconciled only at report time via the header join. If a genuine line-level seam is ever needed, it is a dedicated future orders/pos reopen, not a side effect of this module. This is exactly the class of finding the Design-Phase Integrity adversarial pass exists to catch — caught here, on its first application to a module with no v1 precedent at all.
  • DR-B — automation_source='system' default, the first codebase precedent. See §4. Documented here as a citable precedent for any future genuinely-deterministic-by-default table, not a one-off exception quietly bent for this module.
  • DR-C — no local rate/jurisdiction master tables. Actively rejected during design: duplicating Stripe Tax's rate/jurisdiction definitions locally would drift from the authoritative source and violate the "don't build what the vendor already owns" discipline already established elsewhere (e.g., Billing reads crm.customer's credit terms rather than duplicating them). tax stores results, never rules.
  • DR-D — the 2-table split, not a JSONB shortcut. A single-table design with the jurisdiction breakdown as a JSONB array was considered and rejected: JSONB can't carry a FK-enforced append-only guarantee, and is one UPDATE away from resurrecting the exact "collapsed, unrecoverable" problem this module exists to fix. Real child table, not a shortcut — matches the reasoning that led every other line/breakdown table in this codebase (e.g. orders.order_line, purchasing.purchase_order_line) to be a real table, not a JSONB column.
  • DR-E — the same-source_ref supersession scope is DB-enforced by a trigger, not left as an unenforced convention. An independent post-lock verification pass found that the original build only documented the same-source_ref scoping (DR-3) without any CHECK or trigger behind it — a live cross-source_ref supersede attempt silently succeeded. A CHECK cannot express "compare against a different row," so trg_tax_calculation_validate_supersession (BEFORE INSERT OR UPDATE OF supersedes_calculation_id) was added, mirroring pricing.trg_price_rule_validate_supersession exactly: it rejects any supersedes_calculation_id whose target row doesn't share the same tenant_id/source_module/source_type/source_ref. Live-tested (test F3) and reproduced independently twice. Without this guard, an ordinary application bug — not just malice — could link a correction across two unrelated calculations, the same risk class pricing's trigger was built to close.
  • DR-F — Remediation Phase 1 (2026-07-08): exemption claims now require recorded evidence. A cross-module senior-architect review found tax_calculation.is_exempt = true could be set with no supporting record — a fail-open gap allowing an unevidenced exemption claim. Closed by a new CHECK, chk_tax_calculation_exempt_requires_evidence: is_exempt = true now requires either applied_exemption_certificate_id IS NOT NULL or provider = 'exempt'. No column or table count change. Full cross-module record: PROJECT_DECISIONS #37.
  • DR-G — Remediation Phase 2 (2026-07-08): tax_calculation_jurisdiction moved to UUIDv7 PK generation. id's DEFAULT changed from gen_random_uuid() to platform.uuid_generate_v7() (Remediation Phase 2, Item 6) on this already-append-only-enforced (Remediation Phase 1) ledger table. UUIDv7 is time-ordered, keeping future time-range partitioning possible without a PK rewrite — impossible once data lands on a random UUIDv4 PK. PK-generation-strategy change only, no column or table count change. Full cross-module record: PROJECT_DECISIONS #38.
  • DR-H — Remediation Phase 3 (2026-07-08): refund tax reversal, closing the over-reporting-remittance gap. Before this item, a refund's tax was nowhere represented at all — tax_calculation only ever recorded the original sale's tax, so remitting SUM(total_tax_amount_cents) over-reported every time a refund happened. Fixed by adding calculation_type (original/reversal, DEFAULT 'original') and a self-FK reversed_calculation_id (same self-FK-workaround pattern as supersedes_calculation_id), widening the source-type/source-pair CHECKs to admit pos/sale_refund_line as a valid source, and replacing the old nonnegative CHECKs on taxable_amount_cents/total_tax_amount_cents with sign-aware pairs (original stays >= 0, byte-identical to the old behavior; reversal must be <= 0) so SUM(original + reversal) nets to zero for remittance with no per-row branching. trg_tax_calculation_validate_reversal same-tenant-validates the reversal link, deliberately WITHOUT also requiring matching source_module/source_type/source_ref the way trg_tax_calculation_validate_supersession does — a reversal's source (the refund line) is architecturally always different from what it reverses (the original sale line), unlike a same-line supersession correction. A same-day addendum, after independent post-build verification, found the sign convention was undefended on the child table (tax_calculation_jurisdiction's old _amount_nonneg CHECK still forced >= 0 even for reversal rows) and closed it with a new BEFORE INSERT trigger, trg_tax_calculation_jurisdiction_validate_sign, that looks up the parent's calculation_type and enforces the matching sign — BEFORE INSERT alone suffices since the table is already INSERT-only. Zero backfill risk: 54 live tax_calculation rows were all calculation_type='original' by DEFAULT; pos.sale_refund/sale_refund_line had 0 rows. Full cross-module record: PROJECT_DECISIONS #39.
  • DR-I — Remediation Phase 4 (2026-07-08): legal-entity attribution (Item 15) and the jurisdiction-level catalog (Item 17c). Two independent additions, both additive, zero backfill risk. Item 15: tax_calculation gained a nullable entity_id FK → the new platform.legal_entity (a tenant can now incorporate more than one legal entity without splitting into multiple tenants). tax was one of 10 header tables independently derived as plausibly differing per legal entity within one tenant (no pre-existing list of these 10 was found anywhere in this session's context; the architect chose to have the list derived fresh rather than supply the original). NULL means "the tenant's primary entity," matching today's implicit behavior — no backfill required (confirmed additive-safe against 65 live rows). Per the disclosed scoping rule this phase made explicit for the first time: entity_id goes on HEADER tables only, never their line-item children — tax has no line-item child, so this doesn't apply here, but the rule is now citable. Item 17c: a new global catalog table, tax.jurisdiction_level_catalog (6 cols, 6 seeded rows including the new country level), plus a nullable tax_calculation_jurisdiction.jurisdiction_level_id FK into it — additive-interim, deliberately NOT yet kept in sync with the pre-existing jurisdiction_level CHECK-enum column on the same table (disclosed in the Drizzle TypeScript source itself, not just a migration comment). Unlike the other 3 Item 17 sub-items (POS tender types, payment terms, integration providers), this one ALSO included a REAL widen of the pre-existing CHECK itself — chk_tax_calculation_jurisdiction_level now admits 'country' alongside state/county/city/district/special, independently confirmed a genuine widen (not a no-op) against the original 20260707080000_tax_module.sql migration — closing the VAT/GST gap: country-level tax jurisdictions were not representable before. Because tax_calculation_jurisdiction is append-only (no UPDATE/DELETE grant, plus its own append-only trigger), jurisdiction_level_id can never be backfilled onto pre-migration rows — a permanent, not temporary, gap for historical rows. Full cross-module record: PROJECT_DECISIONS #40.

8. Agent Authority Mapping

No new authority mechanism — pure consumer of identity.agent_duty_grant. Permission code: tax:calculation:flag_anomalydraft_only/observational, logs to ai.agent_execution (target_module='tax').

9. Cross-Module Seams

  • tax → pos/orders: tax_calculation.source_ref (polymorphic, no FK type since it targets more than one table) → pos.sale_line.id, pos.sale_refund_line.id (Remediation Phase 3), or orders.order_line.id, validated by the source_module/source_type pair CHECK. No reciprocal column on any of the three.
  • tax → pos (refund reversal, Remediation Phase 3): tax_calculation.reversed_calculation_id (self-FK) links a calculation_type='reversal' row back to the original row it reverses. pos.sale_refund_line.tax_amount_cents/.tax_rate and pos.sale_refund.tax_refunded_amount_cents are the POS-side counterparts (owned by pos's own schema docs) — all plain positive magnitudes; the sign convention for remittance lives only in tax.
  • tax → crm: customer_id → crm.customer.id; applied_exemption_certificate_id → crm.customer_tax_certificate.id.
  • tax → shared/multi_loc/identity: currency_code, site_id, *_actor_id — standard.
  • billing → tax (forward): ar_charge.tax_calculation_id → tax.tax_calculation.id — see module_spec/billing.md §9.
  • tax → Payments (deferred): provider_ref — plain text, no FK, Payments not built.
  • tax → platform (Remediation Phase 4, Item 15): tax_calculation.entity_id → platform.legal_entity.id — nullable, no reciprocal column; NULL = tenant's primary entity.
  • tax → tax, self-referential catalog (Remediation Phase 4, Item 17c): tax_calculation_jurisdiction.jurisdiction_level_id → tax.jurisdiction_level_catalog.id — nullable, additive-interim alongside the pre-existing jurisdiction_level CHECK-enum on the same table, not yet kept in sync.

10. Deferred / Future Items

provider_ref (Payments); no TaxService; nexus/rate-anomaly detection logic (cross-ref Admin's config scope). The orders↔pos reconciliation is a design decision, not a deferred item — see DR-A. Remediation Phase 4 additions: the Item 17 sync gap between jurisdiction_level_id and the legacy jurisdiction_level CHECK-enum (a future cutover must decide the mapping, not assume trivial 1:1); jurisdiction_level_id can never be backfilled onto pre-migration tax_calculation_jurisdiction rows (permanent, not temporary); the cross-cutting catalog-table grant-model gap (jurisdiction_level_catalog, like all global-reference tables in this codebase, has no RLS restricting mutation by any tenant-scoped session) — out of scope for this phase, logged for whoever owns the grant model.

11. v1 Exclusions Re-Confirmed

N/A — there is no v1 to exclude anything from. This is the module's defining characteristic, disclosed throughout rather than papered over with a fabricated diff.

Last modified: Jul 8, 2026, 2:57 PM PT
On this page
Esc