Appearance
Payment Allocation
Matches bank payments to Syrve/iiko documents. Counterparty matching improves candidate ranking but is not required to build candidates, confirm allocation, or queue Syrve posting.
Operator flow (MHPAY-19)
/payment-candidates/:bankTransactionId— build and review candidates./payment-allocations/preview?bankTransactionId=— allocation workspace:- bank transaction summary (date, amount, currency, direction, match reason);
- Syrve document candidates table (status, iiko unpaid amount, reserved amount, available amount, remaining after operation, confidence, warnings);
- multi-select + editable
allocatedAmountper line, clamped by the current available amount; - FIFO/manual method selector;
- canonical preview totals (
documentSettlementAmount, required future advance,unallocatedPaymentAmount,invoiceDebtRemaining) and typed warnings; - confirm → navigate to allocation detail.
/payment-allocations/:id— allocation header, line statuses, approve / queue posting actions.
Scenarios
- Full payment
- Partial document payment with the bank payment fully assigned
- FIFO across several documents
- Excess bank payment requiring a blocked supplier-advance operation
The existing overpayment/underpayment names describe allocation arithmetic only. They do not create a supplier advance. Supplier advances require a separately verified Syrve write/apply/reversal contract and are tracked in Supplier advances backlog. The complete confirmed/unconfirmed decision record is Supplier Settlement.
Supplier-settlement glossary and arithmetic
The canonical amounts are:
DocumentSettlementAmount— the part of the bank payment assigned to current document debt;SupplierAdvanceAmount— an actual planned supplier-advance operation, never a synonym for a local remainder;UnallocatedPaymentAmount— bank money that still has no accounting destination;InvoiceDebtRemaining— document debt that remains after the planned document settlement.
The invariant is:
UnallocatedPaymentAmount = BankTransaction.Amount - DocumentSettlementAmount - SupplierAdvanceAmount.
PreviewAsync uses persisted BankTransaction.Amount, not the mutable request amount, for this calculation. Confirm additionally requires the request amount to equal the transaction amount. Approve, queue, dry-run, execute, and retry reread the active bank transaction as well. A missing or soft-deleted transaction produces bank_transaction_not_found; saved PaymentAllocation.TotalAmount cannot replace it.
The sign is preserved. A positive value means part of the bank payment has no destination; a negative value means the requested destinations exceed the bank payment. InvoiceDebtRemaining > 0 is valid when the complete bank payment was assigned to a partial document settlement. Legacy allocatedAmount, remainingAmount, hasUnderpayment, and hasOverpayment remain compatibility fields and are not accounting sources of truth.
UnallocatedPaymentAmount must be zero before confirm, allocation approval, posting preview/approval/queue, dry-run, worker execute, or retry can be treated as runnable. Every boundary repeats the check against the authoritative bank transaction amount and the persisted document lines. Failure uses payment_not_fully_distributed; no posting, outbox message, or external write is allowed. A positive remainder is not a locally created advance: until the supplier-advance write/readback contract is verified, overpayment remains blocked.
New allocations persist the canonical values alongside the compatibility fields. InvoiceDebtRemaining is the sum of each line's ExpectedUnpaidAmountAfter; historical rows may keep it unknown until they have trustworthy line evidence. IsPartialPayment compares the requested settlement with the current snapshot UnpaidAmount, not with the invoice's original total. Therefore a bank payment may be fully distributed while the invoice legitimately retains debt. The accountant workbench likewise classifies incomplete allocation from UnallocatedPaymentAmount, not from IsPartialPayment or legacy RemainingAmount.
Candidate generation (MHPAY-79)
POST /api/payment-candidates/build/{bankTransactionId} rebuilds persisted PaymentCandidate rows for a bank transaction. The builder uses only local Pay data:
- when
CounterpartyMatchexists: filter by matched Syrve/iiko supplier id; - when counterparty is unmatched: search all visible payable documents in the same organization (higher score threshold, capped at 50 rows);
- same
OrganizationIdand sameLocationIdwhen outlet context is unambiguous; - allowed document types for the bank transaction direction;
- processing state confirmed by the list RPC (
IsProcessed == true,IsDeleted == false); - unpaid or partially paid Syrve document snapshots with matching currency and positive
UnpaidAmount.
Direction is strict and has no cross-direction fallback: Debit uses configured IncomingInvoice / ServiceReceipt types, while Credit uses configured OutgoingInvoice / ServiceProvision types. Preview and confirm repeat this validation, so a direct API request cannot bypass candidate filtering.
Unknown, not-processed and deleted snapshots are excluded before both counterparty matching and amount-only fallback. Persisted candidates are revalidated against their current snapshot when read, so a candidate disappears after a processing-state regression even before the next rebuild.
Candidates expose reservedAmount and availableAmount. The suggested amount is capped by availableAmount, and documents with no available remainder are excluded from a rebuilt candidate list.
Scoring is deterministic and explainable. Candidates are ranked by confidence descending, then amount distance to UnpaidAmount, document date distance, document date, document number, and external document id. Score inputs are supplier match strength, exact/near amount usefulness, partial/overpayment usefulness for multi-document allocation, document date proximity, and document number/reference matches in safe bank transaction metadata. No external calls or posting side effects happen during candidate generation.
Candidate warnings use the same stable warning strings as allocation preview where applicable, including partial_payment, overpayment, and stale_document.
FIFO foundation
SupplierSettlementAllocator is a pure deterministic policy. It receives the payment, supplier, currency, stable posting date, method, supported document types, and immutable payable-document values. It never accesses the database, IikoConnector, transport, persistence, or the current clock.
Automatic FIFO first excludes documents unless IsProcessed == true, IsDeleted == false, payment status is Unpaid/PartiallyPaid, UnpaidAmount > 0, and supplier/currency/type match. It then sorts by a reliable DueDate when supplied, otherwise DocumentDate, followed by ordinal ExternalDocumentId. Current snapshots do not provide a confirmed due date, so production mapping uses DocumentDate; the final accounting FIFO date still requires confirmation. Manual allocation preserves the explicit input order and amounts.
The result contains saved document lines, their sequence, document settlement total, signed unallocated bank amount, remaining eligible invoice debt, allocation method, and order. It is a proposal/plan only and never creates a posting.
POST /api/payment-allocations/preview accepts allocationMethod: AutomaticFifo | Manual. Automatic FIFO is generated only when the caller sends no explicit lines and the bank transaction has a matched supplier. It uses local, visible, same-organization/same-Outlet snapshots and subtracts active reservations; preview creates no allocation, posting, outbox row, or external call. Explicit lines are never replaced by FIFO. Confirm persists the chosen method, exact amounts, and sequence; posting preview and worker transport consume those saved lines in sequence order and do not rerun FIFO.
The UI renders bank payment, document settlement, future advance requirement, unallocated bank amount, remaining invoice debt, and allocation method separately. FIFO rows are displayed in persisted sequence and a partially settled line is marked explicitly. A positive unallocated amount or unavailable advance transport keeps the confirm action disabled and shows the backend reason; frontend state is presentation only, never the safety boundary.
Outlet ambiguity (MHPAY-80)
The safe default remains same-organization and same-Outlet candidate selection. Candidate generation never crosses organizations and never calls Syrve/iiko live APIs. When the matched supplier has visible payable documents in more than one Outlet, the builder returns only documents visible to the current user's scoped RBAC assignments and marks the rows with review warnings instead of auto-selecting them.
The UI shows each candidate's locationId as Outlet context. Rows with Outlet ambiguity warnings are not auto-selected by confidence; the accountant must explicitly select the intended document/Outlet before preview/confirm.
Preview/confirm accepts explicit document selection only when the document is in the same organization and the current user has scoped access to that document's Outlet. A document selected from a different Outlet than the bank transaction is saved as review-oriented (NeedsReview) through the existing warning path. Posting queue also blocks cross-Outlet or unresolved-Outlet snapshot lines before creating a posting or outbox row.
Warning codes
Preview and allocation lines expose stable warning codes (see PaymentAllocationWarningCodes):
| Code | Meaning |
|---|---|
partial_payment | Allocated less than the current document UnpaidAmount |
underpayment | Legacy name: positive unallocated bank-payment amount |
overpayment | Legacy name: requested document allocations exceed the bank payment |
stale_document | Document marked stale in snapshot |
document_already_paid | Document already paid |
currency_mismatch | Document currency ≠ payment currency |
low_confidence | Candidate confidence < 0.5 |
negative_amount | Negative allocated amount in request |
no_lines | Empty lines in preview request |
outlet_ambiguous | Supplier candidates span multiple visible Outlets |
outlet_unresolved | Payment or document Outlet is missing |
manual_outlet_review_required | Operator must manually review/select Outlet before posting |
amount_exceeds_available | Allocation exceeds the document remainder after active reservations |
document_direction_mismatch | Document type does not match Debit/Credit direction |
document_processing_state_unknown | Processing state is not reliably confirmed; refresh Syrve documents |
document_not_processed | Document is not posted in Syrve/iiko |
document_deleted | Document is deleted in Syrve/iiko |
document_not_payable | Document no longer satisfies payment eligibility |
payment_not_fully_distributed | Part of the bank payment has no document or verified advance destination |
API
GET /api/bank-statements/transactions/{id}— single bank transaction for allocation headerGET /api/payment-candidates/{bankTransactionId}— persisted candidatesPOST /api/payment-candidates/build/{bankTransactionId}— rebuild candidatesPOST /api/payment-allocations/preview— typed warnings + line breakdown (loads snapshot/candidate context)POST /api/payment-allocations/confirm— requires user + stable idempotency key; returns existing allocation on duplicate key
Preview returns canConfirm=false and blockReason for processing-state eligibility failures. Confirm does not trust candidate rows: each explicit documentId must resolve unambiguously to an in-scope snapshot and pass the shared policy. A missing snapshot is treated as unknown, not as a manual override.
Preview and confirm requests may contain multiple allocation lines, but each documentId must appear at most once in the request. Duplicate document ids are rejected instead of being merged implicitly.
The authoritative remainder invariant is:
available = snapshot.UnpaidAmount - sum(active allocation lines)
Reserving allocation states are NeedsReview, Confirmed, ApprovalRequired, Approved, PostingQueued, Posted, and Failed. Cancelled and Reconciled release the reservation. Multiple bank transactions may partially pay one document only while their total remains within the current available amount.
Confirm does not write to Syrve synchronously and does not auto-queue a runnable write through configuration. A new posting always starts in ApprovalRequired; after explicit manual approval the worker performs the asynchronous write. RBAC/scope, idempotency, payable/amount/allocation/anomaly checks, server applicability, credentials and method-specific transport preflight remain mandatory.
Approve, posting preview, queue, posting approval, retry and worker execution revalidate the same snapshots. If a document becomes unknown, not processed, deleted or no longer payable, the allocation moves to NeedsReview; queue creates neither a posting nor an outbox row, and the worker transport never executes the iiko write facade.
If the posting target account or cash-flow article is not already configured, queueing can apply exactly one active SyrvePostingRule (matched from the first line when all lines share the same supported posting type), configured defaults, or the Syrve reference catalog. Missing rules/defaults, unsupported document types, mixed incoming/outgoing lines, or equally applicable rule conflicts keep the allocation in NeedsReview and no runnable posting is queued.
Multi-document allocations (several invoices under one bank payment) are supported when every line resolves to the same posting family (InvoicePayment or OutgoingDocumentPayment). The operator chooses one shared write target (account + cash-flow article) for the whole payment; iiko write uses IikoPaymentWriteRequest.SplitItems for each line.
Workflow guards and concurrency (MHPAY-92)
Canonical allocation states, transitions, terminal states, locks, operation names, and UI mapping rules live in Workflow State Machines. This section keeps only allocation-specific implementation notes.
PaymentAllocation has app-managed rowVersion optimistic concurrency. The API exposes it on allocation DTOs and accepts it on guarded mutations (approve, cancel, and posting queue via allocationRowVersion). Missing rowVersion remains a compatibility path, but UI actions should pass the latest value from detail/list responses.
Workflow state guards are centralized in IWorkflowStateGuardService:
- one confirmed allocation must assign the complete bank-transaction amount; separate bank transactions may still partially settle the same document while their combined reservations remain within its authoritative remainder;
Approvedallocations cannot be edited or cancelled;PostingQueuedallocations cannot be edited or cancelled;- approve is allowed only from pre-approve states (
Confirmed,NeedsReview,ApprovalRequired,Suggested,Preview); - pending hard delete, soft delete, and hard delete lifecycle states block active allocation workflow.
Guard failures return canonical ErrorResponse with 409 codes such as concurrency.conflict, workflow.invalid_transition, workflow.locked, allocation.not_editable_after_approve, and allocation.not_editable_after_queued.
Confirm runs in a PostgreSQL Serializable transaction. It rechecks both the document remainder and the total amount reserved against the bank transaction; a serialization conflict is converted to an explainable 409 concurrency.conflict. Queueing repeats the payable-status and available-remainder check immediately before creating the posting/outbox. If the snapshot no longer permits the write, the allocation moves to NeedsReview and no iiko write is queued.
Idempotency
ConfirmAsync returns the existing allocation when the same idempotencyKey is reused for the same bank transaction and org/location scope.
Frontend builds a stable key from bank transaction, allocation method, and sorted documentId:amount pairs. Replaying the same request returns the existing scoped allocation.