Category — the chart-of-accounts tree (account categories)

The whole category model reduces to one idea:

An AccountCategory is a node of the chart-of-accounts tree, and its type is the single source of every account's normal balance. Categories are arranged in a parentId hierarchy. Each carries an AccountType (type) — which decides debit-vs-credit normal balance via the ACCOUNT_TYPES table — a reportSection (which P&L / balance-sheet bucket it rolls into), and a cashFlowAdjustment flag. Accounts point at a category via categoryId; everything an account "is" (asset/liability/income/…, which report it shows on) is inherited from its category. See the domain overview §1.

Source: BE finance/category/* + finance/finance.model.ts + finance/finance.seed.ts · Admin finance/category/* + src/pages/finance/categories.tsx


1. Purpose & scope

The category module owns the chart-of-accounts structure:

  • CRUD of category nodes (name, type, parent, report section, cash-flow flag).
  • The hierarchical tree (parentId$graphLookup descendants / buildNode nesting).
  • Mapping a category to its normal balance (getTypeACCOUNT_TYPES) and to its report section (reportSection).
  • Computing rolled-up category balances (sum of child + own account balances) for the chart-of-accounts view.
  • Seeding a default chart of accounts per company from FinanceAccountSeed.getAccountTypes().

It explicitly does NOT:

  • Hold any postings (no ledger legs) — categories are a dimension that legs reference via the account → category chain. Balances shown on categories are aggregations over transaction.
  • Define postable accounts — that is account. (The seed can create accounts under a category for leaf nodes flagged isAccount, but the account itself lives in the account module.)

2. Data model

finance_account_categoriesAccountCategory (finance/category/category.schema.ts)

One row = one chart-of-accounts node, scoped to a company, arranged in a parentId tree.

field type required? description
name string yes category name (e.g. Current Assets, Accounts Receivable). Unique-per-branch enforced in service.
type string yes the AccountType value (ASSET/LIABILITY/EQUITY/INCOME/EXPENSE/INVENTORY — also BALANCE/NONE from the seed). Drives normal balance. Inherited from parent on create.
parentId ObjectId no self-ref to the parent category. Root nodes have none. Coerced via BaseSchema.toObjectId.
description string no free text.
reportSection ReportSection enum no, default NONE which financial-statement bucket this node rolls into (see §2 enums). Inherited from parent if not set.
cashFlowAdjustment boolean no, default false flags a non-cash P&L adjustment line (e.g. depreciation, gain/loss on disposal) for the cash-flow statement.
canView boolean no, default true hidden from listings when false (e.g. balancing accounts).
canUpdate boolean no, default false seeded categories set false → not editable in the UI.
canDelete boolean no, default false seeded categories set false → not deletable in the UI.
isContra boolean no, default false contra category (e.g. Accumulated Depreciation, Dividends Declared, Treasury Stock). Propagated to seeded accounts.
isAccount boolean no, default false seed-only marker: a leaf in the seed tree that should materialise as an Account, not a sub-category.
isNonCash boolean no, default false non-cash flag, propagated to seeded accounts.

BaseSchema inherited fields: _id, key, companyId, branchId, documentCode, documentDate, createdAt/createdBy, updatedAt/updatedBy, canPost. Soft-delete via mongoose-delete. Tenant-scoped by companyId.

Non-persisted / transient: children (built via $graphLookup/buildNode), parentCompanyId (filter), balance: { balance, debits, credits } (computed roll-up).

// category.schema.ts
@ApSchema({ collection: "finance_account_categories", timestamps: true })
export class AccountCategory extends BaseSchema {
  @Prop({ required: true }) name: string;
  @Prop({ required: true }) type: string;                 // AccountType value
  @Prop(...)               parentId: Types.ObjectId;
  @Prop({ type: String, enum: ReportSection, default: ReportSection.NONE }) reportSection: ReportSection;
  @Prop({ default: false }) cashFlowAdjustment: boolean;
  @Prop({ default: true })  canView: boolean;
  @Prop({ default: false }) canUpdate / canDelete / isContra / isAccount / isNonCash;
  children: AccountCategory[];
  balance: { balance: number; debits: number; credits: number };
}

Enums (finance/finance.model.ts)

export enum AccountType { NONE, ASSET, LIABILITY, EQUITY, INCOME, EXPENSE, INVENTORY }
// (the seed also uses the string "BALANCE" for the hidden Balancing Accounts node)

export enum CashFlowCategory { INVESTING, FINANCING, OPERATING, UNCLASSIFIED }

export enum ReportSection {
  // Balance Sheet — Assets
  BS_CURRENT_ASSET, BS_FIXED_ASSET,
  // Balance Sheet — Liabilities
  BS_CURRENT_LIABILITY, BS_NON_CURRENT_LIABILITY,
  // Balance Sheet — Equity
  BS_EQUITY,
  // P&L — Income
  PNL_REVENUE, PNL_SALES_ADJUSTMENTS, PNL_OTHER_INCOME,
  // P&L — Expenses
  PNL_COST_OF_SALES, PNL_OPERATING_EXPENSE, PNL_INTEREST_EXPENSE, PNL_TAX_EXPENSE,
  // Special
  BANK_AND_CASH, NONE
}

The normal-balance table — ACCOUNT_TYPES (finance/finance.model.ts)

This is the heart of the whole accounting model: type → debit/credit direction.

export interface IAccountType { type: string; credit: "INCREASE"|"DECREASE"; debit: "INCREASE"|"DECREASE"; }

export const ACCOUNT_TYPES: IAccountType[] = [
  { type: ASSET,     debit: "INCREASE", credit: "DECREASE" },  // balance = debits − credits
  { type: LIABILITY, debit: "DECREASE", credit: "INCREASE" },  // balance = credits − debits
  { type: EQUITY,    debit: "DECREASE", credit: "INCREASE" },  // balance = credits − debits
  { type: INCOME,    debit: "DECREASE", credit: "INCREASE" },  // balance = credits − debits
  { type: EXPENSE,   debit: "INCREASE", credit: "DECREASE" },  // balance = debits − credits
  { type: INVENTORY, debit: "INCREASE", credit: "DECREASE" },  // balance = debits − credits
];
Type Debit Credit Normal balance Report side
ASSET INCREASE DECREASE debits − credits Balance Sheet
LIABILITY DECREASE INCREASE credits − debits Balance Sheet
EQUITY DECREASE INCREASE credits − debits Balance Sheet
INCOME DECREASE INCREASE credits − debits P&L
EXPENSE INCREASE DECREASE debits − credits P&L
INVENTORY INCREASE DECREASE debits − credits Balance Sheet (asset-like)

getType(categoryId) returns the matching IAccountType; mapBalance (in account) applies it: credit === "INCREASE" ? credits − debits : debits − credits. Categories of type NONE/BALANCE have no ACCOUNT_TYPES entry → getType returns undefined → balance 0.

Report-section grouping helpers (finance.model.ts)

getAccountClassificationByGroup(group) maps a free-text group string (e.g. "CURRENT ASSETS", "SALES REVENUE") to an AccountType, defaulting to ASSET. REPORTING_ACCOUNT_MAPPING lists which account names belong to PROFIT_AND_LOSS vs BALANCE_SHEET (used by reporting, not by the category CRUD). These are reference tables for the report layer.


3. API surface

GraphQL (category.resolver.ts, @ApGqlAuthorize(), extends ApBaseResolver<AccountCategory>)

Operation Type Input Returns Permission (audit)
findAccountCategories query AccountCategoryQueryInput [AccountCategory] @ApGqlAuthorize
findOneAccountCategory query AccountCategoryQueryInput AccountCategory @ApGqlAuthorize
bankAccountCategoryNodes query [AccountCategory] (nested tree with rolled-up balances) @ApGqlAuthorize
bankAccountCategorySummary query AccountCategorySummary { totalRecords } @ApGqlAuthorize
createAccountCategory mutation CreateAccountCategoryInput (arg bankAccountCategory) AccountCategory audit category / finance_account_categories / CREATE
updateAccountCategory mutation _id, UpdateAccountCategoryInput Boolean audit UPDATE
deleteAccountCategory mutation _id Boolean audit DELETE

CommonAccountCategoryInput (and thus Create/Update/Query): name, parentId, type, reportSection?, cashFlowAdjustment?. Update/Query are PartialType of it.

Note: bankAccountCategorySummary resolver returns { totalRecords: this.categorySvc.count({}) }count returns a Promise that is not awaited here, so the field resolves to a pending promise / 0. (Flagged: likely a latent bug; the admin reads totalRecords off the bankAccountCategoryNodes query response instead, where it works.)

There is no REST controller in the category module. Category-level reporting XLSX is served by the account controller (api/account/chart-of-account/download) — see account §3.

AccountCategory GraphQL type (schema.gql)

Includes BaseDto fields + name!, type!, parentId, balance: AccountBalance, children: [AccountCategory], reportSection: ReportSection, cashFlowAdjustment.


4. Business rules & calculations

Create (AccountCategoryService.create)

  1. Unique name per branchvalidateType rejects an existing category with the same name (and branchId); throws Account category already exist (406 NOT_ACCEPTABLE).
  2. Inherit from parent — if parentId is set, the child's type is forced to the parent's type, and if no reportSection was supplied it inherits the parent's. This keeps a subtree homogeneous in account-type (you cannot mix an EXPENSE child under an ASSET parent).
  3. Persist via categoryRepo.create.

(There is no service-level circular-parent guard here — the account module has one for accounts, but category create only copies the parent's type. Flagged as an asymmetry.)

Hierarchy reads (category.repository.ts)

  • find / findOne / page join the owning company ($lookupCompany) and build a query via buildQuery (name exact-match ^name$ i, canView, parentCompanyId, schema keys).
  • findNodes$graphLookup (connectFromField:_id, connectToField:parentId, maxDepth:5) to fetch a node + its descendant ids (flat projection).
  • descendants(query, includeParent?)$graphLookup (unbounded depth) returning all descendants of the matched node, optionally prepending the parent.
  • findByReportSection(section, companyId?, parentCompanyId?, cashFlowAdjustment?) — match by reportSection (+ optional company / cashFlowAdjustment / parent-company), used by the report layer.

Chart-of-accounts roll-up (AccountCategoryService.chartOfAccounts)

Powers bankAccountCategoryNodes:

  1. find({ canView: true }) — all visible categories (flat).
  2. accountSvc.balancesByCategoryBatch(categories) — one batched aggregation over the ledger keyed by categoryId, returning per-category { debits, credits, balance } where balance = type.credit==="INCREASE" ? credits−debits : debits−credits (the ACCOUNT_TYPES rule).
  3. buildNode(null, categories+balance) — nests the flat list into a tree by parentId (depth-limited to 6, src/core/util.ts).
  4. mapBalance(category) recursively rolls up balances: a leaf returns its own { balance, debits, credits }; a parent returns the sum of its children's rolled-up totals.

So a parent category's displayed balance = Σ of all descendant account balances; a leaf's = its own accounts' aggregated balance. Nothing is stored — it is recomputed each call.

Seeding (AccountCategoryService.seedAccountService.seed)

  1. FinanceAccountSeed.getAccountTypes() returns the canonical COA tree (Assets → Current Assets → Bank and Cash / Accounts Receivable / Inventory / …; Liabilities; Equity; Income; Other Income; Expenses; Other Expenses; Balancing Accounts). Each node carries name, type, reportSection, canView, and optional cashFlowAdjustment / isContra / isAccount.

  2. Existing categories are pre-fetched into a Map by name (idempotency — no duplicates).

  3. seedType recurses: create the node if missing (canDelete:false, canUpdate:false, reportSection, cashFlowAdjustment, company), then for each child either:

    • if child.isAccountcreateAccount (delegates to AccountService.create with validateByName:true, propagating isContra/isNonCash/canView), or
    • else → recurse seedType with parentId set to the created node.

    Example seeded contra/cash-flow nodes: Accumulated Depreciation (BS_FIXED_ASSET, cashFlowAdjustment:true, isContra:true), Gain/Loss on Asset Disposal (cashFlowAdjustment:true), Dividends Declared / Treasury Stock (isContra:true), Exchange Balancing under Balancing Accounts (type:"BALANCE", isAccount:true, canView:false).

Report-section mapping (seed defaults)

Category (seed) type reportSection
Assets (root) ASSET NONE
Current Assets ASSET BS_CURRENT_ASSET
Bank and Cash ASSET BANK_AND_CASH
Accounts Receivable / Inventory / Other Current Assets / Prepaid Expenses / Loans and Advances ASSET BS_CURRENT_ASSET
Non-Current Assets / PP&E / Intangible Assets / Accumulated Depreciation / Accumulated Amortization / Long-Term Investments / Long-Term Loans Receivable / Other Assets ASSET BS_FIXED_ASSET
Current Liabilities (+ Accounts Payable, Taxes Payable, Dividends Payable, Short-Term Loans, Other Current Liabilities) LIABILITY BS_CURRENT_LIABILITY
Non-Current Liabilities (+ Long-Term Loans Payable, Bonds Payable, Notes Payable) LIABILITY BS_NON_CURRENT_LIABILITY
Equity (+ Owner's Capital, APIC, Retained Earnings, Issued Stock, Dividends Declared, Treasury Stock) EQUITY BS_EQUITY
Income / Sales Revenue / Service Revenue INCOME PNL_REVENUE
Other Income / Gain on Asset Disposal INCOME PNL_OTHER_INCOME
Cost of Sales EXPENSE PNL_COST_OF_SALES
Expenses / Salaries / Depreciation / Amortization / General & Admin / Loss on Disposal / Dividends Expense / Professional Fees / Exchange Loss / Other Expenses EXPENSE PNL_OPERATING_EXPENSE
Interest Paid EXPENSE PNL_INTEREST_EXPENSE
Balancing Accounts / Exchange Balancing BALANCE NONE (canView:false)

(Source: finance/finance.seed.ts getAccountTypes(). PNL_SALES_ADJUSTMENTS and PNL_TAX_EXPENSE sections exist in the enum but are not assigned by the default seed — flagged for awareness.)


5. Permissions

Module value actions
ACCOUNT_CATEGORY account-category view, create, update, delete

(zerp-admin/src/constants/UserAccess.ts.) All resolvers are @ApGqlAuthorize(); mutations carry @AuditMeta({ module:'category', collection:'finance_account_categories', snapshots:[…] }). Page guard categories.tsx requires account-category/view. Seeded categories set canUpdate:false / canDelete:false, so the UI only exposes edit/add-sub for user-created nodes. Full RBAC: ../../platform/permissions-access.md.


6. Flows

6.1 View the chart of accounts (admin → DB)

  1. Admin Account Category page (/finance/categories, guarded account-category/view) → findCategoryNode() (context) → bankAccountCategoryNodes + bankAccountCategorySummary.
  2. Resolver nodes()categorySvc.chartOfAccounts() → flat categories + batched ledger balances → buildNode tree → recursive mapBalance roll-up.
  3. Admin renders an expandable AccountCategoryTable (default expand all) with Name (upper-cased), Type, Debits, Credits, Balance per node (helper.toCurrency). The fragment fetches children nested 5 levels deep.

6.2 Create / edit a category or sub-category

  1. Admin clicks edit (only if canUpdate) → modal in update mode, or the green + on a row → modal in add mode with parent set.
  2. CreateAccountCategory (components/create.tsx): Formik, FormSchema requires only name. Fields: name, Report Section (REPORT_SECTION_OPTIONS select). parentId taken from parent?._id; cashflowCategory/reportSection mapped to .value.
  3. Submit → saveCategory(id, payload)createAccountCategory / updateAccountCategory.
  4. Service create: validateType (unique name per branch) → if parentId, force child type = parent type (+ inherit reportSection) → persist.
  5. Context toasts and refetches findCategoryNode().

Unhappy path: duplicate name → 406 Account category already exist.

6.3 Delete

deleteAccountCategory(_id)categorySvc.delete (soft delete). UI gates on canDelete (false for seeded categories). No referential-integrity guard against accounts still pointing at the category — deleting an in-use category orphans those accounts' categoryId (flagged).


7. Admin UI

  • Route: src/pages/finance/categories.tsx (guarded account-category/view).
  • Module: src/modules/finance/category/page.tsx (AccountCategoryPage), context.tsx (BankCategoryContextProvider / useBankCategoryState), gql/{query,fragment}.ts, model.ts, components/{table,create}.tsx.
  • Context methods (useBankCategoryState): findCategoryNode (loads the tree + total), saveCategorycreateBankCategory / updateBankCategory, deleteCategory; state: categories (tree), totalRecords, filter (keyword), loading. Every mutation refetches findCategoryNode().
  • Table (components/table.tsx): expandable tree (defaultExpandAllRows), columns Name (upper-cased), Type, Debits, Credits, Balance. Row actions: edit (if canUpdate), add-sub-category (green + → modal add with parent=record). No pagination (tree view).
  • UX: the category lookup tree is also reused by the account create form (ApLookupInput fed by useBankCategoryState().categories) for picking an account's category, and by the import confirm grid.

8. Dependencies & integrations

  • account (AccountService) — balancesByCategoryBatch (category balance roll-up), getType/getTypeAndCategory read category type, auto-child accounts look up Accumulated Depreciation/Accumulated Amortization categories by name, COA seed creates isAccount leaves as accounts.
  • transaction — provides the ledger aggregation behind category balances (totalDrAnCrByCategoryBatch).
  • FinanceAccountSeed (finance/finance.seed.ts) — getAccountTypes() is the canonical COA tree; getDefaultAccounts() (consumed by the account module) references category names via chartOfAccount.
  • report module (finance-ops) — consumes findByReportSection and reportSection/type to build trial balance, P&L, balance sheet, cash flow. See report.
  • CompanyService$lookupCompany join + parentCompanyId filter for group reporting.
  • No events / cron. Seeding runs at company bootstrap (AccountService.seed → categorySvc.seed).

9. Gotchas & project-specific rules

  • type is the single source of normal balance — change a category's type and every account under it flips debit/credit interpretation. There is no per-account override.
  • Child type is locked to parent. On create, a sub-category's type is overwritten with the parent's; you cannot create an EXPENSE node under an ASSET parent through the API.
  • type is a free string, not the GraphQL enum. The schema/DTO store type: string; the seed uses "BALANCE" (not in AccountType). getType returns undefined for BALANCE/NONE, yielding balance 0 — by design for the hidden balancing node.
  • Category balances are recomputed every call (roll-up over the ledger). Large charts → heavier bankAccountCategoryNodes queries.
  • bankAccountCategorySummary totalRecords is unreliable — the resolver doesn't await count(); the admin works around it by reading totalRecords from the nodes query response.
  • 5-level fragment / 6-level buildNode / 5-depth graphLookup — deeply nested charts beyond these depths will be truncated in the UI / API responses.
  • No delete guard — deleting a category does not check for accounts referencing it; orphaned categoryIds will fail getType ("category not mapped").
  • Seed idempotency is by category name within a company; renaming a seeded category then re-seeding will create a duplicate.
  • PNL_SALES_ADJUSTMENTS / PNL_TAX_EXPENSE report sections exist in the enum but are not assigned by the default seed; assign them manually if those report lines are needed.