Design Rationale — Billing
Non-obvious design choices for the billing module — the WHY behind each decision.
Billing (locked 2026-06-10)
Merchant A/R + A/P control layer — not GL, not accounting
Decision: billing tracks customer receivables, vendor payables, payment application, and statement generation. It is NOT a general-ledger or double-entry accounting engine.
Why: A retail ERP needs "who owes what, have they paid, and what's outstanding" — not a chart of accounts or journal entries. Tenants use QuickBooks, Xero, or Sage for their bookkeeping; Vrida feeds those systems via the Integrations module.
Guard: Do not add journal entries, chart-of-accounts tables, debit/credit postings, or period-close workflows. Billing is a control layer, not an accounting engine.
NOT Vrida subscription billing — that is Platform
Decision: billing = the tenant charges its customers (A/R) and pays its vendors (A/P). Vrida charging the tenant for its SaaS subscription lives entirely in platform.
Why: This is the exact trap that made the original 11_billing.md spec stale — it conflated two different "billing" meanings. Recording the boundary explicitly prevents re-conflation.
Guard: Subscription, tier, MRR, and SaaS-invoice concepts never belong in billing — they belong in platform (already built). If a billing-looking concept involves Vrida collecting from the tenant, it is a platform concern.
A/R and A/P are separate table sets — not a direction-discriminated ledger
Decision: ar_account, ar_charge, ar_payment, ar_payment_application, ar_statement are distinct from vendor_payable, ap_payment, ap_payment_application. No unified ledger_entry table with a direction flag.
Why: They rhyme structurally but wire to completely different sources: A/R flows from pos.sale, orders.order_header, and crm.customer; A/P flows from purchasing.vendor_invoice and purchasing.vendor. Lifecycle, FK targets, approval paths, and consumer code are all different.
Guard: This is the merge-vs-separate principle applied — do not collapse into one direction-flagged table. The structural similarity is superficial; the wiring is not.
ar_charge is idempotent on source refs; manual charges may intentionally duplicate
Decision: ar_charge deduplicates on (source_module, source_type, source_ref, source_payment_ref) for event-driven charges from POS and Orders. Manual charges (NULL source refs) are allowed to produce multiple rows.
Why: Event-driven charges from POS/Orders must not duplicate on retry — the same sale must not generate two receivable entries. But a manually-entered charge is a deliberate human action and must not be dedup-blocked by a unique constraint.
Guard: The NULL-source path that allows duplicates is intentional — NULL != NULL in Postgres means a unique on nullable columns doesn't block two NULL rows. A composite CHECK enforces that POS/Orders charges have non-null source refs and manual charges have null source refs, so the idempotency guarantee can't be bypassed by a malformed event-driven charge.
ar_charge is a receivable record — not a formal customer invoice
Decision: ar_charge records "this source event created a receivable of this amount." It is not a formatted, numbered customer invoice document.
Why: v1.0 needs balance tracking and payment application. Formal invoice generation (with line items, PDF, sequential numbering, credit memo workflow) is a distinct feature deferred to a later version.
Rejected: customer_invoice / credit_memo tables — deferred; adding them now would complicate the core balance-tracking path without shipping value.
Application tables are append-only; reversals via offsetting rows
Decision: ar_payment_application and ap_payment_application have no updated_at or deleted_at. A misapplied payment is corrected by inserting a new negative-amount row that offsets the original.
Why: Financial allocation records are immutable after the fact — the history of how a payment was applied is itself an audit record. The same principle as stock_movement and gift_card_transaction: the ledger is the truth; correct it by extension, never by mutation.
Guard: Append-only — do not add soft-delete to application tables. Reverse, do not delete.
vendor_payable one-per-invoice; Billing writes payment state back to Purchasing
Decision: vendor_payable.vendor_invoice_id is unique — one payable per Purchasing invoice. When the payable is paid, BillingService writes billing_ap_ref, payment_status_ref, and paid_at back to purchasing.vendor_invoice.
Why: Purchasing owns the invoice document; Billing owns the payment fact. Single owner per fact prevents the two systems disagreeing. The write-back is the seam that closes the Purchasing→Billing payment loop.
Billing references CRM and vendor terms — never copies them
Decision: ar_account reads credit limits and payment terms from crm.customer; vendor_payable reads payment terms from purchasing.vendor. Billing does not duplicate these fields into its own tables.
Why: Single source of truth. Copying terms into Billing creates a drift risk — if a customer's credit limit changes, Billing and CRM would have different values until a sync.
Guard: Do not add credit_limit_cents or payment_terms columns to Billing tables. Read them from the owning module at the time they are needed.
Event-driven ar_charge creation; POS/Orders checkout never blocks on Billing
Decision: POS and Orders emit an AccountChargeCreated event; BillingService creates the ar_charge row asynchronously. The checkout transaction completes without waiting for Billing.
Why: A customer transaction must complete even if the Billing service is temporarily down or slow. Idempotency on the source refs makes the async retry safe — a redelivered event produces no duplicate charge.