Cashbook & Petty Cash — cash/bank receipt & payment vouchers
The whole cashbook reduces to one idea:
A cashbook entry is a one-account-to-many transfer that the system materialises into balanced
AccountTransactionlegs. You pick one "main" account (the cash/bank you pay from or receive into) and a list of counterparty lines (beneficiaries / sources). For each line the service writes two legs sharing onerelationId: one against the main account and one against the line account, of opposite debit/credit type.type=PAYMENTdecreases the main account;type=RECEIPTincreases it. There is no stored balance — everything lands in the shared GL ledger (transaction) and the whole book must balance after every write.
Source: BE src/modules/finance/cashbook · Admin src/modules/finance/cashbook + src/modules/finance/pettycash + src/pages/finance/cashbook/* + src/pages/finance/petty-cash/*
Related: _overview · transaction · account · contra · taxation
1. Purpose & scope
The cashbook is the day-to-day cash/bank movement book:
- Payment voucher (PV) — money leaving a cash/bank account to one or more beneficiaries (
type = PAYMENT). - Official receipt (OR) — money arriving into a cash/bank account from one or more sources (
type = RECEIPT).
A single header (CashBookEntry) groups a list of counterparty transaction lines. Each line can carry its own date, remark, exchange rate, tax(es), cost-center / class / analysis-code analytics, and (for GOLD currency) a purity. The header references one main account; every line references one counterparty account.
It does NOT:
- Store any balance — balances are aggregated from the GL ledger (see account).
- Move stock — it is pure cash/GL.
- Settle specific invoices (knock-off) — that is the payment/knockoff subsystem, not cashbook.
- Do its own multi-currency revaluation beyond a single per-line exchange rate (it posts a balancing leg to the exchange-balancing account when a rate ≠ 1; see §4).
Petty cash is a thin admin convenience on top of the same ledger — a single fixed petty-cash account (configured via config.pettyCashAccountId) with a stripped-down entry form. See §7.4 and the gotcha in §9 about its backend mutation.
2. Data model
2.1 finance_cashbook_entries — CashBookEntry (the header)
cashbook/cashbook.schema.ts. Extends BaseSchema (_id, companyId, branchId, documentCode, documentDate, createdAt/By, updatedAt/By, soft-delete via mongoose-delete).
| field | type | required | description |
|---|---|---|---|
ref |
string | yes | Document number. Auto-generated in the repo: prefix PV for PAYMENT, OR for RECEIPT (generateRef). |
refId |
ObjectId | no | Optional back-reference to a source document (cast via BaseSchema.toObjectId). Carried through from the create input; not used by core posting. |
type |
CashBookEntryTypes |
yes | PAYMENT or RECEIPT — the direction discriminator. |
currency |
string | no | Display currency name (informational; legs store their own). |
currencyRate |
string | no | Informational. |
description |
string | no | Header note; also used as the default leg remark. |
status |
AccountTransactionStatus |
no | SAVED (default) or POSTED. Mirrors onto every leg. |
accountId |
ObjectId | — | The main cash/bank account (paid-from for PAYMENT, received-into for RECEIPT). |
transactionId |
ObjectId | no | Legacy single-leg link (not used by current multi-leg flow). |
transactionIds |
ObjectId[] | no | Set to [] on create; legacy field. |
transactions |
AccountTransaction[] |
— | Not stored — hydrated by the repository via $lookupTransactions (legs where refId == entry._id). |
// cashbook/cashbook.schema.ts
export enum CashBookEntryTypes {
PAYMENT = "PAYMENT", // money out → decreases the main account
RECEIPT = "RECEIPT" // money in → increases the main account
}
@ApSchema({ collection: `finance_cashbook_entries`, timestamps: true })
export class CashBookEntry extends BaseSchema {
ref: string; // PV… / OR…
refId: Types.ObjectId;
type: CashBookEntryTypes; // required
currency: string;
currencyRate: string;
description: string;
status: AccountTransactionStatus = AccountTransactionStatus.SAVED;
accountId: Types.ObjectId; // the main cash/bank account
transactionId: Types.ObjectId;
transactionIds: Types.ObjectId[];
transactions: AccountTransaction[]; // virtual — looked up by refId
}
statusreusesAccountTransactionStatus { SAVED, POSTED }fromtransaction/transaction.schema.ts— the cashbook has no status enum of its own.
2.2 The ledger legs — AccountTransaction
Cashbook does not own a line schema; every line becomes one or more AccountTransaction rows in finance_account_transactions, stamped kind = CashBookEntry. See transaction for the full field table. The relevant fields cashbook sets: accountId, type (DEBIT/CREDIT), amount, refId (= entry _id), relationId (one per line, shared by that line's legs), documentDate, status, remark, kind, plus pass-through analytics (costCenterId, classId, analysisCodeId, departmentId, taxId/taxIds, purity).
2.3 Query/page shapes
CashbookEntryQuery / PageParams (schema file) add filter dimensions: fromDate, toDate, keyword, costCenterId, classId, analysisCodeId, departmentId, accountNumber, skip/take/sortBy/sortOrder.
3. API surface
GraphQL (cashbook/cashbook.resolver.ts, all @ApGqlAuthorize()):
| Operation | Type | Input | Returns | Guard / Audit |
|---|---|---|---|---|
createCashBookEntry |
Mutation | CreateCashBookEntryInput |
CashBookEntry |
Audit CREATE |
updateCashBookEntry |
Mutation | _id, UpdateCashBookEntryInput |
CashBookEntry |
@GuardPostedEntry(CASHBOOK), Audit UPDATE |
deleteCashBookEntry |
Mutation | _id |
Boolean |
@GuardPostedEntry(CASHBOOK), Audit DELETE |
deleteManyCashbookEntry |
Mutation | DeleteManyCashbookEntryInput { ids } |
Boolean |
Audit DELETE |
postCashbookEntry |
Mutation | id |
Boolean |
@GuardLockedPeriod(CASHBOOK), Audit STATUS_CHANGE |
postManyCashbookEntry |
Mutation | ids: [String] |
Boolean |
@GuardLockedPeriod(CASHBOOK, isArray), Audit STATUS_CHANGE |
saveManyCashbookEntry |
Mutation | ids: [String] |
Boolean |
Audit STATUS_CHANGE |
importCashbook |
Mutation | CashbookImportInput { file } |
[CashbookImport] |
Audit CREATE |
confirmCashbookImport |
Mutation | ConfirmCashbookImportInput |
Boolean |
Audit CREATE |
cashBookEntryPage |
Query | CashBookEntryPageInput |
CashBookEntryPageResult |
— |
findOneCashBookEntry |
Query | CashBookEntryQueryInput |
CashBookEntry |
— |
cashbookEntrySummary |
Query | CashBookEntryQueryInput |
CashBookEntrySummary |
— |
Note: there is no single-entry
postCashBookEntry-vs-saveCashBookEntrymutation pair for save; the service exposessaveCashbookEntry/saveCashbookEntriesbut the resolver only wiressaveManyCashbookEntry. A single entry is created as SAVED or POSTED directly via the status on the create payload, and toggled afterward via the post/save-many mutations.
Input DTO (cashbook/cashbook.dto.ts):
@InputType() class CommonCashBookEntryInput {
ref?: string;
refId?: string;
type!: CashBookEntryTypes; // PAYMENT | RECEIPT
description?: string;
documentDate?: number; // unix ts (floored to UTC day server-side)
accountId!: string; // the main account
status?: AccountTransactionStatus; // SAVED | POSTED
transactions!: CashBookEntryTransactionInput[];
}
class CreateCashBookEntryInput extends CommonCashBookEntryInput {}
class UpdateCashBookEntryInput extends PartialType(CommonCashBookEntryInput) {}
// each line — extends CreateAccountTransactionInput (so it carries accountId, amount,
// remark, exchangeRate, documentDate, taxId/taxIds, costCenterId, classId, analysisCodeId,
// departmentId, purity, …) plus an optional _id for edit.
@InputType() class CashBookEntryTransactionInput extends CreateAccountTransactionInput { _id?: string }REST (cashbook/cashbook.controller.ts): GET /api/cashbook-entry/download — XLSX export of filtered entries (Ref, Account, Type, Document Date, Status, Amount, Currency, Cost Center, Class, Analysis Code). Per-row amount = accountTransactionSvc.totalAmount({ accountId, refId }).
4. Business rules & calculations
4.1 The two-leg-per-line posting (the core)
CashBookEntryService.addEntry → for each input line createTransaction(model, entry, transac) (cashbook/cashbook.service.ts):
- Allocate a
relationId(getObjectId()) — unique per line, shared by that line's legs. - Resolve main-account direction via the ledger's normal-balance table (_overview §1.1):
transactType = getTransactionType( model.accountId, // the MAIN account model.type === PAYMENT ? "DECREASE" : "INCREASE" // PAYMENT decreases it, RECEIPT increases it );getTransactionType(accountId, intent)reads the account's categorytypeand returnsDEBIT/CREDITsuch that the intent (increase/decrease) is honoured for that account type (transaction.service.ts). - Main leg (
addMainTransaction): account =entry.accountId,type = transactType,kind = CashBookEntry,status,relationId,remark = line.remark || entry.description || "Cashbook Transaction". Amount =exchangeRate ? exchangeRate * amount : amountand the storedexchangeRateis forced to1(the rate is baked into the amount, not stored — opposite of the journal convention). - List leg (
addListTransaction): account =line.accountId,type =opposite of the main leg,amount = line.amount(the un-converted line amount),kind = CashBookEntry, sharedrelationId. - Tax — if the line carries
taxId/taxIds/taxes,AccountTransactionService.createauto-spawnskind=TaxEntrylegs (additive taxes add, deductive/withholding flip type). See transaction §tax. (addTaxAndChargesin cashbook is currently a no-op stub — it only computes atTypeand returns; tax legs are produced by the transaction layer.) - After all lines:
accountSvc.validateBalanced()— asserts the whole book balances; if not, the surrounding transaction rolls back (_overview §1.3).
So per line you get 2 legs (main + list) of opposite type and equal base amount → net zero.
4.2 Worked GL legs
PAYMENT — pay supplier ₦100,000 cash from "Cash in Hand" (ASSET):
| account | type | amount | why |
|---|---|---|---|
| Cash in Hand (main, ASSET) | CREDIT | 100,000 | PAYMENT → DECREASE asset → credit |
| Accounts Payable / Expense (line) | DEBIT | 100,000 | opposite of main |
RECEIPT — receive ₦100,000 from customer into "Bank" (ASSET):
| account | type | amount | why |
|---|---|---|---|
| Bank (main, ASSET) | DEBIT | 100,000 | RECEIPT → INCREASE asset → debit |
| Accounts Receivable / Income (line) | CREDIT | 100,000 | opposite of main |
The direction is type-aware: it is decided by the main account's category type, not hardcoded. If the main account were a LIABILITY, the debit/credit would flip —
getTransactionTypehandles this so the doc never assumes "cash is always an asset."
4.3 Exchange-rate balancing
When a line has exchangeRate (≠ undefined/0), the main leg is posted in the converted amount (exchangeRate * amount) while the list leg stays in the entered amount. The difference would break the book, so addExchangeBalancing posts a third leg:
diff = (exchangeRate * amount) - amount;
account = accountSvc.getExchangeBalancingAccount(); // dedicated FX gain/loss account
type = opposite of the main leg type;
amount = diff; exchangeRate forced to 1; kind = CashBookEntry; shared relationId;So an FX line yields 3 legs: main (converted) + list (entered) + exchange-balancing (the diff).
4.4 Status / state machine
createCashBookEntry (status from payload: SAVED or POSTED)
│
▼
┌────────┐ postCashbookEntry / postManyCashbookEntry
│ SAVED │ ───────────────────────────────────────────► ┌────────┐
│(draft) │ │ POSTED │
└────────┘ ◄──────────────────────────────────────────── │(final) │
│ saveManyCashbookEntry └────────┘
edit/delete freely while SAVED edit/delete a POSTED entry requires
the `edit-posted` action (@GuardPostedEntry)
- Posting/saving updates the header and
updateManyover every leg byrefId(postCashbookEntry/saveCashbookEntry). Status lives on both. canPostresolve-field =status === SAVED.
4.5 Update & delete (re-derive, never patch in place)
- Update (
update):transacSvc.deleteMany({ refId })then re-runcreateTransactionfor every line, thenentryRepo.update, thenvalidateBalanced("(Update Cashbook Entry)"). The legs are fully rebuilt, not patched. - Delete (
delete):transacSvc.deleteMany({ refId }), soft-delete the header,validateBalanced("(Delete Cashbook Entry)"). Soft-deleted legs drop out of every aggregation.
4.6 Summary & totals
summary(query) → { totalPayments, totalReceipts, totalRecords }. totalPaymentAndReceipt (service version) loads PAYMENT and RECEIPT entries, converts each leg's amount to company currency via exchangeSvc.rate({ from: account.currency, to: company.currency }), filters to legs whose account.canView, and sums. (The repository has an alternate totalPaymentAndReceipt that $divides the unwound leg amount by 2 — because each line produces a balanced pair, dividing by 2 recovers the single line value; the service version is the one wired to summary.)
4.7 Transactionality
addEntry, update, delete, and confirmImport each run inside withRetryTransaction("…cashbook…") — header write, all leg writes, and validateBalanced commit atomically (single Mongo session with WriteConflict/Transient retry). Nested transacSvc.create calls participate in the open session. setSession propagates the session to transacSvc, accountSvc, and fiscalPeriodSvc.
4.8 Date handling
documentDate defaults to today floored to UTC day (DateUtils.startOfDateOnly(DateUtils.now())). Each leg's documentDate is also floored (startOfDateOnly(line.documentDate || entry.documentDate || model.documentDate || now)). Every leg write runs fiscalPeriodSvc.validateTransactionDate — a leg dated into a locked fiscal period is rejected (see workflow-approval-engine).
5. Permissions
- Permission module:
ApModules.CASHBOOK = "cashbook"(permission/permission.enum.ts). - Guards on the resolver:
@GuardPostedEntry({ module: CASHBOOK })on update/delete — blocks mutating aPOSTEDentry unless the user holds theedit-postedaction (CASL).@GuardLockedPeriod({ module: CASHBOOK })on post / post-many — blocks posting into aLOCKED/CLOSEDfiscal period unless the user holdspost-to-locked-period.
- All mutations carry
@AuditMeta({ module: 'cashbook', collection: 'finance_cashbook_entries', … }). - Row-scoping: inherited from the shared transaction/account repositories (_overview §5, permissions-access).
6. Flows
6.1 Create a payment voucher (happy path)
- Admin opens Finance → Cashbook → Payment → New (
pages/finance/cashbook/payment/new.tsx),CreateCashBookEntry type=PAYMENT. - Picks the main account (paid-from cash/bank), date, optional voucher ref; adds beneficiary lines (account + amount + remark, optional rate/tax/analytics/purity).
- Click Save (SAVED) / Save & Post (POSTED) / Save & New.
context.saveCashBookEntry(undefined, payload)→createCashBookEntrymutation →CashBookEntryService.addEntry.- Header created (ref
PV…, date floored), then per line: main CREDIT (cash) + list DEBIT (beneficiary), + FX-balancing leg if rate set, + tax legs if taxed. validateBalanced()passes → transaction commits → entry returned with hydratedtransactions.
6.2 Create a receipt — identical shape, type=RECEIPT; main leg DEBIT (cash up), list CREDIT.
6.3 Post / save toggling
postManyCashbookEntry(ids)→ eachpostCashbookEntry:updateManylegs + header to POSTED (guarded against locked periods).saveManyCashbookEntry(ids)→ revert to SAVED.
6.4 Import (XLSX)
- Import page (
pages/finance/cashbook/import.tsx) uploads a file →importCashbook(service.import): parses rows, resolves each by Account Number then Account Name (throws if not found), maps cost-center / class / analysis-code by uppercased name, parses date and amount. Returns a preview list (CashbookImport[]) — nothing is posted yet. - Confirm Import (
pages/finance/cashbook/confirm-import.tsx) →confirmCashbookImport(service.confirmImport): validates each row's date against the fiscal period, then callsaddEntrywith the chosen mainaccountId,type, and the rows as lines (amountMath.abs), inside a transaction, finishing withvalidateBalanced().
6.5 Unhappy paths
- Same account on both sides — admin blocks it in
create.tsx(duplicateAccountCheck+ Yupno-duplicate-accounts): "You cannot transfer to the same account that you are paying from." - Out of balance —
validateBalancedthrows → whole Mongo transaction rolls back. - Editing a POSTED entry without
edit-posted—@GuardPostedEntryrejects. - Posting into a locked period without
post-to-locked-period—@GuardLockedPeriod/validateTransactionDaterejects. - Import account not found —
CustomError("Account not found for …").
7. Admin UI
7.1 Routes
pages/finance/cashbook/index.tsx— combined list.pages/finance/cashbook/payment/{index,new}.tsx,…/receipt/{index,new}.tsx— typed list + create.pages/finance/cashbook/[_id].tsx— detail.pages/finance/cashbook/import.tsx,…/confirm-import.tsx— two-step import.pages/finance/petty-cash/[_id]/index.tsx— petty-cash account ledger + entry.
7.2 Module files (src/modules/finance/cashbook)
context.tsx, model.ts, gql/{query,fragment}.ts, page.tsx, new.tsx, detail.tsx, columns.tsx, and components/{create,detail,grid,table,summary,import,confirmImport,export}.tsx, validation/create.tsx.
7.3 Context methods (context.tsx, useCashBookEntryState)
cashBookEntryPage, findOneCashBookEntry, getCashBookSummary, saveCashBookEntry (create-or-update dispatcher), deleteCashBookEntry, deleteManyCashbookEntry, postCashbookEntry, postManyCashbookEntry, saveManyCashbookEntry, importCashbook, confirmCashbookImport. The context is the only consumer of useCashBookEntryQuery(); after every mutation it refetches the page and/or summary.
7.4 The create form (components/create.tsx)
- Header: main account select, date, type-labelled voucher ref ("Payment Voucher" / "Receipt …").
- A
TransactionListgrid of lines: Date, Account (beneficiary/source), Remark, Exchange Rate (only shown when the line currency ≠ main currency), Amount, Purity (only when GOLD currency), Cost Center / Class / Analysis Code (master selects, inline-createable, column header sets all rows), Tax (multi-select with Inclusive toggle), Total Amount, delete. - A footer card computes Subtotal / Tax / Grand Total client-side via
computeLineTaxBreakdown(@/modules/taxation/line-tax): per lineamount = amount * (rate||1),base/additive/deductivefrom taxes; Grand Total =Σ(base + additive − deductive). - Three submit buttons: Save (SAVED), Save & New (SAVED + reset), Save & Post (POSTED).
- Yup (
validation/create.tsx): date + main account required; each line account + amount (>0) required; purity required when currency is GOLD; cross-fieldno-duplicate-accountstest.
7.5 Petty cash (src/modules/finance/pettycash)
components/entry.tsx(PettyCashEntry) — readsconfig.pettyCashAccountId; if unset shows "No petty cash account mapped". The main account is fixed to the configured petty-cash account; the form only collects counterparty lines (account + amount), then callsuseTransactionState().addPettyCash({ accountId: config.pettyCashAccountId, transactions }).model.tsx—IPettyCash { accountId; transaction: IAccountTransaction[] }.
8. Dependencies & integrations
- transaction —
AccountTransactionService.create/deleteMany/updateManyis the shared GL writer; cashbook never writes legs directly. Tax legs are generated there. - account —
getTransactionType,getExchangeBalancingAccount,validateBalanced,findById. fiscal—FiscalPeriodService.validateTransactionDate(locked-period chokepoint).exchange—ExchangeService.ratefor converting summary totals to company currency.company— base currency for the summary conversion.master— cost-center / class / analysis-code lookups during import.upload— XLSX import file (GraphQLUpload);XlsxUtils/XlsxColumnUtilsfor parsing.config—pettyCashAccountIdpowers the petty-cash form.
Consumed by: nothing posts into cashbook automatically; it is an entry point operators use.
9. Gotchas & project-specific rules
- Amount-carries-the-rate (opposite of journal). Cashbook bakes the exchange rate into the main leg amount (
amount = rate * amount) and storesexchangeRate = 1; journal storesamount = entered / rateand keeps the rate. Don't mix the conventions —accountBalancedonly rate-multiplies journal-kind legs, so cashbook legs are already in base currency. addTaxAndChargesis effectively a no-op in the service (computes a localtTypeand returns). Tax legs come fromAccountTransactionService.createwhen a line carries tax ids — not from cashbook. TheICashBookEntryTransactionInput.bankCharges/taxfields exist on the interface but the service does not post separate bank-charge legs.- Petty-cash backend mutation is missing. The admin calls
createPettyCashTransaction(pettyCash: PettyCashTransactionInput!)(transaction/gql/query.ts), butzerp-beexposes nocreatePettyCashTransactionmutation andPettyCashTransactionInputappears only as an unused DTO (transaction/transaction.dto.ts:248); it is not inschema.gql. Treat petty-cash entry as not functional on the current backend (admin will error) until the resolver is implemented — likely it should map onto a cashbookaddEntrywith the fixed petty-cash account. Document this before relying on it. updateCashBookEntry/deleteCashBookEntryfully rebuild legs (deleteManybyrefIdthen recreate) rather than patching — any external reference to a leg_idis invalidated on edit.- Ref prefixes are by type:
PV(payment) /OR(official receipt) — generated only when norefis supplied. - Summary "÷2" trick (repository variant): each line yields a balanced pair, so the unwound-leg sum is doubled; the repo divides by 2 to recover the per-line total. The service summary instead filters to
account.canViewlegs and FX-converts — know which one is wired (summary→ service). documentDateis always floored to UTC day — never store a raw instant, or date-sliced reports and fiscal locks will misalign.