Finance — Core Accounting (domain overview)
The whole finance core reduces to one idea:
Every financial event becomes a balanced set of
AccountTransactionrows (the GL ledger). Each row is a single debit OR credit against oneAccount. 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 lifecycleKey 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 sharerefId.relationId= "which legs balance me". Within one document there can be several independent balanced pairs, each with its ownrelationId. Deletes and edits walkrelationIdto 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 1AccountService.balanceWithDrAnCr(accountId, {fromDate,toDate}) then:
- computes opening balance =
totalDrAnCrfor[epoch, fromDate-1ms], - computes current =
totalDrAnCrfor the requested range, - maps each to a signed balance via the account's type (
mapBalance), - returns
{ debits, credits, balance, openingBalance, closingBalance }.
AccountTransactionTypes.OPENINGis 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 toleranceIf 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_SCRAPAccountTransactionKind 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 whyaccountBalancedonly 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 bytransaction.createwhen a leg carriestaxId/taxIds/taxes(except for order kinds, which handle their own tax). Each tax leg getsparentId = spawning-leg._id; withholding (deductive) taxes flip the leg'stype. 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 andupdateManyover its legs (refId). - Posted guard (
@GuardPostedEntry): mutating a POSTED entry is blocked unless the user holds theedit-postedaction on that module (CASL check). See transaction.md and ../../platform/permissions-access.md. - Locked-period guard (
@GuardLockedPeriod): posting an entry whose legs fall in aLOCKED/CLOSEDfiscal period is blocked unless the user holdspost-to-locked-period. Additionally,AccountTransactionService.createcallsfiscalPeriodSvc.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), andpayeeIdon 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.