Note Entries — credit & debit notes (party adjustments, optionally against invoices)
The whole note module reduces to one idea:
A note entry adjusts a party's (debtor/creditor) account up or down, as a balanced two-leg GL posting — and can optionally be applied to a specific invoice to part-settle it. You pick a party (customer or supplier; the
accountIdinput is a user id, resolved to that party's debtor/creditor account), pick a type, and list adjustment lines.type = CREDITincreases the party account;type = DEBITdecreases it. For each line the service writes two balancingAccountTransactionlegs (kind = NoteEntry) sharing onerelationId. WhenapplyInvoiceis on, a line carrying aninvoiceIdinstead books an order payment against that invoice plus a balancing leg on the party's payment account. As always, there is no stored balance — everything lands in the shared GL ledger (transaction).
Source: BE src/modules/finance/note · Admin src/modules/finance/note + src/pages/finance/note-entries/*
Related: _overview · transaction · account · cashbook · trade · taxation
1. Purpose & scope
Credit/debit notes record post-invoice (or standalone) adjustments to a counterparty's running balance:
CREDITnote — increases the party account (e.g. crediting a customer for a return/discount; the order-item union resolves to a SalesInvoiceItem for CREDIT notes).DEBITnote — decreases the party account (e.g. a supplier debit; resolves to a PurchaseInvoiceItem for DEBIT notes).
A note may be standalone (free adjustment lines against arbitrary GL accounts) or applied to an invoice (applyInvoice = true): each invoice line books an order payment against that invoice, effectively a credit/debit-note-driven settlement. Excess (note amount > sum of applied invoice lines) is auto-posted as a separate "Excess Amount" note entry against the payment account (§4.4).
It does NOT:
- Move stock or create the original invoice (it references existing invoices via
invoiceId). - Take a raw GL account as its main account — the
accountIdinput is a party/user id, resolved to a debtor (customer) or creditor (supplier) account viaaccount.getUserAccount(§4.1). - Generate its own bank-charge legs. Tax legs are produced (via
transaction.create) when lines carrytaxId/taxIds.
2. Data model
2.1 finance_note_entries — NoteEntry (the header)
note/note.schema.ts. Extends BaseSchema (_id, companyId, branchId, documentCode, documentDate, createdAt/By, updatedAt/By, soft-delete). Indexes on {branchId,createdAt}, {branchId,type,status}, {ref}, {type}, {status}.
| field | type | required | description |
|---|---|---|---|
ref |
string | yes | Document number. Auto-generated: prefix DNE (DEBIT) / CNE (CREDIT). |
type |
NoteEntryTypes |
yes | CREDIT or DEBIT. |
currency |
string | no | Display currency (informational). |
currencyRate |
string | no | Informational. |
reason |
string | no | Why the note was raised; also default leg remark. |
applyInvoice |
boolean | no | When true, lines with invoiceId settle that invoice (§4.5). |
accountId |
ObjectId | — | The party's resolved account _id (set from getUserAccount). |
paymentAccountId |
ObjectId(str) | — | The cash/bank/payment account used when applyInvoice and for excess. Required when applyInvoice. |
payeeId |
ObjectId | — | The party user _id (customer/supplier) the note is for. |
status |
AccountTransactionStatus |
no | SAVED (default) or POSTED. |
// note/note.schema.ts
export enum NoteEntryTypes {
CREDIT = "CREDIT", // increase the party account
DEBIT = "DEBIT" // decrease the party account
}
@ApSchema({ collection: `finance_note_entries`, timestamps: true })
export class NoteEntry extends BaseSchema {
ref: string; // CNE… / DNE…
type: NoteEntryTypes;
currency: string; currencyRate: string;
reason: string;
applyInvoice: boolean;
accountId: Types.ObjectId; // party's resolved debtor/creditor account
paymentAccountId: string; // payment account (apply-invoice / excess)
payeeId: Types.ObjectId; // party user id
status: AccountTransactionStatus = AccountTransactionStatus.SAVED;
}
// $addAmountField (aggregation): amount = Σ legs where leg.accountId == note.accountId
transactionsis hydrated by aggregation ($lookupTransactions), not stored. The DTO exposes a derivedamount,transactionIds,payee(the user), andpaymentAccount(the payment account + its summed transaction on this note).
2.2 The ledger legs — AccountTransaction
Each note line becomes legs stamped kind = NoteEntry, carrying accountId, type, amount, refId (= note _id), relationId (one per line), payeeId, invoiceId (if applied), status, remark, and pass-through analytics (costCenterId, classId, analysisCodeId, taxId/taxIds, taxInclusive). See transaction for the full table.
2.3 Order-item union (display)
NoteEntryOrderItemUnion resolves a referenced invoice line to a SalesInvoiceItem (CREDIT) or PurchaseInvoiceItem (DEBIT) for the apply-invoice UI.
3. API surface
GraphQL (note/note.resolver.ts, extends FinanceResolver, all @ApGqlAuthorize()):
| Operation | Type | Input | Returns | Guard / Audit |
|---|---|---|---|---|
createNoteEntry |
Mutation | CreateNoteEntryInput |
NoteEntry |
Audit CREATE |
updateNoteEntry |
Mutation | _id, UpdateNoteEntryInput |
NoteEntry |
@GuardPostedEntry(NOTE_ENTRIES), Audit UPDATE |
deleteNoteEntry |
Mutation | _id |
Boolean |
@GuardPostedEntry(NOTE_ENTRIES), Audit DELETE |
deleteManyNoteEntry |
Mutation | DeleteManyNoteEntryInput { ids } |
Boolean |
Audit DELETE |
postNoteEntry |
Mutation | id |
Boolean |
@GuardLockedPeriod(NOTE_ENTRIES), Audit STATUS_CHANGE |
postManyNoteEntry |
Mutation | ids: [String] |
Boolean |
@GuardLockedPeriod(NOTE_ENTRIES, isArray), Audit STATUS_CHANGE |
saveManyNoteEntry |
Mutation | ids: [String] |
Boolean |
Audit STATUS_CHANGE |
importNote |
Mutation | NoteImportInput { file } |
[NoteImport] |
Audit CREATE |
confirmNoteImport |
Mutation | ConfirmNoteImportInput |
Boolean |
Audit CREATE |
noteEntryPage |
Query | NoteEntryPageInput |
NoteEntryPageResult |
— |
findOneNoteEntry |
Query | NoteEntryQueryInput |
NoteEntry |
— |
noteEntrySummary |
Query | NoteEntryQueryInput |
NoteEntrySummary { totalCredits, totalDebits, totalRecords } |
— |
Resolve-fields: payee (userSvc.findById(payeeId)), paymentAccount (the payment account plus the summed transaction on this note — amount + first line's cost-center/class/analysis-code).
Input DTO (note/note.dto.ts):
@InputType() class CommonNoteEntryInput {
ref?: string;
type!: NoteEntryTypes; // CREDIT | DEBIT
reason?: string;
documentDate?: number;
accountId!: string; // PARTY user id (resolved to account via getUserAccount)
applyInvoice?: boolean;
costCenterId?, classId?, analysisCodeId?, taxId?: string;
paymentAccountId?: string; // required if applyInvoice
amount?: number; // required if applyInvoice (the note total; excess auto-handled)
status?: AccountTransactionStatus;
transactions: NoteEntryTransactionInput[]; // defaultValue []
}
class CreateNoteEntryInput extends CommonNoteEntryInput {}
class UpdateNoteEntryInput extends PartialType(CommonNoteEntryInput) {}
@InputType() class NoteEntryTransactionInput extends CommonAccountTransactionInput {
_id?: string;
invoiceId?: string;
// accountId required UNLESS invoiceId is provided (@ValidateIf + @IsNotEmpty)
accountId?: string;
}REST: none (no note controller).
4. Business rules & calculations
4.1 Party resolution (the main account is a user, not an account)
addEntry/update first call account.getUserAccount(model.accountId) — the input accountId is a user id. getUserAccount:
- Customer →
getDebtorAccount(user.accountId)(the customer's debtor/receivable account). - otherwise →
getCreditorAccount(user.accountId)(supplier's creditor/payable account).
The note then stores accountId = account._id and payeeId = user._id. Missing party account → HttpException("…not found, please map a debtor/creditor account…").
4.2 The two-leg-per-line posting (standalone line)
addEntryTransaction (note/note.service.ts) for a line without invoiceId:
- Allocate a
relationIdper line. - Resolve party-account direction:
transactType = getTransactionType( account._id, // the PARTY account model.type === DEBIT ? "DECREASE" : "INCREASE" // DEBIT decreases, CREDIT increases the party ); - Account (list) leg (
addAccountTransaction): account =line.accountId(the offsetting GL account),type = transactType,kind = NoteEntry, carries analytics + tax,remark = line.remark || reason. - Main (party) leg (
addMainAccountTransaction): account = the partyaccount._id,type =opposite,payeeId = user._id,kind = NoteEntry, samerelationId. - After all lines (+ excess if applicable):
accountSvc.validateBalanced()— rolls back if unbalanced.
Note the (slightly unusual) leg naming:
transactTypeis computed for the party account but is applied to the list (offsetting) leg; the party leg gets the opposite. The pair still nets to zero and the party balance moves in the intended direction.
4.3 Worked GL legs
CREDIT note — credit customer ₦20,000, offsetting account = "Sales Returns" (party = Customer, receivable is an ASSET):
| account | type | amount | why |
|---|---|---|---|
| Sales Returns (list) | computed for party INCREASE | 20,000 | getTransactionType(party, INCREASE) |
| Customer Receivable (party) | opposite | 20,000 | the party leg |
The net effect for a CREDIT note is to increase the customer account; for a DEBIT note, decrease it. Exact debit/credit on each leg follows the party account's category type — trace getTransactionType for the real type rather than assuming.
4.4 Excess handling
If applyInvoice and noteAmount − Σ(applied line amounts) > 0, submitExcessEntry creates a second note entry (ref reused, reason "… (Excess Amount)") and books the excess as a leg against the payment account (paymentAccountId), status POSTED. So an over-applied note splits into the invoice applications + one excess entry.
4.5 Apply-to-invoice (the settlement leg)
For a line with invoiceId (addInvoiceTransaction):
orderPaymentSvc.addSalesPayment({ refId: note._id, amount, invoiceId, relationId, documentDate, accountId: note.paymentAccountId })— this is the order payment that part-settles the invoice.addSalesPayment→newPaymentcomputes the payment leg's type viagetTransactionType(paymentAccount, order.kind === PurchaseInvoice ? "DECREASE" : "INCREASE"), writes the payment leg (refId = invoiceId), and validates balance.- The note then writes a balancing main (party) leg of the opposite type to the payment leg (
addMainAccountTransaction), so the note's ownrefId = note._idset still balances. - Validation (
validateInvoicePayment): eachinvoiceIdmust exist and the applied amount must not exceed the invoicetotalAmount, elseHttpException(NOT_ACCEPTABLE).
Effect: a credit/debit note applied to an invoice reduces that invoice's outstanding (via the order-payment subsystem) and moves the party balance — the two halves share the line's
relationId. See [order/payment] in the inventory domain for the invoice side.
4.6 Status / state machine
createNoteEntry (status SAVED or POSTED from payload)
│
▼
┌────────┐ postNoteEntry / postManyNoteEntry
│ SAVED │ ──────────────────────────────────► ┌────────┐
│(draft) │ │ POSTED │
└────────┘ ◄─────────────────────────────────── │(final) │
│ saveManyNoteEntry └────────┘
edit/delete freely while SAVED edit/delete POSTED requires `edit-posted`
Posting/saving updates the header and updateMany legs by refId. Status mirrors onto legs.
4.7 Update & delete
- Update: re-resolves the party account; for lines with
_id→transacSvc.updateWithRelations(updates the leg + its relation partner, re-pointing to the new account); for new lines →addEntryTransaction; ifentry.applyInvoice→updateInvoicePaymentTransactionpatches the payment-account legs (account/cost-center/class/analysis/tax/amount). ThenentryRepo.update+validateBalanced. (Does not delete missing lines the way trade does — only updates/adds.) - Delete:
transacSvc.deleteMany({ refId })+ soft-delete header (in parallel) +validateBalanced.
4.8 Transactionality
addEntry, update, delete, confirmImport each run inside withRetryTransaction("…_note…"). setSession propagates the session to userSvc, accountSvc, transacSvc, orderPaymentSvc, orderSvc so the apply-invoice payment commits in the same session.
4.9 Summary
noteEntrySummary → entrySvc.totalDrAnCr(query) → { totalCredits, totalDebits, totalRecords } (debits/credits aggregated over the note legs).
5. Permissions
- Permission module:
ApModules.NOTE_ENTRIES = "note-entries". - Guards:
@GuardPostedEntry({ module: NOTE_ENTRIES })on update/delete (needsedit-postedfor a POSTED entry);@GuardLockedPeriod({ module: NOTE_ENTRIES })on post / post-many (needspost-to-locked-periodfor a locked fiscal period). PlusvalidateTransactionDateon every leg. - All mutations carry
@AuditMeta({ module: 'note', collection: 'finance_note_entries', … }).
6. Flows
6.1 Create a standalone credit note (happy path)
- Admin opens Finance → Note Entries → new (
CreateNoteEntry type=CREDIT). - Picks the party (customer for CREDIT / supplier for DEBIT via
ApCustomerSelectionetc.), date, ref, reason; adds adjustment lines (offset account + amount, optional analytics/tax). Leaves "apply to invoice" off (val.type.value !== 'invoice'). - Submit →
context.saveNoteEntry(payload)→createNoteEntry→NoteEntryService.addEntry. - Party account resolved; header created (
CNE…); per line: list leg + party leg. validateBalanced()passes → commit → note returned hydrated.
6.2 Create a credit note applied to an invoice
- Same form, set type = "invoice" (
applyInvoice = true), choose a payment account and the note amount; select invoice lines (only selectedkeys are submitted). addEntry→ for each invoice line:addSalesPayment(settles the invoice) + balancing party leg.- If the note amount exceeds the applied lines, the excess is posted against the payment account as a separate "Excess Amount" note.
6.3 Post / save toggling — postManyNoteEntry / saveManyNoteEntry flip status on header + legs.
6.4 Import (XLSX)
- Import (
pages/finance/note-entries/import.tsx) →importNote(service.import): parses rows, batch-resolves vendor (customer first, then supplier), account (by number then name), and master (cost-center/class/analysis/department) lookups; returns a preview (NoteImport[]). - Confirm Import (
…/confirm-import.tsx) →confirmNoteImport: validates dates against fiscal period, groups rows by vendor, and callsaddEntryonce per vendor (applyInvoice: false, amountsMath.abs), thenvalidateBalanced.
6.5 Unhappy paths
- Party has no debtor/creditor account —
HttpExceptionfromgetUserAccount. - Applied amount > invoice total / invoice missing —
validateInvoicePaymentthrows (406). - Out of balance —
validateBalancedthrows → rollback. - Edit/delete POSTED without
edit-posted—@GuardPostedEntryrejects. - Post into locked period without
post-to-locked-period—@GuardLockedPeriod/validateTransactionDaterejects.
7. Admin UI
7.1 Routes & module files
- Pages:
pages/finance/note-entries/{index..tsx, import.tsx, confirm-import.tsx, [_id]/index.tsx}. (Note theindex..tsxdouble-dot filename — see §9.) - Module:
src/modules/finance/note—context.tsx,model.ts,gql/{query,fragment},page.tsx,detail.tsx,validation/{createFormSchema,importFormSchema}.tsx, andcomponents/{create,btnNote,detail-modal,detail-section,detail-columns,summary,table,import, confirmImport,export}.tsx.
7.2 Context (useNoteEntryState)
saveNoteEntry (create-or-update), page/findOne/summary, post/save/delete-many, import/confirm — single consumer of useNoteEntryQuery(), refetches after mutations.
7.3 The create form (components/create.tsx)
- Party select (customer for CREDIT / supplier for DEBIT), date, ref (
ApIdInput), reason. - A mode select (
val.type.value): standalone lines vs'invoice'(apply-to-invoice). In invoice mode it pulls the party's invoices (useOrderState), shows selectable invoice-line rows, and requires a payment account + note amount. - Line grid with amount, analytics (cost-center/class/analysis-code master selects), tax (multi-select + inclusive), with client-side subtotal/tax/grand-total via
computeLineTaxBreakdown. - Save / Save & New / Save & Post buttons (status chosen via
submitActionRef).
8. Dependencies & integrations
- transaction — GL leg writer (
create/update/updateWithRelations/deleteMany), tax-leg generation,getTransactionType. - account —
getUserAccount/getDebtorAccount/getCreditorAccount,validateBalanced,findById. - order/payment (inventory) —
OrderPaymentService.addSalesPaymentfor apply-to-invoice settlement; order —OrderService.findByIdfor invoice validation. - user / customer / supplier — party resolution and import vendor lookup.
- fiscal —
validateTransactionDate(locked-period chokepoint). - master — cost-center/class/analysis-code/department lookups on import.
- upload — XLSX import (
GraphQLUpload).
9. Gotchas & project-specific rules
accountIdis a USER id, not an account id. The note resolves it to the party's debtor/creditor account viagetUserAccount. Passing a raw GL account id will fail party resolution. (Compare: cashbook/tradeaccountIdis a GL account id.)- CREDIT ⇒ customer / SalesInvoiceItem; DEBIT ⇒ supplier / PurchaseInvoiceItem — the union and the admin party selector switch on
type. - Excess auto-splits into a second note posted to the payment account (POSTED), reusing the ref — expect two
finance_note_entriesrows from one over-applied note. - Apply-invoice reaches into the order subsystem — it writes an
OrderPayment(settling the invoice) in the same Mongo session; deleting/editing a note must keep those in sync (the service patches payment-account legs on update viaupdateInvoicePaymentTransaction). - Update does not delete removed lines (unlike trade's relation-aware diff) — it only updates lines with
_idand adds new ones. Removing a line in the UI may leave its legs behind; verify before relying on edit-to-remove. - Ref prefixes:
CNE(credit note) /DNE(debit note). - Direction is type-aware — never assume CREDIT = credit leg on the party; the party leg's debit/credit depends on whether the party account is an asset (receivable) or liability (payable).
pages/finance/note-entries/index..tsxhas a literal double-dot in the filename (likely a typo); the route still resolves but flag it during a rebuild.page()resolver logs"res i am done.."— stray debug log left innote.resolver.ts.