Skip to main content

Balances & Prepaid Includes

Code lives in app/models/balance.py, app/schemas/balance.py, app/services/balances.py, and app/api/v1/routes/balances.py.

A balance is a live counter - a postpaid running total or a prepaid bucket - for a subscriber (or, when subscriber_id IS NULL, shared across an account). A prepaid include is the wallet-type template a balance can be a live grant of (e.g. "1GB data bucket"). This domain covers storage, CRUD, selection, charging and grants - see "Not implemented" for what stays out.

Terminology follows the BillRun documentation: Prepaid plans.

Data model​

Balances are normalized into two tables:

prepaid_includes
id uuid, PK
tenant_id uuid, FK tenants.id
name str, unique per tenant among live rows
external_id str, nullable, unique per tenant among live rows
priority int
charging_by str - "cost" | "usagev" | "total_cost"
charging_by_usaget str, nullable
charging_by_usaget_unit str, nullable
shared bool
unlimited bool
additional_charging_usaget jsonb - usage types that redirect to charging_by_usaget
created_at / updated_at / deleted_at

balances
id uuid, PK
tenant_id uuid, FK tenants.id
account_id uuid, indexed
subscriber_id uuid, indexed, nullable - NULL = shared/account-level
connection_type str, indexed - "postpaid" | "prepaid"
valid_from / valid_to timestamptz, nullable - valid_to=NULL means unlimited
priority int
recurring bool
plan_description str, nullable - postpaid-only, informational
service_name str, nullable - set for an extended/add-on balance
prepaid_include_id uuid, nullable - set when this is a live grant of a template
charging_by / charging_by_usaget / charging_by_usaget_unit - denormalized from the template
unlimited / shared bool
balance jsonb - {"cost": float, "totals": {"<key>": {"usagev","cost","count"}}}
tx jsonb - {"<stamp>": {...applied charge...}}, idempotency log
version int - optimistic-concurrency counter, see below
created_at / updated_at / deleted_at

No foreign keys are enforced between balances.account_id/subscriber_id and Accounts/Subscribers, or between balances.prepaid_include_id and prepaid_includes, matching this codebase's existing convention (Subscribers' account_id). prepaid_includes has one row per name, no revision history - the same simplification Plans/Rates use; its name/external_id uniqueness is enforced by partial unique indexes scoped to live rows, so a retired template frees both (see Identity and deletion).

Account-level balances use a NULL subscriber_id​

A balance shared across an account's subscribers has subscriber_id IS NULL. The account-level lookup needs IS NULL (a = NULL comparison never matches), and the prepaid candidate filter - "this subscriber's own buckets plus the account's shared ones" - is or_(Balance.subscriber_id.is_(None), Balance.subscriber_id == subscriber_id). Omitting subscriber_id from POST /balances or GET /balances/select therefore addresses the account-level balance.

Optimistic concurrency: a version column​

Every mutating balance update (apply_charge, apply_grant) is conditioned on WHERE id = :id AND version = :version_just_read, bumps version by one, and raises ConflictError (mapped to HTTP 409) if zero rows matched. A concurrent modification between load and update is therefore detected instead of silently corrupting state.

Behavior​

  • Balance-totals-key resolution (get_balance_totals_key) - postpaid charges the raw usage type; prepaid checks the linked PrepaidInclude's additional_charging_usaget list and redirects to charging_by_usaget if the usage type is listed there (the "call balance also covers video_call usage" indirection).
  • Postpaid auto-create-on-select - select_balance with connection_type="postpaid" always returns a balance, creating an empty default one if none exists yet, so there is always a balance to write to.
  • Prepaid threshold-based selection (select_balance) - not an identity lookup: any prepaid balance where totals.<usage_type>.usagev/.cost or the top-level balance.cost is at or below the configured balance.minUsage/minCost threshold (top-level balance config section), with an optional per-usage-type override balance.minUsage.<usageType>/balance.minCost.<usageType> (defaults -3/-0.1, via tenant config) qualifies, filtered by account_id / "this subscriber's buckets or the account's shared ones" / validity window, and resolved by priority DESC, valid_to ASC (highest priority, soonest-expiring bucket first).
  • Postpaid vs. prepaid charge-application semantics (apply_charge) - postpaid decrements totals.<key>.{usagev,cost,count} and top-level balance.cost together; prepaid decrements usagev only when cost > 0 (a free line decrements cost instead, never touching usagev).
  • Idempotent, atomic charging (apply_charge) - the tx.<stamp> map is checked before applying a charge; a replayed stamp returns the cached result instead of double-charging. The actual mutation goes through the version check above.
  • Admin grant/top-up (apply_grant) - set/inc/new operations, including the zeroing behavior: by default ( ignore_overuse=True), an inc grant against a positive balance discards the overage rather than netting it - +5 topped up by 100 ends at -100, not -95. After an increment the max-balance clamp applies, recording the clamp delta.

Not implemented​

  • Group tracking (balance.groups.<group>.*, out_group/over_group sub-counters, and group-level resets) - depends on Plan groups, which Rates lists as not implemented since Plans don't carry live group selection in core-api.
  • Multi-include grants and voucher redemption - granting several includes at once and scratch-card/voucher redemption are not built; only the single-balance apply_grant exists.
  • Rating-time charge invocation from a usage line - the full async queue-based rating engine is not built. Lines has a minimal synchronous stand-in (rate_line, composing select_balance/apply_charge as described here), not a batch/realtime pipeline. This domain provides the storage + selection + charge-application primitive (POST /balances/{id}/charge) that rate_line calls.
  • Forcing every balance to account-level scope via a config toggle - no domain needs it yet.

API​

GET /api/v1/balances follows the pagination convention, with account_id/subscriber_id/connection_type filters on BalanceListParams (subclassing PageParams, not a sibling Query() param).

DELETE /api/v1/balances/{balance_id} retires a balance under this codebase's soft-delete convention: it stamps deleted_at, which stops the balance being selectable, chargeable and grantable, while its counters and its tx charge log survive for audit. A financial record is exactly the kind of row that must never be hard-deleted.

MethodPathNotes
POST/api/v1/balancescreate (omit subscriber_id for an account-level balance)
GET/api/v1/balancespaginated list, ?account_id=&subscriber_id=&connection_type=
GET/api/v1/balances/selectfind the applicable balance: ?account_id=&connection_type=&usage_type=, optional &subscriber_id=
GET/api/v1/balances/{balance_id}fetch one
PATCH/api/v1/balances/{balance_id}admin metadata edit (not balance/tx)
DELETE/api/v1/balances/{balance_id}soft delete - retires the balance, keeps its counters and tx log
POST/api/v1/balances/{balance_id}/chargeatomic, idempotent rating-time charge
POST/api/v1/balances/{balance_id}/grantadmin top-up/grant (set/inc/new)
POST/api/v1/prepaid-includescreate a wallet-type template
GET/api/v1/prepaid-includespaginated list
GET/api/v1/prepaid-includes/{include_id}fetch one
PATCH/api/v1/prepaid-includes/{include_id}partial update
DELETE/api/v1/prepaid-includes/{include_id}soft delete - retires the template and frees its name/external_id; balances already granted from it keep working

Two paginated convenience wrappers over GET /api/v1/balances also exist: GET /api/v1/accounts/{account_id}/balances and GET /api/v1/subscribers/{subscriber_id}/balances - see Accounts' "Balances (Counters)".

GET /balances/select returns 404 when no balance qualifies (a postpaid select auto-creates one instead). /charge and /grant return HTTP 409 on a version conflict - the caller should re-fetch the balance and retry.