billing — Module Spec

1. Purpose

billing settles money — module #18, following tax (module #17, which calculates it). A sale/order line has a taxable amount and a tax treatment (Pricing's Hard Contract 1 + tax_treatment); tax calls out to Stripe Tax and durably persists the per-jurisdiction breakdown; billing never touches tax computation — it only optionally references a tax_calculation row on its own ar_charge for jurisdiction-level remittance traceability. v1 had a real, locked Billing module (8 tables / 113 cols) — all 8 preserved 1:1 here, plus 1 NEW table (ar_adjustment, the write-off/dispute-resolution table v1 deferred).

2. Ownership

Owns — 10 tables, 176 columns (up from 164 at 2026-07-08/Remediation Phase 4; +1 table/+12 cols added 2026-07-10 by Header/Line Remediation fix #2 — see §12):

Table Cols Tier Role
ar_account 20 FULL One row per (tenant, customer) receivable account
ar_charge 26 FULL A/R charge — the tax seam lives here
ar_charge_line 12 ZERO, write-once, NEW 2026-07-10 Per-line decomposition of ar_charge.charge_amount_cents — Header/Line Remediation fix #2
ar_payment 23 FULL A payment received against an account
ar_payment_application 9 LIGHT, append-only Payment↔charge application junction
ar_statement 21 LIGHT Periodic statement snapshot
ar_adjustment 22 FULL, NEW Write-off / dispute-resolution lifecycle
vendor_payable 17 LIGHT The write-back target for purchasing.vendor_invoice
ap_payment 17 LIGHT A payment made to a vendor
ap_payment_application 9 LIGHT, append-only Payment↔payable application junction
Total 176

ar_account.entity_id and vendor_payable.entity_id (nullable FK → platform.legal_entity) were added in Remediation Phase 4 (Item 15). ar_payment_application gained a validating trigger in the same phase (Item 16) with no column change. ar_charge_line (2026-07-10, Header/Line Remediation fix #2) decomposes ar_charge.charge_amount_cents into reconstructable per-line detail, reconciled HEADER-IS-TRUTH; ar_charge itself gained a prerequisite UNIQUE(id, tenant_id) for this fix's composite FK (no column change) — see §12.

Does NOT own: tax computation or the jurisdiction breakdown (tax's exclusive domain — ar_charge.tax_calculation_id only references it); the vendor invoice DOCUMENT or the 3-way match (purchasing's domain — billing owns payment, not the invoice record); a GL/journal (v1's own explicit guard, re-confirmed — no double-entry bookkeeping anywhere in this module); Platform's SaaS-subscription billing (a different, unrelated "billing," never re-conflated).

3. Layer & Dependencies

The SETTLE half of the sell/buy path's financial layer, consumed indirectly by pos/orders (charge-account tenders become ar_charge rows) and purchasing (vendor invoices become vendor_payable rows). Depends on: platform, identity (actor), crm (customer), shared (currency), pos (sale, sale_payment — read via polymorphic source_ref/source_payment_ref, no reciprocal column; sale_line since 2026-07-10, same polymorphic pattern via ar_charge_line.source_line_ref), orders (order_header, order_payment — same pattern; order_line since 2026-07-10, same pattern), purchasing (vendor, vendor_invoice), tax (tax_calculation, module #17, built same day; extended to the line level by ar_charge_line.tax_calculation_id since 2026-07-10 — a REAL composite FK, requiring a prerequisite UNIQUE(id, tenant_id) on tax.tax_calculation added by this same fix), platform (legal_entity, Remediation Phase 4, new). Depended on by: none yet (schema-only, no service layer).

4. Capabilities — honest Part D framing

Billing is mostly deterministic recording — a charge/payment records a fact that already happened elsewhere (a sale completed, a check arrived). The named agent surfaces are real but narrow: event-driven ar_charge creation (may_act_alone/automation_source='system' when the future service builds it — recording an already-decided fact is not a judgment call) versus manual/adjustment charges, account-hold proposals, ambiguous-payment-match flags, and write-off proposals (draft_only, review_status='pending').

Billing never authorizes spend or initiates a payment — it only ever records money that moved elsewhere (Payments/a human/a bank). There is no C8-style "commit" boundary the way orders/purchasing had a PO-send gate, because billing has no equivalent autonomous-commitment action to gate.

No BillingService exists yet — schema-only build.

5. Service Contract — BillingService

Not built this pass. Binding future requirements: ar_account.current_balance_cents = SUM(charge) - SUM(payment) for the account (v1's own documented formula, preserved verbatim, not DB-enforced); write-backs to 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 — see DR-A); write-back into pos.sale_payment.charge_account_ref/orders.order_payment.charge_account_ref (text, no FK — a type change to UUID would require reopening pos/orders, avoided).

6. Build Requirements (binding)

DR-1 — ar_charge idempotency is a two-index split, not one

BillingService MUST rely on the two-partial-unique split (ar_charge_idempotency_full_unique + ar_charge_idempotency_no_payment_ref_unique), never assume a single index covers both shapes. See DR-B.

DR-2 — the A/P write-back to purchase_order.amount_paid_cents is a SUM, never a 1:1 copy

One PO can have multiple vendor_invoices (and thus multiple vendor_payables) — BillingService MUST maintain purchase_order.amount_paid_cents as SUM(vendor_payable.paid_amount_cents) across every payable tracing to that PO, never a direct copy from a single payable.

DR-3 — ar_adjustment.status='posted' requires applied_at

DB-enforced (chk_ar_adjustment_posted_requires_applied_at) — BillingService must set applied_at in the same transaction as the status transition.

7. Design Rationale (DR-A…C)

  • DR-A — the purchase_order.amount_paid_cents write-back, adversarial-caught. An adversarial design-phase pass (run jointly with tax's own proposal, see PROJECT_DECISIONS #31) caught that the first draft only addressed the vendor_invoice write-back triple and silently missed this second target. Documented in Block 4 and DR-1 above rather than left implicit.
  • DR-B — ar_charge's polymorphic source_ref/source_payment_ref carry NO single-table FK, and the idempotency index is a two-partial-unique split, both caught during THIS module's own build (not deferred to post-lock). The design proposal's language ("now REAL FKs") was imprecise — a polymorphic column pointing at two different tables structurally cannot carry one FK constraint; validated instead by the source_module/source_type pair CHECK, identical to tax.tax_calculation.source_ref's same-day-established pattern. Separately, source_payment_ref being nullable meant a naive single 5-column unique index would let two charges for the same source_ref both land with source_payment_ref IS NULL without colliding, defeating retry-dedup for the common case — caught by the build's own test-writing pass (test E1 failing), fixed via the two-partial-unique split mirroring orders.order_header's own idempotency_key precedent.
  • DR-C — no GL/journal, no re-conflation with Platform's SaaS billing. Both v1's own explicit guards, re-confirmed rather than revisited. billing is a control layer over receivables/payables, never double-entry bookkeeping, and never the same concept as Platform's tenant-subscription billing.
  • DR-D — Remediation Phase 1 (2026-07-08): approved-requires-reviewer CHECK added to all 4 FULL tables. A cross-cutting senior-architect review found review_status='approved' was fail-open — no DB-enforced requirement for a recorded reviewer. Closed via chk_<table>_approved_requires_reviewer on ar_account, ar_charge, ar_payment, ar_adjustment (review_status='approved' requires reviewed_by_actor_id and reviewed_at both NOT NULL). No column/table count change. See PROJECT_DECISIONS #37.
  • DR-E — Remediation Phase 2 (2026-07-08): id DEFAULT switched to UUIDv7 on both append-only application ledgers. ar_payment_application and ap_payment_application had id DEFAULT changed from gen_random_uuid() to platform.uuid_generate_v7() — UUIDv7 is time-ordered, keeping future time-range partitioning possible on these append-only ledgers without a PK rewrite. DEFAULT-only; no column/table count change. See PROJECT_DECISIONS #38.
  • DR-F — Remediation Phase 4 (2026-07-08): entity_id added to 2 header tables (Item 15) + a cross-table currency-agreement trigger on ar_payment_application (Item 16). ar_account.entity_id and vendor_payable.entity_id (nullable FK → platform.legal_entity.id) let a tenant with multiple incorporated legal entities scope receivables/payables per entity — part of Item 15's wider 10-table rollout; confined to HEADER tables only, per a disclosed scoping rule (never on line-item children like ar_charge/ar_payment, which inherit entity via their header FK). Separately, billing.validate_ar_payment_application_currency() + trg_ar_payment_application_validate_currency (BEFORE INSERT — ar_payment_application is append-only, so INSERT-only coverage suffices) now rejects an ar_payment_application row unless the payment's currency_code, the target charge's currency_code, and (when the same account backs both) the shared ar_account.currency_code all agree — closing a real cross-currency data-integrity gap present since this module's original build. Rejects both a direct payment/charge currency mismatch AND the subtler case where payment and charge agree with each other but the shared account carries a different currency. No column added for Item 16. 162 → 164 columns total (both new columns from Item 15 only). See PROJECT_DECISIONS #40.
  • DR-G — Header/Line Remediation reopen (2026-07-10), fix #2 — new table ar_charge_line, +1 table/+12 cols (164→176). Per-line decomposition of ar_charge.charge_amount_cents, write-once (Decision B case 1, matching tax.tax_calculation_jurisdiction's own precedent), reconciled HEADER-IS-TRUTH via trg_ar_charge_line_validate_against_charge (rejects any line write pushing SUM(lines.amount_cents) over the parent's charge_amount_cents; lines may sum to less, never more). 2 composite FKs, both requiring a new prerequisite UNIQUE(id, tenant_id): ar_charge_line_charge_tenant_fkey → billing.ar_charge(id, tenant_id) and ar_charge_line_tax_calculation_tenant_fkey → tax.tax_calculation(id, tenant_id) (the latter a cross-module prerequisite this fix needed and added on tax.tax_calculation itself). Unlike Platform's own subscription_invoice_line fix (PROJECT_DECISIONS #48), no JSONB blob existed to convert — rows were 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, not a reconciliation ambiguity), and the full reconstructive backfill query was still written and actually run, confirmed INSERT 0 0. Independent verification found the fix fully correct (13 live-reproduced scenarios PASS) and disclosed 2 findings, neither invalidating it: ar_charge.tax_calculation_id was ALSO found to be a bare FK (closed in a LATER, separate migration/docs pass — not this entry's credit) and a minor, informational rounding-methodology note (unreachable in today's data). See §12 and PROJECT_DECISIONS #51.

8. Agent Authority Mapping

No new authority mechanism — pure consumer of identity.agent_duty_grant. Permission codes: billing:ar_charge:propose_adjustment, billing:ar_account:flag_collections, billing:ar_payment:flag_ambiguous_match — all draft_only, logged to ai.agent_execution (target_module='billing').

9. Cross-Module Seams

  • billing → tax: ar_charge.tax_calculation_id → tax.tax_calculation (id, tenant_id) (nullable, composite REAL FK since 2026-07-10, Header/Line Remediation batch 2 — upgraded from bare, see §13). See module_spec/tax.md §9.
  • billing → purchasing: vendor_payable.vendor_id/.vendor_invoice_id → purchasing.vendor/purchasing.vendor_invoice (REAL FKs); the write-back seam (deferred until BillingService exists) is the reverse direction — see DR-A.
  • billing → pos/orders (source seam, polymorphic): ar_charge.source_ref/.source_payment_refpos.sale/orders.order_header and pos.sale_payment/orders.order_payment — plain uuid, no FK, validated by CHECK. No reciprocal column on either locked table.
  • billing → crm/shared/identity: customer_id, currency_code, *_actor_id — standard.
  • billing → Payments (deferred): stripe_payment_intent_id ×2 — plain text, no FK, Payments not built.
  • billing → platform (Remediation Phase 4, Item 15): ar_account.entity_id/vendor_payable.entity_id → platform.legal_entity.id — nullable, header-tier only.
  • billing internal (Remediation Phase 4, Item 16): trg_ar_payment_application_validate_currency on ar_payment_application — enforces payment/charge/account currency agreement at INSERT; reads shared.exchange_rate only as context (no direct FK).
  • billing → pos/orders (line-level, Header/Line Remediation fix #2, 2026-07-10): ar_charge_line.source_line_ref/.source_line_typepos.sale_line.id/orders.order_line.id — plain uuid, no FK, validated by CHECK, same polymorphic pattern as ar_charge.source_ref extended one level down.
  • billing → tax (line-level, Header/Line Remediation fix #2, 2026-07-10): ar_charge_line.tax_calculation_id → tax.tax_calculation(id, tenant_id) — REAL composite FK, nullable, the per-line counterpart to ar_charge.tax_calculation_id's own header-level seam. Required a new prerequisite UNIQUE(id, tenant_id) on tax.tax_calculation (a cross-module addition this fix needed and added, out of tax's own design scope but verified and applied here).

10. Deferred / Future Items

stripe_payment_intent_id ×2 (Payments); no BillingService; the 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 (v1's own Billing Module Boundary deferrals, re-confirmed unchanged). 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 this phase performed is point-in-time, not an ongoing guarantee — logged primarily against platform, cross-referenced here since ar_account.entity_id/vendor_payable.entity_id are downstream consumers). Header/Line Remediation fix #2: 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; the future BillingService's charge-creation path is expected to populate ar_charge_line going forward 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, not duplicated here (see §12) — now closed, see §13.

11. v1 Exclusions Re-Confirmed

All 8 v1 tables preserved 1:1, zero columns dropped. The one considered-and-rejected consolidation — a unified ledger_entry with a direction flag replacing A/R and A/P — was rejected, restating v1's own rationale that A/R and A/P "rhyme structurally but wire to completely different sources" with different lifecycles and consumer code.

12. Header/Line Remediation reopen (2026-07-10) — fix #2

Third reopen of the second batch of the coordinated "Header/Line Remediation" effort (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. Inventory landed first in this batch (fixes #5/#9, PROJECT_DECISIONS #49); Orders second (fix #6, PROJECT_DECISIONS #50); billing is third, per the design doc's own recommended dependency order (§8).

  • billing.ar_charge_line — new table (12 cols), per-line decomposition of ar_charge.charge_amount_cents. Write-once (Decision B case 1, no status/updated_at/deleted_at, matching tax.tax_calculation_jurisdiction's own precedent), PK platform.uuid_generate_v7() (not gen_random_uuid() — the corrected 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, NULL-safe both-or-neither, mirroring ar_charge.source_ref's own established pattern. tax_calculation_id is the per-line tax link (composite FK).
  • Reconciliation is HEADER-IS-TRUTHtrg_ar_charge_line_validate_against_charge rejects any line write that would push SUM(lines.amount_cents) over the parent ar_charge.charge_amount_cents; lines may sum to less (an undecomposed remainder is fine), never more. Mirrors purchasing.validate_vendor_credit_line_against_credit() verbatim in shape (PROJECT_DECISIONS #47's own header-is-truth precedent from the prior batch).
  • 2 composite FKs, 2 new prerequisite UNIQUE(id, tenant_id) constraintsar_charge_line_charge_tenant_fkey → billing.ar_charge(id, tenant_id) (prerequisite: ar_charge_id_tenant_id_unique, added to ar_charge by this fix) and ar_charge_line_tax_calculation_tenant_fkey → tax.tax_calculation(id, tenant_id) (prerequisite: tax_calculation_id_tenant_id_unique, a cross-module addition to tax.tax_calculation 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 to convert; 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 (only the 2 new UNIQUE constraints) — 164 → 176 columns, 9 → 10 tables.
  • Independent verification (separate agent, adversarial, live-DB-checked) — pasted, attributed. Found: schema shape PASS (PK confirmed genuinely uuid_generate_v7()-shaped, not gen_random_uuid()); 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 — all independently re-reproduced in a fresh rolled-back transaction); 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 §13 below and PROJECT_DECISIONS #53.
    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.

13. Header/Line Remediation batch 2 — closing bare-FK fix (2026-07-10)

Constraint-only, no table/column count change (10 tables / 176 cols unchanged). ar_charge.tax_calculation_id — disclosed as a bare FK during fix #2's own independent verification above (§12), not fix #2's own build — is now upgraded to composite ar_charge_tax_calculation_tenant_fkey (tax_calculation_id, tenant_id) → tax.tax_calculation(id, tenant_id), using the 2 prerequisite UNIQUE(id, tenant_id) constraints fix #2 already added (on both ar_charge and tax.tax_calculation — no new prerequisite needed). Landed in the same 3-module migration as platform.payment.invoice_id and purchasing.vendor_invoice_match.vendor_invoice_line_id's own composite-FK upgrades. Pre-migration audit: 40 rows with a populated tax_calculation_id at build time, 0 cross-tenant mismatches. Tests: billing-schema.spec.ts section N (2 tests). 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). Full detail: docs/database/schema_docs/billing.md's own "Header/Line Remediation Batch 2" subsection. See PROJECT_DECISIONS #53.

14. Phase 3 — Gift Card + Store Credit (2026-07-18, PROJECT_DECISIONS #71)

The first phase of the 2026-07 remediation sequence to add real business capability. Full record in docs/database/schema_docs/billing.md and PROJECT_DECISIONS #71 — this section covers the module-spec-level obligations for the future BillingService/PosService.

Shape: gift_card/gift_card_transaction/gift_card_reversal_tracker (bearer instrument) and store_credit_account/store_credit_transaction/store_credit_reversal_tracker (customer liability) — two instruments, never merged (v1's own named GUARD), one shared ledger pattern (ledger-first, trigger-maintained cached balance, atomic guard, reversal tracker — rewards.loyalty_point_ledger's own proven shape). Plus stored_value_liability, a security_invoker VIEW.

Service-layer obligations, not DB-enforced (logged to OPEN_ITEMS with the precise trigger):

  • A pos.sale_payment row with payment_method IN ('gift_card','store_credit') must be written in the SAME transaction as its matching 'redeem' ledger entry — the DB enforces the reverse direction (a redeem entry requires a real payment row, one redeem per payment ever) but does not require every stored-value payment row to have a ledger entry.
  • stored_value/cash_out_allowed (default false) and stored_value/gift_card_default_expiry_days (default unset = never) are tenant policy settings (admin.setting_definition), read via the receiving-trigger site>tenant>catalog precedence pattern — never hardcoded.
  • Refund of a stored-value-paid sale routes back to the ORIGINAL tender (refund_to_instrument) — industry norm, decided at Part 0 of this build.
  • Refund of a gift-card PURCHASE is a clawback entry, doubly capped by the DB (remaining balance + cumulative tracker) — the service only needs to write the entry with the correct reversed_transaction_id; the caps are structural.
  • Escheatment (dormant-balance remittance) has no schema surface — a future compliance/reporting concern, not a schema default.
Last modified: Jul 12, 2026, 10:47 PM PT
On this page
Esc