Payment Entry — receipts & disbursements settled against invoices

The whole payment module reduces to one idea:

A Payment Entry is a header that allocates one lump sum of money across one or more outstanding PurchaseInvoice / SalesInvoice orders, and for each allocation it writes a balanced pair of GL legs (one against the bank/cash payment account, one against the customer/supplier control account). Like everything in finance, it stores no balance — an invoice's outstanding amount is always totalAmount − Σ(allocated payments), re-aggregated on every read.

Source: BE finance/payment (+ the actual GL writer inventory/order/payment) · Admin finance/payment · Routes pages/finance/payment-entries

Two distinct collections cooperate; do not confuse them:

Collection Class Role
finance_payments Payment (BE finance/payment/payment.schema.ts) The header — one document per payment run (who paid/was paid, which payment account, total, status, note). Holds no amounts per invoice.
finance_account_transactions OrderPayment + AccountTransaction (BE inventory/order/payment/payment.schema.ts, finance/transaction/transaction.schema.ts) The GL legs — the actual debits/credits. The per-invoice allocation lives here, not on the header.

A Payment Entry is not the same as an inline invoice payment. The Order screen can also record a payment directly via OrderPaymentService.create (kind OrderPayment + OrderPayment2). The Payment Entry module is the batch settlement UI that drives the same OrderPaymentService under the hood (addSalesPayment, kind OrderPayment) and adds its own PaymentEntry control leg.


1. Purpose & scope

The Payment Entry module lets an operator pick a customer or supplier, list that party's pending invoices, enter a single total to pay/receive, and have the system distribute that amount across the selected invoices (oldest-first by selection order) — recording the cash movement and reducing each invoice's outstanding balance in one atomic transaction.

It is responsible for:

  • Selecting the counterparty and resolving their control account (debtor AR / creditor AP) via AccountService.getUserAccount.
  • Listing pending invoices (paymentBalance > 0) for that party and invoice kind.
  • Distributing a lump-sum payment across selected invoices (partial payments supported).
  • Handling excess (overpayment) by parking the surplus on the counterparty's control account.
  • Writing the balanced GL legs (payment-account leg + control-account leg + per-invoice OrderPayment legs).
  • Posting / saving / deleting entries (the SAVED→POSTED lifecycle).
  • XLSX download of payment entries.

It explicitly does not:

  • Create or modify invoices (it only reads Order headers and writes OrderPayment legs against them).
  • Move stock — see ../inventory/_overview.md.
  • Compute tax bases for invoices — the tax columns on the form are commented out in code (see §7); tax handling that does exist is the generic per-leg tax engine in taxation.md.
  • Reconcile bank statements — that is the cashbook module (see _overview.md §6).

2. Data model

2.1 finance_payments — the header (Payment)

finance/payment/payment.schema.ts. Extends BaseSchema (gives _id, ref, companyId, branchId, documentCode, documentDate, createdAt/By, updatedAt/By, canView/canDelete/...) and registers mongoose-delete (soft delete; soft-deleted rows drop out of all aggregations).

field type required? description
accountId ObjectId yes The counterparty control account (debtor AR for a customer, creditor AP for a supplier). Resolved server-side from the picked user via getUserAccount; the input accountId is actually a userId (see §4.1).
paymentAccountId ObjectId (stored as string-set) yes The bank or cash account money flows through.
payeeId ObjectId yes The counterparty user (user._id), indexed. Subdivides the control account by party.
type PaymentEntryKindTypes yes PurchaseInvoice (disbursement to supplier) or SalesInvoice (receipt from customer). Default PurchaseInvoice.
status AccountTransactionStatus yes SAVED (draft) or POSTED (final). Default SAVED.
note string no Free-text remark; flows to each leg's remark when the line has none.
export enum PaymentEntryKindTypes {
  PurchaseInvoice = "PurchaseInvoice",   // pay a supplier  → disbursement
  SalesInvoice    = "SalesInvoice"       // collect a customer → receipt
}

@ApSchema({ collection: "finance_payments" })
export class Payment extends BaseSchema {
  accountId: Types.ObjectId;        // control account (AR/AP)
  paymentAccountId: string;         // bank/cash account
  payeeId: Types.ObjectId;          // counterparty user
  type: PaymentEntryKindTypes;      // default PurchaseInvoice
  status: AccountTransactionStatus; // default SAVED
  note: string;
}

