Skip to content

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.

TableMeaning
lookup.subscription_planOne row per tier: code, name, limits (max_device_records_per_facility, max_facilities, max_users), is_active
lookup.subscription_plan_provider_pricesOne row per (plan, provider, billing_interval) with the amount_minor actually charged for that interval
core_public.organizationsWhich plan the organization is on, and how it renews

Seeded tiers:

CodeNameDevice records / facilityFacilitiesUsersMonthly
STARTERStarter20013Free, for ever
TEAMTeam50018GHS 199
PROFESSIONALProfessional2000125GHS 499
ADVANCEDAdvanced20005100GHS 1290
ENTERPRISEEnterpriseunlimitedunlimitedunlimitedContract

-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 by AdminCreatePlan: 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:

ColumnMeaning
plan_idCurrent tier
subscription_statusSee the table below
subscription_ends_atEnd of the paid term
billing_intervalmonthly, quarterly, annually
billing_sourceself_serve (card via provider) or manual (invoice / purchase order)
subscription_code, email_token, subscription_providerProvider-side handles; NULL for invoice billing
non_renewingSet by cancellation; access continues to the end of the term
grace_period_ends_atEnd of the seven-day grace window
primary_facility_idThe one facility a single-facility plan covers

Statuses and transitions ​

StatusTenant accessMeaning
PENDING_ACTIVATIONNoSigned up, never paid
ACTIVEYesNormal
GRACEYesTerm ended, seven days (from the later of term end and now)
OVERDUENoPayment failed inside a running term
EXPIREDYesGrace over — plan reverted to Starter, data intact
CANCELLEDNo (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:

  1. StartTermEndGrace — ACTIVE/OVERDUE/CANCELLED with subscription_ends_at <= now() becomes GRACE with a seven-day window. Every tier except ENTERPRISE is swept.
  2. ExpireEndedSubscriptions — GRACE whose term and grace have both ended becomes EXPIRED, plan_id is reverted to STARTER, 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.
  3. ExpireGracePeriods — GRACE whose term is still live becomes OVERDUE (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) ​

  1. POST /billing/initialize creates a PENDING billing_transactions row and returns the provider's authorization URL.
  2. The customer pays; the provider redirects back and/or calls the webhook.
  3. charge.success (webhook) marks the transaction SUCCESS, activates the subscription, and writes a PAID invoice to core_public.billing_invoices. GET /billing/verify/:reference reconciles 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_at once (a repeat call is a no-op),
  • stores an optional external_reference (the PO or transfer reference),
  • writes a SUCCESS billing_transactions row with provider = 'invoice' and reference invoice:<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:

StatusWritten bySettled
PAIDprovider payment webhookpaid_at is stamped at insert
ISSUEDmanual provisioningpaid_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:

IssueMeaning
missing_subscription_codeA paid, self-serve subscription with no provider handle
renewal_dueInvoice billing whose term ends within 14 days
invoice_unpaidAn issued invoice is past due
subscription_status_mismatchLocal status disagrees with the provider
amount_mismatchProvider amount differs from the plan price
provider_not_configured / provider_fetch_failedProvider 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/quota reports 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.

Ovahol developer documentation