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 AccountTransaction legs 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 one relationId so 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 generates PO… refs, several messages say "Purchase entry", and the admin state calls entries purchaseEntries. 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 main accountId + N transfer lines.
  • For each line, writing exactly two balanced AccountTransaction legs (main + list) sharing one relationId, both kind = 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 AccountType in code — despite the accounting term "contra", there is no same-type validation; direction is derived purely from getTransactionType. (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 passes tax/bankCharges numbers that are not used as GL tax).
  • Have a post/save mutation 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 by accountId.
  • amount → Σ of legs whose accountId === header.accountId (the main-account side total).
  • transactions → only the legs whose accountId !== header.accountId (i.e. the list-account legs; the main legs are folded into amount). So the GraphQL transactions you 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 rollback

See 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 — amount is computed at read time by the amount resolve-field (Σ of main-account legs).

4.4 Update flow (update)

Source: contra.service.ts → update (runs in withRetryTransaction):

  1. Load the current entry (with its joined legs).
  2. Group existing legs by relationId (helper.groupArrayObj) → one group per original line.
  3. Deletes: any group whose member ids aren't present in the new input is deleted leg-by-leg via transacSvc.delete(_id, "single").
  4. 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); else createTransaction(...) mints a fresh pair.
  5. 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-posted action (@GuardPostedEntry sits on both updateContraEntry and deleteContraEntry). 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 both updateContraEntry and deleteContraEntry: loads the entry by _id, blocks the mutation if status === POSTED unless the user holds edit-posted on contra-entries (CASL).
  • Period locking applies at the leg level via AccountTransactionService.create → fiscalPeriodSvc.validateTransactionDate (every leg write). There is no @GuardLockedPeriod on 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)

  1. Admin opens Contra Entries → New Contra (modal or /finance/contra/new) → CreateContraEntry Formik form (contra/components/create.tsx).
  2. 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 }.
  3. Submit → createContraEntry(entry) with { accountId, documentDate, description, transactions:[{ accountId, amount, remark, documentDate }] }.
  4. Resolver → ContraEntryService.addEntry: (txn) create header (PO…) → per line createTransaction: getTransactionType(mainAccount, INCREASE) → write main leg + opposite list leg (shared relationId, kind = ContraEntry) → validateBalanced.
  5. Side effects: 2 rows per line in finance_account_transactions (all refId = header._id); audit CREATE snapshot.

6.2 Edit / delete

  1. Edit: open an entry → updateContraEntry(_id, entry). @GuardPostedEntry checks status; service diffs lines (delete removed groups, updateWithRelations existing, create new), re-asserts balance.
  2. Delete: deleteContraEntry(_id). @GuardPostedEntry checks status; service deleteMany({ refId })
    • soft-delete header + validateBalanced.

6.3 Unhappy paths

  • List account missing a category: addListTransactionaccountSvc.getTypeAndCategory(line.accountId) throws (… category not mapped …).
  • Leg dated into a locked period: validateTransactionDate throws on the leg write.
  • Whole-book imbalance after write: validateBalanced throws Account is not balanced → rollback.
  • Edit/delete a POSTED entry without edit-posted: @GuardPostedEntry rejects.

7. Admin UI

Source: zerp-admin/src/modules/finance/contra/.

  • Pages: page.tsx (ContraEntryPage) — list with ApDurationPicker (date range + PDF/XLSX download via contra-entry/download), New Contra button, PurchaseSummary (totals), and ContraEntryTable. 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 of useContraEntryQuery(). Methods: contraEntryPage, findOneContraEntry, saveContraEntry(payload, id) (create if no id, else update), deleteContraEntry, contraEntrySummary. (Legacy: state field is named purchaseEntries and toasts say "Purchase entry".) After mutations it patches local list state.
  • Create/edit form: components/create.tsx — Formik (FormSchema): main accountId (ApAccountSelection), date, description, and a line grid (ApTable + ApAddRowButton) with per-line list account (ApAccountSelection/ApSelectInputAsync, filtered to groups: ['PAYMENT']), amount, remark. Maps to { accountId, transactions:[{ accountId, amount, tax, bankCharges, remark, documentDate }] }. (tax/bankCharges are 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 (getTransactionType via account type, getTypeAndCategory to 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, purchaseEntries state — all refer to the contra feature. Don't infer a purchase or cashbook relationship.
  • One relationId per LINE (not per entry like the journal). Each transfer line is its own balanced pair; edits/deletes walk relationId per 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-book validateBalanced).
  • 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.
  • transactions resolve-field hides the main legs. GraphQL transactions returns only the list-account legs (accountId !== header.accountId); the main-account side is exposed as the amount resolve-field (Σ of main legs). The DB actually holds 2 legs/line.
  • No denormalised totals. Contra has no totalDebit/totalCredit columns; amount is computed at read time. contraEntrySummary.totalAmount is 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 postAccountTransaction path. Edit and delete of a POSTED entry both require edit-posted.
  • tax/bankCharges from the UI are inert — the contra input passes them but they are not wired into GL tax-leg generation. Contra does not produce TaxEntry legs in practice.