Skip to main content

Lines

Code lives in app/models/line.py, app/schemas/line.py, app/services/lines.py, and app/api/v1/routes/lines.py.

A line is a CDR/usage record. The scope is deliberately narrow: storage + CRUD/query API, plus one synchronous service function, rate_line, that composes Rates' and Balances' existing primitives into the compute-and-charge core. It is not an async queue-based multi-stage rating pipeline - see "Not implemented" below.

Terminology follows the BillRun documentation: Definitions.

Data model​

Lines live in one table:

lines
id uuid, PK
account_id uuid, indexed - logical account id, no FK (this codebase's convention)
subscriber_id uuid, indexed, nullable - NULL = account-level line
type str, indexed - usage category, e.g. "call" / "data" / "credit" / "charge"
usage_type str - what Rates.get_charge keys pricing off
usage_volume float - amount of usage
urt timestamptz - unified record time (when the usage happened)
stamp str - idempotent dedup key (content hash) - see below
billrun_key str, nullable - billing cycle this line belongs to
plan str, nullable - plan name snapshot at time of usage
source str, nullable - origin, e.g. "api" / "import" - free text, not enforced
rate_id uuid, FK -> rates.id, nullable
rate_key str, nullable - denormalized, survives the rate being renamed/deleted
price float, nullable - null until rated
balance_id uuid, FK -> balances.id, nullable
rated_at timestamptz, nullable
created_at / updated_at / deleted_at

No foreign keys to accounts/subscribers, matching this codebase's existing convention (Balances' account_id/subscriber_id). subscriber_id IS NULL is an account-level line, the same NULL-as-account-level pattern Balances uses.

stamp's uniqueness is deliberately NOT scoped to live rows​

Every other catalog domain in this codebase (Rates, Plans, PrepaidIncludes) enforces its business-key uniqueness with a partial index scoped to deleted_at IS NULL, so soft-deleting frees the key for reuse (see Identity and deletion). stamp breaks this pattern on purpose: it's a content-hash dedup guard against re-ingesting the same usage record twice, not a human-assigned identifier meant to be reused. Its unique index on (tenant_id, stamp) is a plain (non-partial) unique index, so a soft-deleted line's stamp stays reserved forever.

Indexes follow the list/query patterns: (tenant_id, subscriber_id, urt), (tenant_id, account_id, billrun_key, urt), (tenant_id, type).

What it does​

  • Storage + CRUD (create_line, get_line, list_lines, delete_line) - create_line is idempotent on (tenant_id, stamp): re-ingesting the same usage record returns the existing live row instead of raising, so ingestion is replay-safe. list_lines filters on subscriber_id/account_id/usage_type/billrun_key/urt_from/urt_to (LineListParams, subclassing PageParams). delete_line soft-deletes.

  • The minimal synchronous rating hook (rate_line(db, tenant_id, line_id, rate_key)) - given an explicit rate_key (rate selection is not implemented, see below), composes:

    1. plans_service.get_plan_by_name to resolve the line's plan snapshot to a connection_type, if plan is set.
    2. rates_service.get_charge to price the usage (rate.rates[usage_type], plan_name=line.plan).
    3. balances_service.select_balance to find an eligible balance for (account_id, subscriber_id, connection_type, usage_type) - skipped entirely if connection_type is unknown (no plan on the line), since there's nothing to select a balance by. A line can be priced without being charged.
    4. balances_service.apply_charge, keyed by the line's stamp for the same idempotent, atomic, version-CAS charge guarantee Balances already gives every caller - if a balance was selected in step 3.
    5. Stamps rate_id/rate_key/price/balance_id/rated_at on the line.

    rate_line is itself idempotent: a line with rated_at already set is returned unchanged rather than re-priced, so calling it twice for the same line is a safe no-op (on top of apply_charge's own stamp-keyed idempotency one level down).

Not implemented​

  • Raw usage-file ingestion - Lines only gets a POST /lines creation API, not file import. Connector-ingested usage lands in a separate CDR table (app/models/cdr_record.py, unrated), not in lines.
  • TAP3/GGSN/NRTRDE binary/ASN.1 formats - not built.
  • Rate selection (matching a CDR to a Product by longest prefix on the dialled number) - rate_line still takes an explicit rate_key; the caller is assumed to already know which rate applies. Researched and deliberately not built (2026-09-01): keep requiring the key. When it is built, it is a Rates/Lines retrofit (not the full Calculator), storage is Rate.params.prefix + Line.called_number, longest prefix wins, ties break on key ASC, and a miss leaves the line unrated (HTTP 200).
  • An async queue/multi-stage rating pipeline - no queue table, no multi-stage processing, no orphan/crash recovery. rate_line is the compute-and-charge core only, callable once per line.
  • Tax - lines carry no tax fields.
  • Aggregation and line reset - not built.

API​

GET /api/v1/lines follows the pagination convention with LineListParams' filters. A client user is scoped to their own accounts' lines on list and fetch (own_account_ids_if_scoped).

MethodPathNotes
POST/api/v1/linescreate (201) - idempotent on stamp, returns the existing live line on replay
GET/api/v1/linespaginated list, ?subscriber_id=&account_id=&usage_type=&billrun_key=&urt_from=&urt_to=
GET/api/v1/lines/{line_id}fetch one
DELETE/api/v1/lines/{line_id}soft delete (204) - unlike every other domain here, stamp stays reserved, not freed
POST/api/v1/lines/{line_id}/ratebody {"rate_key": "..."} - the minimal rating hook, idempotent on rated_at