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.
The Company model
Section titled “The Company 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 fieldsUsers, 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.
From user to active companies
Section titled “From user to active companies”During a request, the environment exposes three related notions:
env = env_ctx.get()
env.default_company_id # int | None — the user's home companyenv.allowed_companies # list[dict] — every company the user may accessenv.active_company_ids # list[int] — the companies selected for THIS sessionactive_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: falseIn 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]}Company-scoped fields (company_scoped)
Section titled “Company-scoped fields (company_scoped)”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_accountfield_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.
Limitations
Section titled “Limitations”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 existsvalue = 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.
Company-scoped record rules
Section titled “Company-scoped record rules”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: trueWhen 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-company vs. multi-tenancy
Section titled “Multi-company vs. multi-tenancy”| Multi-Company | Multi-Tenancy | |
|---|---|---|
| Boundary | Multiple companies in one database | One separate database per tenant |
| Data sharing | Companies can share records (global rows) | No cross-tenant access whatsoever |
| Users | A user can span several companies | A user belongs to one tenant DB |
| Scoping | company field + record rules + company_scoped | Database 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.