Indexes: { ref }, { payeeId }, { branchId, documentDate:-1 }, { branchId, type, status }.

The header carries no amount field. The paid total is reconstructed from the legs (AccountTransactionService.totalAmount({ accountId, refId })) — see the controller download and the paymentAccount resolver.

2.2 The GL legs — OrderPayment / AccountTransaction

The per-invoice allocations and the cash movement are rows in finance_account_transactions. They all extend AccountTransactionEntity (finance/transaction/transaction.schema.ts). The fields a payment uses:

field meaning for a payment leg
accountId the GL account this leg hits (payment account or control account)
type DEBIT or CREDIT — direction decided by getTransactionType (§4.3)
amount absolute allocation amount (positive; sign carried by type)
refId the invoiceId for OrderPayment legs; the Payment header _id for the PaymentEntry control leg
ref2Id the Payment header _id on the OrderPayment leg (refId is taken by invoiceId)
relationId shared by the balancing pair (control leg ↔︎ order-payment leg) so edits/deletes stay balanced
payeeId counterparty user (on the control leg)
kind OrderPayment (the per-invoice cash leg) or PaymentEntry (the counterparty control leg) — plus TaxEntry if a tax is attached
documentDate the per-line payment date (falls back to header date) — used for fiscal-period locking
exchangeRate defaults 1; never persisted as 0 (a 0 rate would zero the leg in amount×rate report aggregations)

OrderPayment extends AccountTransactionEntity and is stored in the same collection via the kind discriminator:

@ApSchema({ timestamps: false })
export class OrderPayment extends AccountTransactionEntity {
  kind: string;          // = "OrderPayment"
  cashAccountId: string;
  bankAccountId: string;
}

Relevant AccountTransactionKind values for this module (finance/transaction/transaction.schema.ts):

OrderPayment   // the per-invoice cash leg written by OrderPaymentService.newPayment
OrderPayment2  // the balancing control leg for an *inline* order payment (OrderPaymentService.create)
PaymentEntry   // the control leg written by the Payment Entry module (PaymentService.addInvoiceTransaction)
TaxEntry       // an auto-spawned tax leg, if a line carries taxId/taxIds

Why two kinds for the control leg? The inline order-payment flow (OrderPaymentService.create) writes its own control leg as OrderPayment2. The batch Payment Entry flow writes its control leg as PaymentEntry (in PaymentService.addInvoiceTransaction) and only calls OrderPaymentService.addSalesPayment, which writes the single OrderPayment cash-against-invoice leg (it does not write its own OrderPayment2). So a Payment Entry line = one OrderPayment leg (refId=invoice, ref2Id=header) + one PaymentEntry leg (refId=header).

2.3 Derived / read-time computations

  • Invoice outstanding (Order.paymentBalance): computed in the order list aggregation (inventory/order/order.schema.ts):
    totalAmount     = Σ items.amount
    totalAmountPaid = Σ payments.amount        // payments = OrderPayment legs on this invoice
    paymentBalance  = totalAmount − totalAmountPaid
    An invoice is PENDING when paymentBalance > 0, PAID when = 0 (OrderPaymentStatusTypes).
  • Payment header total (read-time): Σ amount of the legs where accountId == header.accountId && refId == header._id (paymentAccount resolver and controller download).

3. API surface

All resolver-level operations are @ApGqlAuthorize() and audited via @AuditMeta({ module: "payment", collection: "payments", ... })../../platform/audit-trail.md.

Operation Type Input Returns Permission
paymentEntryPage Query PaymentEntryPageInput PaymentEntryPageResult payment-entries view
findOnePaymentEntry Query PaymentEntryQueryInput (_id/keyword/dates) PaymentEntry payment-entries view
createPaymentEntry Mutation CreatePaymentEntryInput PaymentEntry payment-entries create
updatePaymentEntry Mutation _id, UpdatePaymentEntryInput PaymentEntry payment-entries edit
deletePaymentEntry Mutation _id Boolean payment-entries delete
deleteManyPaymentEntry Mutation DeleteManyPaymentEntryInput { ids } Boolean delete
postPaymentEntry Mutation id Boolean post
postManyPaymentEntry Mutation ids: [String!] Boolean post
saveManyPaymentEntry Mutation ids: [String!] Boolean post/save

