Billing and plans
Billing answers three questions: what a customer is entitled to, how they pay for it, and what happens when they stop paying. The answers live in three places — the plan catalog, the organization's subscription columns, and the transaction/invoice ledger.
Plans are tiers, prices are intervals
Since 1.8.0 a plan row is a tier, not a tier-plus-term. TEAM_ANNUAL and friends are gone; the billing interval is a property of the price, not of the plan.
| Table | Meaning |
|---|---|
lookup.subscription_plan | One row per tier: code, name, limits (max_device_records_per_facility, max_facilities, max_users), is_active |
lookup.subscription_plan_provider_prices | One row per (plan, provider, billing_interval) with the amount_minor actually charged for that interval |
core_public.organizations | Which plan the organization is on, and how it renews |
Seeded tiers:
| Code | Name | Device records / facility | Facilities | Users | Monthly |
|---|---|---|---|---|---|
STARTER | Starter | 200 | 1 | 3 | Free, for ever |
TEAM | Team | 500 | 1 | 8 | GHS 199 |
PROFESSIONAL | Professional | 2000 | 1 | 25 | GHS 499 |
ADVANCED | Advanced | 2000 | 5 | 100 | GHS 1290 |
ENTERPRISE | Enterprise | unlimited | unlimited | unlimited | Contract |
-1 means unlimited. ENTERPRISE is never self-serve: a super admin provisions it against an invoice or purchase order, and its term is never swept by the scheduler.
Two rules keep the catalog honest:
- A code that ends in a term suffix (
_ANNUAL,_MONTHLY, …) is rejected byAdminCreatePlan: that would fork the tier back into two plan rows. Point the admin at a provider price row for the interval instead. - Prices are keyed by interval, so the same tier can be sold monthly or annually without duplicating the plan. Updating a tier does not create a second row.
Subscription state
core_public.organizations carries the commercial state:
| Column | Meaning |
|---|---|
plan_id | Current tier |
subscription_status | See the table below |
subscription_ends_at | End of the paid term |
billing_interval | monthly, quarterly, annually |
billing_source | self_serve (card via provider) or manual (invoice / purchase order) |
subscription_code, email_token, subscription_provider | Provider-side handles; NULL for invoice billing |
non_renewing | Set by cancellation; access continues to the end of the term |
grace_period_ends_at | End of the seven-day grace window |
primary_facility_id | The one facility a single-facility plan covers |
Statuses and transitions
| Status | Tenant access | Meaning |
|---|---|---|
PENDING_ACTIVATION | No | Signed up, never paid |
ACTIVE | Yes | Normal |
GRACE | Yes | Term ended, seven days (from the later of term end and now) |
OVERDUE | No | Payment failed inside a running term |
EXPIRED | Yes | Grace over — plan reverted to Starter, data intact |
CANCELLED | No (briefly) | Provider says the subscription stopped; the next scheduler tick moves it to GRACE |
SubscriptionLifecycleScheduler runs once at startup and then hourly. Three queries, in this order, so they cannot race on the same row:
StartTermEndGrace—ACTIVE/OVERDUE/CANCELLEDwithsubscription_ends_at <= now()becomesGRACEwith a seven-day window. Every tier exceptENTERPRISEis swept.ExpireEndedSubscriptions—GRACEwhose term and grace have both ended becomesEXPIRED,plan_idis reverted toSTARTER, and the provider handles are cleared. The facilities module then re-derives which facilities the Starter allowance still covers; the rest go read-only (423 FACILITY_READ_ONLY). Data is never deleted.ExpireGracePeriods—GRACEwhose term is still live becomesOVERDUE(a payment failure that can still recover).
Each transition notifies the organization's admins and every super admin, so a lapse is never silent.
Cancelling does not shorten the term. DELETE /billing/subscription calls the provider to stop future charges and marks the organization non_renewing; access continues until subscription_ends_at, then the scheduler's grace window applies as usual.
How a payment is collected
Self-serve (card)
POST /billing/initializecreates aPENDINGbilling_transactionsrow and returns the provider's authorization URL.- The customer pays; the provider redirects back and/or calls the webhook.
charge.success(webhook) marks the transactionSUCCESS, activates the subscription, and writes aPAIDinvoice tocore_public.billing_invoices.GET /billing/verify/:referencereconciles the same transaction against the provider when the webhook is late and activates the organization; it never writes a second transaction row or an invoice.
charge.success is the revenue writer. If verification and the webhook both arrive, the provider reference makes the second one a no-op.
Webhooks arrive at POST /api/v1/core/webhooks/:provider (Paystack in production, noop when PAYMENT_ENABLED is not true). They are signature checked before anything is applied, and a rejected event is stored for inspection rather than dropped.
Invoice billing (purchase order / bank transfer)
Manual provisioning (POST /core/billing/admin/orgs/:org_id/provision) sets the plan, records billing_source = manual, stops any live provider subscription so a card cannot keep charging, and writes an ISSUED invoice.
When the money arrives, a super admin settles the invoice with PATCH /core/billing/admin/invoices/:id/paid. That endpoint:
- stamps
paid_atonce (a repeat call is a no-op), - stores an optional
external_reference(the PO or transfer reference), - writes a
SUCCESSbilling_transactionsrow withprovider = 'invoice'and referenceinvoice:<invoice_number>, linked back to the invoice, - leaves a zero-amount (comped) invoice out of the ledger.
Because both paths write billing_transactions, revenue reporting is one query over SUCCESS rows regardless of how the customer paid.
Invoices
core_public.billing_invoices has two states:
| Status | Written by | Settled |
|---|---|---|
PAID | provider payment webhook | paid_at is stamped at insert |
ISSUED | manual provisioning | paid_at set when a super admin settles it |
An invoice is overdue when it is ISSUED and either its billing_period_end has passed or it is 30 days old with no period end.
Reconciliation
GET /core/billing/admin/reconciliation compares the database against the payment provider and adds an issues list per organization:
| Issue | Meaning |
|---|---|
missing_subscription_code | A paid, self-serve subscription with no provider handle |
renewal_due | Invoice billing whose term ends within 14 days |
invoice_unpaid | An issued invoice is past due |
subscription_status_mismatch | Local status disagrees with the provider |
amount_mismatch | Provider amount differs from the plan price |
provider_not_configured / provider_fetch_failed | Provider could not be consulted |
invoice_unpaid is why an organization can look healthy (active term) while its invoice was never paid. Settling the invoice clears it.
Quotas and entitlements
GET /billing/quotareports usage against the plan's limits.- Device-record quota is enforced at creation time from the plan row, not a hardcoded constant.
- Feature gates (
governance,utilization,cross_facility_transfers, …) are derived from the plan code and carried in the JWT. See roles and access. - Facility allowance is enforced by the facilities module after any plan change — downgrade, expiry, or super-admin edit. See facility access lock.
Testing billing locally
With PAYMENT_ENABLED unset or false, the provider registry is the noop provider: initialization succeeds, nothing leaves the machine, and the webhook endpoint accepts a synthetic event. The e2e suite uses noop for exactly this reason.