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
AccountTransactionlegs sharing onerelationId.type = PURCHASEincreases the main account (you now owe a supplier → payable up);type = SALESdecreases it. Once booked, a trade entry can be linked to a cashbook payment/receipt (viaCashBookEntry.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/bankChargesinput fields exist on the line interface but the trade service does not post separate tax or charge legs (the line is forwarded totransaction.create, which spawns tax legs only if realtaxId/taxIdsare present — the admin form does not pass them).
2. Data model
2.1 finance_trade_entries — TradeEntry (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)
statusreusesAccountTransactionStatus { 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 whoseaccountId == entry.accountId(the main-account legs).transactions— legs whoseaccountId != entry.accountId(the counterparty side, for display).account—accountSvc.findById(accountId).payment— the linked cashbook entry (args.paymentfrom the lookup, elsecashbookSvc.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):
- Allocate a
relationId(getObjectId()) — unique per line, shared by that line's two legs. - 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 ); - Main leg (
addMainTransaction): account =entry.accountId,type = transactType,amount = line.amount,kind = TradeEntry,status,relationId,remark. - List leg (
addListTransaction): account =line.accountId,type =opposite of the main leg,amount = line.amount,kind = TradeEntry, sharedrelationId. (It also callsaccountSvc.getTypeAndCategory(line.accountId)but does not currently use the result to alter the leg.) - 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.
getTransactionTypereturns the leg type that achieves the requested INCREASE/DECREASE for that account's category type. For SALES the intent passed isDECREASE; 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 throughgetTransactionTypefor 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/saveTradeEntrymutation in the resolver (unlike cashbook's post-many).@GuardPostedEntrystill protects update/delete of any entry whose status is POSTED. - Status mirrors onto legs via the
statuspassed into eachtransacSvc.create.
4.4 Update & delete (relation-aware)
- Update (
update): groups existing legs byrelationId(helper.groupArrayObj), computes which groups are no longer present in the incomingtransactionsand deletes those legs (transacSvc.delete(id, "single")); for incoming lines with an_idit callstransacSvc.updateWithRelations(...)(updates the leg and its balancing partner); for lines without an_iditcreateTransaction(...)(new pair). ThenentryRepo.updateandvalidateBalanced. (This is relation-aware diffing — more surgical than cashbook's full delete-and-recreate.) - Delete (
delete):transacSvc.deleteMany({ refId }), soft-delete header,validateBalanced.
4.5 "Paid" status & settlement (the trade ↔︎ cashbook link)
- A trade entry is considered paid when a cashbook entry exists with
refId == trade._id($lookupPayment→payment). The repobuildQueryhonours apaidfilter: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 andrefId = 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 = amountDueBuckets 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 —dueDatedefaults toorderDate). - Outstanding =
totalAmount − totalAmountPaidon 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 withoutedit-posted);@ApBranchAuthenforces 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)
- Admin opens the trade page (
src/modules/finance/trade,new.tsx) and theCreateTradeEntryform withentryType = PURCHASEorSALES. - Picks the main account (selector pre-filters to
groups: ['CREDITORS','DEBTORS']), date, "Invoice No" ref; adds counterparty lines (account + amount + remark). - Submit →
context.saveTradeEntry(payload, data?._id)→createTradeEntrymutation →TradeEntryService.addEntry. - Header created (ref
PO…if none given), then per line: main leg + opposite list leg (kind=TradeEntry). validateBalanced()passes → transaction commits → entry returned with hydratedtransactionsandpayment(null until paid).
6.2 Settle a trade entry
- On the trade detail/list, click Make Payment (
btn-payment.tsx). - The cashbook create modal opens pre-filled (account = trade's account, amount = trade amount,
refId = trade._id),type= PAYMENT (PURCHASE) or RECEIPT (SALES). - 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 balance —
validateBalancedthrows → whole transaction rolls back. - Editing/deleting a POSTED entry without
edit-posted—@GuardPostedEntryrejects. - No branch on create —
@ApBranchAuth({ branchIdRequired })rejects.
7. Admin UI
7.1 Routes & module files
- Module:
src/modules/finance/trade—context.tsx,model.ts,gql/{query,fragment},page.tsx,new.tsx,detail.tsx, andcomponents/{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 templatespages/templates/report/trade-{payable,receivable}.tsxandpages/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" (
ApIdInputwithstorageKey="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
- transaction —
AccountTransactionService.create / delete / updateWithRelations / deleteManyis the GL writer;getTransactionTypefor direction. - account —
findById,getTypeAndCategory,validateBalanced. - cashbook — settlement: a cashbook entry with
refId = trade._idmarks 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 —
@ApBranchAuthscoping.
9. Gotchas & project-specific rules
- "Trade Receivable/Payable" report ≠ trade entries. The report ages Sales/Purchase Invoices (Orders) off
orderDateinto 0–30/31–60/61–90/90+ buckets; it never readsfinance_trade_entries. Don't conflate the manual trade-entry ledger with the aged report. - Ref prefix is
PO(purchase-order style) regardless oftype— even SALES entries get aPO…ref if none supplied, though the admin labels the field "Invoice No". Supply your own ref to avoid the misleading prefix. tax/bankChargesline fields are inert — the trade service forwards lines totransaction.createbut the admin form passestax/bankChargesas plain numbers, nottaxIds, 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 setstatusdirectly;@GuardPostedEntrystill guards edits of POSTED rows. paidis 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).