Kulcare Docs
DeveloperSubscriptions

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.md
  • docs/plans/2026-09-13-subscription-followups-decisions.md

Data model

TablePurpose
billing.subscriptionsOne live row per tenant. The partial unique index idx_billing_subs_active_tenant enforces this. There is no facility_id.
billing.subscription_itemsOne row per billed item. item_type is facility, facility_module, organisation_module or speciality. status is pending, active or removed.
billing.plan_facility_pricesThe monthly price of a facility type on a plan, per currency.
billing.plan_module_pricesThe monthly price, availability and minimum tier of a module, per scope and currency.
billing.subscription_change_quotesSingle-use quotes for a change, valid for 15 minutes.
billing.subscription_accrualsPart-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 pending item 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_tenant does not bypass the policy.

Lifecycle

StatusAccessNext state
trialFullpast_due with reason trial_ended when trial_end_date passes and no charge has landed.
activeFullpast_due with reason payment_failed when a charge fails.
past_dueDegraded, 7-day graceexpired when the grace period ends. Changes are paused.
cancelledRead-onlyTerminal.
expiredRead-onlyTerminal.

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_through is 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:

  1. POST /billing/subscriptions/changes/preview prices the change and returns a quote_id.
  2. POST /billing/subscriptions/changes applies the quote. Send an Idempotency-Key header 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.

  1. PurchaseService writes a pending item and an order intent, then opens a gateway order for the part-cycle.
  2. ProcessWebhookService records the paid invoice.
  3. ActivateModuleAddonJob activates 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. ProrationService prices a trial change at zero; the charge_until argument 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 is manage_subscription (#2274; it was manage_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_ended or parent_ending.
  • KeepItemService and 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

EndpointReturns
GET /billing/subscriptions/currentThe 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=trueCancelled and expired subscriptions, with the plan and the cancellation fields, newest end date first. /current never returns these.
GET /billing/subscription_plans/:id/pricesThe catalog prices that a tenant can see for a plan.
GET /billing/subscription_plans/:id/checkout_estimateWhat 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_logsThe change list ("changed since your last invoice").
GET /billing/subscriptions/:id/invoicesInvoices for the subscription.

Admin APIs

Platform admin only. The catalogs have no row-level security, so authorization is the only control.

EndpointPurpose
GET/PATCH /billing/admin/plan_facility_pricesFacility price per plan and currency. Editable fields: price, is_available.
GET/PATCH /billing/admin/plan_module_pricesModule price per scope and currency. Editable fields: price, availability, minimum_tier.
POST /billing/admin/subscriptions/manual_subscribeA 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.

JobWhat it does
Billing::StalePendingCleanupJobExpires pending checkouts that the user abandoned.
Billing::ModuleAddons::RenewalSweepJobRetires 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::SubscriptionExpiryJobMoves 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 is SubscriptionHistory.tsx.
  • Data layer: frontend/packages/shared-services/src/domains/billing/. The hooks include useTenantSubscription, usePreviewSubscriptionChange, useApplySubscriptionChange, useKeepSubscriptionItem and useSubscriptionHistory.
  • 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, expired

Run 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.

On this page