Category — the chart-of-accounts tree (account categories)
The whole category model reduces to one idea:
An
AccountCategoryis a node of the chart-of-accounts tree, and itstypeis the single source of every account's normal balance. Categories are arranged in aparentIdhierarchy. Each carries anAccountType(type) — which decides debit-vs-credit normal balance via theACCOUNT_TYPEStable — areportSection(which P&L / balance-sheet bucket it rolls into), and acashFlowAdjustmentflag. Accounts point at a category viacategoryId; 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→$graphLookupdescendants /buildNodenesting). - Mapping a category to its normal balance (
getType→ACCOUNT_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_categories — AccountCategory (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:
bankAccountCategorySummaryresolver returns{ totalRecords: this.categorySvc.count({}) }—countreturns a Promise that is not awaited here, so the field resolves to a pending promise /0. (Flagged: likely a latent bug; the admin readstotalRecordsoff thebankAccountCategoryNodesquery 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)
- Unique name per branch —
validateTyperejects an existing category with the samename(andbranchId); throwsAccount category already exist(406 NOT_ACCEPTABLE). - Inherit from parent — if
parentIdis set, the child'stypeis forced to the parent'stype, and if noreportSectionwas supplied it inherits the parent's. This keeps a subtree homogeneous in account-type (you cannot mix an EXPENSE child under an ASSET parent). - 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/pagejoin the owningcompany($lookupCompany) and build a query viabuildQuery(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 byreportSection(+ optional company / cashFlowAdjustment / parent-company), used by the report layer.
Chart-of-accounts roll-up (AccountCategoryService.chartOfAccounts)
Powers bankAccountCategoryNodes:
find({ canView: true })— all visible categories (flat).accountSvc.balancesByCategoryBatch(categories)— one batched aggregation over the ledger keyed bycategoryId, returning per-category{ debits, credits, balance }wherebalance = type.credit==="INCREASE" ? credits−debits : debits−credits(theACCOUNT_TYPESrule).buildNode(null, categories+balance)— nests the flat list into a tree byparentId(depth-limited to 6,src/core/util.ts).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.seed ← AccountService.seed)
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 carriesname,type,reportSection,canView, and optionalcashFlowAdjustment/isContra/isAccount.Existing categories are pre-fetched into a
Mapby name (idempotency — no duplicates).seedTyperecurses: create the node if missing (canDelete:false,canUpdate:false,reportSection,cashFlowAdjustment, company), then for each child either:- if
child.isAccount→createAccount(delegates toAccountService.createwithvalidateByName:true, propagatingisContra/isNonCash/canView), or - else → recurse
seedTypewithparentIdset 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 BalancingunderBalancing Accounts(type:"BALANCE",isAccount:true,canView:false).- if
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)
- Admin
Account Categorypage (/finance/categories, guardedaccount-category/view) →findCategoryNode()(context) →bankAccountCategoryNodes+bankAccountCategorySummary. - Resolver
nodes()→categorySvc.chartOfAccounts()→ flat categories + batched ledger balances →buildNodetree → recursivemapBalanceroll-up. - Admin renders an expandable
AccountCategoryTable(default expand all) with Name (upper-cased), Type, Debits, Credits, Balance per node (helper.toCurrency). The fragment fetcheschildrennested 5 levels deep.
6.2 Create / edit a category or sub-category
- Admin clicks edit (only if
canUpdate) → modal inupdatemode, or the green+on a row → modal inaddmode withparentset. CreateAccountCategory(components/create.tsx): Formik,FormSchemarequires onlyname. Fields:name,Report Section(REPORT_SECTION_OPTIONSselect).parentIdtaken fromparent?._id;cashflowCategory/reportSectionmapped to.value.- Submit →
saveCategory(id, payload)→createAccountCategory/updateAccountCategory. - Service
create:validateType(unique name per branch) → ifparentId, force childtype= parent type (+ inheritreportSection) → persist. - Context
toasts and refetchesfindCategoryNode().
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(guardedaccount-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),saveCategory→createBankCategory/updateBankCategory,deleteCategory; state:categories(tree),totalRecords,filter(keyword),loading. Every mutation refetchesfindCategoryNode(). - Table (
components/table.tsx): expandable tree (defaultExpandAllRows), columns Name (upper-cased), Type, Debits, Credits, Balance. Row actions: edit (ifcanUpdate), add-sub-category (green+→ modaladdwithparent=record). No pagination (tree view). - UX: the category lookup tree is also reused by the account create form (
ApLookupInputfed byuseBankCategoryState().categories) for picking an account's category, and by the import confirm grid.
8. Dependencies & integrations
- account (
AccountService) —balancesByCategoryBatch(category balance roll-up),getType/getTypeAndCategoryread category type, auto-child accounts look upAccumulated Depreciation/Accumulated Amortizationcategories by name, COA seed createsisAccountleaves 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 viachartOfAccount. - report module (finance-ops) — consumes
findByReportSectionandreportSection/typeto build trial balance, P&L, balance sheet, cash flow. See report. - CompanyService —
$lookupCompanyjoin +parentCompanyIdfilter for group reporting. - No events / cron. Seeding runs at company bootstrap (
AccountService.seed → categorySvc.seed).
9. Gotchas & project-specific rules
typeis the single source of normal balance — change a category'stypeand 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
typeis overwritten with the parent's; you cannot create an EXPENSE node under an ASSET parent through the API. typeis a free string, not the GraphQL enum. The schema/DTO storetype: string; the seed uses"BALANCE"(not inAccountType).getTypereturnsundefinedforBALANCE/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
bankAccountCategoryNodesqueries. bankAccountCategorySummarytotalRecords is unreliable — the resolver doesn't awaitcount(); the admin works around it by readingtotalRecordsfrom 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 failgetType("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_EXPENSEreport sections exist in the enum but are not assigned by the default seed; assign them manually if those report lines are needed.