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 linkedPrepaidInclude'sadditional_charging_usagetlist and redirects tocharging_by_usagetif the usage type is listed there (the "call balance also covers video_call usage" indirection). - Postpaid auto-create-on-select -
select_balancewithconnection_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 wheretotals.<usage_type>.usagev/.costor the top-levelbalance.costis at or below the configuredbalance.minUsage/minCostthreshold (top-levelbalanceconfig section), with an optional per-usage-type overridebalance.minUsage.<usageType>/balance.minCost.<usageType>(defaults-3/-0.1, via tenant config) qualifies, filtered byaccount_id/ "this subscriber's buckets or the account's shared ones" / validity window, and resolved bypriority DESC, valid_to ASC(highest priority, soonest-expiring bucket first). - Postpaid vs. prepaid charge-application semantics (
apply_charge) - postpaid decrementstotals.<key>.{usagev,cost,count}and top-levelbalance.costtogether; prepaid decrementsusagevonly whencost > 0(a free line decrementscostinstead, never touchingusagev). - Idempotent, atomic charging (
apply_charge) - thetx.<stamp>map is checked before applying a charge; a replayedstampreturns the cached result instead of double-charging. The actual mutation goes through theversioncheck above. - Admin grant/top-up (
apply_grant) -set/inc/newoperations, including the zeroing behavior: by default (ignore_overuse=True), anincgrant against a positive balance discards the overage rather than netting it -+5topped up by100ends 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_groupsub-counters, and group-level resets) - depends on Plan groups, which Rates lists as not implemented since Plans don't carry live group selection incore-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_grantexists. - 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, composingselect_balance/apply_chargeas described here), not a batch/realtime pipeline. This domain provides the storage + selection + charge-application primitive (POST /balances/{id}/charge) thatrate_linecalls. - 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.
| Method | Path | Notes |
|---|---|---|
| POST | /api/v1/balances | create (omit subscriber_id for an account-level balance) |
| GET | /api/v1/balances | paginated list, ?account_id=&subscriber_id=&connection_type= |
| GET | /api/v1/balances/select | find 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}/charge | atomic, idempotent rating-time charge |
| POST | /api/v1/balances/{balance_id}/grant | admin top-up/grant (set/inc/new) |
| POST | /api/v1/prepaid-includes | create a wallet-type template |
| GET | /api/v1/prepaid-includes | paginated 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.