Design: billing and invoicing
Status: not built. Salvaged from QwixPBX v1, which had the schema and a billing.go but no
working feature — despite its roadmap marking billing complete.
Today the system records calls (cdr in portal/api/src/db/schema.ts) and nothing rates them.
Shape
Five concerns, in dependency order:
- Rates — what a destination costs.
- Usage — what was actually consumed, derived from CDR.
- Account — a tenant’s balance, credit limit and billing details.
- Invoice — a periodic statement built from usage.
- Payment — money received against an invoice.
Rates
rateCard—(name, description, currency, defaultRate).rateCardRate—(rateCardId, prefix, destinationName, ratePerMinute, setupFee, minDurationSec, incrementSec, effectiveStartDate, effectiveEndDate).tenantRateCard—(organizationId, rateCardId, type: outbound|inbound, priority).
Two properties this design gets right and that are easy to lose:
- Rating is longest-prefix match, not equality.
1416must beat1for a Toronto number. Index on(rateCardId, prefix)and match by descending prefix length. minDurationSecandincrementSecare per-rate, not global. Carriers bill 60/60, 30/6, 1/1 depending on the route, and a single global rounding rule silently misprices everything the moment you add a second carrier.
effectiveStartDate/effectiveEndDate mean a rate lookup is always “the rate in force at the time
of the call”, never “the current rate”. Rating a three-week-old CDR with today’s card is a
correctness bug that looks like a rounding error.
Usage
usageRecord—(organizationId, billingAccountId, cdrId?, type, description, quantity, unit, rate, totalCost, startTime, endTime, invoiceId?).
type is deliberately broader than calls: v1 lists call, did_rental, extension_seat, recording_storage, sms. Recurring charges and metered non-call usage go through the same table, so
an invoice is one query.
cdrId is nullable for exactly that reason — a DID rental has no call behind it.
Account, invoice, payment
billingAccount—(organizationId, accountType: prepaid|postpaid, currency, balance, creditLimit, billingCycleAnchor, taxId, billingEmail, billingAddress jsonb, status, stripeCustomerId, paypalPayerId).invoice—(billingAccountId, invoiceNumber, startDate, endDate, issueDate, dueDate, subtotal, taxAmount, totalAmount, balanceDue, status: draft|posted|paid|void|overdue, pdfUrl).invoiceItem—(invoiceId, description, quantity, unitPrice, amount, taxable, usageRecordId?).payment—(billingAccountId, invoiceId?, amount, currency, method, transactionId, status, paymentDate).paymentMethod—(billingAccountId, provider, providerToken, type).balanceHistory— append-only ledger of balance movements.
Things to get right
Money is numeric, never real or double. v1 used DECIMAL(12,4) for rates and balances
and DECIMAL(12,2) for invoice totals; the drizzle equivalent is numeric with explicit precision
and scale. Floating point money produces balances that are off by fractions of a cent and cannot be
reconciled. Four decimal places on rates matters — per-minute rates are routinely $0.0035.
Prepaid is a real-time concern, postpaid is a batch one. A prepaid account with balance and
creditLimit has to be checked during call setup and the call cut when credit runs out, which
means the balance must be reachable from the call path — i.e. cached in Redis alongside the other
tenant data, with the key shape registered in keys.tenantPatterns
(portal/api/src/services/redis.ts). Postpaid only needs a nightly job. Deciding you support both
means building both paths; MAX_CONCURRENT_CALLS in config.env.example is the existing crude
defence against toll fraud, and real-time credit control is the proper one.
Never store card details. paymentMethod.providerToken holds a Stripe payment_method_id or a
PayPal token — an opaque reference. Storing a PAN puts the whole system in PCI-DSS scope. v1 got
this right; keep it that way.
Invoice numbers are sequential and gapless in most jurisdictions. That is a database sequence
with a per-tenant series, not count(*) + 1, and it must survive a rolled-back transaction without
reusing a number.
Where it plugs in
- Rating consumes
cdrrows.portal/api/src/services/cdr.tsalready ingests them; rating should be a separate pass so a rating bug never blocks CDR capture. - Routes go through
crudRouteswith apermission— billing data is the most sensitive non-credential data in the system, and aviewermust not see another tenant’s invoices. Cross-tenant reads here are a breach, not a bug. - Invoice PDFs are files;
pdfUrlimplies storage with authenticated access, not a public URL.