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 optionally branchId) 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 (collection stores, GraphQL branchPage/createBranch/…). The admin module is named store for legacy reasons and the UI labels it "Branch". Permission key is STORE_MAINTENANCE. Treat "store", "branch" and "warehouse" as the same thing throughout this doc — see branch.dto.ts note and admin store/model.ts header 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 an ACTIVE/ARCHIVED lifecycle. 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 + optional managerId. Used as the branchId carried 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 companiescompany/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. companyId on a company row is effectively self/parent context and is ignored in most queries (ignoreCompanyId is forced on; see §4.1).

// company/company.interface.ts
export enum CompanyStatus {
  ACTIVE = "ACTIVE",
  ARCHIVED = "ARCHIVED"
}

2.2 storesbranch/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).

RESTbranch/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 (_mapDataresolveCompanyId/resolveBranchId, lines ~213-230 / 357-367): every create stamps companyId = payload.companyId || contextSvc.companyId and branchId = payload.branchId || contextSvc.branchId. A branchId of "*" or "" clears it (company-wide row).
  • On read (aggregations) companyMatch() injects { companyId: <context companyId> } into every $match stage that precedes a $group, unless ignoreCompanyId is set or a companyId match already exists. This is why every page/find is silently company-scoped.
  • ignoreCompanyId getter = contextSvc.ignoreCompanyQuery || local flag. Company queries set ignoreCompanyQuery:true so they can read across companies (resolvers use @ApGqlAuthorize({ ignoreCompanyQuery:true })).
  • Branch-level filtering is currently DISABLED in code. branchMatch() and the includeBranchQuery branch-injection block are fully commented out in database.repository.ts. The activeBranchId lives on the JWT (set by switchBranch) and is stamped onto new documents (e.g. stock movements), but reads are not auto-filtered by branch today. The GqlBranchGuard / ApBranchAuth decorator (branch/guards, branch/decorators) can still enforce branchIdRequired (throws "Please select active branch") and set includeBranchQuery, 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 branchId explicitly in the query (see inventory docs), not by the auto-filter.

4.2 Company lifecycle

Create (company.service.ts → create()seedHq()):

  1. Enforce tenant company limit: tenantConfigSvc.getMaxCompanies(); throw if count >= max.
  2. Validate fiscal-year dates (validateViscalYearDates — start ≠ end).
  3. Reject duplicate registrationNo and duplicate name.
  4. Default enabledModules to defaultModules if absent.
  5. seedHq() runs inside withRetryTransaction("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().
  6. 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 not ARCHIVED, then returns { stores: branchSvc.find({companyId}), auth: authSvc.newToken({userId, companyId}) }. The new JWT now carries that companyId.
  • switchBranch (branch.service.ts → switchBranch()): finds the branch, returns a fresh token with activeBranchId + companyId. Drives the branchId stamped on subsequent writes.

5. Permissions

  • All company/branch resolvers sit under class-level @ApGqlAuthorize. Company read ops add ignoreCompanyQuery:true (cross-company) and some authNotRequired:true.
  • SuperAdmin-only mutations: deleteCompany, archiveCompany, unarchiveCompany (roles:["SuperAdmin"]). Service double-checks contextSvc.user.kind === UserKindTypes.SuperAdmin.
  • Branch admin is gated behind the STORE_MAINTENANCE permission module (admin side; see platform/permissions-access.md).
  • GqlBranchGuard (branch/guards/gql-branch.guard.ts) optionally enforces "active branch required" via the ApBranchAuth({branchIdRequired}) decorator.
  • Every mutation is @AuditMeta-tagged (module company/branch/fiscal) → audit trail.

6. Flows

6.1 Create a company (admin → DB)

  1. SuperAdmin opens /companies (or the "New Company" modal on /select-company) → Create2.tsx form (name, industry, admin email/password; FormSchema Yup).
  2. saveCompany() (no id) → createCompany mutation (CreateCompanyInput).
  3. Service runs limit check → dedupe → seedHq() (company + HQ branch + admin + permissions + accounts + config + approval policies) → seeds fiscal periods.
  4. Resolver returns Company; context prepends it to companies and toasts "Company Created".

6.2 Select / switch company (login → workspace)

  1. After auth, /select-company (getServerSideProps) calls fetchUserCompanies(token).
  2. Single company + non-super + not forced → auto switchCompanyAsync and redirect to module home (or /select-module).
  3. Otherwise render selection.tsx card list (search, archived sorted last, sub-count badge). "Sign In" → switchCompany(id, redirect) → updates the next-auth session with new auth, refetches company, switches into the first store, routes to / (or /subsidiaries/balance-sheet when subsidiaries exist).

6.3 Manage enabled modules

  1. ModuleManagement.tsx lists CORE_MODULES as ApSwitchInput toggles, with a master "enable all".
  2. Save → query.updateCompanyModules({companyId, enabledModules})updateCompanyModules mutation → service writes enabledModules.

6.4 Branch (store) CRUD

  • store/page.tsx / store/context.tsx: storePagebranchPage; createStore(name, managerId)createBranch; updateStoreupdateBranch; deleteStoredeleteBranch; switchBranch(branchId)switchBranch then session.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) renders CompaniesPage in SetupLayout. /select-company (pages/select-company.tsx) renders the selection.tsx chooser in PublicLayout. /company + /company/setup for detail/setup. There is no /subsidiaries.tsx page (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, plus uploadFile/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), GraphQL branchPage/createBranch/updateBranch/deleteBranch/switchBranch. UX labels everything "Branch" despite the store filename.

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 is Branch, the admin module folder is store, and the UI says "Branch". Don't add a separate "store" concept.
  • Branch read-filtering is dormant. branchMatch() / includeBranchQuery injection are commented out in database.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 admin CORE_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, companyId is the scoping key.
  • Fiscal periods are auto-seeded from fiscalYearStartDate/EndDate on company create/update — see fiscal-period.md.