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: billing is 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 at purchasing.vendor_invoice's own FULL autonomy gate.
  • ar_charge.source_ref/source_payment_ref are polymorphic — plain uuid, NO single-table FK. A polymorphic column pointing at two different tables (pos.sale/orders.order_header and pos.sale_payment/orders.order_payment) structurally cannot carry one FK constraint. Validated instead by the source_module/source_type pair CHECK — identical pattern to tax.tax_calculation.source_ref, established the same day.
  • ar_charge idempotency is a two-partial-unique split, NOT a single index — a live NULL-distinctness bug, caught and fixed during build-time testing. source_payment_ref is 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 SAME source_ref both land with source_payment_ref IS NULL without colliding — silently defeating retry-dedup for the common event-driven-charge case. Fixed via ar_charge_idempotency_full_unique (both non-null) + ar_charge_idempotency_no_payment_ref_unique (payment_ref null), mirroring orders.order_header's own idempotency_key precedent exactly.
  • tax_calculation_id lives on ar_charge ONLY — no duplication onto ar_payment/ar_statement; statements derive jurisdiction-level tax by joining through ar_charge at report time.
  • ar_adjustment is NEW (v1 deferred it). Resolved decision 1: built now, full autonomy pack, draft→posted lifecycle (applied_at required 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 of ar_charge.charge_amount_cents — write-once by convention (no status/updated_at/deleted_at) but full CRUD grants remain on authenticated (no REVOKE, no blocking trigger); only trg_ar_charge_line_validate_against_charge constrains the sum invariant (lines may sum to less than the header, never more). The opposite reconciliation direction from Platform's own subscription_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) and store_credit_account (a customer liability: customer-tied NOT 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 NULL vs gift_card.customer_id nullable). 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, not pos — a deliberate reversal of v1's placement. v1 put gift_card/store_credit inside pos; 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 — while pos merely tenders against it. pos.sale_payment.gift_card_id/.store_credit_id and returns.return_resolution.store_credit_transaction_id all point INTO this schema.
  • gift_card.code_hash is hash-only at rest — v1 stored plaintext, a disclosed security upgrade. 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. code_last4 exists for human lookup/receipt display only.
  • The negative-balance guarantee is 3 layers, not 1. (1) chk_*_balance_nonnegative on both instrument headers; (2) chk_*_balance_after_nonneg on both ledgers' trigger-derived balance_after_cents snapshot; (3) the sync triggers' own atomic UPDATE ... 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. No updated_at/deleted_at at all — write-once by design, confirmed live. Not append-only-enforced — full CRUD grants exist for authenticated (no REVOKE, no trigger blocking UPDATE/DELETE); only the reconciliation trigger below constrains the sum invariant, the opposite enforcement shape from sale_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. No updated_at/deleted_at at 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. No updated_at/deleted_at at 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. Carries UNIQUE(id, tenant_id) from day one (gift_card_id_tenant_id_unique, the files.file built-in-not-retrofitted precedent) — the prerequisite for pos.sale_payment.gift_card_id's and gift_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 = 0Section 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-derivedbilling.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_only reusing platform.reject_append_only_mutation()). RLS enabled — gift_card_transaction_tenant_isolation. No updated_at/deleted_at. PK DEFAULT platform.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 only tenant_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 require reversed_transaction_id's own gift_card_id/store_credit_account_id to equal NEW's, and 'redeem' requires the cited sale_payment.gift_card_id/.store_credit_id to equal NEW's.
  • Finding 2 (MEDIUM) — a gift card's 'issue' amount was unconstrained against the card's own original_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 for store_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_refund carries no gift_card_id/store_credit_id of its own, only a nullable sale_payment_id, so the SAME sale_refund_id could 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's sale_payment, when known, to name THIS card/account as tender, and (b) regardless of whether a sale_payment is known, cap the TOTAL amount_cents credited across every gift_card_transaction/store_credit_transaction row citing that sale_refund_id at the refund's own refunded_amount_minor_units — closing the fabrication gap even for a receiptless refund. void/expire/adjust/cash_out have no cross-instrument attack surface (independently confirmed); store_credit's 'issue'-with-sale_refund_id path (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. DELETE REVOKEd from authenticated — 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. Carries UNIQUE(id, tenant_id) from day one (store_credit_account_id_tenant_id_unique) — the prerequisite for pos.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. No updated_at/deleted_at. PK DEFAULT platform.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. DELETE REVOKEd from authenticated.


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_idpos.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_idbilling.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:

  1. the applied ar_payment.currency_code,
  2. the target ar_charge.currency_code, and
  3. — when the same ar_account backs both the payment and the charge — that shared ar_account.currency_code.

Rejects two distinct cases:

  • (a) Payment/charge mismatch — the payment's currency_code does not equal the charge's currency_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_code they 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 of ar_charge.charge_amount_cents, write-once (Decision B case 1, matching tax.tax_calculation_jurisdiction's own precedent), PK platform.uuid_generate_v7() (not gen_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_unique on billing.ar_charge (this module's own prerequisite) 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, a UNIQUE constraint adds no column).
  • Backfill was the RECONSTRUCTED kind, not a JSONB-blob conversion — unlike Platform's subscription_invoice_line fix (PROJECT_DECISIONS #48). No blob existed; rows had to be rebuilt 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 (34 rows / 34 distinct tenants at build time) 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, 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, invalid source_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 CLOSEDar_charge.tax_calculation_id was 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 live pos.sale row 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 (new arChargeLine export; arCharge gains unique('ar_charge_id_tenant_id_unique')), packages/db/src/schema/tax/calculation.ts (taxCalculation gains unique('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 when ALL_9_TABLESALL_10_TABLES picked 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_id upgraded from a bare FK to composite ar_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 prerequisite UNIQUE(id, tenant_id) constraints (on ar_charge and on tax.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_id at 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.

Last modified: Jul 13, 2026, 9:16 AM PT
On this page
Esc