Appearance
Workflow State Machines
Canonical workflow reference for Pay MVP import, transaction, allocation, posting, and organization states.
This page is the source of truth for backend guards, API conflict responses, and frontend status mapping. Existing enum names and string constants are public contract values; do not rename or renumber them without updating backend, API docs, frontend contracts, and tests in the same change.
Common Rules
- Guarded writes use
IWorkflowStateGuardService. - Invalid status transitions return
409withworkflow.invalid_transition. - Lifecycle locks return
409withworkflow.locked. - Stale optimistic concurrency tokens return
409withconcurrency.conflict. rowVersionis app-managed onBankStatementImport,BankTransaction,PaymentAllocation, andSyrvePaymentPosting.- UI should map exact status strings and keep an unknown-status fallback that disables destructive or write actions.
- After any
409, UI should refresh the entity/list before offering retry. DeletionLifecycleStateis orthogonal to business status.SoftDeleted,PendingHardDelete, andHardDeletedlock active workflow operations unless an explicit restore/delete lifecycle operation is being performed.
Import Workflow
Entity: BankStatementImport
| State | Meaning |
|---|---|
Pending | Import record is declared but not yet processed. Currently mostly available for future async processing. |
Detecting | Bank/format detection is running. |
Parsing | Adapter parsing is running. |
Parsed | Transactions and quality report were persisted. Current successful import terminal state in the implemented upload path. |
NeedsReview | Parsed successfully but balance validation failed (balance_mismatch). Matching/allocation allowed; posting blocked until override. |
Matching | Available/planned matching stage marker. |
ReadyForAllocation | Available/planned allocation-ready marker. |
Completed | Available/planned end-to-end completion marker. |
Failed | Detection, parsing, or persistence failed. |
Allowed transitions:
| Operation | From | To |
|---|---|---|
processImport | Pending | Detecting |
processImport | Detecting | Parsing |
processImport | Parsing | Parsed |
processImport | Parsing | NeedsReview when balanceMatches === false |
processImport | Parsed | Matching, ReadyForAllocation, Completed when those stages are implemented |
processImport | Detecting, Parsing, Matching, ReadyForAllocation | Failed |
Terminal states: implemented today Parsed, NeedsReview, Failed; planned/available enum state Completed.
Balance review gate (MHPAY-63):
- quality report warning code:
balance_mismatch; - while
BalanceMatches == falseandBalanceReviewOverridden == false, queue posting is blocked withbank_import_balance_review_required; - override endpoint:
POST /api/bank-statements/imports/{id}/quality-review/overridewith mandatory reason; - after override, posting may proceed if allocation/posting lifecycle guards and other safety gates pass.
Locked states: any import with DeletionLifecycleState != Active.
Guard coverage: current import upload owns its internal sequence. There is no public import status mutation API, so MHPAY-93 does not add a new external transition endpoint. Allocation/posting paths already check import lifecycle locks before using its transactions.
Transaction Workflow
Entity: BankTransaction
BankTransaction intentionally has no persisted BankTransactionStatus. Effective workflow state is derived from existing fields:
| Effective state | Derived from |
|---|---|
| Imported active transaction | DeletionLifecycleState = Active and persisted from a parsed import |
| Duplicate candidate | BankTransactionDuplicateCandidate.Status = Candidate or transaction duplicate marker indicates candidate state |
| Duplicate confirmed | duplicate candidate/status marked confirmed |
| Duplicate dismissed | duplicate candidate/status marked dismissed/none |
| Unmatched | CounterpartyMatch.Status = Unmatched or no match row |
| Auto matched | CounterpartyMatch.Status = AutoMatched |
| Manual matched | CounterpartyMatch.Status = ManualMatched |
| Match excluded | CounterpartyMatch.Status = Excluded (not a supplier payment; allocation blocked) |
| Allocation-ready | active transaction, not lifecycle-locked, not Excluded, and not blocked by existing approved/queued allocation workflow |
| Locked | DeletionLifecycleState != Active |
| Operation | Effective transition or check |
|---|---|
manualCounterpartyMatch | Unmatched / low-confidence auto match -> ManualMatched or creates reusable rule. |
buildPaymentCandidates | Reads matched active transactions and eligible document snapshots. |
confirmAllocation | Requires active transaction/import lifecycle and no existing approved/queued allocation lock for the transaction. |
queuePosting | Requires active transaction/import lifecycle through the allocation path. |
Terminal states: no dedicated terminal transaction state exists today. Duplicate-confirmed and lifecycle-locked records should be treated as unavailable for normal allocation/posting UI actions.
Guard coverage: MHPAY-93 does not introduce BankTransactionStatus. Lifecycle locks are enforced in allocation confirm and posting queue paths through the centralized guard.
Allocation Workflow
Entity: PaymentAllocation
| State | Meaning |
|---|---|
Draft | Declared enum state; not commonly emitted by current confirm path. |
Preview | Preview/review state. |
Suggested | Auto-suggested allocation. |
NeedsReview | Operator review needed, usually warning-bearing allocation. |
Confirmed | Operator confirmed allocation lines. |
ApprovalRequired | Mandatory initial state for every new posting; explicit manual approval is required. |
Approved | Allocation approved; edit/cancel lock applies. |
PostingQueued | Posting was queued; edit/cancel lock applies. |
Posted | External write evidence exists; reconciliation is still pending. |
Reconciled | Payment posting reconciled. |
Failed | Allocation/posting path failed and may need review if a service supports it. |
Cancelled | Cancelled allocation; terminal for active allocation workflow. |
| Operation | From | To |
|---|---|---|
confirmAllocation | transaction allocation-ready | Confirmed or NeedsReview |
approveAllocation | Confirmed, NeedsReview, ApprovalRequired, Suggested, Preview | Approved |
cancelAllocation | Draft, Preview, Suggested, NeedsReview, Confirmed, ApprovalRequired | Cancelled |
queuePosting | Approved, Confirmed, NeedsReview when no posting exists | PostingQueued |
| worker/readback | PostingQueued | Posted |
| worker/reconcile | Posted | Reconciled |
Terminal states: Reconciled, Cancelled. Posted is not end-to-end completion.
Locked states:
ApprovedandPostingQueuedblock allocation edit/cancel/confirm conflicts with allocation-specific codes.Posted,Reconciled, andCancelledblock active allocation mutations withworkflow.invalid_transition.- Any allocation with
DeletionLifecycleState != Activereturnsworkflow.locked.
Guard coverage:
EnsureAllocationCanApproveEnsureAllocationCanCancelEnsureAllocationCanQueuePostingEnsureAllocationEditableEnsureExpectedRowVersion
Posting Workflow
Entity: SyrvePaymentPosting
| State | Meaning |
|---|---|
Draft | Posting shell before approval/queue. |
ApprovalRequired | Operator approval required before queue. |
Approved | Approved and eligible for worker pickup. |
Queued | Queued for worker execution. |
Posting | Worker is attempting write. |
Posted | Write completed, readback/reconciliation still pending. |
ReadbackPending | Awaiting retry/readback reconciliation. |
Reconciled | Posted payment reconciled against Syrve/iiko readback. |
Failed | Write or reconciliation failed. |
RetryScheduled | Retry scheduled after failure. |
WriteOutcomeUnknown | Transport may have written before timeout/failure; readback is mandatory and blind retry is forbidden. |
Cancelled | Cancelled before posting. |
Pending | Legacy/available pending marker. |
Skipped | Available skip marker. |
CancelledByHardDelete | Lifecycle hard-delete cancellation marker. |
| Operation | From | To |
|---|---|---|
queuePosting | allocation queue path | ApprovalRequired or Queued depending write approval options |
approvePosting | ApprovalRequired | Queued |
configureWriteTarget | Draft, ApprovalRequired | same state, write target fields updated |
| worker | Approved, Queued, RetryScheduled | Posting |
| worker success | Posting | ReadbackPending |
| worker failure | Posting | Failed or RetryScheduled |
| worker unknown result / expired lease | Posting | WriteOutcomeUnknown |
retryPosting | Failed, RetryScheduled | RetryScheduled |
reconcilePosting | Posted, ReadbackPending, WriteOutcomeUnknown, Failed | Reconciled, Failed, or stays ReadbackPending |
| lifecycle hard delete | unfinished active states | CancelledByHardDelete |
Terminal states: Reconciled, Cancelled, CancelledByHardDelete, Skipped.
ReconciledAtUtc is set only on a successful transition to Reconciled. A readback mismatch creates/links an anomaly, transitions the posting and allocation to Failed, and leaves ReconciledAtUtc null. Thus successful transport evidence (Posted) and even a completed reconciliation attempt are never treated as successful reconciliation without matching per-document debt readback. The accountant workbench maps Posted to Processing; only Reconciled maps to Completed or AlreadyCompleted.
Locked states:
- Posting status outside the allowed operation source states returns
workflow.invalid_transition. - Any posting with
DeletionLifecycleState != Activereturnsworkflow.locked.
Guard coverage:
EnsurePostingCanApproveEnsurePostingCanConfigureWriteTargetEnsurePostingCanRetryEnsurePostingCanReconcileEnsureExpectedRowVersion
Additional posting safety gates:
- open Critical
PaymentAnomalyfor the same external document blocks queue, approve, retry, and worker execute withpayment_anomaly_open; BankTransaction.Amountmust equal document-settlement lines plus confirmed supplier-advance operations; otherwise confirm, approve, queue, dry-run, execute, and retry fail closed withpayment_not_fully_distributed;- if a corrupt or legacy queued row violates that invariant, the worker does not call transport, moves the posting to
Failed, moves the allocation toNeedsReview, and persists the same diagnostic code; - method-specific preflight blocks write when persisted
PostingTypedoes not match the allocation document type write method withposting_type_document_type_mismatch; - dry-run remains read-only and may return
payment_anomaly_openwarning without executing a write.
Organization Workflow
Entity: Organization
| State | Meaning |
|---|---|
Active | Normal scoped operations are allowed subject to permissions, supported quotas, workflow guards and safety controls. |
Suspended | Operational writes should be blocked by domain-specific gates where implemented; admin reads/recovery remain possible. |
Archived | Near-terminal administrative state. Operational workflows should not target archived organizations unless an explicit restore is performed. |
| Operation | From | To |
|---|---|---|
updateOrganization | Active | Suspended |
updateOrganization | Suspended | Active |
archiveOrganization | Active, Suspended | Archived |
restoreOrganization | Archived | Active |
Blocked transitions:
- Unknown status strings are rejected with
workflow.invalid_transition. Archived -> Suspendedis rejected; restore must return toActivefirst.
Location/Outlet activity: Location.IsActive is a separate outlet activity flag. It is not renamed or merged into organization status in this workflow.
Guard coverage: existing organization update paths call EnsureOrganizationCanUpdate. MHPAY-93 intentionally does not add a broad organization lifecycle refactor.
API And UI Contract Notes
- API returns exact enum/string status values.
- Frontend unions may list known statuses, but runtime rendering must handle unknown values safely.
- Disabled UI actions should mirror backend guard source states, but backend remains authoritative.
- Guarded mutation requests should include the latest
rowVersionwhen available:- allocation approve/cancel;
- posting queue via
allocationRowVersion; - posting approve/retry/reconcile/configure write target.
- Missing
rowVersionremains accepted for compatibility, but UI should pass it for stale-action detection. - On
workflow.invalid_transition,workflow.locked, orconcurrency.conflict, refresh the affected entity/list and show a state-specific conflict message.