Resolve-fields on PaymentEntry (payment.resolver.ts):

  • payeeUser (from payeeId).
  • paymentAccount → the bank/cash Account enriched with a synthetic transaction carrying the summed amount and the first leg's costCenterId/classId/analysisCodeId (legs matched by accountId == paymentAccountId && refOrRef2Id == header._id).
  • transactions → the entry's legs filtered to those with an invoiceId (the per-invoice allocations only; the bare control/excess legs are hidden from the grid).

3.1 Input DTOs (payment.dto.ts / schema.gql)

input CreatePaymentEntryInput {
  ref: String
  amount: Float!                 # the lump sum to distribute
  accountId: String!             # the counterparty USER id (→ resolved to control account)
  paymentAccountId: String!      # bank/cash account
  costCenterId: String
  classId: String
  analysisCodeId: String
  type: String!                  # PaymentEntryKindTypes
  status: AccountTransactionStatus!
  note: String
  documentDate: Float
  transactions: [PaymentEntryTransactionInput!]   # @IsNotEmptyArray "At least one transaction is required"
}

input PaymentEntryTransactionInput {     # extends CommonAccountTransactionInput
  _id: String                  # present → update existing leg; absent → new leg
  invoiceId: ID                # the Order being settled
  accountId: String            # (overrides base) the per-line account
  amount: Float!
  bankCharges: Float = 0
  exchangeRate: Float = 0      # coerced to 1 server-side
  purity: Float                # gold purity, used in purityValue = amount*purity/100
  remark: String
  documentDate: Float
  costCenterId / departmentId / classId / analysisCodeId: String
  taxId: String                # single-tax
  taxIds: [ID!]                # multi-tax
  taxInclusive: Boolean = false
  type: AccountTransactionTypes
  status: AccountTransactionStatus
  cashflowCategory: CashFlowCategory
}

UpdatePaymentEntryInput is PartialType(PaymentEntryCommonInput).

3.2 REST controller (payment.controller.ts)

GET /api/payment-entries/download?downloadType=xlsx&... (@ApiAuthorize()). Resolves each entry's amount via totalAmount({ accountId, refId }), joins cost-center/class/analysis-code names from masters, and streams an XLSX (Ref, Account Name/Number, Amount, Type, Date, Cost Center, Class, Analysis Code, Status, Document Date).


4. Business rules & calculations

4.1 Counterparty → control account resolution

The input accountId is the picked user's id, not an account id. addEntry resolves it:

const account = await this.accountSvc.getUserAccount(model.accountId?.toString());
const user    = account?.user;
if (!account) throw new HttpException("Account not found for payment entry", 404);

getUserAccount (finance/account/account.service.ts) routes by user.kind: Customer → getDebtorAccount(user.accountId) (AR), else getCreditorAccount(user.accountId) (AP). The header is then stored with accountId = account._id, payeeId = user._id.

4.2 Validation

  • @IsNotEmptyArray on transactions → at least one line required.
  • validateInvoicePayment(model) — for each line with an invoiceId, loads the Order; if it does not exist, accumulates Invoice <id> not found and throws 406 Not Acceptable. If the line amount exceeds the invoice total it only logs a notice (overpayment is allowed — handled as excess, §4.5).
  • On update, each line date is run through fiscalPeriodSvc.validateTransactionDate(date) — a date in a locked fiscal period is rejected. (AccountTransactionService.create re-checks documentDate on every leg too — the central locked-period chokepoint.)

4.3 Distribution / matching algorithm (distributePayments)

The single lump sum (model.amount) is spread across the selected lines (those with amount > 0), in array order, capped per line at that line's entered amount:

private distributePayments(totalAmount, transactions) {
  let remaining = totalAmount;
  const distributed = [];
  for (const trans of transactions) {
    if (remaining <= 0) break;
    const applyAmount = Math.min(remaining, trans.amount || 0);   // never over-apply a single line
    distributed.push({ ...trans, amount: applyAmount });
    remaining -= applyAmount;
  }
  return { distributedTransactions: distributed, excessAmount: Math.max(0, remaining) };
}

