billing — module #18, the SETTLE half of the financial layer
16 tables, 260 columns — schema-locked 2026-07-07, reopened 2026-07-08 for Remediation Phase 4 (Items 15/16, +2 cols), again 2026-07-10 for Header/Line Remediation fix #2 (ar_charge_line, +1 table / +12 cols), again 2026-07-10 (same day, batch 2's closing bare-FK fix) for ar_charge.tax_calculation_id's composite-FK upgrade — constraint-only — and again 2026-07-18 for the Phase 3 stored-value build (gift card + store credit, +6 tables / +84 cols + 1 view — see the dedicated subsection below, PROJECT_DECISIONS #71). billing settles money — tax calculates what's owed (module #17, PROJECT_DECISIONS #31), billing records what actually moved — and, since 2026-07-18, also carries the tenant's stored-value liability (gift cards and store credit: money the tenant holds but has not yet earned). v1 had a real, locked Billing module (8 tables / 113 cols, self-verified) — all 8 preserved 1:1, zero consolidated or dropped, plus 1 NEW table (ar_adjustment); v1's separate pos.gift_card/store_credit group is now realized HERE, not in pos (see the placement rule below). Depends on platform, identity (actor), crm (customer — composite FKs from the stored-value tables since 2026-07-18, riding a new UNIQUE(id, tenant_id) prerequisite this build added), shared (currency), pos (sale, sale_payment; sale_line since 2026-07-10 — polymorphic, no FK; sale_refund since 2026-07-18 — real composite FKs from both stored-value ledgers), orders (order_header, order_payment; order_line since 2026-07-10 — same polymorphic pattern), purchasing (vendor, vendor_invoice), tax (tax_calculation — the NEW seam, extended to the line level by ar_charge_line.tax_calculation_id since 2026-07-10).
PROJECT_DECISIONS entries: #32 (design/lock), #40 (Remediation Phase 4, Items 15/16), #51 (Header/Line Remediation, fix #2), #53 (Header/Line Remediation batch 2 closing bare-FK fix), #71 (Phase 3 stored-value build — gift card + store credit).
Global rules for this schema:
- Never a GL/journal. v1's own explicit guard, re-confirmed:
billingis a control layer over receivables/payables, not double-entry bookkeeping. Never re-conflated with Platform's SaaS-subscription billing (a different, unrelated "billing"). - Asymmetric autonomy tiering, disclosed deliberately. A/R (
ar_account,ar_charge,ar_payment,ar_adjustment) is FULL — collections/dunning-flag, payment-matching-ambiguity, and write-off/dispute-resolution are real judgment surfaces. A/P (vendor_payable,ap_payment) and both application junction tables stay LIGHT — the real judgment already happened upstream atpurchasing.vendor_invoice's own FULL autonomy gate. ar_charge.source_ref/source_payment_refare polymorphic — plainuuid, NO single-table FK. A polymorphic column pointing at two different tables (pos.sale/orders.order_headerandpos.sale_payment/orders.order_payment) structurally cannot carry one FK constraint. Validated instead by thesource_module/source_typepair CHECK — identical pattern totax.tax_calculation.source_ref, established the same day.ar_chargeidempotency is a two-partial-unique split, NOT a single index — a live NULL-distinctness bug, caught and fixed during build-time testing.source_payment_refis itself nullable; Postgres treats every NULL as distinct in a plain unique index, so a naive single 5-column index would let two charges for the SAMEsource_refboth land withsource_payment_ref IS NULLwithout colliding — silently defeating retry-dedup for the common event-driven-charge case. Fixed viaar_charge_idempotency_full_unique(both non-null) +ar_charge_idempotency_no_payment_ref_unique(payment_ref null), mirroringorders.order_header's own idempotency_key precedent exactly.tax_calculation_idlives onar_chargeONLY — no duplication ontoar_payment/ar_statement; statements derive jurisdiction-level tax by joining throughar_chargeat report time.ar_adjustmentis NEW (v1 deferred it). Resolved decision 1: built now, full autonomy pack, draft→posted lifecycle (applied_atrequired once posted).ar_charge_line(NEW, 2026-07-10, Header/Line Remediation fix #2) is HEADER-IS-TRUTH, not append-only-enforced. Per-line decomposition ofar_charge.charge_amount_cents— write-once by convention (nostatus/updated_at/deleted_at) but full CRUD grants remain onauthenticated(no REVOKE, no blocking trigger); onlytrg_ar_charge_line_validate_against_chargeconstrains the sum invariant (lines may sum to less than the header, never more). The opposite reconciliation direction from Platform's ownsubscription_invoice_line(lines-are-truth, PROJECT_DECISIONS #48), built earlier in the same remediation effort.- TWO stored-value instruments, NEVER merged (2026-07-18, Phase 3 stored-value build).
gift_card(a bearer instrument: code-addressed, anonymous-capable, hashed code at rest) andstore_credit_account(a customer liability: customer-tiedNOT NULL, one account per customer per tenant) are deliberately separate tables — v1's own named GUARD, "the canonical example of the merge-vs-separate principle," honored structurally (store_credit_account.customer_id NOT NULLvsgift_card.customer_idnullable). They share an identical ledger PATTERN (ledger-first, trigger-maintained cached balance, atomic guard, reversal tracker —rewards.loyalty_point_ledger's own proven shape), never a table. - Stored value lives in
billing, notpos— a deliberate reversal of v1's placement. v1 putgift_card/store_creditinsidepos; v2's recorded position (OPEN_ITEMS row 323,CROSS_MODULE_CONTRACTS.md,module_spec/returns.md§9.2) is that a stored-value instrument is a LIABILITY on the tenant's books —billing's charter — whileposmerely tenders against it.pos.sale_payment.gift_card_id/.store_credit_idandreturns.return_resolution.store_credit_transaction_idall point INTO this schema. gift_card.code_hashis hash-only at rest — v1 stored plaintext, a disclosed security upgrade. SHA-256 hex of a >=256-bit CSPRNG value (theidentity.api_key/approvals.approval_tokenconvention); the plaintext code is shown once at issuance and never stored.code_last4exists for human lookup/receipt display only.- The negative-balance guarantee is 3 layers, not 1. (1)
chk_*_balance_nonnegativeon both instrument headers; (2)chk_*_balance_after_nonnegon both ledgers' trigger-derivedbalance_after_centssnapshot; (3) the sync triggers' own atomicUPDATE ... WHERE balance_cents + NEW.amount_cents >= 0 ... RETURNING— the layer that actually rejects an overdraw race-free, with the two CHECKs as belt-and-suspenders behind it.
Cross-Phase Foreign Keys (billing)
| Column | Target | Notes |
|---|---|---|
*.tenant_id (all 16) |
platform.tenant |
NOT NULL |
ar_account.customer_id, ar_charge.customer_id, ar_payment.customer_id, ar_statement.customer_id, ar_adjustment.customer_id |
crm.customer |
NOT NULL |
ar_charge.ar_account_id, ar_payment.ar_account_id, ar_statement.ar_account_id, ar_adjustment.ar_account_id |
billing.ar_account |
NOT NULL |
ar_charge.source_ref |
pos.sale.id or orders.order_header.id |
polymorphic, plain uuid, no FK — validated by source_module/source_type CHECK |
ar_charge.source_payment_ref |
pos.sale_payment.id or orders.order_payment.id |
polymorphic, plain uuid, no FK — same pattern |
ar_charge.tax_calculation_id |
tax.tax_calculation (id, tenant_id) |
nullable — the tax seam. Composite FK since 2026-07-10 (Header/Line Remediation batch 2, ar_charge_tax_calculation_tenant_fkey) — upgraded from bare, PROJECT_DECISIONS #53 |
ar_payment_application.ar_payment_id |
billing.ar_payment |
NOT NULL |
ar_payment_application.ar_charge_id |
billing.ar_charge |
NOT NULL |
ar_adjustment.ar_charge_id |
billing.ar_charge |
nullable — an adjustment can target one charge or the account generally |
ar_charge_line.ar_charge_id |
billing.ar_charge (id, tenant_id) |
composite FK, NOT NULL — Header/Line Remediation fix #2, NEW |
ar_charge_line.tax_calculation_id |
tax.tax_calculation (id, tenant_id) |
composite FK, nullable — per-line tax link, Header/Line Remediation fix #2, NEW |
ar_charge_line.source_line_ref |
pos.sale_line.id or orders.order_line.id |
polymorphic, plain uuid, no FK — validated by source_line_type CHECK, NEW |
vendor_payable.vendor_id, ap_payment.vendor_id |
purchasing.vendor |
NOT NULL |
vendor_payable.vendor_invoice_id |
purchasing.vendor_invoice |
NOT NULL, UNIQUE (one payable per invoice) |
ap_payment_application.ap_payment_id |
billing.ap_payment |
NOT NULL |
ap_payment_application.vendor_payable_id |
billing.vendor_payable |
NOT NULL |
*.currency_code (9 money-bearing tables: ar_account, ar_charge, ar_payment, ar_statement, ar_adjustment, vendor_payable, ap_payment + gift_card/store_credit_account since 2026-07-18) |
shared.currency.iso_code |
char(3), NOT NULL. No default on the 7 pre-existing tables; the 2 stored-value instruments carry DEFAULT 'USD'. (The 2 application junctions, ar_charge_line, and the 4 stored-value ledger/tracker tables carry no currency column of their own — verified live.) |
*_actor_id (various) |
identity.actor |
nullable |
ar_payment.stripe_payment_intent_id, ap_payment.stripe_payment_intent_id |
— (deferred, Payments) | plain nullable text, no FK |
ar_account.entity_id, vendor_payable.entity_id |
platform.legal_entity |
nullable — Remediation Phase 4, Item 15, see below |
gift_card.customer_id |
crm.customer (id, tenant_id) |
nullable, composite — NULL = anonymous bearer card. Rides the new customer_id_tenant_id_unique prerequisite this same migration added on crm.customer, Phase 3 stored-value build, NEW |
gift_card.issued_sale_id |
pos.sale (id, tenant_id) |
nullable, composite — required when source='purchase' (chk_gift_card_purchase_requires_sale), NEW |
store_credit_account.customer_id |
crm.customer (id, tenant_id) |
NOT NULL, composite — the never-merge GUARD's structural half, NEW |
gift_card_transaction.gift_card_id |
billing.gift_card (id, tenant_id) |
NOT NULL, composite, NEW |
store_credit_transaction.store_credit_account_id |
billing.store_credit_account (id, tenant_id) |
NOT NULL, composite, NEW |
gift_card_transaction.sale_payment_id, store_credit_transaction.sale_payment_id |
pos.sale_payment (id, tenant_id) |
nullable, composite — required for 'redeem', forbidden for every other entry type (CHECK pair), NEW |
gift_card_transaction.sale_refund_id, store_credit_transaction.sale_refund_id |
pos.sale_refund (id, tenant_id) |
nullable, composite — refund-to-instrument/clawback context (store credit's 'issue' may also carry it — credit issued FROM a refund, v1's own shape), NEW |
gift_card_transaction.reversed_transaction_id, store_credit_transaction.reversed_transaction_id |
self (id, tenant_id) |
nullable, composite self-FK — 'clawback' only, points at the original issue/reload entry, NEW |
gift_card_reversal_tracker.transaction_id, store_credit_reversal_tracker.transaction_id |
their respective ledgers (id, tenant_id) |
NOT NULL, composite + UNIQUE (tenant_id, transaction_id) — one cap row per original entry, NEW |
billing.ar_account (20 cols, FULL)
One row per (tenant, customer). Credit limit/terms read from crm.customer, never duplicated. entity_id (nullable FK → platform.legal_entity, Remediation Phase 4 Item 15) — a header-tier column, since a tenant with multiple incorporated legal entities may run receivables per entity.
Tenant-scoped. RLS enabled —
ar_account_tenant_isolation. Soft delete:deleted_at.updated_at: trigger-maintained.
CHECK constraints (5): chk_ar_account_status (active/on_hold/closed); chk_ar_account_automation_source; chk_ar_account_review_status; chk_ar_account_reviewer_not_creator; chk_ar_account_approved_requires_reviewer (Remediation Phase 1, see below).
Indexes: PK; tenant_id; (tenant_id, customer_id) UNIQUE WHERE not deleted; review_status='pending' partial.
billing.ar_charge (26 cols, FULL)
source_ref/source_payment_ref REAL target schemas exist live (v1 deferred them) but stay polymorphic plain-uuid (see global rules above). tax_calculation_id is the NEW tax seam.
Header/Line Remediation batch 2 bare-FK fix (2026-07-10): tax_calculation_id was a bare (non-composite) FK to tax.tax_calculation(id) — found during fix #2's own independent verification pass (PROJECT_DECISIONS #51), a separate finding from ar_charge_line's own build and not credited to it. Now upgraded to composite ar_charge_tax_calculation_tenant_fkey (tax_calculation_id, tenant_id) → tax.tax_calculation(id, tenant_id); the old bare constraint (ar_charge_tax_calculation_id_fkey) was DROPPED, not left dangling. The prerequisite UNIQUE(id, tenant_id) on both ar_charge and tax.tax_calculation already existed (both added by fix #2 itself, see below) — no new prerequisite needed here. Pre-migration audit: 40 rows with a populated tax_calculation_id at build time, 0 cross-tenant mismatches; independently re-verified live at doc-writing time — now 47 rows (this shared dev DB's row counts grow slightly over time), still 0 cross-tenant mismatches. See PROJECT_DECISIONS #53.
Tenant-scoped. RLS enabled —
ar_charge_tenant_isolation. Soft delete:deleted_at.updated_at: trigger-maintained.
CHECK constraints (12): chk_ar_charge_source_module; chk_ar_charge_source_type; chk_ar_charge_source_pair; chk_ar_charge_manual_no_source_ref; chk_ar_charge_status; chk_ar_charge_applied_le_charge; chk_ar_charge_charge_amount_nonneg; chk_ar_charge_applied_amount_nonneg; chk_ar_charge_automation_source; chk_ar_charge_review_status; chk_ar_charge_reviewer_not_creator; chk_ar_charge_approved_requires_reviewer (Remediation Phase 1, see below).
Indexes: PK; tenant_id; (tenant_id, ar_account_id); (tenant_id, status); ar_charge_idempotency_full_unique (5-col, WHERE both source_ref+source_payment_ref NOT NULL); ar_charge_idempotency_no_payment_ref_unique (4-col, WHERE source_ref NOT NULL AND source_payment_ref IS NULL) — the NULL-distinctness fix, see global rules; review_status='pending' partial.
Header/Line Remediation fix #2 prerequisite (2026-07-10): gained unique('ar_charge_id_tenant_id_unique') on (id, tenant_id) — confirmed missing before this migration, added as the prerequisite for ar_charge_line's own composite FK below. Zero risk: id is already the sole PK, so the constraint could never be violated by existing data regardless of row count (68 rows at build time).
billing.ar_charge_line (12 cols, ZERO autonomy tier, write-once, NEW — Header/Line Remediation fix #2, 2026-07-10)
Per-line decomposition of an already-resolved ar_charge.charge_amount_cents — Decision B case 1 (write-once, no status/updated_at/deleted_at), matching tax.tax_calculation_jurisdiction's own precedent exactly. PK uses platform.uuid_generate_v7() (not gen_random_uuid()) per Remediation Phase 2's append-only-table PK convention. source_line_ref/source_line_type are polymorphic (plain uuid, no FK) → pos.sale_line.id/orders.order_line.id, mirroring ar_charge.source_ref's own established pattern — NULL-safe both-or-neither. tax_calculation_id is the PER-LINE tax link; the header's own ar_charge.tax_calculation_id scalar is unaffected (a soft convention for zero-line charges, not schema-enforced).
Tenant-scoped. RLS enabled —
ar_charge_line_tenant_isolation. Noupdated_at/deleted_atat all — write-once by design, confirmed live. Not append-only-enforced — full CRUD grants exist forauthenticated(no REVOKE, no trigger blocking UPDATE/DELETE); only the reconciliation trigger below constrains the sum invariant, the opposite enforcement shape fromsale_refund_line/ar_charge's own append-only siblings elsewhere in this codebase.
CHECK constraints (7): chk_ar_charge_line_source_pair (NULL-safe both-or-neither on source_line_ref/source_line_type); chk_ar_charge_line_source_line_type (sale_line/order_line only); chk_ar_charge_line_amount_matches_qty (NULL-safe both-null-or-both-set-and-exact-multiply); chk_ar_charge_line_quantity_positive; chk_ar_charge_line_unit_amount_nonneg; chk_ar_charge_line_amount_nonneg; chk_ar_charge_line_tax_amount_nonneg.
Indexes: PK; tenant_id; ar_charge_id; tax_calculation_id partial WHERE NOT NULL.
Composite FKs (2, both NOT bare): ar_charge_line_charge_tenant_fkey (ar_charge_id, tenant_id) → billing.ar_charge(id, tenant_id); ar_charge_line_tax_calculation_tenant_fkey (tax_calculation_id, tenant_id) → tax.tax_calculation(id, tenant_id). Both required a new prerequisite UNIQUE(id, tenant_id) on their respective parent — ar_charge_id_tenant_id_unique on billing.ar_charge (see above) and tax_calculation_id_tenant_id_unique on tax.tax_calculation (a cross-module prerequisite this fix needed and added — tax's own table/column counts are unaffected, since a UNIQUE constraint adds no column).
Reconciliation is HEADER-IS-TRUTH — trigger trg_ar_charge_line_validate_against_charge (function billing.validate_ar_charge_line_against_charge(), BEFORE INSERT OR UPDATE OF amount_cents, ar_charge_id) rejects any line write that would push SUM(lines.amount_cents) over the parent ar_charge.charge_amount_cents. Lines may legitimately sum to LESS (an undecomposed remainder is fine), never more; ar_charge.charge_amount_cents itself is never mutated by this trigger. Mirrors purchasing.validate_vendor_credit_line_against_credit() verbatim in shape (the prior batch's own header-is-truth precedent, PROJECT_DECISIONS #47).
Backfill outcome (dry-run confirmed, not assumed): unlike Platform's subscription_invoice_line fix, no JSONB blob existed to convert — rows had to be RECONSTRUCTED by joining ar_charge.source_ref back through pos.sale/pos.sale_line. A mandatory pre-migration dry run found zero of the live pos-sourced ar_charge rows share a tenant with any seeded pos.sale row (disconnected seed datasets in this dev environment, not a reconciliation ambiguity); orders.order_header/order_line are both entirely empty, so there was no orders-side data either. The backfill query was still written to the full reconstructive spec (real per-charge proration across sibling charges sharing one source_ref, per ar_charge's own two-partial-unique-index design) and actually RUN — confirmed INSERT 0 0. billing.ar_charge_line has 0 rows in this environment today; this is a disclosed, permanent characteristic of this dev dataset (a real historical gap for any pre-existing charges), not an open action item — see OPEN_ITEMS.
billing.ar_payment (23 cols, FULL)
The payment-matching-ambiguity + refund/dispute flagging surface. stripe_payment_intent_id stays deferred.
Tenant-scoped. RLS enabled —
ar_payment_tenant_isolation. Soft delete:deleted_at.updated_at: trigger-maintained.
CHECK constraints (9): chk_ar_payment_method; chk_ar_payment_status; chk_ar_payment_applied_le_amount; chk_ar_payment_amount_positive; chk_ar_payment_applied_amount_nonneg; chk_ar_payment_automation_source; chk_ar_payment_review_status; chk_ar_payment_reviewer_not_creator; chk_ar_payment_approved_requires_reviewer (Remediation Phase 1, see below).
Indexes: PK; tenant_id; (tenant_id, ar_account_id); review_status='pending' partial.
billing.ar_payment_application (9 cols, LIGHT, append-only)
Reversal-via-offsetting-row discipline (v1 preserved) — never mutated. Remediation Phase 4, Item 16: guarded by trg_ar_payment_application_validate_currency (BEFORE INSERT — see below) enforcing cross-currency agreement; no column added to this table.
Tenant-scoped, append-only. RLS enabled —
ar_payment_application_tenant_isolation. Noupdated_at/deleted_atat all — confirmed live.
CHECK constraints (2): chk_ar_payment_application_amount_nonzero; chk_ar_payment_application_automation_source.
Indexes: PK; tenant_id; ar_payment_id; ar_charge_id.
Triggers: trg_ar_payment_application_validate_currency (BEFORE INSERT, calls billing.validate_ar_payment_application_currency()) — Remediation Phase 4, Item 16, see below.
billing.ar_statement (21 cols, LIGHT)
included_charge_ids JSONB snapshot preserved (v1's own "avoid an ar_statement_line child table" reasoning, still sound).
Tenant-scoped. RLS enabled —
ar_statement_tenant_isolation. Soft delete:deleted_at.updated_at: trigger-maintained.
CHECK constraints (2): chk_ar_statement_delivery_method; chk_ar_statement_automation_source.
Indexes: PK; tenant_id; (tenant_id, ar_account_id); (tenant_id, statement_number) UNIQUE WHERE not deleted.
billing.ar_adjustment (22 cols, FULL, NEW)
The write-off/dispute-resolution table v1 deferred. ar_charge_id nullable — an adjustment can target one specific charge or the account generally. status (draft/posted/reversed) requires applied_at once posted.
Tenant-scoped. RLS enabled —
ar_adjustment_tenant_isolation. Soft delete:deleted_at.updated_at: trigger-maintained.
CHECK constraints (8): chk_ar_adjustment_type (write_off/credit/dispute_resolution/correction); chk_ar_adjustment_status; chk_ar_adjustment_amount_nonzero; chk_ar_adjustment_posted_requires_applied_at; chk_ar_adjustment_automation_source; chk_ar_adjustment_review_status; chk_ar_adjustment_reviewer_not_creator; chk_ar_adjustment_approved_requires_reviewer (Remediation Phase 1, see below).
Indexes: PK; tenant_id; (tenant_id, ar_account_id); review_status='pending' partial.
billing.vendor_payable (17 cols, LIGHT)
One row per vendor_invoice (UNIQUE, preserved from v1). The write-back TARGET for purchasing.vendor_invoice's own billing_ap_ref/payment_status_ref/paid_at placeholder columns. entity_id (nullable FK → platform.legal_entity, Remediation Phase 4 Item 15) — a header-tier column; the payable's own vendor invoice may trace to a specific legal entity within a multi-entity tenant.
Tenant-scoped. RLS enabled —
vendor_payable_tenant_isolation. Soft delete:deleted_at.updated_at: trigger-maintained.
CHECK constraints (5): chk_vendor_payable_status; chk_vendor_payable_open_amount_nonneg; chk_vendor_payable_payable_amount_nonneg; chk_vendor_payable_paid_amount_nonneg; chk_vendor_payable_automation_source.
Indexes: PK; tenant_id; (tenant_id, vendor_id); vendor_invoice_id UNIQUE WHERE not deleted.
billing.ap_payment (17 cols, LIGHT)
stripe_payment_intent_id stays deferred.
Tenant-scoped. RLS enabled —
ap_payment_tenant_isolation. Soft delete:deleted_at.updated_at: trigger-maintained.
CHECK constraints (6): chk_ap_payment_method; chk_ap_payment_status; chk_ap_payment_applied_le_amount; chk_ap_payment_amount_positive; chk_ap_payment_applied_amount_nonneg; chk_ap_payment_automation_source.
Indexes: PK; tenant_id; (tenant_id, vendor_id).
billing.ap_payment_application (9 cols, LIGHT, append-only)
Tenant-scoped, append-only. RLS enabled —
ap_payment_application_tenant_isolation. Noupdated_at/deleted_atat all — confirmed live.
CHECK constraints (2): chk_ap_payment_application_amount_nonzero; chk_ap_payment_application_automation_source.
Indexes: PK; tenant_id; ap_payment_id; vendor_payable_id.
billing.gift_card (25 cols, FULL, NEW — Phase 3 stored-value build, 2026-07-18)
The bearer stored-value instrument — v1's Group B pos.gift_card, relocated to billing (a tenant liability, see global rules). code_hash is SHA-256 hex of a >=256-bit CSPRNG value (the identity.api_key/approvals.approval_token convention) — the plaintext code is shown once at issuance and NEVER stored (v1 stored plaintext; a deliberate, disclosed security upgrade). balance_cents is a cached SUM(gift_card_transaction.amount_cents), maintained EXCLUSIVELY by billing.sync_gift_card_balance() — it starts at 0 and the 'issue' ledger entry loads the face value through the trigger, never a direct write.
Tenant-scoped. RLS enabled —
gift_card_tenant_isolation. Soft delete:deleted_at.updated_at: trigger-maintained. CarriesUNIQUE(id, tenant_id)from day one (gift_card_id_tenant_id_unique, thefiles.filebuilt-in-not-retrofitted precedent) — the prerequisite forpos.sale_payment.gift_card_id's andgift_card_transaction.gift_card_id's own composite FKs.
| Column | Type | Nullable | Default | Notes |
|---|---|---|---|---|
id |
UUID | NOT NULL | gen_random_uuid() |
PK |
tenant_id |
UUID | NOT NULL | — | FK → platform.tenant |
code_hash |
text | NOT NULL | — | SHA-256 hex, plaintext never stored. Partial-unique per tenant — see indexes |
code_last4 |
text | NOT NULL | — | Human lookup/receipt display only |
format |
text | NOT NULL | 'physical' |
CHECK IN (physical,digital) |
source |
text | NOT NULL | 'purchase' |
CHECK IN (purchase,bulk,promotional) |
original_amount_cents |
bigint | NOT NULL | — | CHECK > 0 — the face value |
balance_cents |
bigint | NOT NULL | 0 |
Cached ledger sum, trigger-maintained ONLY. CHECK >= 0 (negative-balance layer 1 of 3) |
currency_code |
char(3) | NOT NULL | 'USD' |
FK → shared.currency.iso_code |
status |
text | NOT NULL | 'active' |
CHECK IN (active,suspended,redeemed,voided,expired) — transitions folded into the sync trigger's own atomic UPDATE |
customer_id |
UUID | nullable | — | Composite FK → crm.customer. NULL = anonymous bearer card |
issued_sale_id |
UUID | nullable | — | Composite FK → pos.sale. NULL = bulk/promotional; required when source='purchase' |
issued_at |
timestamptz | NOT NULL | now() |
|
expires_at |
timestamptz | nullable | — | NULL = never expires (the DEFAULT — many US states prohibit gift-card expiry); expiry is tenant policy (admin.setting_definition stored_value/gift_card_default_expiry_days), never a schema assumption |
note |
text | nullable | — | |
created_by_actor_id |
UUID | nullable | — | FK → identity.actor — FULL-pack |
automation_source |
text | NOT NULL | 'human' |
CHECK IN (human,agent,system,seed) |
review_status |
text | NOT NULL | 'not_required' |
CHECK IN (not_required,pending,approved,rejected) |
review_reason |
text | nullable | — | |
reviewed_by_actor_id |
UUID | nullable | — | FK → identity.actor |
reviewed_at |
timestamptz | nullable | — | |
decision_provenance |
jsonb | nullable | — | Project-wide reason/evidence/confidence/memory_refs convention |
created_at |
timestamptz | NOT NULL | now() |
|
updated_at |
timestamptz | NOT NULL | now() |
Trigger-maintained |
deleted_at |
timestamptz | nullable | — | Soft delete |
CHECK constraints (13, verbatim where load-bearing):
| Name | Definition |
|---|---|
chk_gift_card_format |
format IN ('physical','digital') |
chk_gift_card_source |
source IN ('purchase','bulk','promotional') |
chk_gift_card_original_amount_positive |
original_amount_cents > 0 |
chk_gift_card_balance_nonnegative |
balance_cents >= 0 — negative-balance guarantee, layer 1 of 3 |
chk_gift_card_status |
status IN ('active','suspended','redeemed','voided','expired') |
chk_gift_card_redeemed_zero_balance |
status != 'redeemed' OR balance_cents = 0 |
chk_gift_card_terminal_zero_balance |
status NOT IN ('voided','expired') OR balance_cents = 0 — Section 4 self-audit finding (2026-07-18, fixed pre-commit): a voided/expired card must carry no stranded balance — the liability view excludes these statuses, so a nonzero balance here would silently vanish from the tenant's books. Belt to the sync trigger's own void/expire-must-exactly-zero gate |
chk_gift_card_purchase_requires_sale |
source != 'purchase' OR issued_sale_id IS NOT NULL |
chk_gift_card_expires_after_issued |
expires_at IS NULL OR expires_at > issued_at |
chk_gift_card_automation_source |
standard 4-value enum |
chk_gift_card_review_status |
standard 4-value enum |
chk_gift_card_reviewer_not_creator |
reviewed_by_actor_id IS NULL OR created_by_actor_id IS NULL OR reviewed_by_actor_id != created_by_actor_id |
chk_gift_card_approved_requires_reviewer |
review_status != 'approved' OR (reviewed_by_actor_id IS NOT NULL AND reviewed_at IS NOT NULL) |
Indexes: PK; tenant_id; customer_id partial WHERE NOT NULL; review_status='pending' partial; gift_card_tenant_id_code_hash_unique (UNIQUE, tenant_id+code_hash, WHERE deleted_at IS NULL — partial, NOT plain, per Recurring Bug Class #3 / SCHEMA_CONVENTIONS §15: a plain unique would permanently block re-issuing a card number after a soft delete; this is also the lookup-at-register path).
billing.gift_card_transaction (14 cols, append-only, uuid_generate_v7() PK, NEW — Phase 3 stored-value build, 2026-07-18)
The gift-card money ledger of record. balance_after_cents is trigger-derived — billing.sync_gift_card_balance() ALWAYS overwrites it with the true post-serialization balance via its RETURNING clause; a caller-supplied value never survives (negative-balance layer 2 of 3). amount_cents is signed: value-add entries (issue/reload/refund_to_instrument) must be positive, spend entries (redeem/cash_out/expire/void/clawback) must be negative, adjust must be nonzero — all CHECK-enforced.
Tenant-scoped, append-only (both layers:
REVOKE UPDATE, DELETE ... FROM authenticated+trg_gift_card_transaction_append_onlyreusingplatform.reject_append_only_mutation()). RLS enabled —gift_card_transaction_tenant_isolation. Noupdated_at/deleted_at. PK DEFAULTplatform.uuid_generate_v7()per Remediation Phase 2's append-only-ledger convention.
| Column | Type | Nullable | Default | Notes |
|---|---|---|---|---|
id |
UUID | NOT NULL | platform.uuid_generate_v7() |
PK |
tenant_id |
UUID | NOT NULL | — | FK → platform.tenant |
gift_card_id |
UUID | NOT NULL | — | Composite FK → billing.gift_card |
entry_type |
text | NOT NULL | — | CHECK IN (issue,reload,redeem,refund_to_instrument,cash_out,adjust,void,expire,clawback) |
amount_cents |
bigint | NOT NULL | — | Signed — sign CHECKs per entry type, see below |
balance_after_cents |
bigint | NOT NULL | — | Trigger-derived snapshot, never caller-supplied. CHECK >= 0 (layer 2 of 3) |
sale_payment_id |
UUID | nullable | — | Composite FK → pos.sale_payment — required for 'redeem', forbidden otherwise |
sale_refund_id |
UUID | nullable | — | Composite FK → pos.sale_refund — 'refund_to_instrument'/'clawback' context only |
reversed_transaction_id |
UUID | nullable | — | Composite self-FK — 'clawback' → its original issue/reload entry |
idempotency_key |
text | nullable | — | Service-layer retry dedup, NULL-safe partial unique per tenant |
note |
text | nullable | — | Required for adjust/void (CHECK) |
performed_by_actor_id |
UUID | nullable | — | FK → identity.actor |
automation_source |
text | NOT NULL | 'human' |
CHECK IN (human,agent,system,seed) |
created_at |
timestamptz | NOT NULL | now() |
CHECK constraints (13, verbatim where load-bearing):
| Name | Definition |
|---|---|
chk_gift_card_transaction_entry_type |
the 9-value vocabulary above |
chk_gift_card_transaction_value_add_positive |
entry_type NOT IN ('issue','reload','refund_to_instrument') OR amount_cents > 0 |
chk_gift_card_transaction_spend_negative |
entry_type NOT IN ('redeem','cash_out','expire','void','clawback') OR amount_cents < 0 |
chk_gift_card_transaction_adjust_nonzero |
entry_type != 'adjust' OR amount_cents != 0 |
chk_gift_card_transaction_balance_after_nonneg |
balance_after_cents >= 0 |
chk_gift_card_transaction_redeem_requires_payment |
entry_type != 'redeem' OR sale_payment_id IS NOT NULL |
chk_gift_card_transaction_payment_only_for_redeem |
entry_type = 'redeem' OR sale_payment_id IS NULL — the fail-closed ABSENCE half (the pos companion-CHECK bug class: a mismatched type cannot smuggle a stale ref in) |
chk_gift_card_transaction_refund_requires_sale_refund |
entry_type != 'refund_to_instrument' OR sale_refund_id IS NOT NULL |
chk_gift_card_transaction_sale_refund_type_coherence |
entry_type IN ('refund_to_instrument','clawback') OR sale_refund_id IS NULL |
chk_gift_card_transaction_clawback_requires_original |
entry_type != 'clawback' OR reversed_transaction_id IS NOT NULL |
chk_gift_card_transaction_reversed_only_for_clawback |
entry_type = 'clawback' OR reversed_transaction_id IS NULL |
chk_gift_card_transaction_adjust_void_require_note |
entry_type NOT IN ('adjust','void') OR note IS NOT NULL |
chk_gift_card_transaction_automation_source |
standard 4-value enum |
Indexes: PK; tenant_id; (gift_card_id, created_at); sale_payment_id partial WHERE NOT NULL; gift_card_transaction_redeem_payment_unique (UNIQUE, sale_payment_id, WHERE entry_type = 'redeem' AND sale_payment_id IS NOT NULL — THE double-redemption guard: one 'redeem' entry per pos.sale_payment row, ever; partial, so non-redeem rows are excluded and no NULL-distinctness exposure exists); gift_card_transaction_refund_card_unique (UNIQUE, sale_refund_id+gift_card_id, WHERE entry_type = 'refund_to_instrument' AND sale_refund_id IS NOT NULL — one refund-to-card entry per (refund, card) pair); gift_card_transaction_idempotency_unique (UNIQUE, tenant_id+idempotency_key, WHERE idempotency_key IS NOT NULL — NULL-safe by predicate).
Triggers: trg_gift_card_transaction_sync_balance (BEFORE INSERT → billing.sync_gift_card_balance(), the atomic balance guard — see the Phase 3 subsection below) and trg_gift_card_transaction_append_only (BEFORE UPDATE OR DELETE → platform.reject_append_only_mutation()).
Fix migration 20260718000009 (2026-07-18, same day, post-lock): 3 findings from two rounds of genuinely independent adversarial verification, all closed inside sync_gift_card_balance()/sync_store_credit_balance() (no schema/CHECK changes — trigger-body only):
- Finding 1 (HIGH) — the clawback-tracker lazy-insert and the
'redeem'path originally checked onlytenant_id, not the actual card/account — a clawback or redeem on Card X could cite Card Y's (same-tenant) transaction/sale_payment. Fixed: both trigger functions now also requirereversed_transaction_id's owngift_card_id/store_credit_account_idto equalNEW's, and'redeem'requires the citedsale_payment.gift_card_id/.store_credit_idto equalNEW's. - Finding 2 (MEDIUM) — a gift card's
'issue'amount was unconstrained against the card's ownoriginal_amount_cents(face value). Fixed: enforced inside the trigger (a CHECK constraint cannot reference another table) —'issue'must credit exactly the face value;'reload'stays deliberately unconstrained. No equivalent exists forstore_credit_account(no face-value column — disclosed, not fixed). - Finding 3 (HIGH, found by a SECOND independent pass run against the Finding-1/2 fix) —
'refund_to_instrument'had NO same-instrument check and no aggregate-amount cap at all:pos.sale_refundcarries nogift_card_id/store_credit_idof its own, only a nullablesale_payment_id, so the SAMEsale_refund_idcould be cited against MULTIPLE different cards — live-reproduced fabricating $30 of stored value from one $15 blind (no-receipt) refund record. Fixed: both trigger functions now (a) require the cited refund'ssale_payment, when known, to name THIS card/account as tender, and (b) regardless of whether asale_paymentis known, cap the TOTALamount_centscredited across everygift_card_transaction/store_credit_transactionrow citing thatsale_refund_idat the refund's ownrefunded_amount_minor_units— closing the fabrication gap even for a receiptless refund.void/expire/adjust/cash_outhave no cross-instrument attack surface (independently confirmed); store_credit's'issue'-with-sale_refund_idpath (credit issued FROM a refund) is NOT covered by this fix and is logged to OPEN_ITEMS as a related, disclosed gap.
All 3 findings live-reproduced before/after (both the exact exploit scenarios and the legitimate same-instrument paths, confirming no regression); regression tests added (billing-stored-value.spec.ts I1–I9).
billing.gift_card_reversal_tracker (7 cols, mutable, NEW — Phase 3 stored-value build, 2026-07-18)
The cumulative clawback cap per original issue/reload entry — the rewards/offers/returns tracker idiom, its 4th consumer: because the ledger is append-only, a maintained counter cannot live on the ledger row itself, so a small mutable side-table caps SUM(clawbacks) against one original atomically. Rows are created lazily by billing.sync_gift_card_balance() on the first clawback touching an original (only issue/reload entries are clawback-able — the lazy-insert's own SELECT excludes everything else, so a clawback targeting a 'redeem' or another 'clawback' never creates a tracker row and the capped UPDATE then matches zero rows, rejecting the write).
Tenant-scoped, mutable. RLS enabled —
gift_card_reversal_tracker_tenant_isolation.updated_at: trigger-maintained. No soft delete.DELETEREVOKEd fromauthenticated— a Section 4 self-audit finding, see the Phase 3 subsection below.
| Column | Type | Nullable | Default | Notes |
|---|---|---|---|---|
id |
UUID | NOT NULL | gen_random_uuid() |
PK — deliberately NOT uuid_generate_v7() (a genuinely mutable table, the platform.outbox precedent) |
tenant_id |
UUID | NOT NULL | — | FK → platform.tenant |
transaction_id |
UUID | NOT NULL | — | Composite FK → billing.gift_card_transaction — the original issue/reload entry. UNIQUE (tenant_id, transaction_id) |
original_amount_cents |
bigint | NOT NULL | — | Snapshotted from the original entry at lazy-create. CHECK > 0 |
total_reversed_cents |
bigint | NOT NULL | 0 |
Running total, atomically capped |
created_at |
timestamptz | NOT NULL | now() |
|
updated_at |
timestamptz | NOT NULL | now() |
Trigger-maintained |
CHECK constraints (2): chk_gift_card_reversal_tracker_bounds (total_reversed_cents >= 0 AND total_reversed_cents <= original_amount_cents); chk_gift_card_reversal_tracker_original_positive (original_amount_cents > 0).
Indexes: PK; gift_card_reversal_tracker_tenant_transaction_unique UNIQUE(tenant_id,transaction_id) — doubles as the lazy-insert's ON CONFLICT target.
billing.store_credit_account (17 cols, FULL, NEW — Phase 3 stored-value build, 2026-07-18)
The customer-tied stored-value liability account — one per customer per tenant (v1 fidelity), v1's Group B pos.store_credit relocated to billing. Deliberately SEPARATE from gift_card (the never-merge GUARD): no code, no expiry column, customer_id NOT NULL — the structural half of the bearer-vs-liability distinction. Cached balance_cents maintained only by billing.sync_store_credit_balance(); ledger of record is store_credit_transaction.
Tenant-scoped. RLS enabled —
store_credit_account_tenant_isolation. Soft delete:deleted_at.updated_at: trigger-maintained. CarriesUNIQUE(id, tenant_id)from day one (store_credit_account_id_tenant_id_unique) — the prerequisite forpos.sale_payment.store_credit_id's and the ledger's own composite FKs.
| Column | Type | Nullable | Default | Notes |
|---|---|---|---|---|
id |
UUID | NOT NULL | gen_random_uuid() |
PK |
tenant_id |
UUID | NOT NULL | — | FK → platform.tenant |
customer_id |
UUID | NOT NULL | — | Composite FK → crm.customer — NOT NULL, the never-merge GUARD's structural half. One account per customer (partial unique, see indexes) |
balance_cents |
bigint | NOT NULL | 0 |
Cached ledger sum, trigger-maintained ONLY. CHECK >= 0 |
currency_code |
char(3) | NOT NULL | 'USD' |
FK → shared.currency.iso_code |
status |
text | NOT NULL | 'active' |
CHECK IN (active,suspended,closed) — no auto transitions (an account at 0 balance stays active, unlike a gift card) |
note |
text | nullable | — | |
created_by_actor_id |
UUID | nullable | — | FK → identity.actor — FULL-pack |
automation_source |
text | NOT NULL | 'human' |
CHECK IN (human,agent,system,seed) |
review_status |
text | NOT NULL | 'not_required' |
CHECK IN (not_required,pending,approved,rejected) |
review_reason |
text | nullable | — | |
reviewed_by_actor_id |
UUID | nullable | — | FK → identity.actor |
reviewed_at |
timestamptz | nullable | — | |
decision_provenance |
jsonb | nullable | — | |
created_at |
timestamptz | NOT NULL | now() |
|
updated_at |
timestamptz | NOT NULL | now() |
Trigger-maintained |
deleted_at |
timestamptz | nullable | — | Soft delete |
CHECK constraints (7): chk_store_credit_account_balance_nonnegative (balance_cents >= 0); chk_store_credit_account_status (status IN ('active','suspended','closed')); chk_store_credit_account_closed_zero_balance (status != 'closed' OR balance_cents = 0 — closing an account with a balance would silently strand a liability); chk_store_credit_account_automation_source; chk_store_credit_account_review_status; chk_store_credit_account_reviewer_not_creator; chk_store_credit_account_approved_requires_reviewer.
Indexes: PK; tenant_id; customer_id; review_status='pending' partial; store_credit_account_tenant_customer_unique (UNIQUE, tenant_id+customer_id, WHERE deleted_at IS NULL — one account per customer, partial per Recurring Bug Class #3).
billing.store_credit_transaction (14 cols, append-only, uuid_generate_v7() PK, NEW — Phase 3 stored-value build, 2026-07-18)
The store-credit money ledger of record — an identical pattern to gift_card_transaction (the never-merge GUARD applies to the instruments, not the discipline), with one deliberate vocabulary-coherence difference: 'issue' may also legitimately carry sale_refund_id — store credit issued FROM a refund, v1's own store_credit_transaction shape, which is exactly the row returns.return_resolution.store_credit_transaction_id points at (link-don't-reimplement).
Tenant-scoped, append-only (both layers: REVOKE +
trg_store_credit_transaction_append_only). RLS enabled —store_credit_transaction_tenant_isolation. Noupdated_at/deleted_at. PK DEFAULTplatform.uuid_generate_v7().
| Column | Type | Nullable | Default | Notes |
|---|---|---|---|---|
id |
UUID | NOT NULL | platform.uuid_generate_v7() |
PK |
tenant_id |
UUID | NOT NULL | — | FK → platform.tenant |
store_credit_account_id |
UUID | NOT NULL | — | Composite FK → billing.store_credit_account |
entry_type |
text | NOT NULL | — | Same 9-value vocabulary as gift_card_transaction |
amount_cents |
bigint | NOT NULL | — | Signed — same sign CHECKs |
balance_after_cents |
bigint | NOT NULL | — | Trigger-derived, never caller-supplied. CHECK >= 0 |
sale_payment_id |
UUID | nullable | — | Composite FK → pos.sale_payment — 'redeem' only, both directions CHECK-enforced |
sale_refund_id |
UUID | nullable | — | Composite FK → pos.sale_refund — legal for 'issue'/'refund_to_instrument'/'clawback' (the 'issue'-from-refund difference vs the gift-card ledger) |
reversed_transaction_id |
UUID | nullable | — | Composite self-FK — 'clawback' only |
idempotency_key |
text | nullable | — | NULL-safe partial unique per tenant |
note |
text | nullable | — | Required for adjust/void |
performed_by_actor_id |
UUID | nullable | — | FK → identity.actor |
automation_source |
text | NOT NULL | 'human' |
Standard 4-value enum |
created_at |
timestamptz | NOT NULL | now() |
CHECK constraints (13): the same 13-CHECK shape as gift_card_transaction (chk_store_credit_transaction_*), with the one verbatim difference: chk_store_credit_transaction_sale_refund_type_coherence is CHECK (entry_type IN ('issue','refund_to_instrument','clawback') OR sale_refund_id IS NULL) — 'issue' added to the permitted list, per the issued-from-refund shape above.
Indexes: PK; tenant_id; (store_credit_account_id, created_at); sale_payment_id partial WHERE NOT NULL; sale_refund_id partial WHERE NOT NULL (this ledger gets the extra refund-side index — the returns-driven read path); store_credit_transaction_redeem_payment_unique (same double-redemption guard shape); store_credit_transaction_refund_account_unique (UNIQUE, sale_refund_id+store_credit_account_id, WHERE entry_type = 'refund_to_instrument' AND sale_refund_id IS NOT NULL); store_credit_transaction_idempotency_unique (NULL-safe by predicate).
Triggers: trg_store_credit_transaction_sync_balance (BEFORE INSERT → billing.sync_store_credit_balance()) and trg_store_credit_transaction_append_only.
billing.store_credit_reversal_tracker (7 cols, mutable, NEW — Phase 3 stored-value build, 2026-07-18)
Identical shape and role to gift_card_reversal_tracker, for the store-credit ledger — the same 2 CHECKs (chk_store_credit_reversal_tracker_bounds, chk_store_credit_reversal_tracker_original_positive), the same UNIQUE (tenant_id, transaction_id) (store_credit_reversal_tracker_tenant_transaction_unique), the same lazy-insert + atomic-cap maintenance by billing.sync_store_credit_balance() only, and the same DELETE REVOKE (Section 4 finding, see below).
Tenant-scoped, mutable. RLS enabled —
store_credit_reversal_tracker_tenant_isolation.updated_at: trigger-maintained. No soft delete.DELETEREVOKEd fromauthenticated.
The seams
The tax seam. ar_charge.tax_calculation_id → tax.tax_calculation.id — live-tested (JOIN resolves to the real total). Billing never duplicates the jurisdiction breakdown; single source of truth in tax.
The A/P write-back seams (to Purchasing, deferred until BillingService exists). purchasing.vendor_invoice.billing_ap_ref/.payment_status_ref/.paid_at (direct 1:1) and purchasing.purchase_order.amount_paid_cents (a SUM(vendor_payable.paid_amount_cents) rollup across every payable tracing to that PO — NOT a naive 1:1 copy, since one PO can have multiple invoices/payables). Both target columns exist live, confirmed unwritten.
The idempotency fix, live-tested (tests E1/E1b/E2). The two-partial-unique split correctly rejects a duplicate charge in both the no-payment-ref shape and the full-tuple shape, while leaving manual charges (source_ref IS NULL) intentionally uncollided.
The legal-entity seam (Remediation Phase 4, Item 15). ar_account.entity_id/vendor_payable.entity_id → platform.legal_entity.id — nullable, header-tier only. See the Remediation Phase 4 subsection below.
The currency-agreement trigger (Remediation Phase 4, Item 16). trg_ar_payment_application_validate_currency on ar_payment_application — enforces payment/charge/account currency agreement at insert time. See the Remediation Phase 4 subsection below.
The header/line remediation seam (fix #2, 2026-07-10). ar_charge_line decomposes ar_charge.charge_amount_cents into reconstructable per-line detail, reconciled HEADER-IS-TRUTH via trg_ar_charge_line_validate_against_charge. A mandatory pre-migration dry run found zero live pos-sourced ar_charge rows share a tenant with any pos.sale row in this environment (disconnected seed datasets — confirmed, not assumed) — the reconstructive backfill query was still written to the full spec and actually run, producing INSERT 0 0. This is a disclosed, permanent characteristic of this dev dataset, not an open action item. See the Header/Line Remediation subsection below and OPEN_ITEMS.
The pos stored-value tender seam (Phase 3, 2026-07-18). pos.sale_payment.gift_card_id/.store_credit_id — unenforced forward-refs since pos's own 2026-07-07 lock — are now REAL composite FKs into billing.gift_card/billing.store_credit_account (sale_payment_gift_card_tenant_fkey/sale_payment_store_credit_tenant_fkey), satisfying OPEN_ITEMS row 179's contract; only then was pos's fail-closed tender gate narrowed to payment_method != 'reward' (see pos.md for the pos-side detail). The redemption ledger entry points back the other way: gift_card_transaction/store_credit_transaction.sale_payment_id → pos.sale_payment(id, tenant_id), with the partial-unique double-redemption guard making one 'redeem' entry per tender row a hard invariant.
The returns store-credit seam (Phase 3, 2026-07-18). returns.return_resolution.store_credit_transaction_id → billing.store_credit_transaction(id, tenant_id) — a store-credit/warranty-credit resolution now points at the real 'issue' ledger entry it created (link-don't-reimplement, the same direction as its loyalty_reversal_ledger_id sibling), required via chk_return_resolution_store_credit_requires_transaction; the old free-text store_credit_reference placeholder is deprecated in place (see returns.md).
The liability view (Phase 3, 2026-07-18). billing.stored_value_liability — per-tenant outstanding stored-value liability: SUM(balance_cents) + instrument count across both instruments, grouped by (tenant, instrument_kind, currency), over active/suspended non-deleted rows only. Because the cached balances are trigger-guaranteed equal to their ledgers, this sum is authoritative without scanning the ledgers (the loyalty_account read-path payoff). WITH (security_invoker = true) — the underlying tables' RLS applies to the QUERYING role, deliberately stronger than inventory.stock_reconciliation_shell (the codebase's one prior view, which is owner-RLS — flagged separately, not fixed here); grants are REVOKE ALL + GRANT SELECT only.
Design Patterns Summary
Column-count reconciliation
| Table | Cols |
|---|---|
ar_account |
20 |
ar_charge |
26 |
ar_charge_line |
12 |
ar_payment |
23 |
ar_payment_application |
9 |
ar_statement |
21 |
ar_adjustment |
22 |
vendor_payable |
17 |
ap_payment |
17 |
ap_payment_application |
9 |
gift_card |
25 |
gift_card_transaction |
14 |
gift_card_reversal_tracker |
7 |
store_credit_account |
17 |
store_credit_transaction |
14 |
store_credit_reversal_tracker |
7 |
| Total | 260 |
Verified live (information_schema.columns/.tables, pg_constraint, schema billing, re-run 2026-07-18 after the Phase 3 stored-value build): 16 tables, 260 columns, 108 CHECK constraints (58 + the 50 the 6 new tables carry: 13+13+2+7+13+2), 79 FK constraints (52 + 27 new), 7 UNIQUE constraints (ar_charge_id_tenant_id_unique plus the 6 new stored-value ones: 4 *_id_tenant_id_unique day-one composite-FK prerequisites and 2 tracker *_tenant_transaction_unique), RLS enabled on all 16, plus 1 view (stored_value_liability, security_invoker = true, SELECT-only grant). set_updated_at fires on 11 tables (the prior 7 + gift_card/store_credit_account/both reversal trackers; ar_payment_application/ap_payment_application/ar_charge_line and both new stored-value ledgers have no updated_at column at all). Non-set_updated_at triggers, 8 total: trg_ar_payment_application_validate_currency (Remediation Phase 4, Item 16); trg_ar_charge_line_validate_against_charge (Header/Line Remediation fix #2); trg_ar_payment_application_append_only/trg_ap_payment_application_append_only (added by the 2026-07-18 Phase 1 Security & Integrity Remediation append-only sweep, PROJECT_DECISIONS #68 — previously undocumented in this file's own trigger count, disclosed here rather than silently absorbed); and the 4 Phase 3 stored-value triggers (trg_gift_card_transaction_sync_balance/trg_store_credit_transaction_sync_balance + the 2 *_append_only twins).
Remediation Phase 1 (2026-07-08)
A cross-cutting senior-architect review found a fail-open gap: a financial record could be marked review_status='approved' with no recorded reviewer. Closed by adding one CHECK constraint to each of the 4 FULL-tier billing tables — ar_account, ar_adjustment, ar_charge, ar_payment — each named chk_<table>_approved_requires_reviewer, enforcing: review_status='approved' requires both reviewed_by_actor_id IS NOT NULL and reviewed_at IS NOT NULL. No column or table added; 162 columns unchanged. CHECK-constraint count per affected table rises by 1 (see each table's section above); the module total is now 51 CHECK constraints (was 47), FK count unchanged at 47. Full cross-module record: PROJECT_DECISIONS #37.
Remediation Phase 2 (2026-07-08)
A cross-cutting PK-generation-strategy pass changed id DEFAULT on both append-only application ledgers — ar_payment_application and ap_payment_application — from gen_random_uuid() (UUIDv4) to platform.uuid_generate_v7() (UUIDv7, Item 6). Rationale: UUIDv7 is time-ordered, keeping future time-range partitioning possible on these append-only ledgers without a PK rewrite — something impossible once data lands on a random UUIDv4 PK. DEFAULT-only change; no column or table added or dropped, 162 columns unchanged. Full cross-module record: PROJECT_DECISIONS #38.
Remediation Phase 4 (2026-07-08)
The final phase of the 4-phase remediation plan, covering Items 15 (Legal Entity) and 16 (Exchange Rates) as they touch billing. 162 → 164 columns, 9 tables unchanged. Migrations: packages/db/migrations/20260709010000_phase4_item15_legal_entity.sql (the 2 entity_id ALTERs) and packages/db/migrations/20260709020000_phase4_item16_exchange_rates.sql (the currency-agreement trigger). Full cross-module record: PROJECT_DECISIONS #40.
Item 15 — entity_id added to 2 tables (+1 col each, +2 total):
ar_account.entity_id(nullable, FK →platform.legal_entity.id) — a tenant with more than one incorporated legal entity may run receivables per entity.vendor_payable.entity_id(nullable, FK →platform.legal_entity.id) — the payable's underlying vendor invoice may trace to a specific legal entity within a multi-entity tenant.
Both are part of Item 15's wider 10-table entity_id rollout (see PROJECT_DECISIONS #40 for the full list) — a disclosed, deliberate scoping rule confines entity_id to HEADER tables only, never to line-item children (so ar_charge, ar_payment, and the 2 application junction tables do NOT get entity_id — a line item's entity is always inherited via its header's FK). Nullable, FK-valid, zero backfill risk (both columns start NULL on existing rows).
Item 16 — cross-table currency-agreement trigger on ar_payment_application (NO new column):
billing.validate_ar_payment_application_currency() (trigger function) + trg_ar_payment_application_validate_currency (BEFORE INSERT — INSERT only, since ar_payment_application is itself append-only, so no UPDATE case can arise) on billing.ar_payment_application. On every insert it enforces that three currency codes agree:
- the applied
ar_payment.currency_code, - the target
ar_charge.currency_code, and - — when the same
ar_accountbacks both the payment and the charge — that sharedar_account.currency_code.
Rejects two distinct cases:
- (a) Payment/charge mismatch — the payment's
currency_codedoes not equal the charge'scurrency_code(e.g. a EUR payment applied to a USD charge). - (b) Account-currency drift — the payment and charge agree with each other (e.g. both USD) but the shared
ar_account.currency_codethey both trace to is a different currency (e.g. EUR). This is the subtler branch: two seemingly-consistent rows can still violate the account's own currency of record.
The function reads shared.exchange_rate only as context for Item 16's wider scope (the new reference table itself) — ar_payment_application does not FK to exchange_rate; the trigger performs a direct currency_code equality check across the three rows, not a rate lookup or conversion. This closes a real cross-currency data-integrity gap present since billing's original 2026-07-07 build (no prior mechanism prevented mismatched-currency payment application). Both rejection branches independently live-tested and confirmed by 2 separate adversarial verification lenses.
Header/Line Remediation reopen (2026-07-10) — fix #2
Third reopen of the coordinated, 6-module "Header/Line Remediation" design (vrida-header-line-remediation-design-2026-07-10.md, §7 — the Billing section — based on vrida-header-line-pattern-audit-2026-07-10.md), already run through an independent adversarial verification pass before any of it was built. Second batch, per the design doc's own recommended dependency order (§8) — Inventory landed first (fixes #5/#9, PROJECT_DECISIONS #49), Orders second (fix #6, PROJECT_DECISIONS #50), billing third.
- New table
billing.ar_charge_line(12 cols) — per-line decomposition ofar_charge.charge_amount_cents, write-once (Decision B case 1, matchingtax.tax_calculation_jurisdiction's own precedent), PKplatform.uuid_generate_v7()(notgen_random_uuid()— the corrected append-only-table PK convention). See the table's own section above for full detail. - 2 new prerequisite
UNIQUE(id, tenant_id)constraints, both confirmed missing live before this migration:ar_charge_id_tenant_id_uniqueonbilling.ar_charge(this module's own prerequisite) andtax_calculation_id_tenant_id_uniqueontax.tax_calculation(a cross-module prerequisite this fix needed and added —tax's own table/column counts are unaffected, a UNIQUE constraint adds no column). - Backfill was the RECONSTRUCTED kind, not a JSONB-blob conversion — unlike Platform's
subscription_invoice_linefix (PROJECT_DECISIONS #48). No blob existed; rows had to be rebuilt by joiningar_charge.source_refback throughpos.sale/pos.sale_line. A mandatory pre-migration dry run found zero of the live pos-sourcedar_chargerows (34 rows / 34 distinct tenants at build time) share a tenant with any seededpos.salerow — disconnected seed datasets in this dev environment, not a reconciliation ambiguity;orders.order_header/order_lineare both entirely empty, so there was no orders-side data either. The backfill query was still written to the full reconstructive spec (real per-charge proration across sibling charges sharing onesource_ref, perar_charge's own two-partial-unique-index design) and actually RUN — confirmedINSERT 0 0, not merely reasoned about. - Column-count impact: +1 table, +12 columns, 0 columns changed on any existing table besides the 2 new UNIQUE constraints (constraints, not columns) — 164 → 176 columns, 9 → 10 tables — verified live via
information_schema.columns/.tables, matching the per-table sum in the Column-count reconciliation table above. - Independent verification (separate agent, adversarial, live-DB-checked) — pasted, attributed. Found: schema shape PASS (PK confirmed genuinely
uuid_generate_v7()-shaped); composite-FK verification PASS (both FKs genuinely 2-column, both prerequisite UNIQUE constraints confirmed); all 13 live-reproduced scenarios PASS (reconciliation trigger under/over/exact-sum, cross-tenant rejection on both FKs, both directions of the NULL-safe source-pair CHECK, invalidsource_line_type, amount-matches-qty success/mismatch); dry-run/backfill re-derivation PASS (independently reconfirmed 0 overlap, 0 backfill rows — noted the live tenant/row counts had grown slightly since the build, 35 vs 34, expected drift in a shared dev DB, not a discrepancy); NULL-in-CHECK sweep PASS (full truth-table walk on both multi-column CHECKs); grants PASS (full CRUD, no REVOKE — not append-only-enforced, only the trigger constrains the sum invariant). - 2 findings, both disclosed, neither invalidating fix #2 itself: (1) CONCERN, real but latent and zero-risk today, NOW CLOSED —
ar_charge.tax_calculation_idwas ALSO found to be a bare FK by this same verification pass. This was a SEPARATE finding, closed in a LATER migration/docs pass (packages/db/migrations/20260710070000_headerline_bare_fk_fixes.sql) — not credited to this entry; see the "Header/Line Remediation Batch 2 — Closing Bare-FK Fix" subsection below and PROJECT_DECISIONS #53 for the fix. (2) Minor, latent script fragility, informational only — the backfill's dry-run gate computes a single whole-charge rounding while the actual per-line INSERT computes independent per-line rounding; these aren't always arithmetically identical for a multi-line sale split across sibling charges, though unreachable in today's data (every livepos.salerow has exactly 1 line). Worth a one-line disclosure if this migration pattern is ever reused, not a required fix. - Migration:
packages/db/migrations/20260710050000_headerline_billing_fix2.sql. Schema files:packages/db/src/schema/billing/ar.ts(newarChargeLineexport;arChargegainsunique('ar_charge_id_tenant_id_unique')),packages/db/src/schema/tax/calculation.ts(taxCalculationgainsunique('tax_calculation_id_tenant_id_unique')). - Tests:
apps/api/src/billing/__tests__/billing-schema.spec.ts— new section L (1 test, pre-existing-row-count sanity) and section M (13 tests,ar_charge_line's trigger/FK/CHECK coverage) — fix #2's own contribution is these 14 tests, plus a mechanical +1 each in sections A2/B whenALL_9_TABLES→ALL_10_TABLESpicked up the new table. A later addendum, section N (2 tests), covers the bare-FK finding above — not fix #2's own credit. File total: 70 tests (up from 52 immediately before this reopen), all passing. - See PROJECT_DECISIONS #51 for the full record.
Header/Line Remediation Batch 2 — Closing Bare-FK Fix (2026-07-10)
Constraint-only — 10 tables / 176 cols unchanged. This is the LAST item in the entire 2-batch "Header/Line Remediation" effort (batch 1: POS/Purchasing/Platform, PROJECT_DECISIONS #46-48; batch 2: Inventory/Orders/Billing/Identity/this fix, PROJECT_DECISIONS #49-53), landing in a single 3-module migration alongside platform.payment and purchasing.vendor_invoice_match (see those modules' own schema_docs for their own legs). Full cross-module record in PROJECT_DECISIONS #53.
ar_charge.tax_calculation_idupgraded from a bare FK to compositear_charge_tax_calculation_tenant_fkey (tax_calculation_id, tenant_id) → tax.tax_calculation(id, tenant_id). This was found bare during fix #2's own independent verification pass above (not fix #2's own build) — both prerequisiteUNIQUE(id, tenant_id)constraints (onar_chargeand ontax.tax_calculation) already existed from fix #2 itself, so this follow-up fix needed no new prerequisite.- Pre-migration audit: 40 rows with a populated
tax_calculation_idat build time, 0 cross-tenant mismatches; independently re-verified live at doc-writing time — now 47 rows, still 0 mismatches. - Migration:
packages/db/migrations/20260710070000_headerline_bare_fk_fixes.sql. Schema file:packages/db/src/schema/billing/ar.ts(arCharge's FK definition retargeted to the composite form). - Tests:
apps/api/src/billing/__tests__/billing-schema.spec.ts, new section N (2 tests: N1 cross-tenant UPDATE rejection, N2 valid same-tenant UPDATE) — the file's section N referenced above.
Service layer
No BillingService yet — schema-only this pass.
JSONB columns
decision_provenance (project-wide reason/evidence/confidence/memory_refs convention, all 4 FULL tables); ar_statement.included_charge_ids (array of charge IDs, the statement snapshot).
Open items carried forward (see OPEN_ITEMS.md)
stripe_payment_intent_id ×2 deferred (Payments not built); no BillingService yet; the 2 vendor-invoice/PO write-backs unwritten until BillingService exists; collections/dunning agent-detection service not built; formal customer_invoice/credit_memo, AP payment batches, GL posting, multi-currency all v1's own deferred-boundary items, re-confirmed not revisited. Remediation Phase 4: no trigger/service-layer hook yet creates a platform.legal_entity row for new tenants going forward (the 1-per-tenant backfill is point-in-time, not an ongoing guarantee — logged against platform, cross-referenced here since ar_account.entity_id/vendor_payable.entity_id are downstream consumers). Header/Line Remediation fix #2: billing.ar_charge_line has 0 rows in this environment — a disclosed, permanent characteristic of this dev dataset (pre-existing pos-sourced charges predate the decomposition and share no tenant with any seeded pos.sale row), not an open action item; going forward, the future BillingService's charge-creation path is expected to populate ar_charge_line for both pos- and orders-sourced charges. The ar_charge.tax_calculation_id bare-FK finding surfaced during this fix's own verification is tracked separately (see the note above) — not duplicated here.