Tenant Subscriptions
A tenant has one subscription. The subscription covers every facility in the tenant. Each facility, facility module, organisation-wide module and practitioner speciality is a priced item on that one subscription (#2165). This guide describes the model, the APIs, the daily jobs and the seed data.
The plan documents hold the full design:
docs/plans/2026-08-31-feat-tenant-subscription-backend-plan.md(requirements R1 to R25)docs/plans/2026-09-07-feat-tenant-subscription-frontend-wiring-plan.mddocs/plans/2026-09-13-subscription-followups-decisions.md
Data model
| Table | Purpose |
|---|---|
billing.subscriptions | One live row per tenant. The partial unique index idx_billing_subs_active_tenant enforces this. There is no facility_id. |
billing.subscription_items | One row per billed item. item_type is facility, facility_module, organisation_module or speciality. status is pending, active or removed. |
billing.plan_facility_prices | The monthly price of a facility type on a plan, per currency. |
billing.plan_module_prices | The monthly price, availability and minimum tier of a module, per scope and currency. |
billing.subscription_change_quotes | Single-use quotes for a change, valid for 15 minutes. |
billing.subscription_accruals | Part-cycle amounts that the next invoice collects. |
Rules that the code depends on:
- Money. Amounts cross the API as integer minor units. The database stores
numeric(12,2). The admin price catalog API is the exception: it sends and accepts major units. - Frozen price. An item keeps the amount it was bought at. A catalog change affects new purchases only (R4).
- Null is not zero. A null catalog price means "not priced". The pricing service reports that item as not billed, with a reason. It never charges zero for it.
- Pending grants nothing. A
pendingitem grants no access and is not in the recurring total (R11). - Row-level security. Both subscription tables use FORCE row-level
security. A job that works across tenants must fan out per tenant and wrap
its work in
Shared::Tenancy::RlsContext.with.unscoped_by_tenantdoes not bypass the policy.
Lifecycle
| Status | Access | Next state |
|---|---|---|
trial | Full | past_due with reason trial_ended when trial_end_date passes and no charge has landed. |
active | Full | past_due with reason payment_failed when a charge fails. |
past_due | Degraded, 7-day grace | expired when the grace period ends. Changes are paused. |
cancelled | Read-only | Terminal. |
expired | Read-only | Terminal. |
The product has no paused state. The model enum still lists paused, but no
code path sets it.
Going live: the facilities join the subscription
When a subscription goes live, Billing::Subscriptions::EnrolFacilitiesService
makes every active facility in the tenant an item on it (R3, R5). It runs from
all four places that make a subscription live: the Cashfree activation
webhook, CreateService, VerifyService and ManualSubscribeService.
- The earliest facility takes the included slot and is written at amount 0. The others are written at their plan rate. A facility with no type is priced as a clinic.
- A facility that the plan has no price for is skipped and reported. It is never written at zero, and it does not take the included slot (R6).
- On a trial,
paid_throughis NULL.enrol_trial_items!stamps it when the first charge lands (KTD18). - It is idempotent. A facility that already has a live item keeps it.
- A failure is reported, not raised, so it never blocks an activation.
The renewal sweep moves an item with nothing to collect (amount 0) into the next cycle without a charge. The gateway refuses a zero charge.
GET /billing/subscription_plans/:id/checkout_estimate returns the same lines
before checkout, and the plan picker shows them.
Changes
A change to a live subscription goes through a quote:
POST /billing/subscriptions/changes/previewprices the change and returns aquote_id.POST /billing/subscriptions/changesapplies the quote. Send anIdempotency-Keyheader on each apply that a user starts.
Nothing is charged when a change is applied. The item is usable at once, and the part-cycle amount accrues onto the next invoice (arrears billing). An upgrade applies at once. A downgrade takes effect at the end of the cycle.
Specialities from Users
POST /billing/module_addons charges the module before it turns on (the
2026-09-17 decision). It billed in arrears for a while, which left the money
owed until the item's own renewal — up to eleven months on an annual plan.
PurchaseServicewrites apendingitem and an order intent, then opens a gateway order for the part-cycle.ProcessWebhookServicerecords the paid invoice.ActivateModuleAddonJobactivates the item and grants the entitlement.
Nothing is entitled until the payment lands (R11), and no purchase writes an accrual. Accruals written while the arrears behaviour was live are still collected by their item's renewal.
Billing::ModuleAddons::PurchaseCharge is the one calculator behind the
amount and the day it runs to:
- On a live subscription it runs to the cycle end.
- On a trial it runs to the first charge, so a trial purchase is charged
too.
ProrationServiceprices a trial change at zero; thecharge_untilargument is what turns that rule off for this one path. - Below
FREE_BELOW_MINOR(Rs 50) no order is opened. The module turns on at once and its first full month starts at the renewal, because a gateway refuses a zero order and takes a fee on a tiny one.
All three readers pin it to the item's created_at, so the order, the invoice
and paid_through agree to the minor unit.
GET /billing/module_addons/preview returns the same figures and writes
nothing, so the dialog names the amount before the tenant commits.
Removal and Keep
A removal does not end an item at once. There are no refunds and no credits,
so the removal sets removal_requested_at, and the item stays active until
paid_through. The renewal sweep retires it on that date.
POST /billing/subscriptions/items/:item_id/keep withdraws the removal
(#1853). Nothing is charged or refunded. The item renews at paid_through.
- It needs
change?, which ismanage_subscription(#2274; it wasmanage_billing, as it was then named, until the subscription was split out of practice finance), and an active or trial subscription. - Keeping a facility also keeps the modules that its removal cascaded to:
the ones with the same
removal_requested_at. - The request is refused with
not_ending,already_endedorparent_ending. KeepItemServiceand the renewal sweep both lock the item row. The sweep reads the removal again under the lock, so it cannot retire an item that the tenant has just kept.
Reads
| Endpoint | Returns |
|---|---|
GET /billing/subscriptions/current | The live subscription (active, trial or past_due), with items, totals and not-billed lines. A pending checkout is in meta.pending_checkout, never in data. |
GET /billing/subscriptions?history=true | Cancelled and expired subscriptions, with the plan and the cancellation fields, newest end date first. /current never returns these. |
GET /billing/subscription_plans/:id/prices | The catalog prices that a tenant can see for a plan. |
GET /billing/subscription_plans/:id/checkout_estimate | What each of the tenant's facilities adds to the plan, in minor units. The facility total is monthly and is never added to the plan fee. |
GET /billing/billing_activity_logs | The change list ("changed since your last invoice"). |
GET /billing/subscriptions/:id/invoices | Invoices for the subscription. |
Admin APIs
Platform admin only. The catalogs have no row-level security, so authorization is the only control.
| Endpoint | Purpose |
|---|---|
GET/PATCH /billing/admin/plan_facility_prices | Facility price per plan and currency. Editable fields: price, is_available. |
GET/PATCH /billing/admin/plan_module_prices | Module price per scope and currency. Editable fields: price, availability, minimum_tier. |
POST /billing/admin/subscriptions/manual_subscribe | A manual subscription. The currency comes from the facility country, through ManualSubscribeFacility and SubscriptionPlan.region_for_country. |
The admin app shows the catalogs at Billing > Prices.
Daily jobs
See backend/config/schedule.rb for the times.
| Job | What it does |
|---|---|
Billing::StalePendingCleanupJob | Expires pending checkouts that the user abandoned. |
Billing::ModuleAddons::RenewalSweepJob | Retires items whose removal was requested, retires items on a lapsed subscription, and raises the per-cycle charge for items that are due. Collection is gated by BILLING_ITEM_COLLECTION_ENABLED, which is off by default. |
Billing::SubscriptionExpiryJob | Moves trials past trial_end_date to past_due, and expires past_due subscriptions after the 7-day grace period. |
Frontend
- Tenant workspace:
frontend/apps/shell/src/features/subscription/at/account/subscription, with the tabs Overview, Plans and modules, and Invoices. The "Past subscriptions" card isSubscriptionHistory.tsx. - Data layer:
frontend/packages/shared-services/src/domains/billing/. The hooks includeuseTenantSubscription,usePreviewSubscriptionChange,useApplySubscriptionChange,useKeepSubscriptionItemanduseSubscriptionHistory. - Specialities in Users > Doctors:
frontend/apps/shell/src/features/userManagement/components/tabs/DoctorsTab.tsx. - Admin price screens:
admin/src/features/billing/pages/PlanPricesPage.tsx.
Seed data
cd backend
bundle exec rake dev_seed:seed_tenant_subscription # catalog + one healthy demo tenant
bundle exec rake dev_seed:seed_subscription_edge_cases # past_due, trial, solo, quarterly, held-back, cancelled, expiredRun them in that order. The second task needs the catalog that the first one writes. The edge-case tenants appear in the profile switcher only after a new sign-in.