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 viaplan_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 throughplans.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 gets403("managed by the platform");PlanRead.platform_managedtells 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 andPUT /{id}/tenantswhich 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 Rateby_planoverride is keyed to its name (409); a tenant can't be given a plan whose name it already has (names keyby_planand 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 bymetadata.profileName; a superadmin then maps each profile to one or more tenants. A tenant's ownPOST /plans/sync(and the full connector sync) no longer creates plans: it refreshes plans the tenant already owns and reports the rest asplans_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 -
PriceIntervalschema and_validate_tier_limit_cycles(app/services/plans.py). - Recurrence, gated on
connection_type-Recurrence(app/schemas/common.py) plus aPlanBasemodel validator (check_postpaid_requires_recurrence): postpaid plans requirerecurrence; prepaid plans don't. includegroups — Plan Includes (include.groupswith rates, value/cost, usage_types, account_shared/pool). Storage + CRUD only; runtime group selection is not implemented (see "Not implemented").ratesoverride 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 separateprorated_changecolumn; mid-cycle change vs termination is a consumer distinction usingprorated_endvsprorated_termination. - Price interval
trial— optional onprice[].
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 aLine) 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.
| Method | Path | Notes |
|---|---|---|
| POST | /api/v1/plans | create |
| POST | /api/v1/plans/sync | refresh the tenant's own plans from its connectors (no new plans; see above) |
| GET | /api/v1/plans | paginated 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}/restore | undo a soft delete - 409 if another live plan has since taken the same name |
| GET | /api/v1/plans/{plan_id}/history | this 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 |