Skip to main content

Plans

Code lives in app/models/plan.py, app/schemas/plan.py, app/services/plans.py, and app/api/v1/routes/plans.py.

A Plan is a catalog definition for a postpaid/prepaid billing plan - what Subscriber.plan_id references by id (see Subscribers). That reference is validated for existence - see "Plan existence checks" below. Reads expose the catalog name as plan_name; Rate by_plan maps still key off that name.

Terminology follows the BillRun documentation: Product Catalog and Prepaid plans.

Data model​

plans
id uuid, PK
tenant_id uuid | null, FK - null = platform plan (see below)
name str - unique among live plans per tenant (or per
platform); Rate.by_plan matches on it.
Subscriber references by id.
description str, nullable
connection_type str - "postpaid" or "prepaid"
price jsonb - list of price intervals (+ optional trial)
limit_cycles int, nullable
recurrence_periodicity str, nullable - required if connection_type="postpaid"
recurrence_frequency str, nullable
recurrence_start timestamptz, nullable
include jsonb - free-unit groups (`include.groups`)
rates jsonb - Override Product Price maps
upfront / prorated_start / prorated_end / prorated_termination bool
source str - "manual" or a connector source
metadata jsonb - last-synced raw source record
created_at / updated_at / deleted_at

There is one row per name, with no version history: every create/update/delete is recorded in the shared History log (domain="plan", the audit_logs snapshots).

Decision: audit-log-only, not a revision model​

Decision: A Plan is a single mutable row per name, soft-deleted rather than closed, with the shared audit-log snapshots as its only audit trail - no diff-between-versions, no point-in-time query ("what did this plan look like on 2026-01-01?"), no rollback, and no revision or effective-dated action routes.

Why: the other catalog domain (Rates) uses this exact same soft-delete-plus-audit-log shape - see Identity and deletion. Giving Plans alone a full date-ranged revision chain would mean Plans is the only catalog domain with two different identities (a revision row id vs. a logical entity id, the same split Accounts/Subscribers need - see Identity and deletion), the only one with future/active/expired status derivation, and the only one whose Subscriber.plan_id FK would need to point at a specific revision rather than a stable logical row. That's a disproportionate amount of net-new architecture to introduce for one domain.

What this means concretely:

  • Renaming, re-pricing, or changing recurrence/proration on a live plan is an in-place PATCH, not a new revision - the change applies to every subscriber referencing the plan immediately via plan_id, not from some future effective date.
  • There is no way to schedule "this plan's price changes to $30 starting next month" - a plan change must be applied when it's due, not scheduled ahead. A subscriber scheduling itself onto a different plan later is a narrower, separate concept and isn't built either.
  • GET /api/v1/plans/{plan_id}/history (see below) gives you the what changed and when audit trail, but not a browse the plan as it existed at any past moment view.

If a real requirement for scheduled/effective-dated plan changes shows up, it's a new decision to make - most likely a narrow "schedule a field change to apply at a future date" mechanism rather than reopening this into a full revision chain.

Platform plans and tenant assignment​

