Skip to content

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.

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:

  1. The company’s explicit mapping for that role (an AccountRoleMapping row) — 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.
  2. 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.
  3. 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.

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
)

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)

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: 320

default_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.

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.