Account Roles
When a module posts a journal entry it needs a GL account — the default income account for a sale, the bank-charges account for a processing fee, the payroll-payable account for a payslip. Never ship a blank-required “pick an account” config field for these. A blank field means nothing works until someone hand-maps it, and a forgotten one surfaces as a runtime error mid-transaction.
Instead, resolve an account role: a stable, chart-independent handle for “the account that plays this part”. Roles are the seam between a feature that needs an income account and whichever concrete account a given company’s chart uses for it — including a customer who tore down the shipped chart and imported their own.
Resolving a role
Section titled “Resolving a role”Account = get_model("Account")fee_account = await Account.resolve_role("bank_charges", company_id=company_id)resolve_role(role, company_id=None, fallback_type=None, required=True) returns the
Account that plays role for a company, resolving in order:
- The company’s explicit mapping for that role (an
AccountRoleMappingrow) — the authoritative, per-company binding. Set on install, on company creation, when a company adopts a fiscal localization, and by the user in Setup → Key Accounts. - The first account of the role’s fallback type in that company’s own chart (or a global account) — a sensible default of the right kind, so an unmapped role still posts somewhere valid.
- Otherwise a clear, user-actionable error pointing at Setup → Key Accounts — it does not invent an account.
company_id defaults to the company being acted for (env.company_id) — inside
as_company(X) that is X, otherwise the user’s default company. It is deliberately not
the first ticked company in the switcher, whose order is arbitrary: a role resolved that way
posts to whichever company happened to sort first. Pass company_id explicitly whenever you
are posting for a company the reader didn’t pick — a document’s own company, a subsidiary,
a cron over every company — rather than relying on the default.
Pass required=False for soft contexts (a form default) — it returns None instead of
raising when nothing resolves, so opening a form never errors.
Don’t restate a role as a setting
Section titled “Don’t restate a role as a setting”A company-level “Default Income Account” setting beside a default_income role is the same
account asked for twice, in two places, with nothing on either screen saying which one is in
force. If your setting would name one role at the role’s own granularity, don’t ship it —
resolve the role at the posting and let the user choose the account once, in Setup → Key
Accounts.
A field that narrows the role is different and entirely fine, because it answers a question the role cannot: a per-product-category income account, a per-integration fee account, a rental security deposit that merely defaults to the down-payment role. Check yours first, resolve the role when it’s blank:
fee_account = self.integration.payment_fee_account or await Account.resolve_role( "bank_charges", company_id=company_id)To seed such a field from its role when a record is created, use apply_role_defaults in
_default_get rather than hand-rolling the lookup:
async def _default_get(cls, context=None): defaults = await super()._default_get(context) return await get_model("AccountRole").apply_role_defaults( defaults, {"income_account": "default_income"}, company_id=env_ctx.get().company_id )Binding a role in code
Section titled “Binding a role in code”AccountRoleMapping.set_account(role, account, company_id) is the programmatic form of
editing a row on the Key Accounts screen — the one upsert for the (company, role) pair,
safe to call for a company the reader hasn’t selected in the switcher. account takes a
record or an id; None clears the binding, so the role resolves by type again.
Mapping = get_model("AccountRoleMapping")await Mapping.set_account("bank_charges", account, company_id)Adding a role
Section titled “Adding a role”Roles are data, seeded from data/account_roles.yaml in any module (they compose, so a
third-party module can add its own). A role names a requirement — it carries no account
code/name:
- data_type: AccountRole identifier: my_clearing # the handle you resolve by apply_once: true name: My Clearing Account description: What this role is used for (shown on the Key Accounts screen). account_type: Current Liability fallback_type: Current Liability # defaults to account_type default_account_identifier: my_clearing # the account that fills it in your chart required: false # true → part of the Key Accounts checklist + guardrail category: Payments # group heading on the Key Accounts screen sequence: 320default_account_identifier is the identifier of the account that plays the role in the
shipped chart — used to pre-bind the company’s mapping. Ship that account in your
module’s chart/data.
The chart contract (for localization authors)
Section titled “The chart contract (for localization authors)”Every chart of accounts — the base invoicing chart and every l10n_* chart — must
define an account for every role’s default_account_identifier. That is what lets
resolution land on step 1 (the explicit mapping) on every localization rather than a fuzzy
type fallback. It is enforced by invoicing/tests/test_account_roles.py, which fails if any
shipped chart is missing a role account, and which also lints that no new company-scoped
ManyToOne("Account") config field ships without a resolve_role fallback in its module.
Per-company mapping & custom charts
Section titled “Per-company mapping & custom charts”The role→account binding is per-company (AccountRoleMapping), not tied to account
identifiers — so a company that imports its own chart re-points the roles at their
accounts once, in Setup → Key Accounts (pre-filled by type so it’s a confirm, not a
blank form), and everything that posts a journal entry keeps working. The onboarding “Map
Key Accounts” step walks a new company through confirming the required roles.