Consequences:

  • Partial payment is the natural case: a line's applyAmount may be less than its outstanding balance, leaving the invoice still PENDING.
  • If totalAmount runs out mid-list, trailing selected lines get nothing (no leg written).
  • If totalAmount exceeds the sum of all line amounts, the leftover is excessAmount (§4.5).

The admin UI keeps amount in sync with Σ selected line amounts (see §7), so in normal use excessAmount is 0. Excess only arises when the header amount is forced above the line total.

4.4 GL legs per allocation (addInvoiceTransaction)

For each distributed line, two legs are written, sharing one relationId:

Leg A — the cash-against-invoice leg via OrderPaymentService.addSalesPayment:

addSalesPayment({
  ref2Id:    entry._id,            // header (refId is taken by invoiceId)
  invoiceId: trans.invoiceId,      // → becomes refId
  accountId: entry.paymentAccountId,   // the BANK/CASH account
  amount:    trans.amount,
  relationId, status, documentDate,
  remark:    trans.remark || entry.note
})
// inside newPayment():
type = getTransactionType(paymentAccountId,
         order.kind === PurchaseInvoice ? "DECREASE" : "INCREASE")
kind = OrderPayment

Leg B — the counterparty control leg (the opposite type of Leg A):

transacSvc.create({
  refId:     entry._id,
  accountId: entry.accountId,       // the AR/AP CONTROL account
  payeeId:   entry.payeeId,
  amount:    trans.amount,
  relationId,
  kind:      AccountTransactionKind.PaymentEntry,
  type:      legA.type === CREDIT ? DEBIT : CREDIT,   // mirror
  taxId / taxIds / taxInclusive: trans.*              // tax engine may spawn TaxEntry legs
})

getTransactionType(accountId, "INCREASE"|"DECREASE") (transaction.service.ts) maps a business intent to DEBIT/CREDIT using the account's category type (ACCOUNT_TYPES, see _overview.md §1.1). So the direction is never hardcoded — it follows the normal balance of the bank/cash account.

Worked GL legs

Customer receipt (type = SalesInvoice) — collecting ₦100 into a cash account against a sales invoice:

Leg Account Type Amount kind refId ref2Id
A Cash/Bank (ASSET) DEBIT (INCREASE of an asset) 100 OrderPayment invoiceId header
B Accounts Receivable (ASSET) CREDIT (reduce what customer owes) 100 PaymentEntry header

Cash ↑, AR ↓ — the customer's debt is settled.

Supplier disbursement (type = PurchaseInvoice) — paying ₦100 out of a cash account against a purchase invoice:

Leg Account Type Amount kind refId ref2Id
A Cash/Bank (ASSET) CREDIT (DECREASE of an asset) 100 OrderPayment invoiceId header
B Accounts Payable (LIABILITY) DEBIT (reduce what we owe) 100 PaymentEntry header

Cash ↓, AP ↓ — our liability is settled.

Each pair nets to zero (relationId groups them). The system-wide accountSvc.validateBalanced() runs at the end of addEntry/update/delete and rolls the whole Mongo transaction back if |Σdr − Σcr| ≥ 0.01 — see _overview.md §1.3.

4.5 Excess (overpayment) handling (createExcessTransactions)

When excessAmount > 0, two more legs (own relationId) park the surplus on the counterparty's control account:

transType = getTransactionType(paymentAccountId, "DECREASE")   // both Purchase & Sales use DECREASE
// Leg 1 — payment account
{ accountId: paymentAccountId, type: transType,            amount: excess, remark: "... - Excess Amount" }
// Leg 2 — control account (opposite type)
{ accountId: account._id, payeeId: user._id,
  type: transType === CREDIT ? DEBIT : CREDIT,             amount: excess, remark: "... - Outstanding Balance" }

Net effect: the surplus moves out of cash and onto the customer/supplier account as a credit balance (a prepayment / advance), rather than over-applying any single invoice.

4.6 Status / lifecycle (state machine)

        createPaymentEntry
              │
              ▼
          ┌───────┐   postPaymentEntry / postManyPaymentEntry
          │ SAVED │ ─────────────────────────────────────────►  ┌────────┐
          │(draft)│                                              │ POSTED │
          └───────┘ ◄─────────────────────────────────────────  │(final) │
              │            saveManyPaymentEntry                   └────────┘
        edit / delete freely

