Subscribers
Code lives in app/models/subscriber.py, app/schemas/subscriber.py,
app/services/subscribers.py, and app/api/v1/routes/subscribers.py.
Terminology follows the BillRun documentation: Definitions.
Data model
Each subscriber revision is its own row. A logical subscriber is several rows, each covering a
from/to date range:
subscribers
id uuid, PK - this revision row's own identity
subscriber_id uuid, indexed - shared by every revision of one
logical subscriber, minted only on first create
group_id uuid, indexed - owning subscriber group (no DB-level FK); the group
belongs to an Account. Invariant across revisions
tenant_id uuid, FK tenants.id
plan_id uuid, nullable, FK plans.id - at most one plan, by catalog id (not name)
status string - `SubscriberStatus` value, default "active"
from_date / to_date timestamptz - this revision's active range
activation_date timestamptz - subscriber-level first activation
deactivation_date timestamptz - subscriber-level final deactivation
plan_activation timestamptz, nullable - when the *current* plan started
plan_deactivation timestamptz, nullable - when the *current* plan will end
former_plan_id uuid, nullable, FK plans.id - outgoing plan, set on a plan change
first_name / last_name / address / country
string, nullable - contact fields
serial / msisdn / iccid / imei
string, nullable - SIM/line identifiers (serial is the SIM serial)
external_source / external_id
string, nullable - import provenance
extra / metadata jsonb - import-mapping leftovers / whole-record bucket
overrides jsonb - same shape as Account.overrides - see
[Accounts'](/architecture/accounts#overrides)
"Overrides" section for what `type`/`key` mean
created_at / updated_at / deleted_at / archived_at
Reads join the catalog names back so the UI does not need a second call per row: SubscriberRead
carries plan_name and former_plan_name. Those are populated by
to_subscriber_reads (app/services/subscribers.py) via one IN (...) lookup of all referenced
plan ids on the page, including soft-deleted rows so a historical former_plan_id still displays.
Create/update/batch take plan_id only. Line.plan is a name snapshot taken at usage time - it is not an FK.
This is the one domain that needs two identities, and the distinction matters at every call
site: id addresses one revision row, subscriber_id addresses the logical subscriber whose
revisions all share it. Every endpoint below is keyed by subscriber_id except the
revision-delete route, which needs a specific row. See Identity and
deletion for the full
convention, including the rule that code writing a further revision must copy subscriber_id
explicitly rather than letting the column default mint a new one.
A subscriber belongs to an Account through its group (subscriber_groups), not directly: there is
no account_id column. group_id has no DB-level FK - existence is checked in the service layer
(_get_group_or_raise). The plan reference is a real FK (plan_id / former_plan_id →
plans.id).
Behavior
- Revision create -
create_subscribermints a freshsubscriber_id, defaultsto_datetonow + 149 years(the "no end date" sentinel), and setsactivation_date/deactivation_datefrom the revision's own boundaries (andplan_activationwhen a plan is given).plan_idis optional;group_idis required. - Time-scoped lookup -
get_subscriber(subscriber_id, at=...)resolves to whichever revision's[from_date, to_date)coversat(default now). - Plan-change tracking -
update_subscriber: changingplan_idresetsplan_activationto now and records the outgoing plan asformer_plan_id. - Override validation - same as Accounts:
OverrideIn/PriceInterval(shared with Accounts inapp/schemas/common.py) makefrom/to/pricerequired fields, so a missing one is a422before the service layer runs. - Move -
move_entryshifts a revision'sfromortoboundary, rejects a move that crosses the revision's other edge or would swallow the neighboring revision's own edge, and cascades the neighbor's touching edge to close the gap.plan_activation/deactivation_dateare edge-aligned (when either field equals the edge being moved, it moves with it). - Close -
close_subscribersetsto_date/deactivation_dateto a given date (default now); the date must fall within the revision's own range. - Delete a future revision -
delete_future_revisionretires a not-yet-active future revision and extends the previous revision'sto_date(anddeactivation_date, if it was aligned) out to cover the gap. Deleting the current or a past revision is rejected. The revision is soft-deleted - stampeddeleted_at, not removed - and drops out ofGET .../revisionsand every date-scoped lookup; the gap-closing edit to the previous revision is what makes it invisible, the stamp is what makes it auditable. - Plan and group existence checks -
create_subscriber/update_subscribervalidate thatplan_idexists in the tenant's Plans catalog (_get_plan_or_raise) and thatgroup_idexists, raisingValidationErrorotherwise. There is no Services catalog orservicesfield on subscribers. - Permanent change -
permanent_changeinapp/services/subscribers.py, exposed asPOST /subscribers/{subscriber_id}/revisions/{revision_id}/permanent-change. Corrects a field on a past-or-current revision retroactively, propagating the fix to every later revision of the same logical subscriber - distinct fromupdate_subscriber, which only ever adds a new forward revision. Validates the newfromdate falls inside the target revision's[from_date, to_date); splits the revision (shrinking itsto_date, inserting a new row copying the pre-correction snapshot) when the newfromdoesn't land on an existing boundary; and applies the correction to that revision plus every later one via a single bulkUPDATE ... WHERE from_date >= new_from(revisioned.apply_permanent_change, shared with Accounts).group_idis excluded from the correction; changing it viaPATCHmoves every live revision.
Not implemented
- Services and Plays - no Services catalog or Plays feature exists,
and subscribers carry no
servicesfield. - Config-driven custom fields - admin-defined field schemas
and dynamic validation are not built; free-form data goes in
extra/metadata. - Permanent-change audit trail -
permanent_changeis not audit-logged (seerevisioned.apply_permanent_change), so the pre-correction values the bulkUPDATEoverwrites are not recorded. - Back-fill sweep across revisions -
activation_date/deactivation_date/plan_activation/plan_deactivation/former_plan_idare set correctly on the mutating operation itself (create/update/move/close), but sibling revisions are not re-derived afterwards.
Viewing revision history
GET /api/v1/subscribers/{subscriber_id}/revisions returns every live revision of a subscriber
- past, current, and future - each annotated with
revision_info:
status: "future" | "active" | "expired" - based on now vs. this revision's [from_date, to_date)
is_last: bool - no other revision of this subscriber_id starts
where/after this one ends
early_expiration: bool - future, or (active and is_last), and `to_date` isn't
the +149y "unlimited" sentinel
revision_info also carries updatable/closeandnewable/movable/removable/
movable_from/movable_to. These are returned (RevisionInfo, app/schemas/revision.py, shared
with Accounts; computed by revisioned.compute_revision_info), but billing cycles no longer lock
revisions: the editable-since date is fixed at 1970-01-01, so every flag except removable is
effectively always true. removable is true only when delete_future_revision will accept the
row (status=future). There is no system.closed_cycle_changes override.
This endpoint skips the pagination convention - a deliberate exception (see that page for the reasoning): a subscriber's revision count is small and bounded, not a top-level resource collection.
Batch create/upsert
POST /api/v1/subscribers/batch (SubscriberBatchRequest → SubscriberBatchResponse) is the
bulk-ingestion path: subscriber data is pushed in through core-api's own API rather than pulled
from an external system.
Each item in items (capped at 500) is upserted independently: subscriber_id present means
update (SubscriberUpdate-equivalent patch semantics - only provided fields change),
subscriber_id absent means create (group_id required, same as SubscriberCreate). The endpoint reuses
create_subscriber/update_subscriber directly rather than reimplementing their validation -
both already commit internally on success and raise before any mutation on failure, so looping
straight through them gives per-item isolation for free, no savepoints needed: one item's
failure can neither block nor roll back its siblings. The response is always HTTP 200; each
result carries its own ok/action/subscriber/error since partial success is the point of a
batch endpoint, not an edge case to special-case away.
API
GET /api/v1/subscribers follows the pagination convention with
SubscriberListParams filters (q, group_id, plan_id, status, customer_id,
include_excluded, include_usage, sort, order; SubscribersPaginatedResponse). A client
user only sees subscribers in their own accounts' groups. at (ISO datetime, default now) selects
which revision a lookup/mutation targets on the read and mutation routes below.
Note which identity each path takes: everything is keyed by subscriber_id (the logical
subscriber) except the revision-delete route, which additionally takes a revision_id - the id
of one specific revision row.
| Method | Path | Notes |
|---|---|---|
| POST | /api/v1/subscribers | create |
| POST | /api/v1/subscribers/batch | batch create/upsert, per-item results |
| GET | /api/v1/subscribers | paginated list (filters above) |
| GET | /api/v1/subscribers/archived, /archived/{subscriber_id} | list / fetch archived subscribers |
| POST | /api/v1/subscribers/match-plans | backfill plan_id for plan-less subscribers by Transatel profileName |
| GET | /api/v1/subscribers/usage | month-to-date usage from Transatel CDRs |
| GET | /api/v1/subscribers/stats | line KPIs, month to date vs. last month |
| GET | /api/v1/subscribers/{subscriber_id} | fetch the revision covering ?at= (or now) |
| GET | /api/v1/subscribers/{subscriber_id}/revisions | every revision, each with revision_info (not paginated) |
| GET | /api/v1/subscribers/{subscriber_id}/balances | paginated - see Accounts' "Balances (Counters)" |
| GET | /api/v1/subscribers/{subscriber_id}/cdrs | raw CDRs matched by MSISDN, with per-type totals |
| PATCH | /api/v1/subscribers/{subscriber_id} | partial update of that revision |
| POST | /api/v1/subscribers/{subscriber_id}/move | shift a from/to boundary |
| POST | /api/v1/subscribers/{subscriber_id}/close | end the revision at a given (or now) date |
| DELETE | /api/v1/subscribers/{subscriber_id}/revisions/{revision_id} | soft-delete a future, not-yet-active revision |
| POST | /api/v1/subscribers/{subscriber_id}/revisions/{revision_id}/permanent-change | retroactively correct a revision, cascading to later ones |
| DELETE | /api/v1/subscribers/{subscriber_id} | archive every live revision (archived_at) |
| POST | /api/v1/subscribers/{subscriber_id}/restore | reverse the archive |