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_centswrite-back, adversarial-caught. An adversarial design-phase pass (run jointly withtax's own proposal, see PROJECT_DECISIONS #31) caught that the first draft only addressed thevendor_invoicewrite-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 polymorphicsource_ref/source_payment_refcarry 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 thesource_module/source_typepair CHECK, identical totax.tax_calculation.source_ref's same-day-established pattern. Separately,source_payment_refbeing nullable meant a naive single 5-column unique index would let two charges for the samesource_refboth land withsource_payment_ref IS NULLwithout 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 mirroringorders.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.
billingis 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 viachk_<table>_approved_requires_revieweronar_account,ar_charge,ar_payment,ar_adjustment(review_status='approved' requiresreviewed_by_actor_idandreviewed_atboth NOT NULL). No column/table count change. See PROJECT_DECISIONS #37. - DR-E — Remediation Phase 2 (2026-07-08):
idDEFAULT switched to UUIDv7 on both append-only application ledgers.ar_payment_applicationandap_payment_applicationhadidDEFAULT changed fromgen_random_uuid()toplatform.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_idadded to 2 header tables (Item 15) + a cross-table currency-agreement trigger onar_payment_application(Item 16).ar_account.entity_idandvendor_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 likear_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_applicationis append-only, so INSERT-only coverage suffices) now rejects anar_payment_applicationrow unless the payment'scurrency_code, the target charge'scurrency_code, and (when the same account backs both) the sharedar_account.currency_codeall 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 ofar_charge.charge_amount_cents, write-once (Decision B case 1, matchingtax.tax_calculation_jurisdiction's own precedent), reconciled HEADER-IS-TRUTH viatrg_ar_charge_line_validate_against_charge(rejects any line write pushingSUM(lines.amount_cents)over the parent'scharge_amount_cents; lines may sum to less, never more). 2 composite FKs, both requiring a new prerequisiteUNIQUE(id, tenant_id):ar_charge_line_charge_tenant_fkey → billing.ar_charge(id, tenant_id)andar_charge_line_tax_calculation_tenant_fkey → tax.tax_calculation(id, tenant_id)(the latter a cross-module prerequisite this fix needed and added ontax.tax_calculationitself). Unlike Platform's ownsubscription_invoice_linefix (PROJECT_DECISIONS #48), no JSONB blob existed to convert — rows were RECONSTRUCTED by joiningar_charge.source_refback throughpos.sale/pos.sale_line; a mandatory pre-migration dry run found zero of the live pos-sourcedar_chargerows share a tenant with any seededpos.salerow (disconnected seed datasets, not a reconciliation ambiguity), and the full reconstructive backfill query was still written and actually run, confirmedINSERT 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_idwas 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). Seemodule_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 untilBillingServiceexists) is the reverse direction — see DR-A. - billing → pos/orders (source seam, polymorphic):
ar_charge.source_ref/.source_payment_ref→pos.sale/orders.order_headerandpos.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_currencyonar_payment_application— enforces payment/charge/account currency agreement at INSERT; readsshared.exchange_rateonly 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_type→pos.sale_line.id/orders.order_line.id— plain uuid, no FK, validated by CHECK, same polymorphic pattern asar_charge.source_refextended 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 toar_charge.tax_calculation_id's own header-level seam. Required a new prerequisiteUNIQUE(id, tenant_id)ontax.tax_calculation(a cross-module addition this fix needed and added, out oftax'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 ofar_charge.charge_amount_cents. Write-once (Decision B case 1, nostatus/updated_at/deleted_at, matchingtax.tax_calculation_jurisdiction's own precedent), PKplatform.uuid_generate_v7()(notgen_random_uuid()— the corrected append-only-table PK convention).source_line_ref/source_line_typeare polymorphic (plain uuid, no FK) →pos.sale_line.id/orders.order_line.id, NULL-safe both-or-neither, mirroringar_charge.source_ref's own established pattern.tax_calculation_idis the per-line tax link (composite FK).- Reconciliation is HEADER-IS-TRUTH —
trg_ar_charge_line_validate_against_chargerejects any line write that would pushSUM(lines.amount_cents)over the parentar_charge.charge_amount_cents; lines may sum to less (an undecomposed remainder is fine), never more. Mirrorspurchasing.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)constraints —ar_charge_line_charge_tenant_fkey → billing.ar_charge(id, tenant_id)(prerequisite:ar_charge_id_tenant_id_unique, added toar_chargeby this fix) andar_charge_line_tax_calculation_tenant_fkey → tax.tax_calculation(id, tenant_id)(prerequisite:tax_calculation_id_tenant_id_unique, a cross-module addition totax.tax_calculationthis 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 to convert; 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 (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, notgen_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, invalidsource_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:
- 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 §13 below and PROJECT_DECISIONS #53. - 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.salerow has exactly 1 line). Worth a one-line disclosure if this migration pattern is ever reused, not a required fix.
- CONCERN, real but latent and zero-risk today, NOW CLOSED —
- 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.
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_paymentrow withpayment_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) andstored_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
clawbackentry, doubly capped by the DB (remaining balance + cumulative tracker) — the service only needs to write the entry with the correctreversed_transaction_id; the caps are structural. - Escheatment (dormant-balance remittance) has no schema surface — a future compliance/reporting concern, not a schema default.