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 accountId input is a user id, resolved to that party's debtor/creditor account), pick a type, and list adjustment lines. type = CREDIT increases the party account; type = DEBIT decreases it. For each line the service writes two balancing AccountTransaction legs (kind = NoteEntry) sharing one relationId. When applyInvoice is on, a line carrying an invoiceId instead 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:

  • CREDIT note — increases the party account (e.g. crediting a customer for a return/discount; the order-item union resolves to a SalesInvoiceItem for CREDIT notes).
  • DEBIT note — 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 accountId input is a party/user id, resolved to a debtor (customer) or creditor (supplier) account via account.getUserAccount (§4.1).
  • Generate its own bank-charge legs. Tax legs are produced (via transaction.create) when lines carry taxId/taxIds.

2. Data model

2.1 finance_note_entriesNoteEntry (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

transactions is hydrated by aggregation ($lookupTransactions), not stored. The DTO exposes a derived amount, transactionIds, payee (the user), and paymentAccount (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:

  1. Allocate a relationId per line.
  2. Resolve party-account direction:
    transactType = getTransactionType(
      account._id,                                   // the PARTY account
      model.type === DEBIT ? "DECREASE" : "INCREASE" // DEBIT decreases, CREDIT increases the party
    );
  3. Account (list) leg (addAccountTransaction): account = line.accountId (the offsetting GL account), type = transactType, kind = NoteEntry, carries analytics + tax, remark = line.remark || reason.
  4. Main (party) leg (addMainAccountTransaction): account = the party account._id, type = opposite, payeeId = user._id, kind = NoteEntry, same relationId.
  5. After all lines (+ excess if applicable): accountSvc.validateBalanced() — rolls back if unbalanced.

Note the (slightly unusual) leg naming: transactType is 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):

  1. orderPaymentSvc.addSalesPayment({ refId: note._id, amount, invoiceId, relationId, documentDate, accountId: note.paymentAccountId }) — this is the order payment that part-settles the invoice. addSalesPaymentnewPayment computes the payment leg's type via getTransactionType(paymentAccount, order.kind === PurchaseInvoice ? "DECREASE" : "INCREASE"), writes the payment leg (refId = invoiceId), and validates balance.
  2. The note then writes a balancing main (party) leg of the opposite type to the payment leg (addMainAccountTransaction), so the note's own refId = note._id set still balances.
  3. Validation (validateInvoicePayment): each invoiceId must exist and the applied amount must not exceed the invoice totalAmount, else HttpException(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 _idtransacSvc.updateWithRelations (updates the leg + its relation partner, re-pointing to the new account); for new lines → addEntryTransaction; if entry.applyInvoiceupdateInvoicePaymentTransaction patches the payment-account legs (account/cost-center/class/analysis/tax/amount). Then entryRepo.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

noteEntrySummaryentrySvc.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 (needs edit-posted for a POSTED entry); @GuardLockedPeriod({ module: NOTE_ENTRIES }) on post / post-many (needs post-to-locked-period for a locked fiscal period). Plus validateTransactionDate on every leg.
  • All mutations carry @AuditMeta({ module: 'note', collection: 'finance_note_entries', … }).

6. Flows

6.1 Create a standalone credit note (happy path)

  1. Admin opens Finance → Note Entries → new (CreateNoteEntry type=CREDIT).
  2. Picks the party (customer for CREDIT / supplier for DEBIT via ApCustomerSelection etc.), date, ref, reason; adds adjustment lines (offset account + amount, optional analytics/tax). Leaves "apply to invoice" off (val.type.value !== 'invoice').
  3. Submit → context.saveNoteEntry(payload)createNoteEntryNoteEntryService.addEntry.
  4. Party account resolved; header created (CNE…); per line: list leg + party leg.
  5. validateBalanced() passes → commit → note returned hydrated.

6.2 Create a credit note applied to an invoice

  1. Same form, set type = "invoice" (applyInvoice = true), choose a payment account and the note amount; select invoice lines (only selected keys are submitted).
  2. addEntry → for each invoice line: addSalesPayment (settles the invoice) + balancing party leg.
  3. 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)

  1. 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[]).
  2. Confirm Import (…/confirm-import.tsx) → confirmNoteImport: validates dates against fiscal period, groups rows by vendor, and calls addEntry once per vendor (applyInvoice: false, amounts Math.abs), then validateBalanced.

6.5 Unhappy paths

  • Party has no debtor/creditor accountHttpException from getUserAccount.
  • Applied amount > invoice total / invoice missingvalidateInvoicePayment throws (406).
  • Out of balancevalidateBalanced throws → rollback.
  • Edit/delete POSTED without edit-posted@GuardPostedEntry rejects.
  • Post into locked period without post-to-locked-period@GuardLockedPeriod / validateTransactionDate rejects.

7. Admin UI

7.1 Routes & module files

  • Pages: pages/finance/note-entries/{index..tsx, import.tsx, confirm-import.tsx, [_id]/index.tsx}. (Note the index..tsx double-dot filename — see §9.)
  • Module: src/modules/finance/notecontext.tsx, model.ts, gql/{query,fragment}, page.tsx, detail.tsx, validation/{createFormSchema,importFormSchema}.tsx, and components/{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.
  • accountgetUserAccount / getDebtorAccount / getCreditorAccount, validateBalanced, findById.
  • order/payment (inventory)OrderPaymentService.addSalesPayment for apply-to-invoice settlement; orderOrderService.findById for invoice validation.
  • user / customer / supplier — party resolution and import vendor lookup.
  • fiscalvalidateTransactionDate (locked-period chokepoint).
  • master — cost-center/class/analysis-code/department lookups on import.
  • upload — XLSX import (GraphQLUpload).

9. Gotchas & project-specific rules

  • accountId is a USER id, not an account id. The note resolves it to the party's debtor/creditor account via getUserAccount. Passing a raw GL account id will fail party resolution. (Compare: cashbook/trade accountId is 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_entries rows 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 via updateInvoicePaymentTransaction).
  • Update does not delete removed lines (unlike trade's relation-aware diff) — it only updates lines with _id and 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..tsx has 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 in note.resolver.ts.