Skip to main content

Tenant config

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

Terminology follows the BillRun documentation: Definitions.

What this isn't​

This is not a replacement for app/core/config.py's Settings (pydantic-settings) - that's infra bootstrap config (database URL, CORS origins, debug flag) needed before a DB connection even exists, and stays env-var-driven. This domain is business-level, runtime-editable config (for example billrun.multi_day_cycle, branding, collection exemptions), and it's multi-tenant by design.

Storage and layers​

Config lives only in the database; infra settings are covered by Settings/env vars:

tenant_configs
id uuid, PK
tenant_id uuid, FK -> tenants.id, nullable (NULL = platform), partial-unique WHERE deleted_at IS NULL
data jsonb - this scope's own config tree (only its overrides)
created_at / updated_at / deleted_at

There are three layers, least to most important: the code-level DEFAULT_TENANT_CONFIG dict in app/services/tenant_config.py, the platform row (tenant_id IS NULL, edited by superadmins under /api/v1/platform/config, see Tenant per client company), and the tenant's own row. A tenant's row belongs to a real tenant, resolved from the X-Tenant-ID header (CurrentTenant, app/api/deps.py) - there's no {tenant} path param.

Behavior​

  • Merging - merge_configs()/_merge_values() recursively merge two config trees key by key, the more-important one (the tenant) winning per leaf and preserving sibling keys the tenant didn't touch. A list value is replaced wholesale rather than merged index-by-index: a tenant redefining a list-shaped key like file_types almost always means "replace the whole list," not "splice by index."
  • Reading - get_config_value() does a dot-path lookup ("billrun.multi_day_cycle") over the merged tree, returning default if any segment is missing. No type coercion is applied - callers should validate what they get back themselves.
  • Writing - set_config_value() does a dot-path upsert into that tenant's own row (creating it if it doesn't exist), creating intermediate dict levels as needed.
  • Precedence - get_merged_config() deep-merges DEFAULT_TENANT_CONFIG, then the platform row, then the requested tenant's own row, later wins. A missing row is treated as {}, not an error - a brand-new tenant with no overrides yet just gets the inherited defaults.

Regional display settings​

billrun.currency (ISO 4217 code, default EUR) and billrun.timezone (IANA name, default UTC) are two billrun.*-namespaced keys, edited via Settings > General's "Regional" card in the dashboard. Both are display-only:

  • billrun.currency formats prices in the dashboard (moneyNumber/moneyPreciseNumber/ formatMoney in app/utils/numberField.ts) - it doesn't change any pricing/tax math in core-api, which isn't denominated in a currency at all yet (invoicing is Phase 3).
  • billrun.timezone formats dates in the dashboard (useTenantDateFormat.ts, via Intl.DateTimeFormat) - it only affects how dates are shown, not how any timestamp is computed or stored (always UTC).

Like every other config value, these are freeform/unvalidated on the core-api side - consistent with the lack of server-side value typing (see Limitations below). The dashboard constrains input to sane values instead (a currency dropdown, a searchable IANA timezone list), rather than the server validating on write.

Limitations​

  • No typed values - values are stored and returned as plain JSON, with no type-tag wrapping or server-side validation.
  • No config caching - there's no caching layer yet.
  • No host-based tenant detection - every request names its tenant explicitly in the X-Tenant-ID header; nothing infers it from the host.

Accounts reads collection.subject_to_account_ids and collection.exempted_account_ids from this config (app/services/accounts.py) - see the Accounts architecture page.

API​

No pagination here - a tenant has exactly one merged config tree, not a list of resources. The tenant is resolved from the X-Tenant-ID header on every request, not a path param.

MethodPathNotes
GET/api/v1/configmerged config tree (DEFAULT_TENANT_CONFIG < platform config < tenant's own overrides)
PATCH/api/v1/configbody {"key": "a.b.c", "value": ...}, upserts one path
DELETE/api/v1/configresets the tenant's entire own override row (soft delete - stamps deleted_at), falling back to the platform config and DEFAULT_TENANT_CONFIG; not a per-key reset (404 if the tenant has no override)

The same operations exist for the platform row under /api/v1/platform/config (no X-Tenant-ID, superadmin only). Permissions: tenant_config:read / tenant_config:write.