Skip to main content

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, and price. Enforced declaratively: PriceInterval in app/schemas/common.py makes all three required, so a missing one is a 422 before the service layer runs.
  • Per-account billing cycle day - Account.invoicing_day is stored per revision. No billing cycle or billable-account query exists in core-api; nothing reads it yet.
  • Collection eligibility - is_excluded_from_collection / is_subject_to_collection plus set_collection_status (POST /api/v1/accounts/{account_id}/collection). The exempted/subject-to lists are tenant config keys collection.exempted_account_ids / collection.subject_to_account_ids (JSON lists of UUID strings); setting in_collection=true raises ValidationError if 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 as POST /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 from update_account, which only ever adds a new forward revision. Requires the new from to 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 bulk UPDATE ... WHERE from_date >= new_from (revisioned.apply_permanent_change).

Not implemented​

  • Services on accounts - accounts carry no services array.
  • Permanent-change audit trail - permanent_change is not audit-logged (see the docstring of revisioned.apply_permanent_change), so the pre-correction values the bulk UPDATE overwrites are not recorded.
  • Payment gateway configuration - payment_gateway is 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. DELETE archives 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:

typeWhat key is
plana plan's own name
ratea 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):

  • value is price-only. Only a price tier list can be overridden, not arbitrary catalog fields.
  • No condition. An override always applies once its type/key match; 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:

MethodPathNotes
POST/api/v1/accountscreate (201)
GET/api/v1/accountspaginated list, current revisions only
GET/api/v1/accounts/archivedpaginated 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}/revisionsevery 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}/movemove a revision's from/to edge
POST/api/v1/accounts/{account_id}/closeclose 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-changeretroactively 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}/restorereverse the archive
POST/api/v1/accounts/{account_id}/collectionset in_collection (validated eligibility)
GET/api/v1/accounts/{account_id}/balancespaginated - 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.