Contra Entries — paired account-to-account transfers
The whole contra module reduces to one idea:
A contra entry moves value from ONE "main" account to one-or-more "list" accounts, writing two balanced
AccountTransactionlegs per list line. It is the canonical two-leg posting: pick a main account (e.g. Bank), pick a counterpart account (e.g. Cash), enter an amount → leg 1 increases (or decreases) the main account, leg 2 does the exact opposite on the list account, both sharing onerelationIdso they net to zero. Typical use: bank ↔︎ cash transfers, moving money between two cash books, petty-cash funding. The header (finance_contra_entries) groups the legs; the ledger (transaction) is the truth.
Source: BE src/modules/finance/contra · Admin src/modules/finance/contra
See _overview.md §3 ("Contra: the simplest two-leg posting"), journal.md for the multi-line sibling, and transaction.md for the leg itself and getTransactionType.
Naming note (project-specific): internally the contra module reuses a lot of "cashbook"/"purchase" vocabulary — the schema query class is
CashbookEntryQuery, the repo generatesPO…refs, several messages say "Purchase entry", and the admin state calls entriespurchaseEntries. These are legacy names; the feature is the contra entry. Don't read meaning into them.
1. Purpose & scope
The contra module is responsible for:
- A header (
finance_contra_entries) carrying a single mainaccountId+ N transfer lines. - For each line, writing exactly two balanced
AccountTransactionlegs (main + list) sharing onerelationId, bothkind = ContraEntry. - Deciding leg direction via
getTransactionType(mainAccount, "INCREASE")and giving the list leg the opposite type — so the pair always nets to zero regardless of the two accounts' types. - The SAVED ↔︎ POSTED lifecycle (status lives on header + legs), with the posted guard on edit/delete.
It does NOT:
- Own the ledger leg (that is transaction).
- Restrict the two accounts to the same
AccountTypein code — despite the accounting term "contra", there is no same-type validation; direction is derived purely fromgetTransactionType. (See §9.) - Generate tax legs itself — if a list line carried
taxId, the shared writer would, but the contra input/UI does not surface tax (it passestax/bankChargesnumbers that are not used as GL tax). - Have a
post/savemutation of its own — there is no contra posting mutation; status is set on create and edited under@GuardPostedEntry(see §3, §4.5).
2. Data model
Collection finance_contra_entries — the contra header
Source: contra/contra.schema.ts.
| field | type | required | description |
|---|---|---|---|
_id, ref, companyId, branchId, documentCode, documentDate, createdAt/By, updatedAt/By, canView/canDelete/... |
— | — | from BaseSchema. ref generated PO… (legacy prefix). |
ref |
string | yes | document number. |
accountId |
ObjectId | yes | the main account — the one side of every transfer line. |
description |
string | no | free-text memo. |
documentDate |
number (unix ms) | — | document date (used for period locking / date-range filters). |
currency |
string | no | legacy currency text. |
currencyRate |
string | no | legacy rate text. |
transactionId |
ObjectId | no | legacy single-leg link (largely unused). |
transactionIds |
ObjectId[] | — | written as [] on create; the live legs are joined by $lookup on refId. |
status |
AccountTransactionStatus |
— | SAVED / POSTED. default SAVED (shared enum). |
transactions |
AccountTransaction[] |
— | virtual — joined by $lookup on refId at read time. |
// contra/contra.schema.ts
@ApSchema({ collection: `finance_contra_entries`, timestamps: true })
export class ContraEntry extends BaseSchema {
@Prop({ required: true }) ref: string;
@Prop({}) description: string;
@Prop({ set: (v) => BaseSchema.toObjectId(v) }) accountId: Types.ObjectId; // MAIN account
@Prop({ set: (v) => BaseSchema.toObjectId(v) }) transactionId: Types.ObjectId;
@Prop({ set: (vals) => vals.map(BaseSchema.toObjectId) }) transactionIds: Types.ObjectId[];
@Prop({ type: String, enum: AccountTransactionStatus, default: AccountTransactionStatus.SAVED })
status: string;
transactions: AccountTransaction[]; // virtual — joined by refId
}
// joined at read time
export const $lookupTransactions = [{
$lookup: { from: "finance_account_transactions", localField: "_id", foreignField: "refId", as: "transactions" }
}];Soft delete: plugin(SoftDelete, { deletedAt, deletedBy }). Tenant scoping: companyId auto-injected by AbstractBaseRepository. The status enum is the shared one from transaction/transaction.schema.ts:
export enum AccountTransactionStatus { SAVED = "SAVED", POSTED = "POSTED" }The legs
Each line produces two AccountTransaction rows, both kind = ContraEntry, refId = header._id, sharing one relationId. Full leg schema (all fields, indexes) in transaction.md. The contra-relevant fields per leg: accountId, type (one DEBIT + one CREDIT), amount, remark, status (mirrors header), documentDate.
3. API surface
All resolvers @ApGqlAuthorize(); mutations @AuditMeta({ module:'contra', collection: 'finance_contra_entries', ... }). Source: contra/contra.resolver.ts.
| Operation | Type | Input | Returns | Guards / Permission |
|---|---|---|---|---|
contraEntryPage |
Query | ContraEntryPageInput |
ContraEntryPageResult |
— |
findOneContraEntry |
Query | ContraEntryQueryInput |
ContraEntry |
— |
contraEntrySummary |
Query | ContraEntryQueryInput |
ContraEntrySummary { totalRecords, totalAmount } |
— |
createContraEntry |
Mutation | entry: CreateContraEntryInput |
ContraEntry |
audit CREATE |
updateContraEntry |
Mutation | _id, entry: UpdateContraEntryInput |
ContraEntry |
@GuardPostedEntry(module: CONTRA_ENTRIES) + audit UPDATE |
deleteContraEntry |
Mutation | _id |
Boolean |
@GuardPostedEntry(module: CONTRA_ENTRIES) + audit DELETE |
There is no post/save contra mutation. updateContraEntry and deleteContraEntry are both guarded by @GuardPostedEntry (so a POSTED contra cannot be edited or deleted without edit-posted).
Resolve-fields (contra.resolver.ts):
account→ looks up the main account byaccountId.amount→ Σ of legs whoseaccountId === header.accountId(the main-account side total).transactions→ only the legs whoseaccountId !== header.accountId(i.e. the list-account legs; the main legs are folded intoamount). So the GraphQLtransactionsyou see are the counterpart legs, not all of them.
GraphQL types (generated, src/schema.gql)
type ContraEntry {
_id: String! branchId: String ref: String! documentDate: Float!
amount: Float! description: String! accountId: String! account: Account!
transactionIds: [String!]! transactions: [AccountTransaction!]!
createdAt: Float! canDelete: Boolean canPost: Boolean
}
type ContraEntrySummary { totalRecords: Float! totalAmount: Float! }
type ContraEntryPageResult { totalRecords: Float! data: [ContraEntry!]! }
input CreateContraEntryInput {
ref: String description: String documentDate: Float
accountId: String! # the MAIN account
transactions: [ContraEntryTransactionInput!]! # the list lines
}
input UpdateContraEntryInput { ...same, all nullable... }
input ContraEntryTransactionInput { # extends CreateAccountTransactionInput
_id: String accountId: String! amount: Float! remark: String documentDate: Float
# (+ inherited bankCharges, exchangeRate, taxId, taxIds, cost-center/class/... — unused by contra UI)
}REST
GET /api/contra-entry/download?downloadType=pdf (contra.controller.ts, @ApiAuthorize) — renders a PDF via the web template templates/finance/entries/contra. (XLSX branch is commented out.)
4. Business rules & calculations
4.1 The two-leg posting (the core logic)
For each input line, ContraEntryService.createTransaction writes a main leg and a list leg sharing one relationId, with opposite types:
// contra/contra.service.ts → createTransaction (per line)
const relationId = this.getObjectId(); // one relationId PER LINE
// direction decided by the MAIN account's normal balance
const transactType = await this.transacSvc.getTransactionType(model.accountId, "INCREASE");
// LEG 1 — main account (header.accountId)
await this.addMainTransaction(entry, { ...transac, relationId }, transactType);
// LEG 2 — list account (line.accountId), OPPOSITE type → pair nets to zero
await this.addListTransaction(entry, { ...transac, relationId },
transactType === AccountTransactionTypes.CREDIT ? AccountTransactionTypes.DEBIT : AccountTransactionTypes.CREDIT);// addMainTransaction → leg on header.accountId
transacSvc.create({ ...transaction, type, accountId: model.accountId, amount, remark,
relationId, status: model.status, refId: model._id, kind: AccountTransactionKind.ContraEntry });
// addListTransaction → leg on line.accountId (opposite type)
// (first resolves account.getTypeAndCategory(line.accountId) — validates the list account has a category)
transacSvc.create({ ...transaction, type, accountId: transaction.accountId, amount, remark,
relationId, status: model.status, refId: model._id, kind: AccountTransactionKind.ContraEntry });Which leg is debit/credit (worked example): main account = Bank (ASSET, debit:"INCREASE"), list account = Cash (ASSET). getTransactionType(Bank, "INCREASE") = DEBIT. So the Bank leg is DEBIT (bank increases) and the Cash leg is CREDIT (cash decreases). Both amount equal, same relationId → the pair nets to zero. Flip the intent by choosing which account you make the main one.
The direction is always anchored on "INCREASE the main account". Whether that means money into or out of the main account depends on the account type — but the list leg is unconditionally the opposite, so the entry is always balanced.
4.2 Balancing rule
There is no Σdebit==Σcredit pre-validation like the journal has — balance is structural: every line is one DEBIT + one CREDIT of equal amount, so the entry is balanced by construction. The only explicit check is the system-wide one after the write:
// addEntry: after writing all lines' legs
await this.accountSvc.validateBalanced(); // whole-book |debits − credits| < 0.01, else rollbackSee transaction.md §4.6 and _overview.md §1.3.
4.3 Create flow (addEntry)
// contra/contra.service.ts → addEntry
await this.withRetryTransaction("add_contra_entry", async () => {
const entry = await this.entryRepo.create({ // header (ref="PO…", transactionIds:[])
ref, transactionIds: [], documentDate, accountId, description, createdBy });
for await (const transac of model.transactions)
await this.createTransaction(model, entry, transac); // 2 legs per line
await this.accountSvc.validateBalanced(); // whole-book assertion
return await this.findById(entry._id);
});Unlike the journal, the contra header does not denormalise totals —
amountis computed at read time by theamountresolve-field (Σ of main-account legs).
4.4 Update flow (update)
Source: contra.service.ts → update (runs in withRetryTransaction):
- Load the current entry (with its joined legs).
- Group existing legs by
relationId(helper.groupArrayObj) → one group per original line. - Deletes: any group whose member ids aren't present in the new input is deleted leg-by-leg via
transacSvc.delete(_id, "single"). - For each input line: if it has an
_id,transacSvc.updateWithRelations({ ...line, _id })(which re-balances the paired leg by flipping its type — see transaction.md §4.4); elsecreateTransaction(...)mints a fresh pair. entryRepo.update(id, model)→validateBalanced("(Update Cashbook Entry)").
4.5 Delete flow + reversal/void
// contra/contra.service.ts → delete
await this.withRetryTransaction("delete_contra_entry", async () => {
const entry = await this.findById(id);
if (!entry) throw new Error("Cashbook entry not found");
await this.transacSvc.deleteMany({ refId: entry._id }); // remove ALL legs of this header
await this.entryRepo.delete(id); // soft-delete header
await this.accountSvc.validateBalanced("(Delete Cashbook Entry)");
});- Reversal / void: there is no reverse mutation. To unwind a contra entry you delete it (both legs removed, balances self-correct) or post an opposite contra entry. Deleting/editing a POSTED contra requires the
edit-postedaction (@GuardPostedEntrysits on bothupdateContraEntryanddeleteContraEntry). A "void" is functionally a soft-delete of the header + its legs; the audit trail records the DELETE snapshot.
4.6 Status / state machine
createContraEntry (status from header default → SAVED)
│
▼
┌────────┐
│ SAVED │ (no dedicated post/save contra mutation; status is set on the legs at create
│(draft) │ time and travels via updateWithRelations)
└────────┘
│
edit/delete freely editing OR deleting a POSTED entry → needs `edit-posted`
(@GuardPostedEntry on both updateContraEntry & deleteContraEntry)
Status is mirrored onto every leg (status: model.status in addMainTransaction/addListTransaction). Because there is no contra-specific post mutation, a contra entry is typically created SAVED and posted (if at all) through the shared leg-posting path (postAccountTransaction, which posts the whole relationId group — see transaction.md §4.7).
4.7 Transactionality
addEntry, update, delete each run inside withRetryTransaction(...) (single Mongo session, WriteConflict/Transient retry + backoff). setSession propagates the session to transacSvc and accountSvc so the header, all legs, and validateBalanced commit atomically or roll back together. (Honoured only when mongdb_transaction_enabled === "true".)
5. Permissions
Source: permission/permission.enum.ts, finance/guards/.
| Module enum | value | covers |
|---|---|---|
ApModules.CONTRA_ENTRIES |
contra-entries |
contra entries (passed explicitly to the guard) |
Action enum (RoleActions) |
value | grants |
|---|---|---|
EDIT_POSTED |
edit-posted |
edit/delete a POSTED contra — bypasses @GuardPostedEntry |
@GuardPostedEntry({ repositoryToken: ContraEntryRepository, module: ApModules.CONTRA_ENTRIES })on bothupdateContraEntryanddeleteContraEntry: loads the entry by_id, blocks the mutation ifstatus === POSTEDunless the user holdsedit-postedoncontra-entries(CASL).- Period locking applies at the leg level via
AccountTransactionService.create → fiscalPeriodSvc.validateTransactionDate(every leg write). There is no@GuardLockedPeriodon the contra resolver (no post mutation to guard). - Row-level scoping is inherited from the leg repository (transaction.md §5).
Full RBAC: ../../platform/permissions-access.md.
6. Flows
6.1 Create a contra transfer (happy path)
- Admin opens Contra Entries → New Contra (modal or
/finance/contra/new) →CreateContraEntryFormik form (contra/components/create.tsx). - User picks a date, the main account (
accountId, e.g. Bank), and adds ≥ 1 transfer line, each with a list account (e.g. Cash) + amount. Formik (FormSchema) requires date, main account, and per-line{ account, amount > 0 }. - Submit →
createContraEntry(entry)with{ accountId, documentDate, description, transactions:[{ accountId, amount, remark, documentDate }] }. - Resolver →
ContraEntryService.addEntry: (txn) create header (PO…) → per linecreateTransaction:getTransactionType(mainAccount, INCREASE)→ write main leg + opposite list leg (sharedrelationId,kind = ContraEntry) →validateBalanced. - Side effects: 2 rows per line in
finance_account_transactions(allrefId = header._id); audit CREATE snapshot.
6.2 Edit / delete
- Edit: open an entry →
updateContraEntry(_id, entry).@GuardPostedEntrychecks status; service diffs lines (delete removed groups,updateWithRelationsexisting, create new), re-asserts balance. - Delete:
deleteContraEntry(_id).@GuardPostedEntrychecks status; servicedeleteMany({ refId })- soft-delete header +
validateBalanced.
- soft-delete header +
6.3 Unhappy paths
- List account missing a category:
addListTransaction→accountSvc.getTypeAndCategory(line.accountId)throws (… category not mapped …). - Leg dated into a locked period:
validateTransactionDatethrows on the leg write. - Whole-book imbalance after write:
validateBalancedthrowsAccount is not balanced→ rollback. - Edit/delete a POSTED entry without
edit-posted:@GuardPostedEntryrejects.
7. Admin UI
Source: zerp-admin/src/modules/finance/contra/.
- Pages:
page.tsx(ContraEntryPage) — list withApDurationPicker(date range + PDF/XLSX download viacontra-entry/download), New Contra button,PurchaseSummary(totals), andContraEntryTable.new.tsx(NewContraEntryPage) — standalone create page that routes to/finance/contra/{_id}after save.detail.tsx— entry detail. - State:
context.tsx(useContraEntryState) — the only consumer ofuseContraEntryQuery(). Methods:contraEntryPage,findOneContraEntry,saveContraEntry(payload, id)(create if no id, else update),deleteContraEntry,contraEntrySummary. (Legacy: state field is namedpurchaseEntriesand toasts say "Purchase entry".) After mutations it patches local list state. - Create/edit form:
components/create.tsx— Formik (FormSchema): mainaccountId(ApAccountSelection), date, description, and a line grid (ApTable+ApAddRowButton) with per-line list account (ApAccountSelection/ApSelectInputAsync, filtered togroups: ['PAYMENT']), amount, remark. Maps to{ accountId, transactions:[{ accountId, amount, tax, bankCharges, remark, documentDate }] }. (tax/bankChargesare passed but not used as GL tax — see §9.) - Other components:
table.tsx,summary.tsx,contranTemplate.tsx(PDF template).
8. Dependencies & integrations
- Calls into transaction (
AccountTransactionService.create / updateWithRelations / delete / deleteMany,getTransactionType); account (getTransactionTypevia account type,getTypeAndCategoryto validate the list account,validateBalanced,findById). - Called by: primarily the admin contra UI. (No other subsystem creates contra entries programmatically.)
- No cron/jobs/events owned by this module. PDF render delegates to the web template service.
9. Gotchas & project-specific rules
- Legacy "cashbook"/"purchase" naming everywhere.
CashbookEntryQuery,PO…refs, "Purchase entry" toasts,purchaseEntriesstate — all refer to the contra feature. Don't infer a purchase or cashbook relationship. - One
relationIdper LINE (not per entry like the journal). Each transfer line is its own balanced pair; edits/deletes walkrelationIdper pair. - Direction is anchored on the MAIN account via
getTransactionType(mainAccount, "INCREASE"); the list leg is unconditionally the opposite type. The entry is balanced by construction — there is no Σdr==Σcr pre-check (only the post-write whole-bookvalidateBalanced). - No same-type ("contra") validation in code. Despite the accounting term, the two accounts are not required to share an
AccountType; any two postable accounts work. transactionsresolve-field hides the main legs. GraphQLtransactionsreturns only the list-account legs (accountId !== header.accountId); the main-account side is exposed as theamountresolve-field (Σ of main legs). The DB actually holds 2 legs/line.- No denormalised totals. Contra has no
totalDebit/totalCreditcolumns;amountis computed at read time.contraEntrySummary.totalAmountis computed in the repo asΣ(transactions.amount)/2(each line is double-counted across its two legs, so it halves). - No post/save mutation. Status is set at create and travels with the legs; posting (if used) goes through the shared
postAccountTransactionpath. Edit and delete of a POSTED entry both requireedit-posted. tax/bankChargesfrom the UI are inert — the contra input passes them but they are not wired into GL tax-leg generation. Contra does not produceTaxEntrylegs in practice.