Account — the chart of accounts (postable GL accounts)

The whole account model reduces to one idea:

An Account is 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 its categoryId (AccountCategory) — the account is just the identity (name + number + currency + parent) that an AccountTransaction leg references via accountId. A balance is always derived: Σ(debits) − Σ(credits) (or the reverse, by the category's type) 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), the Exchange Balancing account, 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/reportSection are read through the category.

2. Data model

finance_accountsAccount (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 field

find/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 joined args.category if present, else categorySvc.findOne.
  • currency → uses joined args.currency else masterSvc.findById.
  • useruserSvc.findById(userId) (nullable).
  • payeeTypeCustomer if category name is Accounts Receivable, Supplier if Accounts Payable, else null.
  • canDeletetrue only if the account has no transactions (transactionSvc.findLast({accountId}) is null).
  • balancenull unless a date range is supplied; otherwise calls accountSvc.balanceWithDrAnCrPosted with payeeId, 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.createvalidateAccount + validateAccountName)

  1. Company required — if neither model.companyId nor contextSvc.companyId is set, create silently returns (no-op).
  2. Unique name (case-insensitive) per companyexistsByName(accountName, companyId) via ^name$ regex i; throws Account with name … already exists (406 NOT_ACCEPTABLE).
  3. Inside the transaction, validateAccount:
    • If validateByName → reject duplicate name in company; else (default) reject duplicate accountNumber (global, not company-scoped — findOne({ accountNumber })). Skips the number check entirely when accountNumber is empty.
    • Circular-parent guard — if _id + parentId, detectCircularParent walks the parent chain; throws Account cannot be its own ancestor — circular parent reference detected if a cycle (or self-parent) is found.
    • Cross-company category remap — if the categoryId's companyId differs from the account's company, silently remaps to the same-named category in the correct company.
  4. Auto-children after create (in the same transaction):
    • createAadAccount — if category name == Property, Plant and Equipment (PP&E), create a child ACC.Depr <name> account under category Accumulated Depreciation, isAad:true, isContra:true, parentId = new account._id.
    • createAaaAccount — if category name == Intangible Assets, create a child ACC.Amort <name> under category Accumulated 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 with userKind + user. Debtor/creditor lookups throw if the user has no mapped accountId.
  • getExchangeBalancingAccount() → finds/creates the Exchange Balancing account (currency = config baseCurrencyId, 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)

  1. Admin GL Accounts page → New Account (gated on gl-accounts/create) opens the CreateAccount modal (components/create.tsx).
  2. Formik form, FormSchema requires accountName, category._id, currency._id. Currency defaults to company.currency; category picked from ApLookupInput (chart-of-accounts nodes); optional accountNumber, groups (only PAYMENT offered), description, cashflowCategory.
  3. Submit maps currency → currencyId, category → categoryId, groups → string[], cashflowCategory → value, then saveAccount(id, payload)createAccount / updateAccount mutation.
  4. Resolver createAccount stamps createdBy = user._idAccountService.create.
  5. Service validates name uniqueness → opens withRetryTransaction("create_account")validateAccount (number/circular/category checks) → accountRepo.create (auto number, ref, prefix) → createAadAccount / createAaaAccount if PP&E / Intangible.
  6. On success the context toasts and re-runs accountPage(filter) to refresh. Save & New keeps the modal open (re-keys the form); Save & Close dismisses.

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)

  1. Phase 1 — preview. /finance/accounts/import (components/import.tsx) uses ApFileImportForm (template /templates/account_import_template.xlsx) → importAccount({file})importAccount mutation → 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) and currency (master, ignoreCompanyId).
    • Flags accountExists + duplicateError for 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.
  2. 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})confirmImportAccount mutation → AccountService.confirmImport:
    • Per account: validateAccountwithRetryTransaction("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.

6.3 View balance / detail

  • List/page queries return balance: null (no date range). Selecting a date range (ApDateRangePickerfilter.fromDate/toDate) makes the balance resolve-field compute debits/credits/balance/opening/closing over the range. The list itself is driven by the report query (financeGlAccountReport), not bankAccountPage — 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, guarded gl-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 via financeGlAccountReport + client filtering/sorting), saveAccountcreateAccount/updateAccount, deleteAccount, deleteManyAccounts, getallAccountCategories (→ bankAccountCategoryNodes), importAccount, confirmAccountImport; state: accounts, account, summary, filter (default pageSize:10000, toDate:nowDateOnlyEnd), selectedRowKeys, accountCategories. After every mutation the context refetches via accountPage(filter).
  • Table (components/table.tsx): columns Account Number, Account Name, Currency, Account Category (upper-cased), Total Debit, Total Credit, Balance (all helper.toCurrency with the account currency, sortable via onSortChangefilter.sortBy/sortOrder). Row actions: delete (only if canDelete), edit (only if canUpdate), 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) — provides getType (normal balance), category lookups, cross-company remap, auto-child categories, COA seed.
  • transaction (AccountTransactionService) — totalDrAnCr (balance aggregation), findLast/findOne (delete & canDelete guard), summary, migrations.
  • MasterService — currency masters (currencyId, getExchangeBalancingAccount).
  • UserService — debtor/creditor account resolution (getUserAccount), user resolve-field.
  • ApConfigServicedebtorsAccountTypeId/creditorsAccountTypeId (category naming), baseCurrencyId (exchange-balancing account).
  • FinanceAccountSeedgetDefaultAccounts() (193 seed accounts, all NGN) consumed by seedDefaultAccounts; getAccountTypes() consumed by category seed.
  • FileUploadModuleGraphQLUpload for XLSX import.
  • AccountMigrationService (account.migration.ts) — re-points transactions from one account to another by payeeId (migratePayeeAccount) or itemId (migrateItemAccount).
  • AccountCacheService (account.cache.ts) — Redis balance cache helpers (get/set/clearAccount); currently unused — the setBalance call in balanceWithDrAnCr is 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 return balance: null by design (no date range) to avoid full-collection scans.
  • Account-number uniqueness is global, name uniqueness is per-company. validateAccount's number check runs findOne({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 admin accountPage actually renders the table from the report endpoint (financeGlAccountReport) and filters/sorts client-side. A rebuild should pick one; both are wired.
  • currencyId mismatch: required in CreateAccountInput (GraphQL) but optional on the schema — a programmatic create can omit it.
  • witExchangeRate is a footgun. Only journal-kind legs are stored in account currency; the account balance resolve-field passes witExchangeRate=true, and accountBalanced only 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 the Accumulated Depreciation / Accumulated Amortization categories to exist (seeded).
  • canDelete is 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 a fromDate sums everything before the range start.
  • Seed idempotency: seedDefaultAccounts skips existing names/numbers and within-batch duplicates; accounts whose chartOfAccount category is missing are logged and skipped.