postPaymentEntry(id) / savePaymentEntry(id) flip the header status and updateMany over all legs (refId == id) to the same status. Status thus lives on both header and every leg, matching the journal/contra pattern (_overview.md §3). Posting an entry whose legs fall in a locked fiscal period is gated by the central validateTransactionDate check.

4.7 Update & delete

  • Update (PaymentService.update): re-resolves the account/user, re-runs validateInvoicePayment
    • per-line fiscal-date checks, re-distributes, then per line: if _id present → updateEntryTransaction (re-writes the leg + its relation legs, propagating account/tax/cost-center changes via updateWithRelations); else → addInvoiceTransaction (new pair). Excess legs are re-created if any. validateBalanced() at the end.
  • Delete (PaymentService.delete): inside a transaction, deleteMany({ refId }) and deleteMany({ ref2Id }) (catches both the PaymentEntry control legs and the OrderPayment cash legs), then deletes the header, then validateBalanced("(Delete Payment Entry)"). Because the OrderPayment legs are gone, every affected invoice's paymentBalance self-corrects upward.

4.8 Transactionality

Every write runs in withRetryTransaction("add_payment_entry" | "update_payment_entry" | "delete_payment_entry") — a single Mongo session with WriteConflict/Transient retry. Header create, all leg writes (including the nested OrderPaymentService calls, which participate in the open session), excess legs, and validateBalanced commit atomically or all roll back.


5. Permissions

  • Module: PAYMENT_ENTRIES = "payment-entries" (BE permission/permission.enum.ts; admin constants/UserAccess.ts → PAYMENT_ENTRIES). Actions: view / create / edit / delete / post (the standard set; the route guard checks PAYMENT_ENTRIES.ACTIONS.VIEW).
  • Resolver class is @ApGqlAuthorize(); the Next.js route (pages/finance/payment-entries/index..tsx) is gated server-side by ApGuardBuilder.isAuth().haveAccess(...).haveModuleAccess(...).
  • Row-scoping for non-admins follows the finance-core rule: Customer/Supplier users see only their linked account's rows. Full model: ../../platform/permissions-access.md.

6. Flows

6.1 Record a customer receipt / supplier payment (happy path)

  1. Admin opens /finance/payment-entries, clicks the Receive Payment / Make Payment button (PaymentEntryBtn) → modal with CreatePaymentEntry (Formik).
  2. Operator picks Customer/Supplier (ApCustomerSelection). On selection, InvoiceList calls useOrderState().findInvoices({ userId, kind, paymentStatus: PENDING }) and the grid fills with that party's pending invoices; amount auto-fills to Σ paymentBalance.
  3. Operator adjusts per-line Amount (partial), per-line date, cost-center/class/analysis-code; deselects rows to exclude; sets header Payment Account and Note.
  4. Click Save (status SAVED), Save & New, or Save & Post Entry (status POSTED).
  5. savePaymentEntrycreatePaymentEntry mutation → PaymentEntryResolver.createPaymentService.addEntry.
  6. Service: resolve control account → validateInvoicePayment → filter amount>0 lines → distributePayments → open transaction → create Payment header → per line addInvoiceTransaction (Leg A OrderPayment + Leg B PaymentEntry) → excess legs if any → validateBalanced → return populated entry.
  7. Each settled invoice's totalAmountPaid rises, so its paymentBalance drops; fully-paid invoices flip to PAID and disappear from future pending lists.
  8. Admin context refetch()s the page and (if open) the counterparty's account statement.

6.2 Unhappy paths

  • Missing control accountgetUserAccount throws (Debtor/Creditor Account not found, please map ... in profile) → 404. Map an AR/AP account on the customer/supplier profile.
  • Invoice not foundvalidateInvoicePayment → 406 with Invoice <id> not found.
  • Tax with no GL account → if a line carries a tax whose accountId is unset, the tax-leg generator throws Tax "<name>" does not have a GL account configured.
  • Locked fiscal period (on update/post) → validateTransactionDate rejects the date unless the user holds post-to-locked-period.
  • Book doesn't balancevalidateBalanced throws Account is not balanced; the whole Mongo transaction rolls back, nothing persists.

7. Admin UI

Routes: pages/finance/payment-entries/index..tsx (list) and pages/finance/payment-entries/[_id]/index.tsx (detail) — both wrapped in MainLayout, guarded by ApGuardBuilder. Sidebar selection ['accountCenter', 'payment-entries'].

