Trade Entries — manual receivable / payable ledger postings

The whole trade module reduces to one idea:

A trade entry is a manual two-leg posting that books an amount owed to/by a debtor or creditor. You pick one "main" account (typically a debtor/creditor control or a counterparty's receivable/payable account) and a list of counterparty lines. For each line the service writes two balancing AccountTransaction legs sharing one relationId. type = PURCHASE increases the main account (you now owe a supplier → payable up); type = SALES decreases it. Once booked, a trade entry can be linked to a cashbook payment/receipt (via CashBookEntry.refId = trade._id) to settle it. There is no stored balance — everything lands in the shared GL ledger (transaction).

Source: BE src/modules/finance/trade · Admin src/modules/finance/trade + src/pages/finance/cashbook/* (payment) + src/pages/report/trade-{receivable,payable}/* (aging view)

Related: _overview · transaction · cashbook · account · note


1. Purpose & scope

Trade entries record a buy/sell obligation against a party's account without going through the full inventory Order pipeline. Two directions:

  • PURCHASE — you incurred a payable to a supplier (increase the main account).
  • SALES — you booked a receivable from a customer (decrease the main account, by the ledger's normal-balance convention — see §4.1).

Each header (TradeEntry) references one main accountId and a list of counterparty lines. A trade entry can later be paid: the admin opens a cashbook PAYMENT (for PURCHASE) or RECEIPT (for SALES) whose refId points back to the trade entry, and the resolver hydrates that payment via a $lookup so the UI shows "View Payment" instead of "Make Payment."

It does NOT:

  • Move stock or create Order/OrderItem rows (that is the inventory/order pipeline — see the inventory domain). Trade is a pure GL device for manual trade obligations.
  • Store an outstanding balance or its own aging — see §4.5 / §9 for the important distinction between trade entries and the aged receivable/payable report (which ages invoices, not trade entries).
  • Generate tax/bank-charge legs from its own fields. The tax/bankCharges input fields exist on the line interface but the trade service does not post separate tax or charge legs (the line is forwarded to transaction.create, which spawns tax legs only if real taxId/taxIds are present — the admin form does not pass them).

2. Data model

2.1 finance_trade_entriesTradeEntry (the header)

trade/trade.schema.ts. Extends BaseSchema (_id, companyId, documentCode, createdAt/By, updatedAt/By, soft-delete via mongoose-delete).

field type required description
ref string yes Document number. Auto-generated with prefix PO when not supplied (generateRef). The admin labels it "Invoice No".
type TradeEntryTypes yes PURCHASE or SALES — the direction discriminator.
currency string no Display currency name (informational).
currencyRate string no Informational.
description string no Header note.
documentDate number Unix ts; setter coerces via BaseSchema.toUnixTimestamp.
accountId ObjectId The main account (debtor/creditor / party control account).
transactionId ObjectId no Legacy single-leg link (unused by current multi-leg flow).
transactionIds ObjectId[] no Set to [] on create; legacy.
branchId ObjectId Branch scope (own setter; required by @ApBranchAuth on create).
status AccountTransactionStatus no SAVED (default) or POSTED.
transactions AccountTransaction[] Not stored — hydrated via $lookupTransactions (legs where refId == entry._id).
payment CashBookEntry Not stored — hydrated via $lookupPayment (the cashbook entry whose refId == entry._id).
// trade/trade.schema.ts
export enum TradeEntryTypes {
  PURCHASE = "PURCHASE",   // payable up  → INCREASE the main account
  SALES    = "SALES"       // receivable  → DECREASE the main account
}

@ApSchema({ collection: `finance_trade_entries`, timestamps: true })
export class TradeEntry extends BaseSchema {
  ref: string;                  // PO…  (labelled "Invoice No" in admin)
  type: TradeEntryTypes;        // required
  currency: string;
  currencyRate: string;
  description: string;
  documentDate: number;
  accountId: Types.ObjectId;    // main account
  transactionId: Types.ObjectId;
  transactionIds: Types.ObjectId[];
  branchId: Types.ObjectId;
  status: AccountTransactionStatus = AccountTransactionStatus.SAVED;
  transactions: AccountTransaction[];  // virtual (lookup)
}

// the two aggregation lookups the repo uses on every read:
$lookupTransactions  // finance_account_transactions where refId = _id  → transactions[]
$lookupPayment       // finance_cashbook_entries  where refId = _id  → payment (unwound)

status reuses AccountTransactionStatus { SAVED, POSTED } — trade has no status enum of its own.

2.2 The ledger legs — AccountTransaction

Like cashbook, trade owns no line schema; each line becomes AccountTransaction rows stamped kind = TradeEntry. Relevant fields set: accountId, type, amount, refId (= entry _id), relationId (one per line), status, remark. See transaction for the full table.

2.3 Query/page shapes

TradeEntryQuery adds fromDate, toDate, keyword, accountNumber, and paid (boolean) — the paid filter checks whether a linked cashbook payment exists (see §4.5).


3. API surface

GraphQL (trade/trade.resolver.ts, all @ApGqlAuthorize()):

Operation Type Input Returns Guard / Audit
createTradeEntry Mutation CreateTradeEntryInput TradeEntry @ApBranchAuth({ branchIdRequired }), Audit CREATE
updateTradeEntry Mutation _id, UpdateTradeEntryInput TradeEntry @GuardPostedEntry(TRADE_ENTRIES), Audit UPDATE
deleteTradeEntry Mutation _id Boolean @GuardPostedEntry(TRADE_ENTRIES), Audit DELETE
tradeEntryPage Query TradeEntryPageInput TradeEntryPageResult @ApBranchAuth({ includeBranchQuery })
findOneTradeEntry Query TradeEntryQueryInput TradeEntry
tradeEntrySummary Query TradeEntryQueryInput TradeEntrySummary { totalAmount, totalRecords }

Resolve-fields:

  • amount — sum of legs whose accountId == entry.accountId (the main-account legs).
  • transactions — legs whose accountId != entry.accountId (the counterparty side, for display).
  • accountaccountSvc.findById(accountId).
  • payment — the linked cashbook entry (args.payment from the lookup, else cashbookSvc.findOne({ refId: args._id })).

Input DTO (trade/trade.dto.ts):

@InputType() class CommonTradeEntryInput {
  ref?: string;
  type!: TradeEntryTypes;          // PURCHASE | SALES
  description?: string;
  documentDate?: number;
  accountId!: string;              // the main account
  transactions!: TradeEntryTransactionInput[];
}
class CreateTradeEntryInput extends CommonTradeEntryInput {}
class UpdateTradeEntryInput extends PartialType(CommonTradeEntryInput) {}

// line extends CreateAccountTransactionInput (accountId, amount, remark, documentDate, …) + _id
@InputType() class TradeEntryTransactionInput extends CreateAccountTransactionInput { _id?: string }

REST (trade/trade.controller.ts): GET /api/trade-entry/download — PDF only (downloadType=pdf), rendered from the web template /templates/finance/entries/trade. (XLSX is commented out / not implemented.)


4. Business rules & calculations

4.1 The two-leg-per-line posting (the core)

TradeEntryService.addEntry → for each input line createTransaction(model, entry, transac) (trade/trade.service.ts):

  1. Allocate a relationId (getObjectId()) — unique per line, shared by that line's two 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 === PURCHASE ? "INCREASE" : "DECREASE"  // PURCHASE increases, SALES decreases
    );
  3. Main leg (addMainTransaction): account = entry.accountId, type = transactType, amount = line.amount, kind = TradeEntry, status, relationId, remark.
  4. List leg (addListTransaction): account = line.accountId, type = opposite of the main leg, amount = line.amount, kind = TradeEntry, shared relationId. (It also calls accountSvc.getTypeAndCategory(line.accountId) but does not currently use the result to alter the leg.)
  5. After all lines: accountSvc.validateBalanced() — asserts the whole book balances; rolls back if not.

So per line you get 2 legs (main + list) of opposite type and equal amount → net zero.

Unlike cashbook, trade does not bake an exchange rate into the amount and has no FX-balancing leg; both legs carry the raw line amount.

4.2 Worked GL legs

PURCHASE — book a ₦500,000 payable, main account = "Supplier ABC (Payable)" (LIABILITY):

account type amount why
Supplier ABC Payable (main, LIABILITY) CREDIT 500,000 PURCHASE → INCREASE liability → credit
Purchases / Inventory / Expense (line) DEBIT 500,000 opposite of main

SALES — book a ₦500,000 receivable, main account = "Customer XYZ (Receivable)" (ASSET):

account type amount why
Customer XYZ Receivable (main, ASSET) DEBIT 500,000 SALES → DECREASE? No — see note
Sales / Income (line) CREDIT 500,000 opposite of main

Direction is type-aware, not hardcoded. getTransactionType returns the leg type that achieves the requested INCREASE/DECREASE for that account's category type. For SALES the intent passed is DECREASE; what "decrease" maps to (debit vs credit) depends entirely on the main account's type. The examples above show the common case where the main account is the party's receivable/payable control. Always trace through getTransactionType for the actual account type rather than assuming a fixed debit/credit.

4.3 Status / state machine

        createTradeEntry (status defaults to SAVED on the header)
                 │
                 ▼
            ┌────────┐
            │ SAVED  │  ──(no single post mutation wired)──►  POSTED
            └────────┘
   edit/delete a POSTED entry requires the `edit-posted` action (@GuardPostedEntry)
  • There is no postTradeEntry/saveTradeEntry mutation in the resolver (unlike cashbook's post-many). @GuardPostedEntry still protects update/delete of any entry whose status is POSTED.
  • Status mirrors onto legs via the status passed into each transacSvc.create.

4.4 Update & delete (relation-aware)

  • Update (update): groups existing legs by relationId (helper.groupArrayObj), computes which groups are no longer present in the incoming transactions and deletes those legs (transacSvc.delete(id, "single")); for incoming lines with an _id it calls transacSvc.updateWithRelations(...) (updates the leg and its balancing partner); for lines without an _id it createTransaction(...) (new pair). Then entryRepo.update and validateBalanced. (This is relation-aware diffing — more surgical than cashbook's full delete-and-recreate.)
  • Delete (delete): transacSvc.deleteMany({ refId }), soft-delete header, validateBalanced.
  • A trade entry is considered paid when a cashbook entry exists with refId == trade._id ($lookupPaymentpayment). The repo buildQuery honours a paid filter: paid: true → { 'payment._id': { $ne: null } }, paid: false → { 'payment._id': null }.
  • The admin "Make Payment" button (components/btn-payment.tsx) opens the cashbook create form pre-filled with the trade entry's account + amount and refId = trade._id, type = PAYMENT (for PURCHASE) or RECEIPT (for SALES). On success the trade entry is refetched and now shows "View Payment" linking to /finance/cashbook/{payment._id}.
  • The settlement GL legs are written by cashbook, not trade — see cashbook §4.

4.6 Totals

tradeEntrySummary{ totalAmount, totalRecords }. totalAmount (repo): unwinds legs, groups by type, sums transactions.amount / 2 — the ÷2 recovers the per-line value because each line produces a balanced pair (both legs counted in the unwind would double it).

4.7 Transactionality

addEntry, update, delete each run inside withRetryTransaction("…_trade_entry") — header write, all leg writes, and validateBalanced commit atomically. setSession propagates the session to transacSvc and accountSvc.


4.5-bis Aging basis (important — trade entries are NOT what gets aged)

The brief asks for the "aging basis." The admin's Trade Receivable / Trade Payable report pages (pages/report/trade-receivable, pages/report/trade-payable) call the aged receivable/payable report (financeAgedReceivableReport / financeAgedPayableReport), which ages Orders (OrderKindTypes.SalesInvoice / PurchaseInvoice) — not finance_trade_entries.

The aging algorithm (report/report.aged.utils.ts + report.service.ts → getAgedReport):

// per invoice (Order):
amountDue = totalAmount - totalAmountPaid;            // skip if <= 0
dueDate   = inv.orderDate;                            // NB: due date = the order date itself
daysOverdue = startOfDay(reportDate) - startOfDay(dueDate)   // in whole days

// bucket assignment (calculateAgingBuckets):
daysOverdue <= 30  → bucket_0_30      = amountDue
daysOverdue <= 60  → bucket_31_60     = amountDue
daysOverdue <= 90  → bucket_61_90     = amountDue
else               → bucket_over_90   = amountDue

Buckets are summed per party (grouped by customerId/supplier) and into grand totals (AgedReportResponse { parties[], grandTotals }). Key facts:

  • Aging is off orderDate (there is no separate stored due-date term — dueDate defaults to orderDate).
  • Outstanding = totalAmount − totalAmountPaid on the invoice; fully-paid invoices are excluded.
  • The whole amount lands in a single bucket (no partial-aging across buckets).
  • This report belongs to the report sub-module; trade entries themselves carry no due-date or partial-payment tracking. If you need trade entries to age, that is an extension point — currently their only "settled" signal is the boolean paid (linked cashbook payment exists or not).

5. Permissions

  • Permission module: ApModules.TRADE_ENTRIES = "trade-entries" (permission/permission.enum.ts).
  • Guards: @GuardPostedEntry({ module: TRADE_ENTRIES }) on update/delete (blocks mutating a POSTED entry without edit-posted); @ApBranchAuth enforces a branch on create and injects a branch filter on the page query.
  • All mutations carry @AuditMeta({ module: 'trade', collection: 'finance_trade_entries', … }).

6. Flows

6.1 Create a trade entry (happy path)

  1. Admin opens the trade page (src/modules/finance/trade, new.tsx) and the CreateTradeEntry form with entryType = PURCHASE or SALES.
  2. Picks the main account (selector pre-filters to groups: ['CREDITORS','DEBTORS']), date, "Invoice No" ref; adds counterparty lines (account + amount + remark).
  3. Submit → context.saveTradeEntry(payload, data?._id)createTradeEntry mutation → TradeEntryService.addEntry.
  4. Header created (ref PO… if none given), then per line: main leg + opposite list leg (kind=TradeEntry).
  5. validateBalanced() passes → transaction commits → entry returned with hydrated transactions and payment (null until paid).

6.2 Settle a trade entry

  1. On the trade detail/list, click Make Payment (btn-payment.tsx).
  2. The cashbook create modal opens pre-filled (account = trade's account, amount = trade amount, refId = trade._id), type = PAYMENT (PURCHASE) or RECEIPT (SALES).
  3. Saving the cashbook entry writes the settlement legs (cashbook flow) and links it to the trade entry; the trade row refetches and shows View Payment.

6.3 Unhappy paths

  • No account selected — admin guards (toastSvc.error('Select account')) + Yup.
  • Amount ≤ 0 — Yup .moreThan(0).
  • Out of balancevalidateBalanced throws → whole transaction rolls back.
  • Editing/deleting a POSTED entry without edit-posted@GuardPostedEntry rejects.
  • No branch on create@ApBranchAuth({ branchIdRequired }) rejects.

7. Admin UI

7.1 Routes & module files

  • Module: src/modules/finance/tradecontext.tsx, model.ts, gql/{query,fragment}, page.tsx, new.tsx, detail.tsx, and components/{create,detail,summary,table,btn-payment, tradeTemplate}.tsx.
  • Report views: pages/report/trade-receivable/{index,detailed}.tsx, pages/report/trade-payable/{index,detailed}.tsx (→ aged report, see §4.5-bis), plus PDF templates pages/templates/report/trade-{payable,receivable}.tsx and pages/templates/finance/entries/trade/{index,[_id]}.tsx.

7.2 Context methods (context.tsx, useTradeEntryState)

tradeEntryPage, findOneTradeEntry, saveTradeEntry (create-or-update dispatcher), deleteTradeEntry, plus summary, modal, filter state. Single consumer of useTradeEntryQuery(); refetches after mutations.

7.3 The create form (components/create.tsx)

  • Header: main account select (pre-filter CREDITORS/DEBTORS), date, "Invoice No" (ApIdInput with storageKey="finance_trade_ref" for auto-increment memory).
  • Line grid: Account, Amount (currency-prefixed), delete; a TOTAL row sums the line amounts; a Note textarea.
  • Yup: date + main account required; each line account + amount (>0) required; ≥1 line.
  • Payment is launched from btn-payment.tsx, which reuses the cashbook create component.

8. Dependencies & integrations

  • transactionAccountTransactionService.create / delete / updateWithRelations / deleteMany is the GL writer; getTransactionType for direction.
  • accountfindById, getTypeAndCategory, validateBalanced.
  • cashbook — settlement: a cashbook entry with refId = trade._id marks the trade entry paid; the admin reuses the cashbook create component for "Make Payment."
  • report — the aged receivable/payable report (which ages invoices, not trade entries) is what the "Trade Receivable/Payable" admin pages render.
  • branch@ApBranchAuth scoping.

9. Gotchas & project-specific rules

  • "Trade Receivable/Payable" report ≠ trade entries. The report ages Sales/Purchase Invoices (Orders) off orderDate into 0–30/31–60/61–90/90+ buckets; it never reads finance_trade_entries. Don't conflate the manual trade-entry ledger with the aged report.
  • Ref prefix is PO (purchase-order style) regardless of type — even SALES entries get a PO… ref if none supplied, though the admin labels the field "Invoice No". Supply your own ref to avoid the misleading prefix.
  • tax / bankCharges line fields are inert — the trade service forwards lines to transaction.create but the admin form passes tax/bankCharges as plain numbers, not taxIds, so no tax legs are generated. Don't expect trade entries to compute tax.
  • No single post/save mutation — a trade entry is created SAVED; there is no postTradeEntry. The POSTED state is reachable only by paths that set status directly; @GuardPostedEntry still guards edits of POSTED rows.
  • paid is a derived boolean, not a stored field — it reflects whether a linked cashbook payment exists (payment._id != null). Partial settlement is not modelled on trade entries.
  • Update is relation-aware diffing (delete-removed groups, update lines with _id, create new) — not a full delete-and-recreate like cashbook. Leg _ids survive an edit when the line keeps its _id.
  • Direction is type-aware — never assume PURCHASE = credit / SALES = debit; it depends on the main account's category type via getTransactionType (the leg flips for a liability vs an asset main account).