A plan is either tenant-owned (plans.tenant_id set - visible to that tenant alone, as before) or a platform plan (tenant_id IS NULL, the same console-scope convention as integrations and connectors, see Tenant per client). A platform plan belongs to no tenant until a superadmin assigns it: plan_tenants (plan_id, tenant_id, assigned_at, assigned_by) makes it visible to - and usable on subscribers of - each assigned tenant, so one plan can belong to many tenants.

  • Reads (get_plan, list_plans, name lookups) go through plans.visible_to(tenant_ids): own plans plus assigned platform plans. Every other service reaches plans through these, so subscribers, rates and lines see assigned plans without changes.
  • Writes go through get_owned_plan: a tenant that tries to change a platform plan gets 403 ("managed by the platform"); PlanRead.platform_managed tells the dashboard to render it read-only. Connector plan sync skips platform plans.
  • Console (/api/v1/platform/plans, superadmin + MFA via the /platform/* rule): CRUD on the catalog and PUT /{id}/tenants which replaces the plan's tenant set. The dashboard's "Assign tenants" modal calls it.
  • Invariants enforced when the set changes (one transaction, plan row locked FOR UPDATE): a tenant can't be removed while its live subscribers are on the plan or a Rate by_plan override is keyed to its name (409); a tenant can't be given a plan whose name it already has (names key by_plan and discount conditions, so they must stay unambiguous per tenant); a platform plan can't be deleted while assigned. A rename of a platform plan is checked against every assigned tenant. Grants and revocations are audited per tenant plus one tenant-less platform entry.
  • Transatel profiles: the profile list is global, not per tenant. POST /platform/plans/sync (dashboard: Console → Plans → "Sync from data sources") pulls it from the platform data sources into platform plans keyed by metadata.profileName; a superadmin then maps each profile to one or more tenants. A tenant's own POST /plans/sync (and the full connector sync) no longer creates plans: it refreshes plans the tenant already owns and reports the rest as plans_skipped. Subscriber-to-plan matching still runs and picks up assigned plans.
  • Known gap: the subscriber-usage check and a concurrent "create subscriber on this plan" are not serialized against each other; the window is one request wide.

Validation and fields​

  • Price intervals - PriceInterval schema and _validate_tier_limit_cycles (app/services/plans.py).
  • Recurrence, gated on connection_type - Recurrence (app/schemas/common.py) plus a PlanBase model validator (check_postpaid_requires_recurrence): postpaid plans require recurrence; prepaid plans don't.
  • include groups — Plan Includes (include.groups with rates, value/cost, usage_types, account_shared/pool). Storage + CRUD only; runtime group selection is not implemented (see "Not implemented").
  • rates override maps — Override Product Price (rates[rateKey][usageType]), stored on the plan. See Rates for how tariffs are resolved.
  • Commercial flags — upfront, prorated_start / prorated_end / prorated_termination. There is no separate prorated_change column; mid-cycle change vs termination is a consumer distinction using prorated_end vs prorated_termination.
  • Price interval trial — optional on price[].

Decision: prepaid and postpaid share one catalog shape​

Prepaid and postpaid plans use one Plan row shape. A prepaid plan can carry price/limit_cycles/upfront exactly like a postpaid one; the only structural difference enforced is _postpaid_requires_recurrence (postpaid must have a recurrence, prepaid must not need one). This matches how the dashboard's Plan form works - one shared form component for both connection types, differing only in whether the Recurrence/Charging Mode fields show.

Why: a separate prepaid shape (unpriced, with its own field set) would mean rejecting Plan.price/limit_cycles for connection_type="prepaid" and maintaining two models for the same catalog. If a future need for prepaid-specific fields (notification thresholds, blocked products, recharge limits) shows up, that's new functionality to add alongside the shared shape, not a reason to split Plan into two shapes.

Decision: renaming a plan is blocked while name-keyed references exist​

Rate.rates[usageType].by_plan[planName] overrides match a plan by its name, not its id - unlike Subscriber.plan_id, which is a real FK. A rename would silently orphan them (the override would simply stop applying, with no error).

Decision: update_plan rejects a name change with 409 if any live Rate by_plan entry still references the plan's current name - see find_plan_name_references in app/services/plans.py (_reject_rename_if_referenced). delete_plan applies the same check. It does not cascade the rename into those references automatically.

Why not cascade: a rename would need to rewrite JSONB inside Rate rows, which this service doesn't otherwise write to, and "quietly rewrite another domain's data as a side effect of a Plan PATCH" is a bigger blast radius and harder to reason about than "refuse and tell the caller what's blocking it." Blocking is also consistent with how delete_plan already handles the equivalent "can't remove this while something depends on it" situation for subscriber references (see below) - reject-with-a-clear-reason is the established pattern for plans, not silent cascade.

The check does not cover Subscriber.former_plan_id: that's a real FK to plans.id, not a name-keyed reference, so a rename can't orphan it - get_plan_names_by_ids resolves whatever name the plan currently has (or had, if since soft-deleted) for display.

Plan existence checks​

create_subscriber/update_subscriber (app/services/subscribers.py) validate that plan_id exists in the Plans catalog visible to the tenant, rejecting the request with a ValidationError otherwise. Soft-deleting a plan stops it resolving for new subscriber references (get_plan filters deleted_at); existing plan_id FKs keep pointing at the row so historical revisions still join plan_name. delete_plan additionally refuses (409) while any non-deleted subscriber has the plan as its current plan_id or a Rate by_plan override is keyed to its name - see "API" below.

Not implemented​

  • Recurring plan charging (cycle-based proration of price[] tiers into a Line) was removed along with billing cycles - see Current feature scope.
  • Include-group runtime selection and pool sharing - choosing the applicable group, computing usage left in a group, and sharing across an account need live subscriber scans and balance group counters (Balances does not track groups yet).

API​

/api/v1/plans writes need a tenant-owned plan (get_owned_plan); POST /api/v1/plans/sync is described above. GET /api/v1/plans follows the pagination convention plus connection_type/name (partial match)/include_deleted filters (PlansPaginatedResponse). There are no revision-chain routes - per the versioning decision above, a catalog entity has no such operations; restore is the one action route that does exist, and it undoes a soft delete rather than manipulating a revision.

MethodPathNotes
POST/api/v1/planscreate
POST/api/v1/plans/syncrefresh the tenant's own plans from its connectors (no new plans; see above)
GET/api/v1/planspaginated list, filterable by connection_type/name/include_deleted
GET/api/v1/plans/{plan_id}fetch one
PATCH/api/v1/plans/{plan_id}partial update - 409 if renaming name while a live Rate by_plan override still references the current name (see above); 403 for a platform plan
DELETE/api/v1/plans/{plan_id}soft delete - stamps deleted_at; the name stops resolving for new subscriber references (existing plan_id FKs remain). 409 if any non-deleted subscriber has the plan as its current plan_id or a Rate by_plan override references its name
POST/api/v1/plans/{plan_id}/restoreundo a soft delete - 409 if another live plan has since taken the same name
GET/api/v1/plans/{plan_id}/historythis plan's history entries, newest first - a convenience over GET /history?domain=plan&entity_id=...; 404s only if plan_id never belonged to the tenant, not if it's since been soft-deleted