Company / Branch / Store — the organizational backbone
The whole org model reduces to one chain: Company → Branch (a.k.a. Store) → every transactional document. A company is the accounting/tenancy unit (it owns the chart of accounts, fiscal periods, modules and feature flags); a branch is a physical location/warehouse inside a company; every business document inherits
companyId(and optionallybranchId) from the logged-in context so the data is automatically scoped. Companies can also nest (parentId→ subsidiaries) to form a group/consolidation tree.
Source: BE src/modules/company, src/modules/branch, src/modules/fiscal · Admin src/modules/company, src/modules/store, src/modules/fiscalPeriod
⚠️ Naming: the backend entity is
Branch(collectionstores, GraphQLbranchPage/createBranch/…). The admin module is namedstorefor legacy reasons and the UI labels it "Branch". Permission key isSTORE_MAINTENANCE. Treat "store", "branch" and "warehouse" as the same thing throughout this doc — seebranch.dto.tsnote and adminstore/model.tsheader comment.
1. Purpose & scope
- Company (
companies) — the top-level accounting & licensing entity within a tenant database. Holds profile/legal data, currency, industry, fiscal-year bounds, the enabled-modules list, workflow feature flags, and anACTIVE/ARCHIVEDlifecycle. Creating a company seeds an entire working environment (HQ branch, admin user, permissions, chart of accounts, config, approval policies, fiscal periods). - Branch / Store (
stores) — a location/warehouse inside a company. Minimal entity:name+ optionalmanagerId. Used as thebranchIdcarried on stock movements and as the unit users "switch into". - Fiscal periods are owned by the company but documented separately in fiscal-period.md.
Does NOT do: tenant (database-level) isolation — that is platform-level; see platform/multi-tenancy.md. Company/branch only scope within a tenant DB via companyId/branchId filters.
2. Data model
2.1 companies — company/company.schema.ts
| field | type | req? | description |
|---|---|---|---|
ref |
string | ✅ unique | generated document number (from BaseSchema.generateRef()) |
name |
string | — | display name; uniqueness enforced in service, not by index |
industryId |
ObjectId | — | → masters (industry lookup); resolved as industry |
parentId |
ObjectId | — | → companies; set for subsidiaries. Drives subsidiaries[] |
currencyId |
ObjectId | — | → masters (base/reporting currency) |
email, phoneNumber, address, city, state, postalCode |
string | — | contact/profile |
taxNo |
string | — | tax registration number |
registrationNo |
string | unique idx | company registration no.; service rejects duplicates |
registrationDate |
number (unix) | — | stored via BaseSchema.toUnixTimestamp |
fiscalYearStartDate / fiscalYearEndDate |
number (unix) | — | bounds used to seed fiscal periods (see §4.3) |
logoId, letterHeadHeaderId, letterHeadFooterId |
ObjectId | — | → file_uploads; print/branding assets |
enabledModules |
string[] | default [] |
list of module keys this company licenses (see §2.3) |
approvalThresholdsEnabled |
boolean | default false |
workflow capability flag |
salesFulfillmentEnabled |
boolean | default false |
workflow capability flag |
stockReservationEnabled |
boolean | default false |
workflow capability flag |
fulfillmentAllowOversell |
boolean | default false |
allow reserving/shipping beyond available-to-promise |
goodsReceiptEnabled |
boolean | default false |
workflow capability flag |
defaultWorkflows |
{transactionType, workflowId}[] |
default [], _id:false |
default workflow per transaction type |
status |
enum CompanyStatus |
default ACTIVE |
ACTIVE | ARCHIVED |
Soft-deleted via mongoose-delete (deletedAt, deletedBy). Non-persisted resolve-only properties: admin, logo, letterHeadHeader, letterHeadFooter, currency, subsidiaries.
Note: unlike most schemas the company is not branch-scoped; it is the scope.
companyIdon a company row is effectively self/parent context and is ignored in most queries (ignoreCompanyIdis forced on; see §4.1).
// company/company.interface.ts
export enum CompanyStatus {
ACTIVE = "ACTIVE",
ARCHIVED = "ARCHIVED"
}2.2 stores — branch/branch.schema.ts
| field | type | req? | description |
|---|---|---|---|
name |
string | — | branch/warehouse name |
managerId |
ObjectId | — | → users; resolved as manager |
companyId |
ObjectId | (from BaseSchema) | owning company; auto-injected on create |
Soft-deleted via mongoose-delete (deletedAt). The GraphQL Branch type also exposes the inherited BaseSchema audit fields (createdAt/By, updatedAt/By, canDelete/Update/View/Post, ref, documentCode, documentDate).
2.3 Module catalogue (the licensing list)
The BE seed default is defaultModules (company/company.interface.ts, 28 keys) — applied at company creation when no list is given:
export const defaultModules = [
"dashboard","shortcuts","customers","sales-quotations","sales-order","sales-invoice",
"vendors","purchase-requisition","purchase-order","purchase-invoice","inventory","cashbook",
"gl-accounts","account-category","payment-entries","journal-entries","note-entries","assets",
"workflow","exchanges","company-financials","inventory-report","ar-report","ap-report",
"asset-report","user-maintenance","store-maintenance","tax-maintenance"
];The admin presents a superset for toggling — CORE_MODULES (company/module-config.ts, 32 entries): the 28 above plus the optional sub-items, clients-sub-accounts, manufacturing, multi-pricing, multi-uom, projects. These extras are off by default and enabled per company via updateCompanyModules.
2.4 COMPANY_SCOPED_COLLECTIONS (cascade-delete target list)
company/company.interface.ts defines the authoritative list of ~60 collections that carry a companyId and are wiped when a company is hard-deleted (see §4.2). It includes stores, fiscal_periods, all finance_*, orders/order_items/stocks, items/item_*, employees, permission_*, notifications, audit_logs, and companies itself (filtered by _id).
3. API surface
3.1 Company — company/company.resolver.ts
| Operation | Type | Input | Returns | Auth notes |
|---|---|---|---|---|
companyPage |
Query | CompanyPageInput {skip,take,keyword} |
CompanyPageResult |
ignoreCompanyQuery, authNotRequired |
findOneCompany |
Query | CompanyQueryInput {_id,name,ref} |
Company |
ignoreCompanyQuery, authNotRequired |
findCompany |
Query | CompanyQueryInput |
[Company] |
ignoreCompanyQuery, authNotRequired |
currentUserCompany |
Query | — (uses user.companyId) |
Company |
ignoreCompanyQuery |
userCompanies |
Query | — (uses current user) | [Company] |
ignoreCompanyQuery; SuperAdmin → all; else via employees |
createCompany |
Mutation | CreateCompanyInput |
Company |
ignoreCompanyQuery; audit CREATE |
updateCompany |
Mutation | id, UpdateCompanyInput |
Company |
audit UPDATE |
updateCompanyModules |
Mutation | UpdateCompanyModulesInput {companyId, enabledModules} |
Company |
audit UPDATE |
switchCompany |
Mutation | SwitchCompanyInput {_id} |
SwitchCompanyResponse {auth, stores} |
audit STATUS_CHANGE |
deleteCompany |
Mutation | DeleteCompanyInput {companyId, confirmationName} |
DeleteCompanyResponse |
roles:["SuperAdmin"], audit DELETE |
archiveCompany |
Mutation | ArchiveCompanyInput {companyId} |
ArchiveCompanyResponse |
roles:["SuperAdmin"] |
unarchiveCompany |
Mutation | ArchiveCompanyInput {companyId} |
ArchiveCompanyResponse |
roles:["SuperAdmin"] |
Resolve-fields: industry, parent, subsidiaries (find({parentId})), admins (employees.find({companyId})), logo/letterHeadHeader/letterHeadFooter (file lookups), currency, and computed credits/debits (via accountTransSvc.totalDrAnCr({companyId})).
// CreateCompanyInput extends CompanyCommonInput + admin
@InputType() class CreateCompanyInput extends CompanyCommonInput {
admin: CreateCompanyUserInput; // { email, phoneNumber?, password? }
}
@InputType() class UpdateCompanyInput extends PartialType(CompanyCommonInput) {}CompanyCommonInput carries name, the five workflow flags, defaultWorkflows, industry/currency/parent ids, registration/contact fields, fiscal-year dates, and three GraphQLUpload slots (logo, letterHeadHeader, letterHeadFooter) plus their *Id equivalents.
3.2 Branch — branch/branch.resolver.ts
| Operation | Type | Input | Returns | Auth |
|---|---|---|---|---|
branchPage |
Query | BranchPageInput {skip,take,keyword,sortBy,sortOrder} |
BranchPageResult |
ignoreCompanyQuery:false |
findBranch |
Query | QueryBranchInput |
[Branch] |
— |
findOneBranch |
Query | QueryBranchInput |
Branch |
— |
branchReport |
Query | BranchReportInput |
BranchReportResult |
delegates to ReportService |
createBranch |
Mutation | CreateBranchInput {name, managerId?} |
Branch |
audit CREATE |
updateBranch |
Mutation | id, QueryBranchInput |
Branch |
audit UPDATE |
deleteBranch |
Mutation | id |
Boolean |
audit DELETE |
switchBranch |
Mutation | branchId |
Auth (new JWT) |
audit STATUS_CHANGE |
Resolve-fields: company (companySvc.findById), manager (userSvc.findById).
REST — branch/branch.controller.ts mounts at api/branch and api/store. GET download?downloadType=xlsx exports a Branches XLSX (Name, Manager, Document Date) via ApiAuthorize.
4. Business rules & calculations
4.1 How a branch/company scopes data (the auto-filter)
Scoping is implemented in the base repository, not per module — core/database/database.repository.ts:
- On write (
_mapData→resolveCompanyId/resolveBranchId, lines ~213-230 / 357-367): every create stampscompanyId = payload.companyId || contextSvc.companyIdandbranchId = payload.branchId || contextSvc.branchId. AbranchIdof"*"or""clears it (company-wide row). - On read (aggregations)
companyMatch()injects{ companyId: <context companyId> }into every$matchstage that precedes a$group, unlessignoreCompanyIdis set or acompanyIdmatch already exists. This is why every page/find is silently company-scoped. ignoreCompanyIdgetter =contextSvc.ignoreCompanyQuery || local flag. Company queries setignoreCompanyQuery:trueso they can read across companies (resolvers use@ApGqlAuthorize({ ignoreCompanyQuery:true })).- Branch-level filtering is currently DISABLED in code.
branchMatch()and theincludeBranchQuerybranch-injection block are fully commented out indatabase.repository.ts. TheactiveBranchIdlives on the JWT (set byswitchBranch) and is stamped onto new documents (e.g. stock movements), but reads are not auto-filtered by branch today. TheGqlBranchGuard/ApBranchAuthdecorator (branch/guards,branch/decorators) can still enforcebranchIdRequired(throws "Please select active branch") and setincludeBranchQuery, but the latter has no read effect while the repo block is commented.
Mental model: company scoping is automatic and enforced; branch scoping is "stamp on write, filter on demand" and the demand path is currently dormant. Stock on-hand is read per-branch by passing
branchIdexplicitly in the query (see inventory docs), not by the auto-filter.
4.2 Company lifecycle
Create (company.service.ts → create() → seedHq()):
- Enforce tenant company limit:
tenantConfigSvc.getMaxCompanies(); throw ifcount >= max. - Validate fiscal-year dates (
validateViscalYearDates— start ≠ end). - Reject duplicate
registrationNoand duplicatename. - Default
enabledModulestodefaultModulesif absent. seedHq()runs insidewithRetryTransaction("seed_company_hq"):- create the company row (
ignoreCompanyId:true), contextSvc.setCompany({_id})so subsequent seeds scope to it,permissionSvc.seed(),- in parallel: create HQ branch
"<name> - HQ", create config, find Admin access group, - create-or-link the admin user (
employeeSvc.create/invite) +accountSvc.seed()(chart of accounts) + invite the context user, approvalPolicySvc.seedDefaults().
- create the company row (
- If fiscal-year dates present →
fiscalPeriodSvc.seedFiscalPeriods(start, end, companyId).
Update — toggles ignoreCompanyQuery:true, uploads logo/letterhead files if provided, re-seeds fiscal periods if year dates change, then writes. Always restores ignoreCompanyQuery:false (even on error).
Archive / Unarchive (archiveCompany/unarchiveCompany, SuperAdmin only): flips status to ARCHIVED/ACTIVE via raw collection update, writes an audit_logs entry, and on archive invalidates all tokens of the company's employees (force logout). An archived company cannot be switched into (switch() throws).
Delete (hard cascade) (deleteCompanyWithCascade, SuperAdmin only): requires confirmationName === company.name. Writes an audit log, identifies exclusive users (employees belonging only to this company), then deleteMany across every collection in COMPANY_SCOPED_COLLECTIONS (filter {companyId}, except companies → {_id}), then deletes exclusive users + their tokens. Optionally wrapped in a Mongo transaction when process.env.mongdb_transaction_enabled === "true"; aborts + rolls back on any error. Returns {success, message, deletedCollections, totalRecordsDeleted}.
State machine: ACTIVE ⇄ ARCHIVED (archive/unarchive) · ACTIVE|ARCHIVED → (deleted) (hard cascade).
4.3 Company → branch → fiscal relationship
companies (parentId → companies) ← group / consolidation tree
│ 1
│ N
stores (companyId) ← branches / warehouses (HQ auto-created)
│
└─ branchId stamped onto orders, stocks, stock_transfers, … (write-time)
companies.fiscalYearStartDate/EndDate ──seed──▶ fiscal_periods (companyId) → see fiscal-period.md
4.4 Switch company / switch branch
- switchCompany (
company.service.ts → switch()): verifies the company exists and is notARCHIVED, then returns{ stores: branchSvc.find({companyId}), auth: authSvc.newToken({userId, companyId}) }. The new JWT now carries thatcompanyId. - switchBranch (
branch.service.ts → switchBranch()): finds the branch, returns a fresh token withactiveBranchId+companyId. Drives thebranchIdstamped on subsequent writes.
5. Permissions
- All company/branch resolvers sit under class-level
@ApGqlAuthorize. Company read ops addignoreCompanyQuery:true(cross-company) and someauthNotRequired:true. - SuperAdmin-only mutations:
deleteCompany,archiveCompany,unarchiveCompany(roles:["SuperAdmin"]). Service double-checkscontextSvc.user.kind === UserKindTypes.SuperAdmin. - Branch admin is gated behind the
STORE_MAINTENANCEpermission module (admin side; see platform/permissions-access.md). GqlBranchGuard(branch/guards/gql-branch.guard.ts) optionally enforces "active branch required" via theApBranchAuth({branchIdRequired})decorator.- Every mutation is
@AuditMeta-tagged (modulecompany/branch/fiscal) → audit trail.
6. Flows
6.1 Create a company (admin → DB)
- SuperAdmin opens
/companies(or the "New Company" modal on/select-company) →Create2.tsxform (name, industry, admin email/password;FormSchemaYup). saveCompany()(no id) →createCompanymutation (CreateCompanyInput).- Service runs limit check → dedupe →
seedHq()(company + HQ branch + admin + permissions + accounts + config + approval policies) → seeds fiscal periods. - Resolver returns
Company; context prepends it tocompaniesand toasts "Company Created".
6.2 Select / switch company (login → workspace)
- After auth,
/select-company(getServerSideProps) callsfetchUserCompanies(token). - Single company + non-super + not forced → auto
switchCompanyAsyncand redirect to module home (or/select-module). - Otherwise render
selection.tsxcard list (search, archived sorted last, sub-count badge). "Sign In" →switchCompany(id, redirect)→ updates the next-auth session with newauth, refetches company, switches into the first store, routes to/(or/subsidiaries/balance-sheetwhen subsidiaries exist).
6.3 Manage enabled modules
ModuleManagement.tsxlistsCORE_MODULESasApSwitchInputtoggles, with a master "enable all".- Save →
query.updateCompanyModules({companyId, enabledModules})→updateCompanyModulesmutation → service writesenabledModules.
6.4 Branch (store) CRUD
store/page.tsx/store/context.tsx:storePage→branchPage;createStore(name, managerId)→createBranch;updateStore→updateBranch;deleteStore→deleteBranch;switchBranch(branchId)→switchBranchthensession.update+ page reload.
Unhappy paths: archived company switch → error toast; delete without exact name match → "confirmation does not match"; duplicate registration/name on create → error; non-SuperAdmin delete/archive → "Only SuperAdmin…".
7. Admin UI
- Pages:
/companies(pages/companies.tsx, SuperAdmin-gated SSR → redirect/otherwise) rendersCompaniesPageinSetupLayout./select-company(pages/select-company.tsx) renders theselection.tsxchooser inPublicLayout./company+/company/setupfor detail/setup. There is no/subsidiaries.tsxpage (the subsidiaries view is reached via the post-switch redirect/subsidiaries/balance-sheet). - Module:
company/{context.tsx, page.tsx, detail.tsx, selection.tsx, model.ts, module-config.ts, gql/{query,fragment}.ts, components/, setup/}. - Context methods (
useCompanyState):companyPage,findCompany,saveCompany(create-or-update),currentCompanyUser,fetchUserCompanies,switchCompany,deleteCompany,archiveCompany,unarchiveCompany, plusuploadFile/deleteFile. State:company,companies,loading,modal. - Components:
Create2.tsx(create form),CompanyInfo.tsx,ModuleManagement.tsx(module toggles),deleteCompany.tsx(DeleteCompanyConfirmation— type-name-to-confirm),setup/info.tsx(onboarding wizard with industry/admin Yup schema). - Store module (
store/):page.tsx,context.tsx(useStoreState),components/select.tsx(branch picker), GraphQLbranchPage/createBranch/updateBranch/deleteBranch/switchBranch. UX labels everything "Branch" despite thestorefilename.
8. Dependencies & integrations
CompanyModule wires (all forwardRef): Auth, User, Employee (admin invite/link), Branch (HQ seed), Master (industry/currency), FileUpload (logo/letterhead), AuditLog, Permission + AccessGroup (seed), Config, Account (chart-of-accounts seed), FiscalPeriod (period seed), AccountTransaction (credits/debits resolve), Token (archive logout), HrApprovalPolicy (default policies). It also reads TenantConfigService for the company limit.
BranchModule → Auth (token on switch), Company, User, Report (branch report). FiscalPeriodModule → Company, Auth, Account, User, Config, AuditLog.
9. Gotchas & project-specific rules
- store = branch = warehouse. The collection is
stores, the BE entity isBranch, the admin module folder isstore, and the UI says "Branch". Don't add a separate "store" concept. - Branch read-filtering is dormant.
branchMatch()/includeBranchQueryinjection are commented out indatabase.repository.ts. Branch is stamped on writes and queried explicitly (e.g. stock balance), but list endpoints are not branch-scoped automatically. Re-enabling requires uncommenting that block. - Two module lists exist: BE
defaultModules(28, seed default) vs adminCORE_MODULES(32, full toggle catalogue). The extra 4+ keys (sub-items,clients-sub-accounts,manufacturing,multi-pricing,multi-uom,projects) are opt-in only. - Company queries deliberately bypass company scoping (
ignoreCompanyQuery:true) — needed so a user can list/select across the companies they belong to. - Hard delete is irreversible and confirmation-gated by exact name; prefer archive (reversible, force-logs-out users). Both are SuperAdmin-only.
- Company is the tenant's accounting unit, not the tenant. True multi-tenant DB isolation is a platform concern — see platform/multi-tenancy.md. Within one tenant DB,
companyIdis the scoping key. - Fiscal periods are auto-seeded from
fiscalYearStartDate/EndDateon company create/update — see fiscal-period.md.