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_idonly works when both rows share the samesource_ref, and no column anywhere (not evenorder_header.fulfilled_sale_id, which is header-level only) maps a specificorder_lineto the specificsale_lineit 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 futureorders/posreopen, 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).taxstores 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
UPDATEaway 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_refsupersession 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_refscoping (DR-3) without any CHECK or trigger behind it — a live cross-source_refsupersede attempt silently succeeded. A CHECK cannot express "compare against a different row," sotrg_tax_calculation_validate_supersession(BEFORE INSERT OR UPDATE OF supersedes_calculation_id) was added, mirroringpricing.trg_price_rule_validate_supersessionexactly: it rejects anysupersedes_calculation_idwhose target row doesn't share the sametenant_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 = truecould 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 = truenow requires eitherapplied_exemption_certificate_id IS NOT NULLorprovider = 'exempt'. No column or table count change. Full cross-module record: PROJECT_DECISIONS #37. - DR-G — Remediation Phase 2 (2026-07-08):
tax_calculation_jurisdictionmoved to UUIDv7 PK generation.id's DEFAULT changed fromgen_random_uuid()toplatform.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_calculationonly ever recorded the original sale's tax, so remittingSUM(total_tax_amount_cents)over-reported every time a refund happened. Fixed by addingcalculation_type(original/reversal, DEFAULT'original') and a self-FKreversed_calculation_id(same self-FK-workaround pattern assupersedes_calculation_id), widening the source-type/source-pair CHECKs to admitpos/sale_refund_lineas a valid source, and replacing the old nonnegative CHECKs ontaxable_amount_cents/total_tax_amount_centswith sign-aware pairs (originalstays>= 0, byte-identical to the old behavior;reversalmust be<= 0) soSUM(original + reversal)nets to zero for remittance with no per-row branching.trg_tax_calculation_validate_reversalsame-tenant-validates the reversal link, deliberately WITHOUT also requiring matchingsource_module/source_type/source_refthe waytrg_tax_calculation_validate_supersessiondoes — 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_nonnegCHECK still forced>= 0even for reversal rows) and closed it with a newBEFORE INSERTtrigger,trg_tax_calculation_jurisdiction_validate_sign, that looks up the parent'scalculation_typeand enforces the matching sign —BEFORE INSERTalone suffices since the table is already INSERT-only. Zero backfill risk: 54 livetax_calculationrows were allcalculation_type='original'by DEFAULT;pos.sale_refund/sale_refund_linehad 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_calculationgained a nullableentity_idFK → the newplatform.legal_entity(a tenant can now incorporate more than one legal entity without splitting into multiple tenants).taxwas 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_idgoes on HEADER tables only, never their line-item children —taxhas 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 newcountrylevel), plus a nullabletax_calculation_jurisdiction.jurisdiction_level_idFK into it — additive-interim, deliberately NOT yet kept in sync with the pre-existingjurisdiction_levelCHECK-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_levelnow admits'country'alongsidestate/county/city/district/special, independently confirmed a genuine widen (not a no-op) against the original20260707080000_tax_module.sqlmigration — closing the VAT/GST gap: country-level tax jurisdictions were not representable before. Becausetax_calculation_jurisdictionis append-only (no UPDATE/DELETE grant, plus its own append-only trigger),jurisdiction_level_idcan 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_anomaly — draft_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), ororders.order_line.id, validated by thesource_module/source_typepair CHECK. No reciprocal column on any of the three. - tax → pos (refund reversal, Remediation Phase 3):
tax_calculation.reversed_calculation_id(self-FK) links acalculation_type='reversal'row back to theoriginalrow it reverses.pos.sale_refund_line.tax_amount_cents/.tax_rateandpos.sale_refund.tax_refunded_amount_centsare the POS-side counterparts (owned bypos's own schema docs) — all plain positive magnitudes; the sign convention for remittance lives only intax. - 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— seemodule_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-existingjurisdiction_levelCHECK-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.