Account — the chart of accounts (postable GL accounts)
The whole account model reduces to one idea:
An
Accountis a named, numbered node of the chart of accounts that ledger legs post against. It carries no balance of its own. Its normal balance, report section, and cash-flow class are all inherited from itscategoryId(AccountCategory) — the account is just the identity (name + number + currency + parent) that anAccountTransactionleg references viaaccountId. A balance is always derived:Σ(debits) − Σ(credits)(or the reverse, by the category'stype) aggregated over the ledger for a date range. See the domain overview §1 for the double-entry model.
Source: BE finance/account/* · Admin finance/account/* + src/pages/finance/accounts*, src/pages/finance/account/[_id].tsx
1. Purpose & scope
The account module owns the postable GL accounts of the chart of accounts:
- Create / update / delete accounts (name, number, currency, category, groups, description, bank name).
- Generate sequential account numbers.
- XLSX import (two-phase: preview → confirm) and XLSX/PDF export.
- Derive an account's balance over a date range from the GL ledger (debits, credits, balance, opening balance, closing balance) — read-only aggregation, never stored.
- Special account resolvers: debtor/creditor sub-ledger accounts (
getUserAccount), theExchange Balancingaccount, auto-created accumulated-depreciation / accumulated-amortization contra accounts. - Whole-book balance assertion (
validateBalanced) used by every posting flow. - Seed a default chart of accounts per company.
It explicitly does NOT:
- Store balances, post transactions, or own debit/credit legs — that is transaction.
- Define the chart-of-accounts tree structure / report sections — that is category. Account
type/reportSectionare read through the category.
2. Data model
finance_accounts — Account (finance/account/account.schema.ts)
One row = one postable GL account, scoped to a company.
| field | type | required? | description |
|---|---|---|---|
ref |
string | yes | generated document reference (generateRef() → sequential uniqueId). |
parentId |
ObjectId | no | self-ref to a parent account (e.g. an auto-created Acc.Depr/Acc.Amort child points at the asset account). Coerced via BaseSchema.toObjectId. |
accountName |
string | yes | display name. Indexed text; unique-by-name per company is enforced in the service. |
accountNumber |
string | yes | the account code. Auto-generated from the account_number unique-id sequence if not supplied. |
prefix |
string | no | first segment of accountNumber split on - (e.g. 100 from 100-080). Auto-derived on create. |
isAad |
boolean | no, default false |
marks an auto-created Accumulated-Amort/Depreciation child account. |
bankName |
string | no | bank name (for bank/cash accounts). |
description |
string | no | free text. |
groups |
string[] | no | tag groups (e.g. PAYMENT). Used for filtering / role exclusions. |
currencyId |
ObjectId | no¹ | FK → masters (currency master). currencyId is required in the GraphQL CreateAccountInput but not at the schema level. |
itemTypeId |
ObjectId | no | FK → item type (rarely used; present for item-linked accounts). |
categoryId |
ObjectId | no² | FK → finance_account_categories. Drives normal balance, report section. Not schema-required, but most flows reject an account without it. |
userId |
ObjectId | no | FK → users — set for debtor/creditor accounts linked to a Customer/Supplier. |
isNonCash |
boolean | no, default false |
inherited from category on seed; flags non-cash accounts (for cash-flow). |
isContra |
boolean | no, default false |
inherited from category; flags contra accounts (e.g. accumulated depreciation). |
¹ CreateAccountInput.currencyId is non-nullable in GraphQL; the Mongoose field is optional. ² Seeding and the create form make category effectively mandatory; getType/getTypeAndCategory throw if it is missing.
BaseSchema inherited fields (all five finance collections): _id, key, companyId, branchId, documentCode, documentDate, createdAt/createdBy, updatedAt/updatedBy, canView/canUpdate/canDelete/canPost. Soft-delete via mongoose-delete (deleted/deletedAt/deletedBy) — soft-deleted accounts drop out of every aggregation.
Indexes: accountName text index; compound { _type: 1, "$**": "text" }.
Non-persisted / transient fields carried on the document for queries and resolvers: category (joined via $lookupCategory), currency (joined via $lookupCurrency), categoryIds, excludeGroups, excludeAccountIds, validateByName, userKind, parentCompanyId, user, plus payeeId/payeeType/fromDate/toDate/cashflowCategory/ accountExists/duplicateError (on the GraphQL DTO).
Aggregation lookups (account.schema.ts)
$lookupCategory // finance_account_categories → category (unwound, preserve null)
$lookupCurrency // masters → currency (unwound, preserve null)
$combineLookups = [...$lookupCurrency, ...$lookupCategory, ...$lookupCompany]
$sortByAccountNumber // $toLong(accountNumber) → numeric ascending sort, then drop the temp fieldfind/findOne/page all run $combineLookups so category, currency, and company are joined inline.
Derived: AccountBalance (computed, never stored)
// account.dto.ts → AccountBalance (GraphQL ObjectType)
{ type, balance, debits, credits, openingBalance: AccountBalance, closingBalance: AccountBalance }balance is a GraphQL resolve-field; it is only computed when a date range is supplied (see §3).
3. API surface
GraphQL (account.resolver.ts, @ApGqlAuthorize(), resolver extends ApBaseResolver<Account>)
| Operation | Type | Input | Returns | Permission (audit) |
|---|---|---|---|---|
bankAccountPage |
query | AccountPageInput (skip/take/sortBy/sortOrder + filters) |
AccountPageResult { totalRecords, data:[Account] } |
@ApGqlAuthorize |
findAccounts |
query | AccountQueryInput |
[Account] |
@ApGqlAuthorize |
findOneAccount |
query | AccountQueryInput |
Account (nullable; fromDate/toDate forced null) |
@ApGqlAuthorize |
bankAccountsSummary |
query | AccountQueryInput |
AccountSummary { totalCredits, totalDebits } |
@ApGqlAuthorize |
createAccount |
mutation | CreateAccountInput (arg name bankAccount) |
Account |
audit account / finance_accounts / CREATE |
updateAccount |
mutation | _id, UpdateAccountInput (bankAccount) |
Boolean |
audit UPDATE |
deleteAccount |
mutation | _id |
Boolean |
audit DELETE |
deleteManyAccounts |
mutation | ids:[String] |
Boolean |
audit DELETE |
importAccount |
mutation | ImportAccountInput { file: Upload } |
[Account] (parsed preview, not persisted) |
audit CREATE |
confirmImportAccount |
mutation | ConfirmAccountImport { accounts:[ImportAccountItem] } |
ConfirmAccountImportResult { successCount, errorAccounts[] } |
audit CREATE |
Resolve-fields on Account:
category→ uses the joinedargs.categoryif present, elsecategorySvc.findOne.currency→ uses joinedargs.currencyelsemasterSvc.findById.user→userSvc.findById(userId)(nullable).payeeType→Customerif category name isAccounts Receivable,SupplierifAccounts Payable, else null.canDelete→trueonly if the account has no transactions (transactionSvc.findLast({accountId})is null).balance→ null unless a date range is supplied; otherwise callsaccountSvc.balanceWithDrAnCrPostedwithpayeeId,fromDate(default opening date = epoch 0),toDate(default now + 50 years),witExchangeRate=true. Skipping it on list/page queries avoids a full-collection aggregation per row.
AccountQueryInput filter fields (also on AccountPageInput): accountName, accountNumber, bankName, groups[], currencyId, categoryId, description, cashflowCategory, _id, userId, payeeId, fromDate, toDate, category, keyword, parentId, categories[], types[], excludeGroups[]. AccountPageInput adds skip, take, sortBy, sortOrder.
REST (account.controller.ts, base api/account, @ApiAuthorize())
| Method | Route | Returns | Notes |
|---|---|---|---|
| GET | api/account/download?downloadType=xlsx |
XLSX | Account report: per-account Debit/Credit/Balance over an optional date range. |
| GET | api/account/:accountId/account/download?downloadType=xlsx |
XLSX | One account's POSTED transactions with running balance + header (name/number/currency/COA). |
| GET | api/account/chart-of-account/download?downloadType=xlsx |
XLSX | Category-level Debit/Credit/Balance report (calls balanceWithDrAnCrCategory). |
4. Business rules & calculations
Account-number generation
generateAccountNumber() → accountRepo.generateRef({ key: "account_number" }) → the account_number unique-id sequence. On create, if accountNumber is absent the repository pulls the next account_number id; prefix defaults to accountNumber.split("-")[0].
Create validation (AccountService.create → validateAccount + validateAccountName)
- Company required — if neither
model.companyIdnorcontextSvc.companyIdis set, create silently returns (no-op). - Unique name (case-insensitive) per company —
existsByName(accountName, companyId)via^name$regexi; throwsAccount with name … already exists(406 NOT_ACCEPTABLE). - Inside the transaction,
validateAccount:- If
validateByName→ reject duplicate name in company; else (default) reject duplicateaccountNumber(global, not company-scoped —findOne({ accountNumber })). Skips the number check entirely whenaccountNumberis empty. - Circular-parent guard — if
_id+parentId,detectCircularParentwalks the parent chain; throwsAccount cannot be its own ancestor — circular parent reference detectedif a cycle (or self-parent) is found. - Cross-company category remap — if the
categoryId'scompanyIddiffers from the account's company, silently remaps to the same-named category in the correct company.
- If
- Auto-children after create (in the same transaction):
createAadAccount— if category name ==Property, Plant and Equipment (PP&E), create a childACC.Depr <name>account under categoryAccumulated Depreciation,isAad:true,isContra:true,parentId = new account._id.createAaaAccount— if category name ==Intangible Assets, create a childACC.Amort <name>under categoryAccumulated Amortization,isAad:true,isContra:true.- Both throw
Account category not found(406) if the target category is missing.
Delete (AccountService.delete)
Throws Account with existing transaction cannot be deleted (406 NOT_ACCEPTABLE) if any AccountTransaction exists for accountId. Otherwise soft-deletes. deleteManyAccounts loops delete per id (so one transacted account aborts only that id).
Balance derivation — the core read
balanceWithDrAnCr(accountId, query, witExchangeRate) (and …Posted, which forces status=POSTED):
hasFromDate = !!query.fromDate
openingRange = { fromDate: 0 (epoch), toDate: hasFromDate ? query.fromDate − 1ms : null }
openingBalance = hasFromDate ? totalDrAnCr({accountId, ...query, ...openingRange}, rate) : {0,0}
currentBalance = totalDrAnCr({accountId, ...query}, rate)
type = getType(accountId) // IAccountType from the category
balance = mapBalance(type, currentBalance)
openingBalance = mapBalance(type, openingBalance)
closingBalance = {
credits: balance.credits + opening.credits,
debits: balance.debits + opening.debits,
balance: formatAmt(balance.balance + opening.balance)
}
mapBalance(type, {debits,credits}):
balance = type.credit === "INCREASE"
? credits − debits // LIABILITY/EQUITY/INCOME
: debits − credits // ASSET/EXPENSE/INVENTORY
The normal-balance direction comes from ACCOUNT_TYPES in finance/finance.model.ts — see category §2 for the full table. getType(accountId) throws if the account has no categoryId (… category not mapped. please go to accounts and set category to proceed).
balanceWithDrAnCrCategory(categoryId, query) does the same but aggregates by categoryId across all the category's accounts. balancesByCategoryBatch(categories[]) batches it: one totalDrAnCrByCategoryBatch(categoryIds) call, then per category balance = credit==="INCREASE" ? credits−debits : debits−credits.
Whole-book balance assertion (accountBalanced / validateBalanced)
Used by journal/contra/transaction flows after every write:
{ debits, credits } = totalDrAnCr(
{ companyId, kind: { $in: [JournalEntry, AdvanceTransaction, LoanRepayment] } },
witExchangeRate = true
)
balanced = Math.abs(debits − credits) < 0.01 // 1-kobo tolerance
Only these three kinds are summed with exchange rate because they are stored in account currency (amount = base ÷ rate); other kinds are stored in base currency and would double-convert. validateBalanced throws Account is not balanced … (406) → rolls back the surrounding Mongo transaction. See overview §1.3 and transaction.
findById bypasses the company filter
AccountService.findById deliberately calls super.findById (no companyId clause) because _id is globally unique and debtor/creditor accounts are looked up across company boundaries.
Special accounts
getUserAccount(userId)→ debtor account if user.kind == Customer, else creditor; tags result withuserKind+user. Debtor/creditor lookups throw if the user has no mappedaccountId.getExchangeBalancingAccount()→ finds/creates theExchange Balancingaccount (currency = configbaseCurrencyId,canView:false) used to absorb FX rounding.
5. Permissions
| Module | value | actions |
|---|---|---|
GL_ACCOUNTS |
gl-accounts |
view, create, import-accounts, view-details, update, delete |
ACCOUNT_CATEGORY |
account-category |
(see category) |
(zerp-admin/src/constants/UserAccess.ts.) All resolvers are @ApGqlAuthorize(); every mutation carries @AuditMeta({ module:'account', collection:'finance_accounts', snapshots:[…] }).
Row-level scoping in account.repository.buildQuery: non-privileged users are filtered — Customer/Supplier with a userAccountId see only that one account (_id = userAccountId); other non-admins see only accounts they createdBy; admins (privileged) see all. The company filter is applied unless user.ignoreCompanyQuery. Full RBAC: ../../platform/permissions-access.md.
Admin page guards: accounts.tsx requires gl-accounts/view; the page action buttons gate New Account on gl-accounts/create and import on gl-accounts/import-accounts via ApAccessGuard / permission prop.
6. Flows
6.1 Create an account (admin → DB)
- Admin
GL Accountspage →New Account(gated ongl-accounts/create) opens theCreateAccountmodal (components/create.tsx). - Formik form,
FormSchemarequiresaccountName,category._id,currency._id. Currency defaults tocompany.currency; category picked fromApLookupInput(chart-of-accounts nodes); optionalaccountNumber,groups(onlyPAYMENToffered),description,cashflowCategory. - Submit maps
currency → currencyId,category → categoryId,groups → string[],cashflowCategory → value, thensaveAccount(id, payload)→createAccount/updateAccountmutation. - Resolver
createAccountstampscreatedBy = user._id→AccountService.create. - Service validates name uniqueness → opens
withRetryTransaction("create_account")→validateAccount(number/circular/category checks) →accountRepo.create(auto number, ref, prefix) →createAadAccount/createAaaAccountif PP&E / Intangible. - On success the context
toasts and re-runsaccountPage(filter)to refresh.Save & Newkeeps the modal open (re-keys the form);Save & Closedismisses.
Unhappy paths: duplicate name/number → 406; circular parent → 406; missing auto-child category → 406; no company context → silent no-op.
6.2 Import accounts (two-phase)
- Phase 1 — preview.
/finance/accounts/import(components/import.tsx) usesApFileImportForm(template/templates/account_import_template.xlsx) →importAccount({file})→importAccountmutation →AccountService.import:- Parses XLSX (
XlsxUtils.getXlsxRawData, trims column names). Resolves columns by alias: Currency/Currencies, Category/Categories, Account Name variants, Account Number variants, Date variants. - Resolves
category(by name, case-insensitive, company-scoped) andcurrency(master,ignoreCompanyId). - Flags
accountExists+duplicateErrorfor existing name or number (per company), and for duplicate name/number within the upload batch. - Returns the parsed rows (NOT persisted); redirect to
./confirm-import.
- Parses XLSX (
- Phase 2 — confirm.
/finance/accounts/confirm-import(components/confirmImport.tsx): editable grid (category lookup, name, number, currency per row; bulk "set default currency"). Yup validates only selected rows (number must match/^\d+$/). Non-existing rows can be checked; existing rows are checkbox-disabled.confirmAccountImport({accounts})→confirmImportAccountmutation →AccountService.confirmImport:- Per account:
validateAccount→withRetryTransaction("confirm_account_import")→accountRepo.create+createAadAccount+createAaaAccount. Errors are collected per row. - Returns
{ successCount, errorAccounts[] }. Errors switch the grid to "error mode" for correction + retry; full success routes to/finance/accounts.
- Per account:
6.3 View balance / detail
- List/page queries return
balance: null(no date range). Selecting a date range (ApDateRangePicker→filter.fromDate/toDate) makes thebalanceresolve-field compute debits/credits/balance/opening/closing over the range. The list itself is driven by the report query (financeGlAccountReport), notbankAccountPage— the context maps report rows to{...account, balance:{debits,credits,balance}}and does client-side category/keyword/group filtering + sorting. (See report — finance-ops pass.) - Detail page route
/finance/account/[_id].tsx. XLSX exports via the REST controller.
7. Admin UI
- Routes:
src/pages/finance/accounts.tsx(list, guardedgl-accounts/view),accounts/import.tsx,accounts/confirm-import.tsx,account/[_id].tsx(detail),cash-accounts.tsx(filtered variant). - Module:
src/modules/finance/account/—page.tsx(AccountsPage),context.tsx(AccountContextProvider/useAccountState),gql/{query,fragment}.ts,model.ts,detail.tsx,components/{create,table,import,confirmImport,detail-modal,name,select,summary}.tsx. - Context methods (
useAccountState):findAccount,accountPage(drives the table viafinanceGlAccountReport+ client filtering/sorting),saveAccount→createAccount/updateAccount,deleteAccount,deleteManyAccounts,getallAccountCategories(→bankAccountCategoryNodes),importAccount,confirmAccountImport; state:accounts,account,summary,filter(defaultpageSize:10000,toDate:nowDateOnlyEnd),selectedRowKeys,accountCategories. After every mutation the context refetches viaaccountPage(filter). - Table (
components/table.tsx): columns Account Number, Account Name, Currency, Account Category (upper-cased), Total Debit, Total Credit, Balance (allhelper.toCurrencywith the account currency, sortable viaonSortChange→filter.sortBy/sortOrder). Row actions: delete (only ifcanDelete), edit (only ifcanUpdate), view-detail. Bulk-select →deleteManyAccounts. - UX: date-range filter, category filter, currency filter, keyword search; download (PDF/XLSX); import button (access-guarded); inline "Save & New" on the create modal.
8. Dependencies & integrations
- category (
AccountCategoryService) — providesgetType(normal balance), category lookups, cross-company remap, auto-child categories, COA seed. - transaction (
AccountTransactionService) —totalDrAnCr(balance aggregation),findLast/findOne(delete &canDeleteguard),summary, migrations. - MasterService — currency masters (
currencyId,getExchangeBalancingAccount). - UserService — debtor/creditor account resolution (
getUserAccount),userresolve-field. - ApConfigService —
debtorsAccountTypeId/creditorsAccountTypeId(category naming),baseCurrencyId(exchange-balancing account). - FinanceAccountSeed —
getDefaultAccounts()(193 seed accounts, all NGN) consumed byseedDefaultAccounts;getAccountTypes()consumed by category seed. - FileUploadModule —
GraphQLUploadfor XLSX import. - AccountMigrationService (
account.migration.ts) — re-points transactions from one account to another bypayeeId(migratePayeeAccount) oritemId(migrateItemAccount). - AccountCacheService (
account.cache.ts) — Redis balance cache helpers (get/set/clearAccount); currently unused — thesetBalancecall inbalanceWithDrAnCris commented out. (Flagged: cache wiring is present but inactive.) - No events / cron. Seeding is invoked at company bootstrap (
AccountService.seed(companyId)→ category seed + default accounts).
9. Gotchas & project-specific rules
- No stored balance. Every balance is an aggregation over
finance_account_transactions. List/page queries returnbalance: nullby design (no date range) to avoid full-collection scans. - Account-number uniqueness is global, name uniqueness is per-company.
validateAccount's number check runsfindOne({accountNumber})without a company clause; the name check is company-scoped (existsByName). - Two list paths exist.
bankAccountPage(server,$combineLookups+ numeric account-number sort) is defined and exposed, but the adminaccountPageactually renders the table from the report endpoint (financeGlAccountReport) and filters/sorts client-side. A rebuild should pick one; both are wired. currencyIdmismatch: required inCreateAccountInput(GraphQL) but optional on the schema — a programmatic create can omit it.witExchangeRateis a footgun. Only journal-kind legs are stored in account currency; the accountbalanceresolve-field passeswitExchangeRate=true, andaccountBalancedonly sums the three account-currency kinds for the same reason. Mixing kinds with rate double-converts.- Auto-children (
ACC.Depr/ACC.Amort) are created on every PP&E / Intangible account and require theAccumulated Depreciation/Accumulated Amortizationcategories to exist (seeded). canDeleteis computed (no transactions) and also re-checked server-side on delete; deleting a posted/used account is impossible.- Default opening date is epoch 0 (
DateUtils.getDefaultOpeningDate() => 0), so "opening balance" without afromDatesums everything before the range start. - Seed idempotency:
seedDefaultAccountsskips existing names/numbers and within-batch duplicates; accounts whosechartOfAccountcategory is missing are logged and skipped.