Finance — Core Accounting (domain overview)

The whole finance core reduces to one idea:

Every financial event becomes a balanced set of AccountTransaction rows (the GL ledger). Each row is a single debit OR credit against one Account. A "document" (journal entry, contra entry, payment, sales invoice, …) is just a header that groups its rows. The ledger is the source of truth; headers exist for editing, display, and posting workflow. An account's balance is never a stored number — it is always Σ(debits) − Σ(credits) (or the reverse, by account type) aggregated over the ledger.

Source: BE src/modules/finance/* · Admin src/modules/finance/* + src/pages/finance/*

This _overview.md covers the core accounting sub-modules only: account, category, journal, transaction, contra. The wider finance domain (cashbook, note, payment, trade, taxation, shortcut, report) is documented by the finance-ops pass; those sub-modules are listed in §6 with a one-line each but are not detailed here.


1. The double-entry / GL model

zerp implements a classic double-entry general ledger:

AccountCategory  (chart-of-accounts node: type + report section, hierarchical)
   │  categoryId
   ▼
Account          (a postable GL account: name + number + currency + category)
   ▲  accountId
   │
AccountTransaction  (ONE ledger leg: debit OR credit, amount, account, document date)
   ▲  refId (parent document)   ▲ relationId (its balancing partner-set)
   │
JournalEntry / ContraEntry / Payment / CashBook / Note / Order / Trade / Asset …
                       (document headers that emit balancing transaction legs)

1.1 Account type → normal balance

The normal balance of every account is decided purely by its category's type. The mapping lives in finance/finance.model.ts (ACCOUNT_TYPES):

export enum AccountType { NONE, ASSET, LIABILITY, EQUITY, INCOME, EXPENSE, INVENTORY }

// ACCOUNT_TYPES — the normal-balance table
ASSET     → debit  INCREASE, credit DECREASE   (balance = debits − credits)
LIABILITY → debit  DECREASE, credit INCREASE   (balance = credits − debits)
EQUITY    → debit  DECREASE, credit INCREASE   (balance = credits − debits)
INCOME    → debit  DECREASE, credit INCREASE   (balance = credits − debits)
EXPENSE   → debit  INCREASE, credit DECREASE   (balance = debits − credits)
INVENTORY → debit  INCREASE, credit DECREASE   (balance = debits − credits)

IAccountType = { type, credit: "INCREASE"|"DECREASE", debit: "INCREASE"|"DECREASE" }. AccountService.mapBalance() reads this table: balance = type.credit === "INCREASE" ? credits − debits : debits − credits.

getTransactionType(accountId, "INCREASE"|"DECREASE") (in transaction.service.ts) turns a business intent ("I want to increase this account") into the correct DEBIT/CREDIT for that account's type. This is how higher-level flows decide which leg is debit vs credit without hardcoding it per account.

1.2 The ledger leg — AccountTransaction

One row = one debit or one credit. Defined in transaction/transaction.schema.ts:

export enum AccountTransactionTypes { CREDIT, DEBIT, OPENING }
export enum AccountTransactionStatus { SAVED, POSTED }   // posting lifecycle

Key linking fields (full table in transaction.md):

field meaning
accountId the GL account this leg hits (required)
type DEBIT or CREDIT
amount absolute amount (always positive; sign is carried by type)
refId FK to the parent document header (JournalEntry/ContraEntry/Order/…)
relationId groups a leg with its balancing partner(s) — the legs that must net to zero
kind discriminator: which subsystem created this leg (JournalEntry, ContraEntry, SalesInvoice, TaxEntry, …)
documentDate the date used for period/fiscal locking and balance date-ranges
exchangeRate rate to base currency (defaults 1; a 0/null rate is treated as 1 when summing)
parentId a tax leg points to the transaction leg that spawned it

refId vs relationId — the two grouping keys, often confused:

  • refId = "which document do I belong to" (the header). All legs of a journal entry share refId.
  • relationId = "which legs balance me". Within one document there can be several independent balanced pairs, each with its own relationId. Deletes and edits walk relationId to keep each pair balanced.

1.3 The balance invariant

There is no stored balance. Balances are aggregations (transaction.repository.ts):

// totalDrAnCr(query) → { debits, credits }
debits  = Σ amount where type == DEBIT
credits = Σ amount where type == CREDIT
// witExchangeRate=true → amount × safeRate, where a 0/null rate is coerced to 1

AccountService.balanceWithDrAnCr(accountId, {fromDate,toDate}) then:

  1. computes opening balance = totalDrAnCr for [epoch, fromDate-1ms],
  2. computes current = totalDrAnCr for the requested range,
  3. maps each to a signed balance via the account's type (mapBalance),
  4. returns { debits, credits, balance, openingBalance, closingBalance }.

AccountTransactionTypes.OPENING is a synthetic display row injected by the transaction page resolver (the "Opening Balance" line); it is never persisted to the ledger.

System-wide balance check (AccountService.accountBalanced / validateBalanced): After every journal/contra/transaction write, the service asserts the books balance:

// Only JournalEntry / AdvanceTransaction / LoanRepayment kinds are summed with exchangeRate,
// because those are stored in account currency. Summing other kinds with rate double-converts.
{ debits, credits } = totalDrAnCr({ companyId, kind: { $in: [JournalEntry, AdvanceTransaction, LoanRepayment] } }, true)
balanced = Math.abs(debits − credits) < 0.01     // 1-kobo tolerance

If not balanced, validateBalanced throws Account is not balanced and the surrounding Mongo transaction rolls back. (Tolerance: 0.01 at the system level; the per-journal validation uses a tighter 0.001, see §3.)


2. Entity map

finance_account_categories   AccountCategory   chart-of-accounts tree (type, reportSection)
finance_accounts             Account           postable GL accounts (categoryId → type)
finance_account_transactions AccountTransaction the GL ledger (debit/credit legs)   ← heart
finance_journal_entries      JournalEntry      manual/auto multi-line journal headers
finance_contra_entries       ContraEntry       same-account-type transfer headers (e.g. bank↔cash)

Collection names are literal (@ApSchema({ collection: ... })). All five extend BaseSchema (gives _id, ref, companyId, branchId, documentCode, documentDate, createdAt/By, updatedAt/By, canView/canDelete/...) and register mongoose-delete (soft delete via deleted/deletedAt/deletedBy; soft-deleted rows drop out of every aggregation automatically).

Multi-tenant scoping: AbstractBaseRepository auto-injects companyId into queries (unless ignoreCompanyQuery). Account.findById deliberately bypasses the company filter because _id is globally unique and debtor/creditor accounts are looked up across company boundaries.

Shared enums (all in finance/finance.model.ts unless noted)

AccountType        NONE | ASSET | LIABILITY | EQUITY | INCOME | EXPENSE | INVENTORY
CashFlowCategory   INVESTING | FINANCING | OPERATING | UNCLASSIFIED
ReportSection      BS_CURRENT_ASSET | BS_FIXED_ASSET | BS_CURRENT_LIABILITY |
                   BS_NON_CURRENT_LIABILITY | BS_EQUITY | PNL_REVENUE | PNL_SALES_ADJUSTMENTS |
                   PNL_OTHER_INCOME | PNL_COST_OF_SALES | PNL_OPERATING_EXPENSE |
                   PNL_INTEREST_EXPENSE | PNL_TAX_EXPENSE | BANK_AND_CASH | NONE

// transaction/transaction.schema.ts
AccountTransactionTypes   CREDIT | DEBIT | OPENING
AccountTransactionStatus  SAVED | POSTED
AccountPaymentTypes       CASH | BANK | STOCK
AccountTransactionKind    OrderPayment | OrderPayment2 | SalesInvoice | PurchaseRequisition |
                          PurchaseInvoice | CashBookEntry | StockAdjustment | StockTransfer |
                          NoteEntry | PaymentEntry | JournalEntry | AssetEntry | ContraEntry |
                          TradeEntry | KnockoffEntry | SalesReturnOrder | PurchaseReturnOrder |
                          TaxEntry | AdvanceTransaction | LoanRepayment

// journal/journal.schema.ts
JournalEntryTypes   BANK | CASH | GENERAL | PURCHASE | SALES | ORDER_FIXING |
                    ASSET_DISPOSAL | ASSET_DEPRECIATION | ORDER_RETURN |
                    MFG_WIP | MFG_COMPLETION | MFG_VARIANCE | MFG_SCRAP

AccountTransactionKind is the single most important enum for understanding the ledger: every leg is stamped with the subsystem that wrote it, so reports/balance-checks can include or exclude classes of postings (e.g. TaxEntry legs are excluded from journal/document line displays).


3. How a posting becomes journal lines (the core end-to-end flow)

The canonical flow is JournalEntry → AccountTransaction legs. Every other document type follows the same shape (header + balancing legs); they differ only in how they derive the legs.

Admin "New Journal Entry" form (debit/credit grid)
   │  CreateJournalEntryInput { type, description, documentDate, currencyId,
   │                            transactions: [{ accountId, debit|credit, exchangeRate, ... }] }
   ▼
createJournalEntry  (resolver → JournalEntryService.addEntry)
   1. validateEntry(model)                      ← Σdebit == Σcredit  (|diff| < 0.001), and not all-zero
   2. withRetryTransaction("add_journal_entry"):
        a. entryRepo.create({ ref:"JN…", transactions:[], status, documentDate=startOfDateOnly(today) })
        b. addTransaction(entry, transactions):
             for each line, share ONE relationId across the whole entry, and write a leg:
               amount      = (debit || credit || amount) / (exchangeRate || 1)   ← store in account ccy
               type        = line.debit ? DEBIT : CREDIT
               refId       = entry._id          (links leg → header)
               relationId  = relationId         (shared by all legs of this entry)
               kind        = JournalEntry
               documentDate= startOfDateOnly(line.documentDate || entry.documentDate)
             → transacSvc.create(leg)
        c. entryRepo.computeAndStoreTotals(entryId)   ← totalDebit/totalCredit cached on header
        d. accountSvc.validateBalanced("(Add Journal Entry)")  ← whole-book balance assertion
   ▼
finance_account_transactions now holds N balanced DEBIT/CREDIT rows, all refId=entry._id

Key facts a rebuild must preserve:

  • Amount storage convention: journal legs are stored in account currency (amount = entered / exchangeRate). The journal/account balance code re-multiplies by rate when it needs base currency. This is why accountBalanced only multiplies journal-kind legs by rate — other kinds are already stored in base currency and would be double-converted.
  • Document date is floored to UTC day (DateUtils.startOfDateOnly) so date-only rendering, month-sliced reports, and fiscal-period locks line up. Never store a raw instant.
  • Totals are denormalised onto the header (totalDebit/totalCredit) via an aggregation (computeAndStoreTotals) for fast list rendering; the ledger remains the truth.
  • Tax legs (kind=TaxEntry) are spawned automatically by transaction.create when a leg carries taxId/taxIds/taxes (except for order kinds, which handle their own tax). Each tax leg gets parentId = spawning-leg._id; withholding (deductive) taxes flip the leg's type. Tax legs are excluded from journal totals and line displays.

Contra: the simplest two-leg posting

A contra entry moves value between accounts of the same type (e.g. bank → cash). For each input line, ContraEntryService.createTransaction writes two legs sharing one relationId:

main leg:  accountId = entry.accountId,         type = getTransactionType(entry.accountId, INCREASE)
list leg:  accountId = line.accountId,           type = opposite of main
both:      kind = ContraEntry, refId = entry._id, amount = line.amount, relationId shared
→ validateBalanced() after all lines

Posting lifecycle (state machine)

            createJournalEntry / createContraEntry
                      │
                      ▼
                  ┌────────┐   postJournalEntry / postManyJournalEntry / postAccountTransaction
                  │ SAVED  │ ───────────────────────────────────────────────► ┌────────┐
                  │(draft) │                                                    │ POSTED │
                  └────────┘ ◄─────────────────────────────────────────────────│(final) │
                      │            saveJournalEntry / saveManyJournalEntry      └────────┘
                      │
        edit/delete freely while SAVED                  edit/delete a POSTED entry requires
                                                        the `edit-posted` permission (GuardPostedEntry)
  • Status lives on both the header (JournalEntry.status / ContraEntry.status) and on every leg (AccountTransaction.status). Posting/saving an entry updates the header and updateMany over its legs (refId).
  • Posted guard (@GuardPostedEntry): mutating a POSTED entry is blocked unless the user holds the edit-posted action on that module (CASL check). See transaction.md and ../../platform/permissions-access.md.
  • Locked-period guard (@GuardLockedPeriod): posting an entry whose legs fall in a LOCKED/CLOSED fiscal period is blocked unless the user holds post-to-locked-period. Additionally, AccountTransactionService.create calls fiscalPeriodSvc.validateTransactionDate(documentDate, companyId) on every write — the central chokepoint that rejects any leg dated into a locked period (a date in a gap is treated as locked iff the company uses fiscal periods at all). See ../../platform/workflow-approval-engine.md.

Transactionality

Every multi-leg write runs inside withRetryTransaction(...) (a single Mongo session with WriteConflict/Transient retry + exponential backoff). Header create, all leg writes, total denormalisation, and validateBalanced commit atomically — if the book doesn't balance the whole thing rolls back. (Honoured only when mongdb_transaction_enabled === "true"; otherwise it runs without a session.) Nested calls participate in the already-open session rather than starting a new one.


4. Cross-submodule relationships

writes legs into the ledger header collection leg kind balance rule
journal finance_journal_entries JournalEntry Σdr == Σcr enforced in validateEntry (0.001)
contra finance_contra_entries ContraEntry 2 legs/line, opposite types
cashbook / note / payment / trade (finance-ops) their own matching kinds balanced pairs
sales / purchase / order / asset / stock (other domains) orders, assets, … SalesInvoice, PurchaseInvoice, AssetEntry, StockTransfer, … per-domain
  • account and category define the chart of accounts; they hold no postings themselves — they are the dimensions every leg references.
  • transaction is the shared ledger writer + reader. Journal, contra, and all document subsystems call AccountTransactionService.create/update/delete. It owns: tax-leg generation, relation-aware update/delete, posting, fiscal-date validation, balance aggregations.
  • The debtor/creditor sub-ledger reuses accounts: each Customer/Supplier user is linked to an Accounts-Receivable/Payable account (Account.getUserAccount), and payeeId on legs subdivides that account by counterparty.

5. Permissions (finance core)

Permission modules (permission.enum.ts → ApModules) and the two bypass actions (RoleActions) used by the finance guards:

Module enum value covers
GL_ACCOUNTS gl-accounts accounts (chart of accounts)
ACCOUNT_CATEGORY account-category account categories
JOURNAL_ENTRIES journal-entries journal entries (default subject for both guards)
CONTRA_ENTRIES contra-entries contra entries
Action enum value grants
EDIT_POSTED edit-posted modify a POSTED entry (bypass @GuardPostedEntry)
POST_LOCKED_PERIOD post-to-locked-period post into a locked fiscal period (bypass @GuardLockedPeriod)

All resolvers are @ApGqlAuthorize() and every mutation carries @AuditMeta({ module, collection, snapshots }) → see ../../platform/audit-trail.md. Non-privileged users are row-scoped in the repositories: Customer/Supplier see only their linked account's rows; other non-admins see only rows they created; admins see all. Full RBAC model: ../../platform/permissions-access.md.


6. Finance sub-modules

Core accounting (this pass):

Sub-module One-line
account Chart of accounts — postable GL accounts: number, name, currency, category, hierarchy, opening balances, XLSX import.
category Account categories — the chart-of-accounts tree: type (drives normal balance), reportSection, cash-flow flag; seeded hierarchy.
journal Journal entries — balanced multi-line manual/auto headers that emit DEBIT/CREDIT ledger legs; post/save lifecycle, import.
transaction Account transactions — the GL ledger leg itself + the shared writer/reader: tax legs, relations, posting, balance aggregations.
contra Contra entries — paired transfers between same-type accounts (e.g. bank↔︎cash), two balancing legs per line.

Wider finance domain (finance-ops pass — not detailed here):

Sub-module One-line
cashbook Cash/bank receipt & payment book entries that post to the ledger.
note Debit/credit note entries against accounts.
payment Payment entries (settling invoices / paying parties), payment-entry pages.
trade Trade entries (buy/sell with purity/weight) posting to the ledger.
taxation Tax definitions (rate, direction additive/deductive, GL account) consumed by tax-leg generation.
shortcut Saved transaction shortcuts / templates for fast repeat postings.
report Trial balance, P&L, balance sheet, aged receivable/payable, cash flow — read-only over the ledger + categories.

See ../../glossary.md and ../../module-map.md for the full BE↔︎admin correspondence.