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 likefile_typesalmost 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, returningdefaultif 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-mergesDEFAULT_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.currencyformats prices in the dashboard (moneyNumber/moneyPreciseNumber/formatMoneyinapp/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.timezoneformats dates in the dashboard (useTenantDateFormat.ts, viaIntl.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-IDheader; 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.
| Method | Path | Notes |
|---|---|---|
| GET | /api/v1/config | merged config tree (DEFAULT_TENANT_CONFIG < platform config < tenant's own overrides) |
| PATCH | /api/v1/config | body {"key": "a.b.c", "value": ...}, upserts one path |
| DELETE | /api/v1/config | resets 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.