Accounts
Code lives in app/models/account.py, app/schemas/account.py, app/services/accounts.py, and
app/api/v1/routes/accounts.py. Dashboard and current domain term: Account — a billing account of a client company. The
client company itself is the Tenant (see Tenant per client):
tenant "Celtic" holds the accounts "Celtic", "Celtic DEMO" and "Celtic Migration", which subscriber
groups belong to. Creating an account never creates or links a user (email is a contact field only); client users
are linked and restricted to specific accounts through account members
(POST /accounts/{account_id}/members, app/api/v1/routes/account_members.py). Distinct from Customer (a real user, who can have many Subscriber
lines) at /api/v1/customers.
Terminology follows the BillRun documentation: Definitions, which calls this entity Customer/Account.
Data model
Accounts have their own table, one date-ranged revision per row (same shape as Subscribers):
accounts
id uuid, PK - this revision row's own identity
account_id uuid, indexed - the logical account every revision shares;
minted only on first create
tenant_id uuid, FK tenants.id
from_date / to_date timestamptz - revision window
activation_date / deactivation_date
timestamptz - entity-level first activation / final deactivation
invoicing_day int, nullable - per-account billing cycle day (None = tenant default)
in_collection / in_collection_from
bool / timestamptz, nullable
first_name / last_name / email / phone / address / country / zip_code / salutation
string, nullable - contact fields
company_name / contact_name / vat_number / description / ref
nullable - `ref` is a manual reference, not import provenance
user_id uuid, nullable - no longer written by `POST /accounts`
payment_gateway / invoice_shipping_method / invoice_language
string, nullable - free strings, no closed set
detailed_invoice bool
external_source / external_id
string, nullable - import provenance (see Customer import)
extra / metadata jsonb - import-mapping leftovers / whole-record bucket
overrides jsonb - [{"type", "key", "value": {"price": [{"from","to","price"}]}}]
see "Overrides" below
created_at / updated_at / deleted_at / archived_at
The logical account identity is account_id (not id), a UUID. Other
tables reference it as account_id, without a DB-level FK. See Identity and
deletion for the reasoning.
Accounts are date-ranged revisions, as Subscribers are, because a single mutable row can't
reconstruct a billing-relevant attribute (e.g. invoicing_day) as it stood at a past cycle. account_id is
minted only when a new account is created; any later revision must copy it explicitly. The
revision logic is shared with Subscribers in app/services/revisioned.py; app/services/accounts.py
wraps it (move_entry, close_account, delete_future_revision, list_revisions,
compute_revision_info, permanent_change). There is no closeandnew action: nothing in the API
creates a second revision by splitting one, except permanent_change.
Behavior
- Override validation - every price interval needs
from,to, andprice. Enforced declaratively:PriceIntervalinapp/schemas/common.pymakes all three required, so a missing one is a422before the service layer runs. - Per-account billing cycle day -
Account.invoicing_dayis stored per revision. No billing cycle or billable-account query exists incore-api; nothing reads it yet. - Collection eligibility -
is_excluded_from_collection/is_subject_to_collectionplusset_collection_status(POST /api/v1/accounts/{account_id}/collection). The exempted/subject-to lists are tenant config keyscollection.exempted_account_ids/collection.subject_to_account_ids(JSON lists of UUID strings); settingin_collection=trueraisesValidationErrorif the account is exempted, or if a non-empty subject-to list doesn't contain it. - Batched id loading -
list_accounts_by_ids, point-in-time (at, default now): each id resolves to the revision covering that moment. - Move / close / delete a future revision -
move_entry/close_account/delete_future_revision, identical to Subscribers'. - Permanent change -
permanent_change, exposed asPOST /accounts/{account_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 account - distinct fromupdate_account, which only ever adds a new forward revision. Requires the newfromto fall inside the target revision's[from_date, to_date); splits the revision when it doesn't land on an existing boundary; applies the correction to that revision plus every later one via a single bulkUPDATE ... WHERE from_date >= new_from(revisioned.apply_permanent_change).
Not implemented
- Services on accounts - accounts carry no
servicesarray. - Permanent-change audit trail -
permanent_changeis not audit-logged (see the docstring ofrevisioned.apply_permanent_change), so the pre-correction values the bulkUPDATEoverwrites are not recorded. - Payment gateway configuration -
payment_gatewayis a bare string identifier; no gateway-specific configuration is modeled. - Dunning process - no collection-step creation/removal; no Collections/Dunning domain exists.
- Cascading retirement of an account's subscribers and balances.
DELETEarchives only the account's own revisions. There are no FK constraints, so subscriber and balance rows are left behind - see the callout in Identity and deletion.
Overrides
Account.overrides (and Subscriber.overrides - same shape, see
Subscribers) is [{"type", "key", "value": {"price": [...]}}].
type is an OverrideType (app/schemas/enums.py) and is required:
type | What key is |
|---|---|
plan | a plan's own name |
rate | a rate's own key |
Stored overrides whose type is no longer in OverrideType are dropped on read
(ACTIVE_OVERRIDE_TYPES, app/schemas/common.py). No charging/rating pipeline consumes
overrides in core-api, so precedence between Account and Subscriber overrides is not applied
anywhere.
Two limitations, both documented on the schema (app/schemas/common.py:OverrideValue/
OverrideIn):
valueis price-only. Only apricetier list can be overridden, not arbitrary catalog fields.- No
condition. An override always applies once itstype/keymatch; there is no extra eligibility condition.
API
GET /api/v1/accounts follows the pagination convention with no
extra filters (AccountsPaginatedResponse). A client user is scoped to the accounts they hold an
account membership for (_own_account_ids_if_scoped). The rest mirrors Subscribers' revisioning
routes:
| Method | Path | Notes |
|---|---|---|
| POST | /api/v1/accounts | create (201) |
| GET | /api/v1/accounts | paginated list, current revisions only |
| GET | /api/v1/accounts/archived | paginated list of archived accounts |
| GET | /api/v1/accounts/archived/{account_id} | fetch an archived account (?at=) |
| GET | /api/v1/accounts/{account_id} | fetch the revision covering at (default now) |
| GET | /api/v1/accounts/{account_id}/revisions | every revision, each with revision_info (not paginated) |
| PATCH | /api/v1/accounts/{account_id} | partial update to the revision covering at |
| POST | /api/v1/accounts/{account_id}/move | move a revision's from/to edge |
| POST | /api/v1/accounts/{account_id}/close | close the current revision early |
| DELETE | /api/v1/accounts/{account_id}/revisions/{revision_id} | soft-delete a not-yet-active future revision (204) |
| POST | /api/v1/accounts/{account_id}/revisions/{revision_id}/permanent-change | retroactively correct a past-or-current revision, cascading to later ones |
| DELETE | /api/v1/accounts/{account_id} | archive - stamps archived_at on every live revision, does not cascade (204) |
| POST | /api/v1/accounts/{account_id}/restore | reverse the archive |
| POST | /api/v1/accounts/{account_id}/collection | set in_collection (validated eligibility) |
| GET | /api/v1/accounts/{account_id}/balances | paginated - see "Balances (Counters)" below |
Balances (Counters)
GET /api/v1/accounts/{account_id}/balances is a thin, paginated convenience wrapper over GET /api/v1/balances?account_id=... -
404s if the account doesn't exist (live or archived), otherwise delegates to
balances_service.list_balances. Includes the account-level shared balance
(subscriber_id IS NULL) and every one of the account's subscribers' balances undifferentiated;
narrow further with the top-level /balances route's subscriber_id/connection_type filters, or
use Subscribers' own GET /api/v1/subscribers/{subscriber_id}/balances
for one subscriber only.
No FK enforced between balances.account_id and accounts.account_id (nor
balances.subscriber_id and subscribers.subscriber_id) - see Balance in
app/models/balance.py and Identity and deletion. A balance
whose account_id doesn't match any account is simply unreachable via this route (404) but is not
otherwise invalid or cleaned up.