Skip to content

Multi-Company

Fullfinity supports multiple companies sharing a single database. One install of one database can hold several legal entities, with users belonging to one or more of them, records scoped per company, and configuration values that differ per company.

This is not the same as multi-tenancy. Multi-tenancy gives each tenant a separate database; multi-company puts several companies inside one database. See Multi-Tenancy for the separate-database model.

A company is a record like any other. The core Company model holds the legal-entity details — name, address, currency, tax id, branding:

class Company(Model):
_verbose_name = "Company"
_track = True
name = Char(max_length=255, index=True, required=True)
currency = ManyToOne("Currency", related_name="companies", on_delete="RESTRICT")
country = ManyToOne("Country", related_name="companies", on_delete="SET NULL")
image = File(description="Logo")
tax_number = Char(max_length=50, description="Tax ID")
# ... address, contact, and social fields

Users, default company, and allowed companies

Section titled “Users, default company, and allowed companies”

Every user belongs to a company and may have access to several. Two fields on User drive this:

class User(Model):
default_company = ManyToOne(
"Company",
related_name="users_with_default_company",
required=True,
on_delete="CASCADE",
default=lambda self: self.get_default_company(),
description="Default Company",
)
allowed_companies = ManyToMany(
"Company",
related_name="users",
through="FkUserCompany",
description="Allowed Companies",
default=lambda self: self.get_all_companies(),
)
  • default_company (required) is the user’s home company — the one selected by default.
  • allowed_companies (M2M) is the full set of companies the user may work with.

During a request, the environment exposes three related notions:

env = env_ctx.get()
env.default_company_id # int | None — the user's home company
env.allowed_companies # list[dict] — every company the user may access
env.active_company_ids # list[int] — the companies selected for THIS session

active_company_ids is the subset the user has currently selected to work with — supplied per request via the X-Company-IDs header and set on the env by middleware. It is a subset of allowed_companies, defaulting to the user’s selection for the session. Application code filters and creates against this active set:

env = env_ctx.get()
for company_id in env.active_company_ids:
# process for each active company
...

Gating company UI: the core_multi_company group

Section titled “Gating company UI: the core_multi_company group”

On a single-company install, company fields are noise. They are gated behind the core_multi_company group, defined in modules/core/security/security.yaml:

- data_type: Group
name: Multi Company
identifier: core_multi_company
category: Others
exclusive: false

In views, add groups: [core_multi_company] to the company column/field so it only renders for multi-company users. This matches the List-view convention of showing company only on multi-company models:

- field: company
properties: {widget: DataCombo, label: Company, groups: [core_multi_company]}

Sometimes the same record needs a different value per company — a product category whose income account differs by entity, or a configuration toggle that differs per company. That’s the company_scoped field option:

class ProductCategory(Model):
name = Char(max_length=255)
income_account = ManyToOne(
"Account",
description="Income Account",
company_scoped=True, # value stored per company
)

A company_scoped field does not live in the model’s own column. Its per-company values are stored against the record, keyed by company, and read back for the active company with a fallback to the global default. The storage key format is:

Model.record_id.field_name # regular models, e.g. ProductCategory.5.income_account
field_name # transient models (wizards/configuration), e.g. crm_auto_assign

(get_company_scoped_key in the engine builds these.) A company value falls back to a global default when no company-specific value is set.

Because the value is stored as a per-company blob rather than a plain column, you cannot query against it the way you would a normal field:

  • Cannot filter on a company-scoped field: filter(income_account=x) won’t work.
  • Cannot sort on a company-scoped field.
  • No database-level uniqueness constraints.

Use company-scoped fields for configuration and properties read by id, not for fields you need to search or order by. See Field Types for the full behavior of company_scoped.

CompanyConfig: the per-company key-value store

Section titled “CompanyConfig: the per-company key-value store”

Underpinning company-scoped configuration is the CompanyConfig model — a key-value store where company = NULL is the global default and company = <id> is a company-specific override. Lookups try the company-specific value first, then fall back to global:

CompanyConfig = get_model("CompanyConfig")
# Read for a company, with global fallback if no company-specific value exists
value = await CompanyConfig.get_value(
"exchange_gain_account",
company_id=env.default_company_id,
default=None,
)
# Write a company-specific value (omit company_id to set the global default)
await CompanyConfig.set_value(
"exchange_gain_account",
account_id,
company_id=env.default_company_id,
)

get_value resolves in order: company-specific value → global value → the supplied default. set_value writes the company-specific row when company_id is given, otherwise the global row. Values are JSON-serialized and cached per request.

To scope records to companies, use a record rule that allows the active companies plus the company-less (global) records. The record-rule context exposes cids (the active company ids):

- data_type: RecordRule
identifier: sale_order_all_company_orders_rule
name: 'Sale Order: All Company Orders'
model: SaleOrder
groups:
- sales_manager_group
rule: Q(company__id__in=cids)
read_perm: true
write_perm: true
create_perm: true
delete_perm: true

When a model has both company-specific and shared records, the common pattern is to allow both — the company-less rows are visible to everyone, the company rows only to that company:

company_filter = Q(company__isnull=True)
if self.company:
company_filter = company_filter | Q(company=self.company)

This Q(company__isnull=True) | Q(company=...) shape keeps shared/global records visible across all companies while restricting company-owned records to their company.

Multi-CompanyMulti-Tenancy
BoundaryMultiple companies in one databaseOne separate database per tenant
Data sharingCompanies can share records (global rows)No cross-tenant access whatsoever
UsersA user can span several companiesA user belongs to one tenant DB
Scopingcompany field + record rules + company_scopedDatabase routing by host/subdomain

Reach for multi-company when several legal entities operate together and need to share master data; reach for multi-tenancy when tenants must be fully isolated. See Multi-Tenancy.