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 AccountTransaction legs. 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 one relationId: one against the main account and one against the line account, of opposite debit/credit type. type=PAYMENT decreases the main account; type=RECEIPT increases 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_entriesCashBookEntry (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
}

status reuses AccountTransactionStatus { SAVED, POSTED } from transaction/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-saveCashBookEntry mutation pair for save; the service exposes saveCashbookEntry/saveCashbookEntries but the resolver only wires saveManyCashbookEntry. 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):

  1. Allocate a relationId (getObjectId()) — unique per line, shared by that line's legs.
  2. 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 category type and returns DEBIT/CREDIT such that the intent (increase/decrease) is honoured for that account type (transaction.service.ts).
  3. Main leg (addMainTransaction): account = entry.accountId, type = transactType, kind = CashBookEntry, status, relationId, remark = line.remark || entry.description || "Cashbook Transaction". Amount = exchangeRate ? exchangeRate * amount : amount and the stored exchangeRate is forced to 1 (the rate is baked into the amount, not stored — opposite of the journal convention).
  4. List leg (addListTransaction): account = line.accountId, type = opposite of the main leg, amount = line.amount (the un-converted line amount), kind = CashBookEntry, shared relationId.
  5. Tax — if the line carries taxId/taxIds/taxes, AccountTransactionService.create auto-spawns kind=TaxEntry legs (additive taxes add, deductive/withholding flip type). See transaction §tax. (addTaxAndCharges in cashbook is currently a no-op stub — it only computes a tType and returns; tax legs are produced by the transaction layer.)
  6. 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 — getTransactionType handles 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 updateMany over every leg by refId (postCashbookEntry/saveCashbookEntry). Status lives on both.
  • canPost resolve-field = status === SAVED.

4.5 Update & delete (re-derive, never patch in place)

  • Update (update): transacSvc.deleteMany({ refId }) then re-run createTransaction for every line, then entryRepo.update, then validateBalanced("(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 a POSTED entry unless the user holds the edit-posted action (CASL).
    • @GuardLockedPeriod({ module: CASHBOOK }) on post / post-many — blocks posting into a LOCKED/CLOSED fiscal period unless the user holds post-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)

  1. Admin opens Finance → Cashbook → Payment → New (pages/finance/cashbook/payment/new.tsx), CreateCashBookEntry type=PAYMENT.
  2. Picks the main account (paid-from cash/bank), date, optional voucher ref; adds beneficiary lines (account + amount + remark, optional rate/tax/analytics/purity).
  3. Click Save (SAVED) / Save & Post (POSTED) / Save & New.
  4. context.saveCashBookEntry(undefined, payload)createCashBookEntry mutation → CashBookEntryService.addEntry.
  5. 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.
  6. validateBalanced() passes → transaction commits → entry returned with hydrated transactions.

6.2 Create a receipt — identical shape, type=RECEIPT; main leg DEBIT (cash up), list CREDIT.

6.3 Post / save toggling

  • postManyCashbookEntry(ids) → each postCashbookEntry: updateMany legs + header to POSTED (guarded against locked periods).
  • saveManyCashbookEntry(ids) → revert to SAVED.

6.4 Import (XLSX)

  1. 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.
  2. Confirm Import (pages/finance/cashbook/confirm-import.tsx) → confirmCashbookImport (service.confirmImport): validates each row's date against the fiscal period, then calls addEntry with the chosen main accountId, type, and the rows as lines (amount Math.abs), inside a transaction, finishing with validateBalanced().

6.5 Unhappy paths

  • Same account on both sides — admin blocks it in create.tsx (duplicateAccountCheck + Yup no-duplicate-accounts): "You cannot transfer to the same account that you are paying from."
  • Out of balancevalidateBalanced throws → whole Mongo transaction rolls back.
  • Editing a POSTED entry without edit-posted@GuardPostedEntry rejects.
  • Posting into a locked period without post-to-locked-period@GuardLockedPeriod / validateTransactionDate rejects.
  • Import account not foundCustomError("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 TransactionList grid 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 line amount = amount * (rate||1), base/additive/deductive from 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-field no-duplicate-accounts test.

7.5 Petty cash (src/modules/finance/pettycash)

  • components/entry.tsx (PettyCashEntry) — reads config.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 calls useTransactionState().addPettyCash({ accountId: config.pettyCashAccountId, transactions }).
  • model.tsxIPettyCash { accountId; transaction: IAccountTransaction[] }.

8. Dependencies & integrations

  • transactionAccountTransactionService.create/deleteMany/updateMany is the shared GL writer; cashbook never writes legs directly. Tax legs are generated there.
  • accountgetTransactionType, getExchangeBalancingAccount, validateBalanced, findById.
  • fiscalFiscalPeriodService.validateTransactionDate (locked-period chokepoint).
  • exchangeExchangeService.rate for 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 / XlsxColumnUtils for parsing.
  • configpettyCashAccountId powers 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 stores exchangeRate = 1; journal stores amount = entered / rate and keeps the rate. Don't mix the conventions — accountBalanced only rate-multiplies journal-kind legs, so cashbook legs are already in base currency.
  • addTaxAndCharges is effectively a no-op in the service (computes a local tType and returns). Tax legs come from AccountTransactionService.create when a line carries tax ids — not from cashbook. The ICashBookEntryTransactionInput.bankCharges/tax fields 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), but zerp-be exposes no createPettyCashTransaction mutation and PettyCashTransactionInput appears only as an unused DTO (transaction/transaction.dto.ts:248); it is not in schema.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 cashbook addEntry with the fixed petty-cash account. Document this before relying on it.
  • updateCashBookEntry/deleteCashBookEntry fully rebuild legs (deleteMany by refId then recreate) rather than patching — any external reference to a leg _id is invalidated on edit.
  • Ref prefixes are by type: PV (payment) / OR (official receipt) — generated only when no ref is 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.canView legs and FX-converts — know which one is wired (summary → service).
  • documentDate is always floored to UTC day — never store a raw instant, or date-sliced reports and fiscal locks will misalign.