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/SalesInvoiceorders, 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 alwaystotalAmount − Σ(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(kindOrderPayment+OrderPayment2). The Payment Entry module is the batch settlement UI that drives the sameOrderPaymentServiceunder the hood (addSalesPayment, kindOrderPayment) and adds its ownPaymentEntrycontrol 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
OrderPaymentlegs). - Posting / saving / deleting entries (the SAVED→POSTED lifecycle).
- XLSX download of payment entries.
It explicitly does not:
- Create or modify invoices (it only reads
Orderheaders and writesOrderPaymentlegs 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
amountfield. The paid total is reconstructed from the legs (AccountTransactionService.totalAmount({ accountId, refId })) — see the controller download and thepaymentAccountresolver.
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/taxIdsWhy two kinds for the control leg? The inline order-payment flow (
OrderPaymentService.create) writes its own control leg asOrderPayment2. The batch Payment Entry flow writes its control leg asPaymentEntry(inPaymentService.addInvoiceTransaction) and only callsOrderPaymentService.addSalesPayment, which writes the singleOrderPaymentcash-against-invoice leg (it does not write its ownOrderPayment2). So a Payment Entry line = oneOrderPaymentleg (refId=invoice, ref2Id=header) + onePaymentEntryleg (refId=header).
2.3 Derived / read-time computations
- Invoice outstanding (
Order.paymentBalance): computed in the order list aggregation (inventory/order/order.schema.ts):An invoice is PENDING whentotalAmount = Σ items.amount totalAmountPaid = Σ payments.amount // payments = OrderPayment legs on this invoice paymentBalance = totalAmount − totalAmountPaidpaymentBalance > 0, PAID when= 0(OrderPaymentStatusTypes). - Payment header total (read-time):
Σ amountof the legs whereaccountId == header.accountId && refId == header._id(paymentAccountresolver 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):
payee→User(frompayeeId).paymentAccount→ the bank/cashAccountenriched with a synthetictransactioncarrying the summedamountand the first leg'scostCenterId/classId/analysisCodeId(legs matched byaccountId == paymentAccountId && refOrRef2Id == header._id).transactions→ the entry's legs filtered to those with aninvoiceId(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
@IsNotEmptyArrayontransactions→ at least one line required.validateInvoicePayment(model)— for each line with aninvoiceId, loads theOrder; if it does not exist, accumulatesInvoice <id> not foundand throws406 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
dateis run throughfiscalPeriodSvc.validateTransactionDate(date)— a date in a locked fiscal period is rejected. (AccountTransactionService.createre-checksdocumentDateon 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
applyAmountmay be less than its outstanding balance, leaving the invoice still PENDING. - If
totalAmountruns out mid-list, trailing selected lines get nothing (no leg written). - If
totalAmountexceeds the sum of all line amounts, the leftover isexcessAmount(§4.5).
The admin UI keeps
amountin sync withΣ selected line amounts(see §7), so in normal useexcessAmountis 0. Excess only arises when the headeramountis 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 = OrderPaymentLeg 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 (
relationIdgroups them). The system-wideaccountSvc.validateBalanced()runs at the end ofaddEntry/update/deleteand 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-runsvalidateInvoicePayment- per-line fiscal-date checks, re-distributes, then per line: if
_idpresent →updateEntryTransaction(re-writes the leg + its relation legs, propagating account/tax/cost-center changes viaupdateWithRelations); else →addInvoiceTransaction(new pair). Excess legs are re-created if any.validateBalanced()at the end.
- per-line fiscal-date checks, re-distributes, then per line: if
- Delete (
PaymentService.delete): inside a transaction,deleteMany({ refId })anddeleteMany({ ref2Id })(catches both thePaymentEntrycontrol legs and theOrderPaymentcash legs), then deletes the header, thenvalidateBalanced("(Delete Payment Entry)"). Because theOrderPaymentlegs are gone, every affected invoice'spaymentBalanceself-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"(BEpermission/permission.enum.ts; adminconstants/UserAccess.ts → PAYMENT_ENTRIES). Actions: view / create / edit / delete / post (the standard set; the route guard checksPAYMENT_ENTRIES.ACTIONS.VIEW). - Resolver class is
@ApGqlAuthorize(); the Next.js route (pages/finance/payment-entries/index..tsx) is gated server-side byApGuardBuilder.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)
- Admin opens
/finance/payment-entries, clicks the Receive Payment / Make Payment button (PaymentEntryBtn) → modal withCreatePaymentEntry(Formik). - Operator picks Customer/Supplier (
ApCustomerSelection). On selection,InvoiceListcallsuseOrderState().findInvoices({ userId, kind, paymentStatus: PENDING })and the grid fills with that party's pending invoices;amountauto-fills toΣ paymentBalance. - Operator adjusts per-line Amount (partial), per-line date, cost-center/class/analysis-code; deselects rows to exclude; sets header Payment Account and Note.
- Click Save (status
SAVED), Save & New, or Save & Post Entry (statusPOSTED). savePaymentEntry→createPaymentEntrymutation →PaymentEntryResolver.create→PaymentService.addEntry.- Service: resolve control account →
validateInvoicePayment→ filteramount>0lines →distributePayments→ open transaction → createPaymentheader → per lineaddInvoiceTransaction(Leg AOrderPayment+ Leg BPaymentEntry) → excess legs if any →validateBalanced→ return populated entry. - Each settled invoice's
totalAmountPaidrises, so itspaymentBalancedrops; fully-paid invoices flip toPAIDand disappear from future pending lists. - Admin context
refetch()s the page and (if open) the counterparty's account statement.
6.2 Unhappy paths
- Missing control account →
getUserAccountthrows (Debtor/Creditor Account not found, please map ... in profile) → 404. Map an AR/AP account on the customer/supplier profile. - Invoice not found →
validateInvoicePayment→ 406 withInvoice <id> not found. - Tax with no GL account → if a line carries a tax whose
accountIdis unset, the tax-leg generator throwsTax "<name>" does not have a GL account configured. - Locked fiscal period (on update/post) →
validateTransactionDaterejects the date unless the user holdspost-to-locked-period. - Book doesn't balance →
validateBalancedthrowsAccount 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/payment — page.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:
InvoiceListloads pending invoices on counterparty change and renders them in anApTablewith row selection; selecting/deselecting recomputes the headeramount = Σ 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 UItaxis never set and the Tax total renders 0. (Backend tax legs are still possible viataxId/taxIdson 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 aspaymentAccountId(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().findInvoicesfeeds the pending-invoice grid;useTransactionStateis refreshed after writes. - No cron/jobs. No events emitted. XLSX export is the only external-ish output.
9. Gotchas & project-specific rules
accountIdinput = userId, not accountId. The header column isaccountIdbut the GraphQL input value is the customer/supplier user id, resolved server-side. Easy to misread.- No
amounton 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 bothrefIdandref2Id. exchangeRateis coerced to 1 if falsy/0 — a 0 rate would zero the leg inamount×ratereport aggregations (trial balance, etc.).- Distribution is order-sensitive and never over-applies a line. If the header
amountis 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. transactionsresolve-field hides non-invoice legs (only legs withinvoiceIdare 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 (
OrderPayment2vsPaymentEntry). Reports/balance checks distinguish them bykind.