Module: finance/paymentpage.tsx (list + duration picker + search + cost-center/class/ analysis-code filters + XLSX download via ApDownloadButton2), components/table.tsx, components/create.tsx (the form), detail.tsx + components/detail-*.

Context (context.tsx) — the only consumer of usePaymentEntryQuery(). Exposes: paymentEntryPage, findOnePaymentEntry, savePaymentEntry (create-or-update switch), createPaymentEntry, updatePaymentEntry, deletePaymentEntry, deleteManyPaymentEntry, postPaymentEntry, postManyPaymentEntry, saveManyPaymentEntry, plus list/modal/selection state. After every mutation it calls refetch() (re-page + refresh the open account's transactions).

Form (components/create.tsx): Formik + Yup (FormSchema requires account, documentDate, paymentAccount, amount, and ≥1 transaction; gold purity required when account currency is GOLD). Key behaviors:

  • InvoiceList loads pending invoices on counterparty change and renders them in an ApTable with row selection; selecting/deselecting recomputes the header amount = Σ selected line amounts.
  • Per-line inline ApMasterSelectInput (createable) for cost center / class / analysis code, plus a header-level select that bulk-applies a value to every line.
  • Footer shows Subtotal / Tax / Grand Total. The Tax line multiplies each selected line's amount × tax.percentage/100 — but the tax column is commented out (lines ~482–508), so in the shipped UI tax is never set and the Tax total renders 0. (Backend tax legs are still possible via taxId/taxIds on the input, just not surfaced by this grid.)
  • Three submit buttons: Save (SAVED), Save & New (SAVED + reset), Save & Post Entry (POSTED).

UX note: the form passes the userId as accountId (val.account._id) and the bank/cash account as paymentAccountId (val.paymentAccount._id). The server resolves the userId to the control account — a deliberate naming overload to watch for when porting.


8. Dependencies & integrations

Calls / is called by Why
AccountService.getUserAccount / getDebtor/CreditorAccount resolve counterparty control (AR/AP) account
AccountService.validateBalanced whole-book balance assertion after every write
AccountTransactionService.create/updateWithRelations/deleteMany/totalAmount/getTransactionType write & read the GL legs; tax-leg generation
OrderPaymentService.addSalesPayment (+ newPayment) write the per-invoice OrderPayment cash leg
OrderService.findById validate invoices, read kind/documentDate
TaxationService.resolveLineTaxes (indirect, via transaction tax legs) compute tax amounts on a line — see taxation.md
FiscalPeriodService.validateTransactionDate reject postings into locked periods
FileUploadService attach files to order-payment legs (S3 stream)
MasterService resolve cost-center/class/analysis-code names for XLSX download
  • Admin useOrderState().findInvoices feeds the pending-invoice grid; useTransactionState is refreshed after writes.
  • No cron/jobs. No events emitted. XLSX export is the only external-ish output.

9. Gotchas & project-specific rules

  • accountId input = userId, not accountId. The header column is accountId but the GraphQL input value is the customer/supplier user id, resolved server-side. Easy to misread.
  • No amount on the header. Totals are always re-aggregated from legs; don't add a stored total.
  • Two collections, two leg kinds. OrderPayment (refId=invoice, ref2Id=header) carries the per-invoice allocation; PaymentEntry (refId=header) is the control-side mirror. Delete must sweep both refId and ref2Id.
  • exchangeRate is coerced to 1 if falsy/0 — a 0 rate would zero the leg in amount×rate report aggregations (trial balance, etc.).
  • Distribution is order-sensitive and never over-applies a line. If the header amount is less than Σ line amounts, trailing lines silently get nothing; if greater, the surplus becomes a prepayment on the counterparty account, not an over-applied invoice.
  • Tax columns are dead UI. The per-line/header tax selects in the grid are commented out; the footer "Tax" total therefore always reads 0 in the shipped build, even though the backend can attach tax via taxId/taxIds.
  • transactions resolve-field hides non-invoice legs (only legs with invoiceId are returned), so the detail view shows allocations, not the raw control/excess legs.
  • Inline order payment vs batch payment entry are different code paths writing different kinds (OrderPayment2 vs PaymentEntry). Reports/balance checks distinguish them by kind.