Skip to main content

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_subscriber mints a fresh subscriber_id, defaults to_date to now + 149 years (the "no end date" sentinel), and sets activation_date/deactivation_date from the revision's own boundaries (and plan_activation when a plan is given). plan_id is optional; group_id is required.
  • Time-scoped lookup - get_subscriber(subscriber_id, at=...) resolves to whichever revision's [from_date, to_date) covers at (default now).
  • Plan-change tracking - update_subscriber: changing plan_id resets plan_activation to now and records the outgoing plan as former_plan_id.
  • Override validation - same as Accounts: OverrideIn/PriceInterval (shared with Accounts in app/schemas/common.py) make from/to/price required fields, so a missing one is a 422 before the service layer runs.
  • Move - move_entry shifts a revision's from or to boundary, 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_date are edge-aligned (when either field equals the edge being moved, it moves with it).
  • Close - close_subscriber sets to_date/deactivation_date to a given date (default now); the date must fall within the revision's own range.
  • Delete a future revision - delete_future_revision retires a not-yet-active future revision and extends the previous revision's to_date (and deactivation_date, if it was aligned) out to cover the gap. Deleting the current or a past revision is rejected. The revision is soft-deleted - stamped deleted_at, not removed - and drops out of GET .../revisions and 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_subscriber validate that plan_id exists in the tenant's Plans catalog (_get_plan_or_raise) and that group_id exists, raising ValidationError otherwise. There is no Services catalog or services field on subscribers.
  • Permanent change - permanent_change in app/services/subscribers.py, exposed as POST /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 from update_subscriber, which only ever adds a new forward revision. Validates the new from date falls inside the target revision's [from_date, to_date); splits the revision (shrinking its to_date, inserting a new row copying the pre-correction snapshot) when the new from doesn't land on an existing boundary; and applies the correction to that revision plus every later one via a single bulk UPDATE ... WHERE from_date >= new_from (revisioned.apply_permanent_change, shared with Accounts). group_id is excluded from the correction; changing it via PATCH moves every live revision.

Not implemented​

  • Services and Plays - no Services catalog or Plays feature exists, and subscribers carry no services field.
  • 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_change is not audit-logged (see revisioned.apply_permanent_change), so the pre-correction values the bulk UPDATE overwrites are not recorded.
  • Back-fill sweep across revisions - activation_date/deactivation_date/plan_activation/ plan_deactivation/former_plan_id are 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.

MethodPathNotes
POST/api/v1/subscriberscreate
POST/api/v1/subscribers/batchbatch create/upsert, per-item results
GET/api/v1/subscriberspaginated list (filters above)
GET/api/v1/subscribers/archived, /archived/{subscriber_id}list / fetch archived subscribers
POST/api/v1/subscribers/match-plansbackfill plan_id for plan-less subscribers by Transatel profileName
GET/api/v1/subscribers/usagemonth-to-date usage from Transatel CDRs
GET/api/v1/subscribers/statsline 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}/revisionsevery revision, each with revision_info (not paginated)
GET/api/v1/subscribers/{subscriber_id}/balancespaginated - see Accounts' "Balances (Counters)"
GET/api/v1/subscribers/{subscriber_id}/cdrsraw 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}/moveshift a from/to boundary
POST/api/v1/subscribers/{subscriber_id}/closeend 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-changeretroactively 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}/restorereverse the archive