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 intentionalNULL != 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.


Last modified: Jun 17, 2026, 8:37 PM PT
On this page
Esc