Skip to content

API Endpoints (MVP) ​

MethodPathDescription
POST/api/auth/loginLogin
POST/api/auth/logoutLogout
GET/api/auth/meCurrent user (scoped roles, orgs, locations, permissions)
GET/api/auth/contextAvailable active organization/Outlet contexts for current user
POST/api/auth/contextValidate and set active organization/Outlet context (stateless, audit-friendly)
POST/api/auth/invites/checkValidate invitation token and return safe invite metadata
POST/api/auth/invites/acceptAccept invitation, set password, activate/create user, assign role
GET/api/platform/organizationsList organizations for platform registry/settings workflows (platform.organizations.view)
POST/api/platform/organizationsCreate organization
GET/api/platform/organizations/{id}Organization details
PUT/api/platform/organizations/{id}Update organization
DELETE/api/platform/organizations/{id}Hard delete archived organization without business data (MegaAdmin only)
GET/api/platform/organizations/{id}/quotaOrganization quota (organization.quotas.view)
PUT/api/platform/organizations/{id}/quotaUpdate quota (platform.quotas.manage, audit log)
GET/api/platform/settingsPlatform operational settings overrides + env defaults (platform.settings.view)
PUT/api/platform/settingsUpdate platform settings (platform.settings.manage, audit log)
GET/api/telegram/linkTelegram link status for current user + active Outlet (location.bank_statements.upload); returns enabled=false when feature off
POST/api/telegram/link-challengesCreate one-time personal activation code (~10 min) + deep link
DELETE/api/telegram/linkRevoke Active Telegram link for current user + org
POST/api/telegram/webhookTelegram Bot webhook (AllowAnonymous + X-Telegram-Bot-Api-Secret-Token). v2: recognition notify, Ready list, optional post when Telegram__AllowPostFromBot (see ADR telegram-safe-post-v2)
GET/api/platform/usersList platform users and role assignments (platform.users.manage)
POST/api/platform/usersCreate platform-managed user without invite (platform.users.manage, audit log)
POST/api/platform/users/invitesCreate Redis-backed invite token with TTL 3h (platform.users.manage, audit log)
PUT/api/platform/users/{targetUserId}Update platform-managed user display name / active flag; optional password resets credentials and revokes refresh sessions (platform.users.manage, audit log)
GET/api/platform/users/rolesAssignable MVP roles for direct platform assignment (platform.users.manage)
POST/api/platform/users/{targetUserId}/rolesAssign platform/org/Outlet scoped role (platform.users.manage, audit log)
DELETE/api/platform/users/{targetUserId}/roles/{assignmentId}Remove direct role assignment (platform.users.manage, audit log)
GET/api/organizations/currentCurrent user's organizations
GET/api/organizations/{id}Organization details within scoped access, including parentOrganizationId
PUT/api/organizations/{id}Update organization details within scoped admin access
POST/api/organizations/{id}/childrenCreate child organization / sub-organization under an organization admin scope
GET/api/organizations/{id}/locationsList locations
POST/api/organizations/{id}/locationsCreate location
PUT/api/organizations/{id}/locations/{locationId}Update location
GET/api/organizations/{id}/usersOrganization users / role assignments
POST/api/organizations/{id}/users/{userId}/rolesAssign scoped role
DELETE/api/organizations/{id}/users/{userId}/roles/{assignmentId}Remove role assignment
GET/api/locations/{id}Location details
GET/api/locations/{id}/usersLocation users
POST/api/locations/{id}/users/{userId}/rolesAssign location role
POST/api/bank-statements/import?organizationId=&locationId=BankStatementImportUploadResultDto (202 Accepted)
GET/api/bank-statements/imports?organizationId=&locationId=List imports (requires active organization + Outlet headers or equivalent query scope)
GET/api/bank-statements/imports/{id}Import details
GET/api/bank-statements/imports/{id}/fileDownload original bank file before retention expiry
GET/api/bank-statements/imports/{id}/transactionsImport transactions
GET/api/bank-statements/transactions/{id}Single bank transaction (allocation header)
GET/api/bank-statements/supported-banksSupportedBankFormatDto[] — registered bank adapters (public, no auth required)
GET/api/bank-statements/quality-summaryAggregated import quality counters (requires active organization + Outlet headers)
GET/api/bank-statements/import-health-summary?organizationId=&locationId=Operational bank import metrics summary (requires active organization + Outlet headers or equivalent query scope)
GET/api/bank-statements/imports/{id}/quality-reportImport quality report (confidence, warnings, balance)
POST/api/bank-statements/imports/{id}/quality-review/overrideOverride balance mismatch review gate
DELETE/api/bank-statements/imports/{id}Delete import bundle (transactions, matches, file); counterparty rules kept; blocked if non-cancelled allocations exist
GET/api/bank-statements/imports/{id}/duplicatesDuplicate transaction candidates for import
POST/api/bank-statements/imports/{id}/reprocess-previewRun stored reprocess preview (MegaAdmin/MegaManager only)
GET/api/bank-statements/imports/{id}/reprocess-previewsList stored reprocess previews for import
GET/api/bank-statements/imports/{id}/reprocess-previews/{previewId}Get stored reprocess preview by id
POST/api/bank-statements/duplicates/{candidateId}/confirmBankDuplicateCandidateMutationResultDto
POST/api/bank-statements/duplicates/{candidateId}/dismissBankDuplicateCandidateMutationResultDto
GET/api/counterpartiesMatch rules (active, legacy list for matching page)
GET/api/counterparties/rulesCounterparty rules directory list
PUT/api/counterparties/rules/{id}Update counterparty match rule
POST/api/counterparties/rules/{id}/deactivateSoft-deactivate counterparty match rule
POST/api/counterparties/rules/{id}/activateReactivate counterparty match rule
POST/api/counterparties/rules/conflict-previewPreview counterparty rule conflicts before create
POST/api/counterparties/rulesCreate match rule from manual match
POST/api/counterparty-matchesManual match
POST/api/counterparty-matches/revertRevert manual match
POST/api/counterparty-matches/excludeExclude payment from matching queue
POST/api/counterparty-matches/unexcludeRestore excluded payment to matching queue
GET/api/counterparty-matchesList matches
GET/api/payment-allocations?status=List payment allocations, optionally filtered by status (requires active organization + Outlet headers or equivalent query scope)
POST/api/payment-allocations/previewAllocation preview
POST/api/payment-allocations/confirmConfirm allocation. Rejects with allocation.already_in_flight when the bank transaction already has a queued/posted allocation or in-flight Syrve posting.
POST/api/payment-allocations/{id}/approveApprove allocation; accepts optional { "rowVersion": number }
POST/api/payment-allocations/{id}/cancelCancel allocation; accepts { "reason": "...", "rowVersion": number? }

Payment Allocation And Posting Gates ​

This section documents the operator-facing allocation and Syrve/iiko posting flow. The flow is Pay-owned until a background write worker executes an approved posting. UI/API queue actions do not perform a synchronous Syrve/iiko write.

Upstream payment candidate context:

  • POST /api/payment-candidates/build/{bankTransactionId} rebuilds candidate rows for a matched supplier using local Syrve document snapshots. It returns PaymentCandidateDto[] sorted by confidence, amount distance, document date distance, document date, document number, and external document id.
  • GET /api/payment-candidates/{bankTransactionId} returns the persisted candidate list with the same deterministic ordering.
  • Candidate generation does not create a posting or outbox row.
  • PaymentCandidateDto.document is SyrveDocumentSnapshotDto and includes organizationId plus nullable locationId. Do not treat locationId = null as a default Outlet.
  • SyrveDocumentSnapshotDto exposes nullable isProcessed, isDeleted, nullable processingStateCheckedAtUtc, and nullable processingStateSource. Only isProcessed=true with isDeleted=false can be used for payment.
  • PaymentCandidateDto also exposes reservedAmount and availableAmount; suggestedAmount never exceeds availableAmount.
  • Candidate document types are strictly isolated by configured bank direction. There is no fallback from Debit document types to Credit document types or vice versa.

Outlet ambiguity warning codes:

  • outlet_ambiguous: same supplier/payment has visible payable documents in multiple Outlets.
  • outlet_unresolved: transaction or document Outlet is missing.
  • manual_outlet_review_required: operator must explicitly select/review the document/Outlet before posting.

Status values exposed by import, allocation, posting, transaction-derived match/duplicate fields, and organization responses are formalized in Workflow State Machines. UI clients must map exact strings, keep an unknown-status fallback, disable actions according to the documented guard source states, and refresh affected data after 409 workflow/concurrency responses.

BankStatementImportListItemDto, BankStatementImportDetailsDto, BankTransactionDto, PaymentAllocationDto, and SyrvePaymentPostingDto expose rowVersion for guarded write flows. RowVersion is an app-managed PostgreSQL-compatible optimistic concurrency token. Clients should send the latest rowVersion on approve/cancel/queue/configure/retry/reconcile mutations when available. Missing rowVersion keeps legacy compatibility, but guarded UI paths should pass it to detect stale actions.

Allocation endpoints ​

MethodPathPurpose
GET/api/payment-allocations?status=&bankTransactionId=&organizationId=&locationId=&page=&pageSize=List payment allocations with optional filters.
GET/api/payment-allocations/{id}Get one allocation with lines and concurrency token.
POST/api/payment-allocations/previewCalculate allocation outcome without writing.
POST/api/payment-allocations/confirmCreate or return an idempotent allocation.
POST/api/payment-allocations/{id}/approveApprove an allocation; optional body { "rowVersion": number }.
POST/api/payment-allocations/{id}/cancelCancel an allocation; body { "reason": "...", "rowVersion": number? }.

Allocation preview ​

Request DTO: PaymentAllocationPreviewRequest.

json
{
  "bankTransactionId": "019edf00-0000-7000-8000-000000100101",
  "paymentAmount": 1500000,
  "allocationMethod": "Manual",
  "lines": [
    {
      "documentId": "doc-demo-001",
      "documentNumber": "INV-DEMO-001",
      "documentAmount": 2000000,
      "allocatedAmount": 1500000
    }
  ]
}

Fields:

  • bankTransactionId
  • paymentAmount
  • allocationMethod: Manual or AutomaticFifo
  • lines[].documentId
  • lines[].documentNumber
  • lines[].documentAmount
  • lines[].allocatedAmount

For AutomaticFifo, send an empty lines array. The backend builds a proposal only when the bank transaction has a matched supplier, using visible same-scope processed documents and current reservations. Explicit lines are always preserved as manual intent and are never replaced by FIFO.

Response DTO: PaymentAllocationPreviewResult.

json
{
  "bankTransactionId": "019edf00-0000-7000-8000-000000100101",
  "paymentAmount": 1500000,
  "allocatedAmount": 1500000,
  "remainingAmount": 0,
  "hasOverpayment": false,
  "hasUnderpayment": false,
  "hasPartialPayments": true,
  "documentSettlementAmount": 1500000,
  "supplierAdvanceAmount": 0,
  "unallocatedPaymentAmount": 0,
  "invoiceDebtRemaining": 500000,
  "requiredSupplierAdvanceAmount": 0,
  "allocationMethod": "Manual",
  "lines": [
    {
      "documentId": "doc-demo-001",
      "documentNumber": "INV-DEMO-001",
      "documentAmount": 2000000,
      "allocatedAmount": 1500000,
      "isPartialPayment": true,
      "isOverpayment": false,
      "paymentStatus": "PartiallyPaid",
      "unpaidAmount": 2000000,
      "paidAmount": 0,
      "reservedAmount": 250000,
      "availableAmount": 1750000,
      "remainingAfterAllocation": 250000,
      "invoiceDebtRemainingAfter": 500000,
      "sequence": 1,
      "warningCodes": ["partial_payment"]
    }
  ],
  "warnings": [
    {
      "code": "partial_payment",
      "message": "Partial allocation requires review context before posting."
    }
  ],
  "canConfirm": true,
  "blockReason": null
}

Response fields:

  • bankTransactionId, paymentAmount, allocatedAmount, remainingAmount
  • canonical documentSettlementAmount, supplierAdvanceAmount, unallocatedPaymentAmount, invoiceDebtRemaining, requiredSupplierAdvanceAmount, allocationMethod
  • paymentAmount in the result is canonicalized from persisted BankTransaction.Amount; reducing the request amount cannot make an incomplete allocation confirmable
  • hasOverpayment, hasUnderpayment, hasPartialPayments
  • canConfirm, blockReason
  • lines[].documentId, documentNumber, documentAmount, allocatedAmount
  • lines[].isPartialPayment, isOverpayment, paymentStatus, invoiceDebtRemainingAfter, sequence

Explicit lines that resolve to an unknown, not-processed, deleted or no-longer-payable snapshot return a typed warning and canConfirm=false. Confirm/approve/queue repeat the check and return 409 ErrorResponse with one of: document_processing_state_unknown, document_not_processed, document_deleted, document_not_payable.

  • lines[].unpaidAmount, paidAmount, reservedAmount, availableAmount, remainingAfterAllocation, warningCodes
  • warnings[] as { code, message }

Operator meaning:

  • A partial document payment is allowed when the complete bank amount is assigned; positive invoiceDebtRemaining is not unallocated bank money.
  • Any non-zero unallocatedPaymentAmount blocks confirm with payment_not_fully_distributed.
  • A positive requiredSupplierAdvanceAmount describes the advance required to consume an excess bank payment. Because advance transport/readback is not verified, supplierAdvanceAmount remains zero and the preview is blocked.
  • Legacy overpayment/underpayment flags remain compatibility fields and must not drive accounting.
  • Warning-bearing allocation may require review before approval or queue.
  • Outlet ambiguity must not be silently defaulted.

Allocation confirm ​

Request DTO: PaymentAllocationConfirmRequest.

json
{
  "bankTransactionId": "019edf00-0000-7000-8000-000000100101",
  "paymentAmount": 1500000,
  "idempotencyKey": "alloc-demo-019edf00-100101",
  "allocationMethod": "Manual",
  "lines": [
    {
      "documentId": "doc-demo-001",
      "documentNumber": "INV-DEMO-001",
      "documentAmount": 2000000,
      "allocatedAmount": 1500000
    }
  ]
}

Response DTO: PaymentAllocationConfirmResult.

json
{
  "allocationId": "019edf00-0000-7000-8000-000000200201",
  "status": "NeedsReview",
  "message": "Allocation created and requires review."
}

Rules:

  • Duplicate idempotencyKey for the same bank transaction returns the existing allocation and does not create a second write.
  • Confirm requires BankTransaction.Amount = documentSettlementAmount + supplierAdvanceAmount; the current runtime has no supplier-advance write, so every confirmed allocation must consume the full bank amount through document lines.
  • Confirm persists allocationMethod, ordered line sequence, current unpaid values, and expected unpaid values. Worker/retry use these saved lines and never rebuild FIFO.
  • Multiple lines are supported, but a documentId may appear only once in one request.
  • The request is checked in a PostgreSQL Serializable transaction. Active allocation lines reserve the document remainder and the bank transaction amount.
  • NeedsReview, Confirmed, ApprovalRequired, Approved, PostingQueued, Posted, and Failed reserve amounts; Cancelled and Reconciled do not.
  • Exceeding the document remainder, bank transaction amount, or selecting an opposite-direction document returns an explainable 409.
  • Confirmed and NeedsReview allocation statuses must be interpreted through workflow docs.

Allocation approve and cancel ​

Approve body is optional:

json
{
  "rowVersion": 12
}

Cancel body:

json
{
  "reason": "Operator selected another document",
  "rowVersion": 12
}

Rules:

  • rowVersion is optimistic concurrency. Stale values return 409 concurrency.conflict.
  • Approved and posting queued allocations are locked against edit/cancel according to workflow guards.
  • After any 409, UI should refresh the affected entity/list before retry.

Allocation DTOs ​

PaymentAllocationDto includes:

  • id
  • bankTransactionId
  • syrveConnectionId
  • status
  • totalAmount
  • allocatedAmount
  • remainingAmount
  • documentSettlementAmount
  • supplierAdvanceAmount
  • unallocatedPaymentAmount
  • invoiceDebtRemaining
  • allocationMethod
  • currency
  • createdAtUtc
  • rowVersion
  • lines[]

PaymentAllocationLineDto includes:

  • id
  • documentId
  • documentNumber
  • documentAmount
  • allocatedAmount
  • isPartialPayment
  • isOverpayment
  • paymentStatusBefore
  • expectedPaymentStatusAfter
  • unpaidAmountBefore
  • expectedUnpaidAmountAfter
  • sequence
  • warningCodes

Syrve/iiko posting endpoints ​

MethodPathPurpose
POST/api/syrve/postings/preview/{allocationId}Build posting preview without writing or queueing.
POST/api/syrve/postings/queue/{allocationId}Queue a posting for worker-gated execution; optional body { "allocationRowVersion": number }.
GET/api/syrve/postings?status=&connectionId=&organizationId=&locationId=&page=&pageSize=List postings with optional filters.
GET/api/syrve/postings/{id}Get one posting with gate state and concurrency token.
POST/api/syrve/postings/{id}/approveApprove a posting; optional body { "rowVersion": number? }.
POST/api/syrve/postings/{id}/retryRetry a failed/retry-scheduled posting; optional body { "rowVersion": number? }.
POST/api/syrve/postings/{id}/reconcileForce reconciliation; optional body { "rowVersion": number? }.
PUT/api/syrve/postings/{id}/write-targetConfigure account and cash-flow target before execution.
POST/api/syrve/postings/{id}/dry-runRead-only write simulation and gate report.
GET/api/syrve/postings/{id}/preflightRead-only gate evaluation.
GET/api/syrve/postings/{id}/attemptsPosting attempt history.
GET/api/syrve/postings/{id}/evidenceSafe evidence summary for write/readback/reconciliation.

Posting preview ​

Response DTO: SyrvePostingPreviewResult.

json
{
  "allocationId": "019edf00-0000-7000-8000-000000200201",
  "bankTransactionId": "019edf00-0000-7000-8000-000000100101",
  "totalAmount": 1500000,
  "currency": "IDR",
  "counterpartyName": "Demo Supplier",
  "lines": [
    {
      "documentNumber": "INV-DEMO-001",
      "documentType": "Invoice",
      "allocatedAmount": 1500000,
      "paymentStatusBefore": "Unpaid",
      "expectedPaymentStatusAfter": "PartiallyPaid"
    }
  ],
  "warnings": [],
  "canQueue": true,
  "blockReason": null
}

Fields:

  • allocationId
  • bankTransactionId
  • totalAmount
  • currency
  • counterpartyName
  • lines[]
  • warnings[]
  • canQueue
  • blockReason

Queue posting ​

Request body is optional:

json
{
  "allocationRowVersion": 12
}

Rules:

  • Queueing moves the allocation to PostingQueued when successful and blocks later allocation edits/cancel.
  • Duplicate posting queue for the same allocation is rejected.
  • Queue revalidates every selected snapshot and the full-payment invariant. Unknown/not-processed/deleted/no-longer-payable documents or non-zero unallocatedPaymentAmount create neither a posting nor an outbox message.
  • Multi-line allocations are supported when all lines share the same posting family (incoming vs outgoing). Mixed directions return posting_rule_mixed_document_directions. Queue uses one shared write target; connector SplitItems carries per-document amounts.
  • A selected document from a different or unresolved Outlet remains review-blocked and must not create a runnable posting/outbox row.
  • No synchronous Syrve/iiko write happens from UI/API queue; writes are outbox/worker-gated.

Posting approve, retry, reconcile, and write target ​

Approve, retry, and reconcile bodies are optional:

json
{
  "rowVersion": 7
}

Write-target request:

json
{
  "accountId": "acct-demo-001",
  "accountName": "Demo payment account",
  "cashFlowArticleId": "cfa-demo-001",
  "cashFlowArticleName": "Demo supplier payment",
  "rowVersion": 7
}

Allowed source states:

  • Approve only from ApprovalRequired.
  • Configure write target only before queued/posted/reconciled/cancelled, currently Draft or ApprovalRequired.
  • Retry only from Failed or RetryScheduled. WriteOutcomeUnknown requires readback and rejects retry with write_outcome_unknown_readback_required.
  • Reconcile only from Posted, ReadbackPending, WriteOutcomeUnknown, or Failed.

Posting DTOs and evidence ​

SyrvePaymentPostingDto includes:

  • id
  • paymentAllocationId
  • syrveConnectionId
  • status
  • postingType
  • accountId
  • accountName
  • cashFlowArticleId
  • cashFlowArticleName
  • requestHash
  • attemptCount
  • lastAttemptAtUtc
  • nextRetryAtUtc
  • reconciledAtUtc
  • errorCode
  • errorMessage
  • idempotencyKey
  • requiresApproval
  • approvedByUserId
  • postedAtUtc
  • createdAtUtc
  • rowVersion
  • reconciliationAttemptCount
  • nextReconciliationAtUtc
  • openAnomalyId
  • postingDate (stable date persisted before worker execution)
  • workerClaimedAtUtc
  • workerLeaseExpiresAtUtc

Dry-run, preflight, attempts, and evidence are read-only operator views:

  • Dry-run is read-only and does not write to Syrve/iiko.
  • Preflight evaluates account/cash-flow target, idempotency, processed/deleted/payment/completeness validity, posting type, critical anomalies, server applicability, credentials and HTTP/RPC/method-specific readiness.
  • Evidence separates write result, optimistic local calculation, live readback, and reconciliation.
  • Posted write evidence is not the same as Reconciled.
  • WriteOutcomeUnknown means transport may have written before timeout/failure. The worker never retries it blindly; reconciliation/readback must resolve it first.
  • UI clients should refresh the allocation detail after queueing, the posting detail after approve/retry/reconcile/write-target changes, and the posting preflight/evidence/attempt queries after mutations that may change write eligibility or worker evidence.

Reconciliation evidence CSV export ​

GET /api/reports/reconciliation-evidence/export.csv

Exports a scoped, read-only CSV report for payment allocation, Syrve/iiko posting write evidence, live readback, reconciliation, and anomaly metadata. The endpoint reads Pay-owned local tables only. It does not refresh documents, perform live reads, queue postings, retry postings, or call IikoConnector.

Permission: perm:syrve.postings.view.

Supported query parameters:

  • organizationId
  • locationId
  • postingId
  • allocationId
  • bankTransactionId
  • status
  • fromUtc
  • toUtc

Response:

  • Content type: text/csv; charset=utf-8.
  • Filename: reconciliation-evidence-yyyyMMddHHmmss.csv.
  • Max rows: 5000.
  • Fields use standard CSV quoting; embedded quotes are doubled.

Stable columns:

text
postingId,allocationId,bankTransactionId,organizationId,locationId,
supplierExternalIdMasked,supplierName,documentIdMasked,documentNumber,documentType,
currency,allocatedAmount,allocationStatus,postingStatus,postingType,
idempotencyKey,requestHash,postedAtUtc,reconciledAtUtc,
writeStatus,externalPaymentIdMasked,writeResultHash,writeDiagnosticCode,
optimisticSource,optimisticPaymentStatus,optimisticUnpaidAmount,optimisticPaidAmount,
liveReadbackSource,liveReadbackSecondarySource,liveReadbackSourcesDisagree,
liveReadbackCheckedAtUtc,liveReadbackPaymentStatus,liveReadbackUnpaidAmount,liveReadbackPaidAmount,
reconciliationStatus,reconciliationCheckedAtUtc,reconciliationDiagnosticCode,
anomalyId,anomalySeverity,anomalyStatus,errorCode,errorMessageSafe

Scope behavior:

  • Platform users can export across accessible scope or apply explicit organization/location filters.
  • Non-platform users receive only rows inside their effective organization/location scope.
  • Explicit foreign organizationId or locationId filters return a safe empty export, matching nearby scoped list behavior.

Masking and data safety:

  • External supplier/document/payment identifiers are masked when they may be sensitive.
  • Operator-facing supplier names and document numbers are included.
  • Raw request JSON, raw result JSON, raw bank lines, raw bank files, full account numbers, secrets, credentials, and private payloads are not exported.
  • Write evidence and live readback evidence remain separate columns. writeStatus=Posted is not treated as reconciliationStatus=Reconciled.

Audit behavior:

  • Export writes a safe AuditAction.Export event for ReconciliationEvidence.
  • Audit details include format, rowCount, maxRows, and filters only.
  • Audit details do not include exported rows, raw evidence JSON, private payloads, or unmasked external identifiers.

Allocation and posting business error codes ​

CodeHTTP classSafe meaningOperator/UI actionRetry guidance
workflow.invalid_transition409Current workflow state does not allow the requested action.Refresh entity and enable only actions allowed by state.Retry only after refresh or valid state change.
workflow.locked409Entity is soft-deleted, pending hard delete, or hard-deleted and cannot be used in active workflow.Refresh list/detail and show lifecycle lock.Retry only after lifecycle recovery if allowed.
concurrency.conflict409Supplied rowVersion is stale.Refresh affected entity/list and ask operator to repeat the action.Retry with latest rowVersion.
outlet_ambiguousWarning/review resultVisible payable documents span multiple Outlets.Require explicit Outlet/document selection.Retry after manual review/selection.
outlet_unresolvedWarning/review resultTransaction or document Outlet is missing.Resolve Outlet context before posting.Retry after data/context correction.
manual_outlet_review_requiredWarning/review resultOperator must review Outlet/document scope.Block automatic posting path.Retry after review confirms safe scope.
bank_import_balance_review_required409Source bank import balance quality gate is not cleared.Review import quality and apply allowed override if appropriate.Retry after quality review/override.
payment_anomaly_open409 or preflight/dry-run gateOpen critical anomaly blocks unsafe repeat write/retry.Resolve or dismiss anomaly after manual review.Retry only after anomaly is no longer open/critical.
document_processing_state_unknownWarning/409Syrve/iiko has not reliably confirmed that the document is processed.Refresh Syrve documents and reselect only after confirmation.Retry only after a reliable list-state refresh.
document_not_processedWarning/409Selected document is not processed.Process the document in Syrve or remove it from allocation.Retry after a reliable processed-state refresh.
document_deletedWarning/409Selected document is deleted.Remove it from allocation.Do not retry against the deleted document.
document_not_payableWarning/409Document type/payment/currency/amount is no longer payable.Refresh and review current debt.Retry only after the payable state is valid.
payment_not_fully_distributedPreview block/409Part of the bank payment has no document-settlement or verified supplier-advance destination.Allocate the whole payment; overpayment remains blocked until advance transport exists.Retry only when the invariant is zero.
supplier_advance_transport_unavailablePreview/plan block/409A supplier advance is required, but its verified write/readback/reversal contract is unavailable.Keep the settlement blocked and follow the advance backlog.Do not retry as document payment.
write_outcome_unknownWorker diagnosticA transport timeout/failure may have occurred after external write.Run readback/reconciliation.No blind retry.
write_outcome_unknown_readback_required409Retry was requested before resolving an unknown write outcome.Reconcile/read back first.Retry only after evidence resolves the outcome.
posting_type_document_type_mismatchPreflight/dry-run gate; conflict where surfaced by mutationPosting type does not match selected document/write target.Fix posting rule/target or re-create safe allocation/posting.Retry after configuration or review.
syrve_posting_rule_unsupported409 on queueCurrent allocation cannot be converted into a supported posting rule.Keep in review or adjust allocation/rule support.Retry after supported rule/configuration exists.
posting_rule_mixed_document_directionsPreview block / 409 on queueAllocation mixes incoming and outgoing document families.Split into separate allocations by direction.Retry after correcting document selection.

| GET | /api/syrve/references/accounts | Payment accounts, transition-compatible global/default connection read | | GET | /api/syrve/references/cash-flow-articles | Cash flow articles, transition-compatible global/default connection read | | GET | /api/syrve/connections/{connectionId}/document-sync-runs | Paginated document sync run history for the selected Syrve connection | | POST | /api/syrve/connections/{id}/sync-documents | Soft document sync by default; query forceRefresh=true explicitly bypasses fresh Outgoing snapshot reuse | | GET | /api/syrve/connections/{connectionId}/references/accounts | Payment accounts for the selected Syrve connection | | GET | /api/syrve/connections/{connectionId}/references/cash-flow-articles | Cash flow articles for the selected Syrve connection | | GET | /api/syrve/counterparties | Syrve counterparties (from supplier snapshot when available; catalog fallback if empty) | | GET | /api/syrve/supplier-sync-runs | Paginated supplier sync run history | | POST | /api/syrve/connections/{id}/sync-suppliers | Trigger supplier sync for a connection. Query full=true forces full catalog resync via getPersistedEntities (not incremental revision catch-up). | | GET | /api/syrve/debts | Open debts | | GET | /api/syrve/connections/operational-flags | Deprecated compatibility capability fields; registered runtime capabilities are always on | | POST | /api/syrve/connections/{id}/credentials/rotate | Rotate Syrve credentials without changing BaseUrl | | POST | /api/syrve/connections/{id}/service-account/ensure | Create or restore the durable MyHorecaPayUser iiko service account | | DELETE | /api/syrve/connections/{id} | Soft-delete a Pay-owned Syrve connection record; does not mutate Syrve/iiko | | GET | /api/audit-log | Audit log (append-only, scoped read) | | GET | /api/audit-log/export.csv | Scoped audit export as UTF-8 CSV | | GET | /api/audit-log/export.pdf | Scoped audit export as PDF | | GET | /api/reports/reconciliation-evidence/export.csv | Scoped reconciliation/payment evidence export as UTF-8 CSV | | GET | /api/health | Health check | | GET | /api/name-matches/summary | Name matching statistics | | GET | /api/name-matches | Name matching list | | GET | /api/dashboard | Operational dashboard summary; requires active organization + Outlet headers | | GET | /api/accountant-work-items | Authoritative accountant work-item projection (workflow state, reasons, nextAction, capabilities); scoped org/Outlet; server-side pagination | | GET | /api/accountant-work-items/{bankTransactionId} | Single work-item projection by bank transaction id | | POST | /api/accountant-work-items/{bankTransactionId}/prepare | Idempotent candidate rebuild + safe proposal classification; no external financial write; blocked for Excluded | | GET | /api/syrve/postings?status= | List Syrve/iiko payment postings, optionally filtered by status | | POST | /api/syrve/postings/queue/{allocationId} | Queue payment posting; accepts optional { "allocationRowVersion": number } | | POST | /api/syrve/postings/{id}/approve | Approve posting; accepts optional { "rowVersion": number } | | POST | /api/syrve/postings/{id}/retry | Retry posting; accepts optional { "rowVersion": number } | | POST | /api/syrve/postings/{id}/reconcile | Force reconciliation; accepts optional { "rowVersion": number } | | PUT | /api/syrve/postings/{id}/write-target | Configure posting account/cash-flow target; accepts optional rowVersion |

Nested Syrve read endpoints under /api/syrve/connections/{connectionId} require access to the selected Pay-owned SyrveConnection. The legacy /api/syrve/document-sync-runs, /api/syrve/references/accounts, and /api/syrve/references/cash-flow-articles paths remain available during the transition window and keep their default/global connection behavior. Explicit nested references read from the selected connection and must not silently switch to the default connection when connectionId is supplied.

SyrveDocumentSyncRun returned by an immediate sync includes structured read telemetry: syncMode (batch, hybrid, or fallback), rpcList, rpcUnpaidBatch, rpcUnpaidSum, rpcGetDocument, rpcAccounts, rpcTotal, unpaidBatchCount, listCount, snapshotReuseCount, and optional fallbackReason. snapshotReuseCount is the number of uncovered Outgoing documents served from a fresh unchanged local financial snapshot without an unpaid RPC. Historical rows retain the same machine-readable summary in errorMessage without an EF migration; the additive telemetry fields may be zero/null when the outcome is not available in memory. Bulk sync guarantees rpcAccounts=0.

Audit log API contracts ​

Audit log endpoints are append-only and scoped by effective user access. GET /api/audit-log keeps its existing paginated behavior. Export endpoints reuse the same scoped query path and export only already-masked AuditLogEntry.Message and AuditLogEntry.DetailsJson.

Operator UI (/audit-log): filters (action / entity / user / date), localized one-sentence event narrative (actor + action + result), action badge, object deep-link when known, and a drawer with the narrative first. Highlighted business fields (reason, amount, fileName, status, changes) remain visible; raw message and detailsJson are collapsed under Technical details. Pagination and export respect active filters.

Critical writers (posting approve/queue/retry, allocation approve/cancel, Syrve connection mutations, posting rules, match create/exclude) should pass typed details into LogAsync (related ids, human anchors, { changes } / { reason } where applicable) and scope organizationId / locationId when known. Secrets and raw bank lines must not appear in details.

Endpoints:

  • GET /api/audit-log
  • GET /api/audit-log/export.csv
  • GET /api/audit-log/export.pdf

Supported query parameters:

  • organizationId
  • locationId
  • fromUtc
  • toUtc
  • action
  • userEmail
  • entityType
  • entityId
  • page / pageSize for list endpoint only

Scope behavior:

  • MegaAdmin and MegaManager can query/export platform-wide audit or apply explicit organization/location filters.
  • OrganizationAdmin, Accountant, and Viewer can query/export only entries inside their effective organization/location scope.
  • Explicit foreign organizationId or locationId filters for non-platform users return a safe empty result, matching the list endpoint behavior.
  • Export generation writes a safe AuditAction.Export audit event containing format, row count, max row cap, and filters only. It does not include exported rows or raw details.

CSV response:

  • Content type: text/csv; charset=utf-8.
  • Stable columns: id, createdAtUtc, organizationId, locationId, userId, userEmail, action, entityType, entityId, message, detailsJson.
  • Fields are escaped with standard CSV quoting; embedded quotes are doubled.

PDF response:

  • Content type: application/pdf.
  • Simple generated report with title, generated timestamp, applied filters, row count, and safe audit row summary.

Masking and data safety:

  • Export uses persisted, already-masked audit fields only.
  • Export does not unmask, rehydrate, or expand private payloads.
  • Export must not include raw bank lines, full account numbers, secrets, credentials, raw bank files, or private customer payloads.

Counterparty matching API contracts ​

This section is the frontend contract for the matching wizard, rules directory, conflict preview, and manual match flows. Related design notes: Counterparty Matching, Scoped RBAC and Tenancy, and Workflow State Machines.

There is no dedicated matching wizard API and no separate matching reapply endpoint. The wizard composes existing endpoints. Re-application happens for future imports because import matching evaluates active rules during bank statement import.

Wizard flow:

  1. Transaction review: GET /api/name-matches/summary, then GET /api/name-matches?status=&organizationId=&locationId=.
  2. Select Syrve/iiko counterparty: GET /api/syrve/counterparties.
  3. Manual match: POST /api/counterparty-matches.
  4. Optional rule conflict preview: POST /api/counterparties/rules/conflict-preview.
  5. Optional rule create: POST /api/counterparties/rules.
  6. Completion and cache refresh: refresh name-matches, counterparty-matches, counterparties, and dashboard queries.

Rules directory endpoints:

  • GET /api/counterparties
  • GET /api/counterparties/rules?organizationId=&locationId=&includeInactive=
  • PUT /api/counterparties/rules/{id}
  • POST /api/counterparties/rules/{id}/deactivate
  • POST /api/counterparties/rules/{id}/activate
  • GET /api/counterparty-matches?status=&organizationId=&locationId=

Auth, permissions, and scope behavior ​

All endpoints in this section require PayAuthorizationPolicies.CounterpartyMatch.

Manual matching (POST /api/counterparty-matches) requires scoped LocationTransactionsMatch or LocationCounterpartiesManage on the matched transaction organization/location (legacy policy counterparty.match accepts either).

Rule create and conflict preview derive organizationId / locationId from bankTransactionId. They require scoped LocationCounterpartiesManage on that transaction scope and never implicitly create global rules.

Rule update, activation, and deactivation keep the rule scope fixed. They first hide inaccessible rules as not_found, then enforce scoped LocationCounterpartiesManage; missing manage permission returns a scoped RBAC 403 code from ScopeAccessDeniedException.Code.

Rule lists support:

  • organizationId
  • locationId
  • includeInactive

When organizationId points to a child organization, GET /api/counterparties and GET /api/counterparties/rules include rules from that organization plus inherited parent organization-level rules. Parent Outlet/location-specific rules are not inherited into the child Outlet scope. A user outside the requested organization receives an empty list for list endpoints.

Active-only behavior:

  • Import matching uses active rules only.
  • GET /api/counterparties returns active rules for the matching page.
  • GET /api/counterparties/rules excludes inactive rules by default.
  • includeInactive=true includes inactive rules in the directory list.
  • Deactivation does not delete rules and does not rewrite historical matches.

Statuses and filters ​

Domain match statuses:

  • Unmatched
  • AutoMatched
  • ManualMatched
  • Excluded — operator marked the payment as non-supplier (salary, tax, fee, internal transfer, other); skipped by rule reapply and blocked from allocation

LowConfidence is a UI/query filter, not a domain enum. It means AutoMatched with confidenceScore < 0.85.

GET /api/name-matches/summary returns:

  • autoMatchedCount: count of AutoMatched.
  • needsReviewCount: count of AutoMatched with confidenceScore < 0.85.
  • notFoundCount: count of Unmatched.
  • manualMatchedCount: count of ManualMatched.
  • excludedCount: count of Excluded.
  • accuracyPercent: autoMatchedCount / total * 100, rounded to one decimal.

GET /api/name-matches/summary ​

Query params:

  • importId — optional; limit counts to one bank statement import
  • organizationId
  • locationId

Response:

json
{
  "autoMatchedCount": 12,
  "needsReviewCount": 3,
  "notFoundCount": 5,
  "manualMatchedCount": 7,
  "accuracyPercent": 63.2,
  "excludedCount": 2
}

GET /api/name-matches/import-summaries ​

Query params:

  • organizationId
  • locationId

Returns one row per imported bank statement with matching progress:

  • reviewStatus: ok | needs_attention | import_issue
  • counters: totalCount, unmatchedCount, needsReviewCount, manualMatchedCount, autoMatchedCount
  • import metadata: fileName, importedAtUtc, detectedBankCode, importStatus, qualityStatus

Ordered by latest import first.

GET /api/name-matches ​

Query params:

  • status: Unmatched, AutoMatched, ManualMatched, Excluded, or LowConfidence.
  • importId — optional; filter to one bank statement import
  • organizationId
  • locationId

Returns up to 500 rows ordered by latest update/create time. Response items are NameMatchDto:

json
{
  "id": "019edf00-0000-7000-8000-000000000101",
  "bankTransactionId": "019edf00-0000-7000-8000-000000000102",
  "importId": "019edf00-0000-7000-8000-000000000099",
  "importFileName": "statement.csv",
  "transactionDate": "2026-03-03",
  "amount": 25000,
  "direction": "Debit",
  "currency": "IDR",
  "bankName": "PERMATA",
  "counterpartyRawName": "PT DEMO SUPPLIER",
  "rawDescription": "Sanitized transfer description",
  "syrveCounterpartyId": "cp-demo",
  "syrveCounterpartyName": "Demo Supplier",
  "confidenceScore": 0.72,
  "status": "AutoMatched",
  "matchType": "FuzzyCandidate",
  "isManual": false,
  "reason": "Похоже на контрагента, требуется проверка",
  "matchedSignals": {
    "counterpartyRawName": "PT DEMO SUPPLIER",
    "rawDescription": "Sanitized transfer description",
    "normalizedBankName": "PT DEMO SUPPLIER",
    "rulePattern": "PT DEMO SUPPLIER",
    "normalizedMatch": true,
    "manualSelection": false
  },
  "matchedRuleId": "019edf00-0000-7000-8000-000000000181",
  "matchedRulePattern": "PT DEMO SUPPLIER",
  "normalizedBankName": "PT DEMO SUPPLIER",
  "explanation": "Похоже на контрагента, требуется проверка",
  "createdAtUtc": "2026-06-30T10:05:00Z",
  "updatedAtUtc": null
}

rawDescription and matchedSignals are present for UI explainability. Clients must treat them as display-only bank metadata and must not log them.

POST /api/counterparty-matches ​

Request:

json
{
  "bankTransactionId": "019edf00-0000-7000-8000-000000000102",
  "syrveCounterpartyId": "cp-demo",
  "syrveCounterpartyName": "Demo Supplier"
}

Behavior:

  • Updates the existing CounterpartyMatch for bankTransactionId.
  • Sets status = ManualMatched.
  • Sets matchType = ManualMatched.
  • Sets confidenceScore = 1.
  • Sets isManual = true.
  • Sets selected Syrve/iiko fields.
  • Sets matchedByUserId.
  • Clears automatic rule attribution (matchedRuleId, matchedRulePattern).
  • Updates explainability signals for manual selection.
  • Writes an audit entry.
  • Does not require a free-text operator reason; backend sets the manual-match reason.

Response is CounterpartyMatchDto:

json
{
  "id": "019edf00-0000-7000-8000-000000000201",
  "bankTransactionId": "019edf00-0000-7000-8000-000000000102",
  "status": "ManualMatched",
  "matchType": "ManualMatched",
  "confidenceScore": 1,
  "reason": "Сопоставлено вручную",
  "matchedSignals": {
    "counterpartyRawName": "PT DEMO SUPPLIER",
    "rawDescription": "Sanitized transfer description",
    "normalizedBankName": "PT DEMO SUPPLIER",
    "syrveCounterpartyId": "cp-demo",
    "syrveCounterpartyName": "Demo Supplier",
    "matchedByUserId": "019edf00-0000-7000-8000-000000000301",
    "manualSelection": true
  },
  "matchedRuleId": null,
  "matchedRulePattern": null,
  "normalizedBankName": "PT DEMO SUPPLIER",
  "syrveCounterpartyId": "cp-demo",
  "syrveCounterpartyName": "Demo Supplier",
  "isManual": true,
  "matchedByUserId": "019edf00-0000-7000-8000-000000000301",
  "createdAtUtc": "2026-06-30T10:00:00Z",
  "updatedAtUtc": "2026-06-30T10:05:00Z",
  "bankTransaction": {
    "id": "019edf00-0000-7000-8000-000000000102",
    "importId": "019edf00-0000-7000-8000-000000000401",
    "transactionDate": "2026-03-03",
    "amount": 25000,
    "direction": "Debit",
    "currency": "IDR",
    "rawDescription": "Sanitized transfer description",
    "counterpartyRawName": "PT DEMO SUPPLIER",
    "bankReference": null,
    "customerReference": null,
    "paymentPurpose": "Sanitized payment purpose"
  }
}

Validation and access:

  • Missing transaction id returns 400 with legacy ApiError.code = bank_transaction_required.
  • Missing Syrve/iiko id returns 400 with legacy ApiError.code = syrve_counterparty_required.
  • Missing Syrve/iiko name returns 400 with legacy ApiError.code = syrve_counterparty_name_required.
  • Match not found returns plain 404.
  • Forbidden scope returns 403 with legacy ApiError.code from scoped RBAC.

POST /api/counterparty-matches/exclude ​

Marks a bank transaction as excluded from counterparty matching (salary, taxes, bank fees, internal transfers, other non-supplier payments).

Request:

json
{
  "bankTransactionId": "019edf00-0000-7000-8000-000000000102",
  "category": "Salary",
  "pattern": "PAYROLL",
  "comment": "Payroll May"
}

category values: Salary, Tax, BankFee, InternalTransfer, Other. pattern is a required, operator-editable auto-exclusion key. comment and priority are optional.

Behavior:

  • Allowed only when current status is Unmatched or AutoMatched.
  • Blocked when an active (non-cancelled) payment allocation exists for the transaction.
  • Upserts an active CounterpartyMatchRule with ruleKind = ExcludeNonSupplier, the selected excludeCategory, and the normalized pattern.
  • Sets the current row to status = Excluded, matchType = Excluded, clears the Syrve counterparty, and records the exclusion rule id/pattern.
  • Reapplies active rules to other non-manual rows in the same scope; matching rows are auto-excluded.
  • Stores category/comment in matchedSignals and a human-readable reason.
  • Skipped by active-rule reapply (same protection as ManualMatched).
  • Allocation preview/confirm returns 409 with counterparty_match_excluded.
  • Requires both manual-match and counterparty-rule management permissions.
  • Writes an audit entry with category, pattern, rule id, duplicate flag, and reapply count.

Response: { match, rule, ruleAlreadyExists, reappliedMatchCount }.

POST /api/counterparty-matches/unexclude ​

Restores an Excluded match to the queue: re-evaluates active rules or falls back to Unmatched.

Request:

json
{
  "bankTransactionId": "019edf00-0000-7000-8000-000000000102"
}

Conflict codes: counterparty_match_not_excluded, counterparty_match_allocation_blocked.

POST /api/counterparties/rules/conflict-preview ​

Request (CounterpartyRuleConflictPreviewRequest):

json
{
  "bankTransactionId": "019edf00-0000-7000-8000-000000000102",
  "pattern": "PT DEMO SUPPLIER",
  "normalizedName": "PT DEMO SUPPLIER",
  "syrveCounterpartyId": "cp-demo",
  "syrveCounterpartyName": "Demo Supplier",
  "priority": null
}

Behavior:

  • Validates the same required fields as rule create.
  • Does not persist anything.
  • Uses active rules only in the source bank transaction organization/location scope.
  • If priority is omitted, effectivePriority is max(priority) + 10; first backend priority is 100.
  • Blocking conflicts are returned for UI decision-making. Create currently treats exact duplicates specially, but does not block every preview conflict type.

Response (CounterpartyRuleConflictPreviewResult):

json
{
  "pattern": "PT DEMO SUPPLIER",
  "normalizedPattern": "PT DEMO SUPPLIER",
  "effectivePriority": 100,
  "hasConflicts": true,
  "conflicts": [
    {
      "type": "OverlappingPattern",
      "severity": "Warning",
      "message": "Паттерн пересекается с существующим правилом. При импорте возможно сопоставление по частичному вхождению (ContainsRule).",
      "existingRule": {
        "ruleId": "019edf00-0000-7000-8000-000000000181",
        "pattern": "DEMO",
        "normalizedPattern": "DEMO",
        "syrveCounterpartyId": "cp-demo",
        "syrveCounterpartyName": "Demo Supplier",
        "priority": 10
      }
    }
  ]
}

Conflict types:

  • ExactDuplicate
  • DuplicateScopeDifferentCounterparty
  • OverlappingPattern
  • PriorityShadowing
  • DifferentSyrveMapping

Severity values:

  • Info
  • Warning
  • Blocking

An ExactDuplicate preview is informational. A matching create request returns success with alreadyExists = true.

POST /api/counterparties/rules ​

Request (CreateCounterpartyRuleRequest):

json
{
  "bankTransactionId": "019edf00-0000-7000-8000-000000000102",
  "pattern": "PT DEMO SUPPLIER",
  "normalizedName": "PT DEMO SUPPLIER",
  "syrveCounterpartyId": "cp-demo",
  "syrveCounterpartyName": "Demo Supplier",
  "priority": 100
}

Behavior:

  • Derives organization/location from bankTransactionId.
  • Trims pattern, syrveCounterpartyId, and syrveCounterpartyName.
  • Normalizes pattern using the backend counterparty normalizer.
  • Rejects empty normalized pattern with pattern_required.
  • If the same active rule exists in the same scope with the same normalized pattern and Syrve/iiko id, returns 200 OK with existing rule and alreadyExists = true.
  • Otherwise creates an active rule and writes an audit entry.
  • Default priority is backend-calculated when omitted.
  • Does not require a free-text reason; backend writes typed audit details.

Created response (CreateCounterpartyRuleResult):

json
{
  "rule": {
    "id": "019edf00-0000-7000-8000-000000000181",
    "organizationId": "019edf00-0000-7000-8000-000000000001",
    "locationId": "019edf00-0000-7000-8000-000000000002",
    "pattern": "PT DEMO SUPPLIER",
    "normalizedPattern": "PT DEMO SUPPLIER",
    "syrveCounterpartyId": "cp-demo",
    "syrveCounterpartyName": "Demo Supplier",
    "priority": 100,
    "isActive": true,
    "createdAtUtc": "2026-06-30T10:06:00Z",
    "updatedAtUtc": null
  },
  "alreadyExists": false,
  "message": "Правило создано. Обновлено сопоставлений: 3.",
  "reappliedMatchCount": 3
}

After create (or duplicate), backend re-applies all active rules to non-manual matches in the same org/location scope. reappliedMatchCount is the number of rows whose match status/metadata changed. Manual matches are never overwritten.

Duplicate response:

json
{
  "rule": {
    "id": "019edf00-0000-7000-8000-000000000181",
    "organizationId": "019edf00-0000-7000-8000-000000000001",
    "locationId": "019edf00-0000-7000-8000-000000000002",
    "pattern": "PT DEMO SUPPLIER",
    "normalizedPattern": "PT DEMO SUPPLIER",
    "syrveCounterpartyId": "cp-demo",
    "syrveCounterpartyName": "Demo Supplier",
    "priority": 100,
    "isActive": true,
    "createdAtUtc": "2026-06-30T10:06:00Z",
    "updatedAtUtc": null
  },
  "alreadyExists": true,
  "message": "Такое правило уже существует. Повторное правило не создано."
}

GET /api/counterparties ​

Legacy active list for the matching page.

Query params:

  • organizationId
  • locationId

Returns active CounterpartyRuleDto[]. Ordering is scope closeness for requested organization, then ascending priority.

GET /api/counterparties/rules ​

Directory list for rule management.

Query params:

  • organizationId
  • locationId
  • includeInactive: default false.

Response (CounterpartyRuleDto[]):

json
[
  {
    "id": "019edf00-0000-7000-8000-000000000181",
    "organizationId": "019edf00-0000-7000-8000-000000000001",
    "locationId": "019edf00-0000-7000-8000-000000000002",
    "pattern": "PT DEMO SUPPLIER",
    "normalizedPattern": "PT DEMO SUPPLIER",
    "syrveCounterpartyId": "cp-demo",
    "syrveCounterpartyName": "Demo Supplier",
    "priority": 100,
    "isActive": true,
    "createdAtUtc": "2026-06-30T10:06:00Z",
    "updatedAtUtc": null
  },
  {
    "id": "019edf00-0000-7000-8000-000000000182",
    "organizationId": "019edf00-0000-7000-8000-000000000001",
    "locationId": null,
    "pattern": "PT OLD DEMO",
    "normalizedPattern": "PT OLD DEMO",
    "syrveCounterpartyId": "cp-old-demo",
    "syrveCounterpartyName": "Old Demo Supplier",
    "priority": 120,
    "isActive": false,
    "createdAtUtc": "2026-06-29T09:00:00Z",
    "updatedAtUtc": "2026-06-30T09:30:00Z"
  }
]

Ordering for the directory is organization id, organization-level before Outlet-specific rules, active first, priority, then pattern.

PUT /api/counterparties/rules/{id} ​

Request (UpdateCounterpartyRuleRequest):

json
{
  "pattern": "PT DEMO SUPPLIER",
  "syrveCounterpartyId": "cp-demo",
  "syrveCounterpartyName": "Demo Supplier",
  "priority": 50
}

Behavior:

  • Updates pattern, normalized pattern, Syrve/iiko fields, and priority.
  • Scope remains fixed.
  • Rejects empty normalized pattern with pattern_required.
  • Rejects active duplicate with validation.failed.
  • Writes audit.
  • Does not require a free-text reason; backend writes typed audit details.

Response is CounterpartyRuleDto:

json
{
  "id": "019edf00-0000-7000-8000-000000000181",
  "organizationId": "019edf00-0000-7000-8000-000000000001",
  "locationId": "019edf00-0000-7000-8000-000000000002",
  "pattern": "PT DEMO SUPPLIER",
  "normalizedPattern": "PT DEMO SUPPLIER",
  "syrveCounterpartyId": "cp-demo",
  "syrveCounterpartyName": "Demo Supplier",
  "priority": 50,
  "isActive": true,
  "createdAtUtc": "2026-06-30T10:06:00Z",
  "updatedAtUtc": "2026-06-30T10:10:00Z"
}

POST /api/counterparties/rules/{id}/deactivate ​

Behavior:

  • Idempotent for already inactive rules.
  • Sets isActive = false.
  • Does not delete the rule.
  • Does not rewrite historical matches.
  • Writes audit when state changes.
  • Does not require a free-text reason.

Response is CounterpartyRuleDto:

json
{
  "id": "019edf00-0000-7000-8000-000000000181",
  "organizationId": "019edf00-0000-7000-8000-000000000001",
  "locationId": "019edf00-0000-7000-8000-000000000002",
  "pattern": "PT DEMO SUPPLIER",
  "normalizedPattern": "PT DEMO SUPPLIER",
  "syrveCounterpartyId": "cp-demo",
  "syrveCounterpartyName": "Demo Supplier",
  "priority": 50,
  "isActive": false,
  "createdAtUtc": "2026-06-30T10:06:00Z",
  "updatedAtUtc": "2026-06-30T10:12:00Z"
}

POST /api/counterparties/rules/{id}/activate ​

Behavior:

  • Idempotent for already active rules.
  • Rejects active duplicate with validation.failed.
  • Writes audit when state changes.
  • Does not require a free-text reason.

Response shape is the same CounterpartyRuleDto as deactivate, with isActive = true.

GET /api/counterparty-matches ​

Technical/list API with explainability fields. /api/name-matches is the operator-friendly matching list.

Query params:

  • status: domain enum only (Unmatched, AutoMatched, ManualMatched, Excluded). LowConfidence is not handled by this endpoint.
  • organizationId
  • locationId

Returns up to 200 CounterpartyMatchDto rows ordered by create time descending.

Error codes and reason-required cases ​

Matching endpoints use both canonical error helpers and legacy ApiError responses. Stable matching codes:

  • pattern_required
  • syrve_counterparty_required
  • syrve_counterparty_name_required
  • bank_transaction_required
  • bank_transaction_not_found
  • validation.failed
  • forbidden
  • not_found
  • scoped RBAC denial codes from ScopeAccessDeniedException.Code

Reason behavior:

  • POST /api/counterparty-matches does not require a free-text operator reason.
  • POST /api/counterparties/rules does not require a free-text reason.
  • PUT /api/counterparties/rules/{id} does not require a free-text reason.
  • activate / deactivate do not require a free-text reason.
  • This is separate from bank import quality review override, where a reason is required.

Sanitization requirements for examples and logs ​

Examples in this section are sanitized. Do not place raw bank files, raw bank lines, full account numbers, real bank/customer references, secrets, tokens, or private customer payloads in docs, logs, or frontend diagnostics.

API error contract ​

API errors use a canonical ErrorResponse shape. Backend returns stable code plus optional safe params / details; frontend localizes user-facing text from code + params. traceId is mandatory and should be sent to support when reporting an error.

message may be present only as a legacy compatibility field for existing UI consumers. New UI must not depend on backend language text as the primary i18n source.

json
{
  "code": "validation.failed",
  "params": {
    "entity": "counterpartyRule"
  },
  "details": "Safe diagnostic text without secrets or raw bank lines.",
  "traceId": "00-abcdef...",
  "fieldErrors": {
    "pattern": ["validation.invalid"]
  }
}

Responses must not include raw exception messages, stacktraces, secrets, raw bank files, full account numbers, or raw bank lines.

Error examples ​

Validation error (400):

json
{
  "code": "validation.failed",
  "details": "Request validation failed.",
  "traceId": "00-abcdef...",
  "fieldErrors": {
    "pattern": ["validation.invalid"]
  }
}

Unauthorized / forbidden (401 / 403):

json
{
  "code": "unauthorized",
  "traceId": "00-abcdef..."
}
json
{
  "code": "forbidden",
  "traceId": "00-abcdef..."
}

Not found (404):

json
{
  "code": "not_found",
  "traceId": "00-abcdef..."
}

Business conflict / quota (409 where endpoint already uses conflict semantics):

json
{
  "code": "organization_user_quota_exceeded",
  "traceId": "00-abcdef..."
}

Concurrency / workflow conflict (409):

json
{
  "code": "concurrency.conflict",
  "params": {
    "entity": "PaymentAllocation",
    "id": "019edf00-0000-7000-8000-000000000001"
  },
  "traceId": "00-abcdef..."
}
json
{
  "code": "workflow.locked",
  "params": {
    "entity": "BankTransaction",
    "state": "PendingHardDelete",
    "operation": "confirmAllocation"
  },
  "traceId": "00-abcdef..."
}

Internal error (500):

json
{
  "code": "internal_error",
  "details": "Internal server error.",
  "traceId": "00-abcdef..."
}

Registered error codes ​

CodeHTTP statusParamsWhen it occursUI localization note
invalid_credentials401nonePOST /api/auth/login invalid email/passwordShow login failure text
empty_file400nonePOST /api/bank-statements/import empty uploadShow upload file validation text
pattern_required400noneCounterparty rule pattern is empty or normalizes to emptyHighlight rule pattern
syrve_counterparty_required400noneCounterparty rule/manual match lacks Syrve/iiko counterparty idAsk user to choose counterparty
syrve_counterparty_name_required400noneCounterparty rule/manual match lacks Syrve/iiko counterparty nameAsk user to choose counterparty again
bank_transaction_required400noneCounterparty rule/manual match lacks bank transaction idAsk user to select transaction
bank_transaction_not_found404noneReferenced bank transaction does not existRefresh data or select another transaction
request.failed400noneGeneric request failure fallbackGeneric retry/support text
unauthorized401noneMissing/expired authenticationRedirect/login text
forbidden403nonePermission or scoped RBAC gate denies actionExplain missing access
not_found404noneResource missing with no more specific business codeExplain stale/missing data
validation.failed400fieldErrorsModel binding/validation failedMap field keys to form errors
concurrency.conflict409entity, idClient supplied stale rowVersion for guarded writeRefresh entity and ask operator to retry
workflow.invalid_transition409entity, state, operationRequested workflow transition is not allowed for current stateDisable action or show state-specific conflict text
workflow.locked409entity, state, operationEntity is soft-deleted, pending hard delete, or hard-deleted and cannot be used in active workflowRefresh list/detail; explain lifecycle lock
allocation.not_editable_after_approve409allocationId, state, operationAllocation edit/cancel/confirm conflicts with an already approved allocationShow approved-allocation lock text
allocation.not_editable_after_queued409allocationId, state, operationAllocation edit/cancel/confirm conflicts with an allocation already queued for postingShow posting-queue lock text
allocation.already_in_flight409bankTransactionId, allocationIdPOST /api/payment-allocations/confirm rejected: bank transaction already has Approved/PostingQueued/Posted/Reconciled allocation or an in-flight/completed Syrve postingDo not open allocate; resume posting result; show already-in-flight message
bank_import_balance_review_required409importIdLegacy balance-review gate (no longer blocks posting; bank statement is source of truth)Informational only
internal_error500noneUnhandled server exceptionShow safe support text with traceId
Existing ScopeAccessDeniedException.Code values403none unless endpoint defines themScoped RBAC/application permission denialLocalize exact code when known, fallback to forbidden
Existing QuotaExceededException.Code values403 or existing endpoint conflict statusnone unless endpoint defines themQuota gate denial; invite accept keeps 409 conflict semanticsLocalize exact code when known, fallback to quota/forbidden text

Auth active context flow (MHPAY-53) ​

GET /api/auth/context returns available contexts for the current user plus optional auto-selection hint.

Response example:

json
{
  "organizations": [
    {
      "organizationId": "019edf00-0000-7000-8000-000000000001",
      "organizationName": "Default Organization",
      "allowOrganizationWideContext": true,
      "locations": [
        {
          "locationId": "019edf00-0000-7000-8000-000000000002",
          "name": "Default Outlet",
          "code": "default",
          "isActive": true
        }
      ]
    }
  ],
  "autoSelectedContext": {
    "organizationId": "019edf00-0000-7000-8000-000000000001",
    "organizationName": "Default Organization",
    "locationId": null,
    "locationName": null,
    "locationCode": null
  },
  "requiresSelection": false
}

POST /api/auth/context validates a selected context against effective user scope.

Request:

json
{
  "organizationId": "019edf00-0000-7000-8000-000000000001",
  "locationId": "019edf00-0000-7000-8000-000000000002"
}

Rules:

  • selected organization must be inside the effective user scope;
  • if locationId is provided, the Outlet/location must belong to organizationId;
  • selected Outlet/location must be directly allowed by scope or available via org-wide access;
  • locationId = null is allowed only for org-wide access (or platform users);
  • cross-org/cross-outlet attempts return 403 with ErrorResponse;
  • successful context selection writes an audit entry (entityType: ActiveContext).

Stateless MVP note:

  • backend does not persist active context in Redis/session in this iteration;
  • frontend stores active context client-side and sends it with each API request headers:
    • X-Organization-Id (required for active context),
    • X-Location-Id (optional, nullable org-wide mode).

Operational Outlet-required endpoints revalidate these headers on every request and do not fall back to platform-wide data for MegaAdmin / MegaManager. For the operational dashboard and affected list/summary endpoints, X-Organization-Id and X-Location-Id are both required unless the endpoint receives an explicit equivalent organizationId + locationId query scope.

Current Outlet-required operational endpoints:

  • GET /api/dashboard
  • GET /api/bank-statements/imports
  • GET /api/bank-statements/quality-summary
  • GET /api/bank-statements/import-health-summary
  • GET /api/payment-allocations
  • GET /api/payment-anomalies

Context errors use ErrorResponse:

CodeHTTPMeaning
context.organization.required400Active organization is missing for an Outlet operational endpoint
context.location.required400Active Outlet is missing for an Outlet operational endpoint
context.organization.invalid400X-Organization-Id is not a UUID
context.location.invalid400X-Location-Id is not a UUID
context.organization.denied403Selected organization is outside the caller's effective scope
context.location.denied403Selected Outlet is outside the caller's effective scope
context.location.organization_mismatch403Selected Outlet does not belong to selected organization
context.organization.not_found403Selected organization cannot be used as active context
context.location.not_found403Selected Outlet cannot be used as active context

Platform User Management ​

MHPAY-52 adds direct platform user management for MegaAdmin / MegaManager through the scoped permission platform.users.manage. Authorization is permission-based, not role-name-only.

GET /api/platform/users returns PlatformUserDto[] with current role assignments across platform, organization, and Outlet/location scopes.

POST /api/platform/users creates a user without an invitation link.

Request:

json
{
  "email": "accountant@example.test",
  "displayName": "Accountant User",
  "password": "temporary-password",
  "isActive": true
}

Response is 201 Created with PlatformUserDto. Passwords are never returned.

POST /api/platform/users/{targetUserId}/roles directly assigns a scoped role.

Request:

json
{
  "roleId": "019edf00-0000-7000-8000-0000000000d3",
  "organizationId": "019edf00-0000-7000-8000-000000000001",
  "locationId": null
}

Rules:

  • one user may have multiple role assignments across organizations and Outlets;
  • the same user may have different roles in different organizations;
  • assignable roles are limited to MVP roles and exclude PaymentOperator, LocationAdmin, and legacy aliases;
  • MegaAdmin / MegaManager are platform-scope assignments only;
  • OrganizationAdmin, Accountant, and Viewer require organization or Outlet scope;
  • locationId is optional; when provided it must belong to the provided organization;
  • organization-scoped assignment enforces organization user quota;
  • create/update/assign/remove mutations write audit entries and do not include passwords or secrets in audit details.

Organization-scoped user management remains under /api/organizations/{id}/users.... OrganizationAdmin must not use platform endpoints unless granted platform.users.manage.

Invitation flow (MHPAY-54) ​

Invitation runtime state is Redis-only and expires exactly after 3 hours.

Redis object key:

mhpay:{env}:org:{organizationId}:invite:object:{inviteId}

POST /api/platform/users/invites creates invite token and stores only token hash in Redis.

Request:

json
{
  "email": "new.user@example.test",
  "roleId": "019edf00-0000-7000-8000-0000000000d3",
  "organizationId": "019edf00-0000-7000-8000-000000000001",
  "locationId": null
}

Response (201 Created):

json
{
  "inviteId": "019f0000-0000-7000-8000-000000000123",
  "inviteToken": "<token-shown-once>",
  "expiresAtUtc": "2026-06-26T12:00:00Z",
  "organizationId": "019edf00-0000-7000-8000-000000000001",
  "locationId": null,
  "roleCode": "Viewer"
}

POST /api/auth/invites/check validates token and returns safe metadata only.

Request:

json
{
  "inviteToken": "<token>"
}

Response (200 OK):

json
{
  "inviteId": "019f0000-0000-7000-8000-000000000123",
  "expiresAtUtc": "2026-06-26T12:00:00Z",
  "organizationId": "019edf00-0000-7000-8000-000000000001",
  "locationId": null,
  "roleCode": "Viewer"
}

POST /api/auth/invites/accept accepts token, sets user password hash, and creates/activates assignment. The SPA then shows a dedicated success screen (account ready, not signed in) with a primary action to /login; accept does not issue session tokens.

Request:

json
{
  "inviteToken": "<token>",
  "password": "ChangeMe123!",
  "displayName": "Invited User"
}

Response (200 OK):

json
{
  "inviteId": "019f0000-0000-7000-8000-000000000123",
  "userId": "019f0000-0000-7000-8000-000000000124",
  "userCreated": true,
  "roleAssignmentCreated": true
}

Security notes:

  • plaintext invite token is returned only once at create response;
  • Redis value stores token hash, not plaintext token;
  • audit details do not include plaintext token/password.

Bank Statement Upload and Duplicate Detection ​

POST /api/bank-statements/import?organizationId=&locationId= ​

Uploads a bank statement file for parsing. Permission and quota checks run before file processing.

Duplicate key (MVP): SHA256(original file bytes) + OrganizationId. locationId is not part of the duplicate key — the same file uploaded to another outlet in the same organization returns the existing import.

Response (202 Accepted) — BankStatementImportUploadResultDto:

json
{
  "importId": "019f0000-0000-7000-8000-000000000200",
  "status": "Parsed",
  "errorMessage": null,
  "errors": [],
  "isDuplicate": false,
  "existingImportId": null,
  "message": null
}

Duplicate upload example (no new import/transactions are created):

json
{
  "importId": "019f0000-0000-7000-8000-000000000100",
  "status": "Parsed",
  "errorMessage": "Этот файл уже был загружен ранее в этой организации.",
  "errors": [],
  "isDuplicate": true,
  "existingImportId": "019f0000-0000-7000-8000-000000000100",
  "message": "Этот файл уже был загружен ранее в этой организации."
}

Notes:

  • importId always points to the import the UI should open (existingImportId when duplicate).
  • Duplicate attempts are audited with safe metadata only (organization, location, extension, content type, size, existing import id, short SHA256 prefix). Raw file bytes/lines and account numbers are never logged.
  • File-level duplicate detection is separate from post-parse transaction duplicate candidates (GET .../duplicates).

BankStatementFiles.Sha256 stores the full lowercase hex digest of the original upload.

Original Bank File Storage and Download ​

MHPAY-61 stores uploaded original bank files on disk and keeps only metadata in BankStatementFiles. Parsed BankTransaction rows remain after the original file expires, is cleaned up, or is removed by hard-delete lifecycle handling.

Storage defaults:

  • config section: BankFiles;
  • BankFiles__RootPath: controlled server-side storage root, default data/bank-files;
  • BankFiles__RetentionHours: original-file TTL, default 24;
  • API responses never expose the raw storage path/key.

Migration note: legacy DB blob content is removed from BankStatementFiles.Content. Legacy rows without a corresponding disk file are treated as missing/expired for download and return a safe 404.

GET /api/bank-statements/imports/{id}/file ​

Downloads the original uploaded bank file for an import while the retained file still exists on disk.

Permission:

  • ASP.NET policy: PayAuthorizationPolicies.BankFilesDownload;
  • scoped permission: bankFiles.download;
  • scope check: user must have access to the import's organizationId and locationId;
  • allowed MVP profiles: MegaAdmin, MegaManager, scoped OrganizationAdmin, scoped Accountant;
  • denied MVP profile: Viewer.

Response:

http
HTTP/1.1 200 OK
Content-Type: text/csv
Content-Disposition: attachment; filename="permata.csv"
Content-Length: 12345

The response body is the file stream. The filename is sanitized metadata from the original upload; raw filesystem paths are not returned.

Errors:

StatusCodeWhen
401unauthorizedMissing/expired authentication.
403forbidden or scoped RBAC denial codeMissing bankFiles.download or cross-tenant/cross-location access.
404not_foundImport/file metadata is absent, file expired, file already cleaned up/deleted, or physical file is missing.

Security notes:

  • no raw bank file content, raw bank lines, full account numbers, storage paths, or private customer data are logged;
  • download audit writes safe metadata only: import id, extension, size, and expiry;
  • cleanup deletes only expired physical originals and marks file metadata; it does not delete parsed transactions;
  • hard-delete lifecycle handling for BankStatementImport removes the physical file immediately and leaves parsed transaction rows subject to the existing lifecycle rules.

POST /api/bank-statements/imports/{id}/quality-review/override ​

Overrides the balance-mismatch review gate for a parsed import. Matching and allocation remain available while review is open; Syrve/iiko queue posting stays blocked until this override succeeds.

Permission:

  • ASP.NET policy: PayAuthorizationPolicies.BankStatementsQualityOverride;
  • scoped permission: location.bank_statements.quality_override;
  • allowed MVP profiles: MegaAdmin, MegaManager, scoped OrganizationAdmin, scoped Accountant;
  • denied MVP profile: Viewer.

Request:

json
{
  "reason": "Checked bank statement totals manually",
  "rowVersion": 123
}

Response: updated BankStatementImportQualityReportDto including balanceReviewOverridden, reviewOverrideReason, reviewOverriddenByUserId, and reviewOverriddenAtUtc.

Errors:

StatusCodeWhen
400review_override_reason_requiredEmpty or whitespace-only reason.
403scoped RBAC denialMissing override permission or cross-scope access.
404not_foundImport or quality report missing.
409concurrency.conflictStale rowVersion.
409workflow.invalid_transitionBalance review not required or already overridden.

Posting gate:

  • while balanceMatches === false and balanceReviewOverridden === false, POST /api/syrve/postings/queue/{allocationId} returns 409 with code bank_import_balance_review_required;
  • GET /api/syrve/postings/preview/{allocationId} returns canQueue=false and blockReason=bank_import_balance_review_required.

Audit writes safe metadata only: import id, organization id, location id, quality status, balance flag, reason, actor id. No raw bank file content, raw bank lines, or full account numbers.

DELETE /api/bank-statements/imports/{id} ​

Hard-deletes a bank statement import and its dependent Pay data: transactions, counterparty matches for those transactions, duplicate candidates, quality report, adapter detection metadata, reprocess previews, stored file (physical + metadata), cancelled allocations, payment candidates, and related manual corrections. Counterparty match rules are not deleted.

Permission:

  • ASP.NET policy: PayAuthorizationPolicies.BankStatementsImport;
  • scoped permission: location.bank_statements.upload;
  • allowed MVP profiles: MegaAdmin, scoped OrganizationAdmin, scoped Accountant.

Request:

json
{
  "reason": "Wrong file uploaded, need to re-import"
}

Response: 204 No Content.

Errors:

StatusCodeWhen
400import_delete_reason_requiredEmpty or whitespace-only reason.
403scoped RBAC denialMissing upload permission or cross-scope access.
404not_foundImport missing.
409bank_import_has_allocationsAt least one transaction has a non-cancelled active payment allocation.

After successful delete, the same file SHA256 can be uploaded again for the organization.

POST /api/bank-statements/imports/{id}/reprocess-preview ​

Runs a read-only reprocess preview for an existing import using the current adapter/parser pipeline and stores the result in BankStatementReprocessPreviews. Does not mutate BankTransactions, matches, duplicate candidates, allocations, postings, or the existing import/quality report.

Authorization:

  • explicit platform role check only: MegaAdmin, MegaManager;
  • denied: OrganizationAdmin, Accountant, Viewer, PaymentOperator, LocationAdmin → 403 forbidden.

Response: BankStatementReprocessPreviewDto with status (Completed, Failed, OriginalFileUnavailable), adapter/version metadata, parse/detection confidence, existing vs preview transaction counts, added/removed/changed/unchanged counts, bounded diff samples, warnings/errors summary, and safe operator message when the original file is unavailable.

GET /api/bank-statements/imports/{id}/reprocess-previews ​

Lists stored previews for the import, newest first. Same authorization as POST.

GET /api/bank-statements/imports/{id}/reprocess-previews/{previewId} ​

Returns one stored preview row. Same authorization as POST.

Security notes:

  • audit stores import/preview ids, adapter metadata, summary counts, safe file extension/size/expiry only;
  • no raw bank file content, raw bank lines, full account numbers, or storage paths in API/audit/logs;
  • preview is stored only — there is no apply/reprocess mutation endpoint.

Bank Import Health Summary ​

GET /api/bank-statements/import-health-summary?organizationId=&locationId= ​

Returns BankStatementImportHealthSummaryDto for operational monitoring. The endpoint is protected by PayAuthorizationPolicies.BankStatementsView and applies the current user's organization/location scope plus optional query filters.

Response example:

json
{
  "totalImports": 12,
  "successfulImports": 9,
  "failedImports": 3,
  "unsupportedFormatCount": 1,
  "importsNeedingReview": 2,
  "duplicateCandidateCount": 5,
  "unmatchedTransactionCount": 7,
  "averageParseConfidence": 0.86,
  "lastSuccessfulImportAtUtc": "2026-06-24T08:30:00Z",
  "lastFailedImportAtUtc": "2026-06-24T09:10:00Z",
  "importsByBankFormat": [
    {
      "bankCode": "PERMATA",
      "formatCode": "permata-transaction-inquiry-csv-v1",
      "importCount": 8,
      "successfulImports": 7,
      "failedImports": 1,
      "averageParseConfidence": 0.91
    }
  ]
}

The response is aggregate-only. It must not include raw descriptions, references, account numbers, file content, raw JSON, or personal bank transaction fields.

Accountant work items ​

Authoritative backend projection for the Accountant Workbench. Replaces frontend N+1 aggregation over imports/transactions/allocations/postings.

GET /api/accountant-work-items ​

Requires bank_statements.view and active organization + Outlet context.

Query:

ParamNotes
stateCSV of Attention, ReadyForApproval, Processing, Completed, Excluded, NeedsPreparation
reasonCSV of stable reason codes (e.g. balance_review_required, posting_failed)
importIdFilter by import
dateFrom / dateToDateOnly on transaction date
searchCounterparty / description / purpose / bank reference / import file name
documentContainsCase-insensitive import file name filter
matchStatuse.g. Unmatched, Excluded
page / pageSizeDefault pageSize 20, max 100
sort`transactionDate:asc

Response: PagedResultDto<AccountantWorkItemDto> with workflowState, attentionReasons, nextAction, capabilities, matchedRuleId, and matchedRulePattern. No persisted BankTransactionStatus. Excluded is classified before other states and never Ready.

GET /api/accountant-work-items/{bankTransactionId} ​

Same DTO for a single scoped transaction; 404 accountant_work_item.not_found when missing/out of scope.

POST /api/accountant-work-items/{bankTransactionId}/prepare ​

Requires payment_candidates.build (and scoped outlet context). Rebuilds payment candidates via existing IPaymentCandidateService.BuildCandidatesAsync, optionally runs allocation preview for a single safe full-amount candidate, and returns updated work item projection.

  • No external Syrve/iiko financial write.
  • Excluded transactions return 409 counterparty_match_excluded.
  • Safe unmatched transactions may become ReadyForApproval when exactly one high-confidence warning-free full-amount candidate exists (counterparty resolved from document).
  • Response: AccountantWorkItemPrepareResult (workItem, safeProposal, candidateCount, isIdempotentReplay).

POST /api/accountant-work-items/{bankTransactionId}/approve-and-queue ​

Requires payment.confirm (PaymentConfirm policy) and active organization + Outlet context.

Single accountant command that composes existing services (candidates → allocation confirm/approve → posting preview → queue outbox). Does not perform a synchronous live iiko write; posting remains asynchronous via outbox worker.

Request body:

FieldNotes
idempotencyKeyRequired; replay returns AlreadyQueued / Processing / AlreadyCompleted / mapped posting outcome
expectedTransactionRowVersionOptimistic concurrency on bank transaction
expectedAllocationRowVersion / expectedPostingRowVersionOptional concurrency for follow-up steps
proposalHashOptional; mismatch → NeedsAttention
linesOptional; when omitted, resolves from safe single full-amount candidate
writeTargetOptional account / cash-flow article; otherwise uses posting preview suggestions
acceptRiskDefault false; risky allocation preview without accept → NeedsAttention + isRisky (no mutate)

Outcomes: Queued, Processing, NeedsAttention, Blocked, AlreadyQueued, AlreadyCompleted. Posted awaiting readback maps to Processing; AlreadyCompleted requires Reconciled.

  • Excluded → Blocked (409/Blocked path; no queue).
  • Risky without acceptRisk → no allocation/posting mutation.
  • Safe Ready item → one UI click from Workbench Drawer; risky → ConfirmDialog then retry with acceptRisk: true.

Response: AccountantApproveAndQueueResult (outcome, workItem, allocationId, postingId, message, warnings, isRisky).

POST /api/accountant-payment-runs/preview ​

Requires payment.confirm and active organization + Outlet context. No mutations.

Body (optional): bankTransactionIds, importId. When IDs omitted, previews up to 100 ReadyForApproval items in scope.

Eligibility (safe batch only): ReadyForApproval, canApproveAndQueue, no attention reasons, not Excluded, single safe full-amount proposal, non-risky allocation preview. Risky items must use single approve-and-queue with acceptRisk.

Response: eligible/excluded lists, counts, total amount, currency, warnings, proposal hashes. See ADR accountant-payment-runs-v1.md — no durable batch entity.

POST /api/accountant-payment-runs/approve ​

Requires payment.confirm. Body: correlationId + items (bankTransactionId, expectedTransactionRowVersion, proposalHash).

Each item is processed via existing approve-and-queue with AcceptRisk=false and idempotency key {correlationId}:{bankTransactionId}. Partial success is returned; double-submit maps to AlreadyQueued/Processing/AlreadyCompleted. Batch never bypasses single-item safety.

Response: AccountantPaymentRunApproveResult (correlationId, results, succeededCount, failedCount, skippedCount).

Review Queue ​

Frontend route /review-queue redirects to /accountant-workbench?state=attention. The authoritative task list is GET /api/accountant-work-items.

BlockLink
Failed imports/bank-statements/list?status=Failed
Quality warnings/bank-statements/list?quality=warnings
Duplicate candidates/bank-statements/list?duplicates=true
Unmatched counterparties/name-matching?status=Unmatched
Low confidence matches/name-matching?status=LowConfidence
Allocation previews/payment-allocations?status=Preview
Failed postings/syrve/postings?status=Failed
Open payment anomalies/payment-anomalies?status=Open

Organization / Sub-organization / Outlet API ​

Ownership endpoints manage Organization, Sub-organization, and Outlet records. Product docs use Outlet as the business term; current runtime routes and DTO names still use location / LocationDto for compatibility.

Related docs:

Endpoint table ​

MethodPathProduct conceptActionRequired permission / policySuccess responseImportant error codes
GET/api/platform/organizationsOrganization / Sub-organizationPlatform-scoped list used by /platform/organizations registry and settings/quota context selectionplatform.organizations.view200 OrganizationDto[]unauthorized, forbidden
POST/api/platform/organizationsOrganization / Sub-organizationCreate root organization, or child when parentOrganizationId is setplatform.organizations.manage201 OrganizationDtounauthorized, forbidden, validation.failed, not_found / organization_not_found
GET/api/platform/organizations/{id}Organization / Sub-organizationPlatform route for organization detailplatform.organizations.view policy, then scoped organization.view service guard200 OrganizationDtounauthorized, forbidden, not_found, scoped denial codes
PUT/api/platform/organizations/{id}Organization / Sub-organizationPlatform route for details/status updateplatform.organizations.manage policy; service allows platform manage or scoped organization.users.manage200 OrganizationDtounauthorized, forbidden, validation.failed, not_found, workflow.invalid_transition
DELETE/api/platform/organizations/{id}Organization / Sub-organizationHard delete archived organization without operational dataplatform.organizations.manage policy; service requires MegaAdmin204 No Contentunauthorized, forbidden, not_found, workflow.invalid_transition, workflow.locked, confirmation/reason errors
GET/api/platform/organizations/{id}/quotaOrganization quotaRead quota limits and flagsorganization.quotas.view200 OrganizationQuotaDtounauthorized, forbidden, not_found, scoped denial codes
PUT/api/platform/organizations/{id}/quotaOrganization quotaUpdate quota limits and flagsplatform.quotas.manage200 OrganizationQuotaDtounauthorized, forbidden, validation.failed, not_found, quota/scoped denial codes
GET/api/organizations/currentOrganizationGet first organization from current user's scopeAuthenticated user200 OrganizationDto or 200 nullunauthorized, forbidden
GET/api/organizations/{id}Organization / Sub-organizationScoped organization detailorganization.view200 OrganizationDtounauthorized, forbidden, not_found, scoped denial codes
PUT/api/organizations/{id}Organization / Sub-organizationScoped details/status updateorganization.users.manage policy; service also accepts platform.organizations.manage200 OrganizationDtounauthorized, forbidden, validation.failed, not_found, workflow.invalid_transition
POST/api/organizations/{id}/childrenSub-organizationCreate child organization under parent {id}organization.locations.manage on parent201 OrganizationDtounauthorized, forbidden, validation.failed, not_found / organization_not_found
GET/api/organizations/{id}/locationsOutletList outlets/locations for organizationorganization.view200 LocationDto[]unauthorized, forbidden, scoped denial codes
POST/api/organizations/{id}/locationsOutletCreate outlet/locationorganization.locations.manage; quota gate EnsureCanCreateLocationAsync201 LocationDtounauthorized, forbidden, validation.failed, quota codes
PUT/api/organizations/{id}/locations/{locationId}OutletUpdate outlet/location details or active flagorganization.locations.manage200 LocationDtounauthorized, forbidden, validation.failed, not_found
GET/api/locations/{id}OutletGet outlet/location detaillocation.view200 LocationDtounauthorized, forbidden, not_found, scoped denial codes

DTO shapes ​

OrganizationDto:

json
{
  "id": "018f6f2d-4d40-7a7d-9e5d-0f89944b5a01",
  "parentOrganizationId": null,
  "name": "PT Demo Hospitality",
  "legalName": "PT Demo Hospitality Indonesia",
  "taxId": "01.234.567.8-901.000",
  "status": "Active",
  "createdAtUtc": "2026-06-30T08:15:00Z"
}

LocationDto (Outlet):

json
{
  "id": "018f6f2d-4d40-7a7d-9e5d-0f89944b5a11",
  "organizationId": "018f6f2d-4d40-7a7d-9e5d-0f89944b5a01",
  "name": "Jakarta Central",
  "code": "JKT-CENTRAL",
  "timeZone": "Asia/Jakarta",
  "chainKey": "demo-chain",
  "isActive": true,
  "createdAtUtc": "2026-06-30T08:20:00Z"
}

Request / response examples ​

Create platform organization:

http
POST /api/platform/organizations
Content-Type: application/json
json
{
  "name": "PT Demo Hospitality",
  "legalName": "PT Demo Hospitality Indonesia",
  "taxId": "01.234.567.8-901.000",
  "parentOrganizationId": null
}
json
{
  "id": "018f6f2d-4d40-7a7d-9e5d-0f89944b5a01",
  "parentOrganizationId": null,
  "name": "PT Demo Hospitality",
  "legalName": "PT Demo Hospitality Indonesia",
  "taxId": "01.234.567.8-901.000",
  "status": "Active",
  "createdAtUtc": "2026-06-30T08:15:00Z"
}

List platform organizations:

http
GET /api/platform/organizations
json
[
  {
    "id": "018f6f2d-4d40-7a7d-9e5d-0f89944b5a01",
    "parentOrganizationId": null,
    "name": "PT Demo Hospitality",
    "legalName": "PT Demo Hospitality Indonesia",
    "taxId": "01.234.567.8-901.000",
    "status": "Active",
    "createdAtUtc": "2026-06-30T08:15:00Z"
  },
  {
    "id": "018f6f2d-4d40-7a7d-9e5d-0f89944b5a02",
    "parentOrganizationId": "018f6f2d-4d40-7a7d-9e5d-0f89944b5a01",
    "name": "PT Demo Hospitality - Central Region",
    "legalName": null,
    "taxId": null,
    "status": "Active",
    "createdAtUtc": "2026-06-30T08:18:00Z"
  }
]

Get organization:

http
GET /api/organizations/018f6f2d-4d40-7a7d-9e5d-0f89944b5a01
json
{
  "id": "018f6f2d-4d40-7a7d-9e5d-0f89944b5a01",
  "parentOrganizationId": null,
  "name": "PT Demo Hospitality",
  "legalName": "PT Demo Hospitality Indonesia",
  "taxId": "01.234.567.8-901.000",
  "status": "Active",
  "createdAtUtc": "2026-06-30T08:15:00Z"
}

Update organization status/details:

http
PUT /api/organizations/018f6f2d-4d40-7a7d-9e5d-0f89944b5a01
Content-Type: application/json
json
{
  "name": "PT Demo Hospitality",
  "legalName": "PT Demo Hospitality Indonesia",
  "taxId": "01.234.567.8-901.000",
  "status": "Suspended"
}
json
{
  "id": "018f6f2d-4d40-7a7d-9e5d-0f89944b5a01",
  "parentOrganizationId": null,
  "name": "PT Demo Hospitality",
  "legalName": "PT Demo Hospitality Indonesia",
  "taxId": "01.234.567.8-901.000",
  "status": "Suspended",
  "createdAtUtc": "2026-06-30T08:15:00Z"
}

Create child organization / Sub-organization:

http
POST /api/organizations/018f6f2d-4d40-7a7d-9e5d-0f89944b5a01/children
Content-Type: application/json
json
{
  "name": "PT Demo Hospitality - Central Region",
  "legalName": null,
  "taxId": null
}
json
{
  "id": "018f6f2d-4d40-7a7d-9e5d-0f89944b5a02",
  "parentOrganizationId": "018f6f2d-4d40-7a7d-9e5d-0f89944b5a01",
  "name": "PT Demo Hospitality - Central Region",
  "legalName": null,
  "taxId": null,
  "status": "Active",
  "createdAtUtc": "2026-06-30T08:18:00Z"
}

The path {id} is the parent organization and overrides any parentOrganizationId in the request body.

List outlets/locations:

http
GET /api/organizations/018f6f2d-4d40-7a7d-9e5d-0f89944b5a01/locations
json
[
  {
    "id": "018f6f2d-4d40-7a7d-9e5d-0f89944b5a11",
    "organizationId": "018f6f2d-4d40-7a7d-9e5d-0f89944b5a01",
    "name": "Jakarta Central",
    "code": "JKT-CENTRAL",
    "timeZone": "Asia/Jakarta",
    "chainKey": "demo-chain",
    "isActive": true,
    "createdAtUtc": "2026-06-30T08:20:00Z"
  }
]

Create outlet/location:

http
POST /api/organizations/018f6f2d-4d40-7a7d-9e5d-0f89944b5a01/locations
Content-Type: application/json
json
{
  "name": "Jakarta Central",
  "code": "JKT-CENTRAL",
  "timeZone": "Asia/Jakarta",
  "chainKey": "demo-chain"
}
json
{
  "id": "018f6f2d-4d40-7a7d-9e5d-0f89944b5a11",
  "organizationId": "018f6f2d-4d40-7a7d-9e5d-0f89944b5a01",
  "name": "Jakarta Central",
  "code": "JKT-CENTRAL",
  "timeZone": "Asia/Jakarta",
  "chainKey": "demo-chain",
  "isActive": true,
  "createdAtUtc": "2026-06-30T08:20:00Z"
}

Update outlet/location:

http
PUT /api/organizations/018f6f2d-4d40-7a7d-9e5d-0f89944b5a01/locations/018f6f2d-4d40-7a7d-9e5d-0f89944b5a11
Content-Type: application/json
json
{
  "name": "Jakarta Central",
  "code": "JKT-CENTRAL",
  "timeZone": "Asia/Jakarta",
  "chainKey": "demo-chain",
  "isActive": false
}
json
{
  "id": "018f6f2d-4d40-7a7d-9e5d-0f89944b5a11",
  "organizationId": "018f6f2d-4d40-7a7d-9e5d-0f89944b5a01",
  "name": "Jakarta Central",
  "code": "JKT-CENTRAL",
  "timeZone": "Asia/Jakarta",
  "chainKey": "demo-chain",
  "isActive": false,
  "createdAtUtc": "2026-06-30T08:20:00Z"
}

Statuses and deleted behavior ​

Organization status values are stable strings: Active, Suspended, Archived.

  • Active allows normal scoped operations subject to permissions and quotas.
  • Suspended is an organization-level operational hold. Backend guards block writes where workflow rules apply.
  • Archived is the current organization-level inactive/archived business state.

Unknown organization status values on update are rejected with 409 workflow.invalid_transition. UI should map exact status strings and use a safe fallback for unknown future values.

Outlet/location active state is isActive: true | false. It is not the Organization status enum. Setting isActive: false is the current outlet deactivation path.

Current ownership delete/restore behavior:

  • There is no restore HTTP route for Organization or Location.
  • DELETE /api/platform/organizations/{id} hard-deletes an archived organization without operational business data. Only MegaAdmin may call it; confirmation name and delete reason are required. Default seeded organization cannot be deleted.
  • The generic deletion lifecycle service does not include Organization or Location as supported lifecycle entities.
  • Organization inactive/archive behavior is represented by status: "Archived".
  • Outlet inactive behavior is represented by isActive: false.
  • Soft-delete, hard-delete, and restore semantics for ownership entities are a documented gap/future task unless a separate Plane issue adds implementation.

Error contract notes ​

Ownership endpoints reuse the canonical ErrorResponse contract above.

  • 401 unauthorized: missing or expired authentication.
  • 403 forbidden: ASP.NET authorization policy or scoped RBAC guard denied the request.
  • 404 not_found: requested organization/location is absent or intentionally concealed by endpoint behavior.
  • 400 validation.failed: model binding or validation failure.
  • 409 workflow.invalid_transition: invalid organization status transition or unknown organization status.
  • 409 workflow.locked: general lifecycle conflict code; Organization/Location do not currently use deletion lifecycle locks.
  • Existing ScopeAccessDeniedException.Code values are returned for scoped RBAC/application denials where the service raises them, for example permission_denied_for_scope, organization_not_found, or location_scope_mismatch.
  • Quota-protected actions, especially outlet creation and quota writes, can return existing quota-related codes from QuotaExceededException.

Frontend behavior:

  • Map status strings exactly and keep an unknown-status fallback.
  • Disable actions from the endpoint permissions and backend workflow rules, but still handle 403/409 from stale UI state.
  • After 409, refresh the affected organization/outlet before retrying.

Scoped RBAC / tenancy ​

See Scoped RBAC and Tenancy.

Organization responses include nullable parentOrganizationId. parentOrganizationId = null means a root organization; a non-null value means the organization is a Sub-organization under the parent. An Organization can exist without any Outlet/location rows.

POST /api/organizations/{id}/children creates a child organization. The path id is the parent organization and overrides any parentOrganizationId in the request body. OrganizationAdmin access on the parent organization is inherited by child organizations according to ADR: Organization / Sub-organization / Outlet ownership v1.

The runtime route names still expose legacy locations paths for existing code compatibility. Product/UI terminology for new documentation and future API work is Outlet.

List endpoints accept optional organizationId and locationId query parameters; backend enforces scope via IScopedAuthorizationService and ApplyLocationScopeFilter.

Tenant isolation behavior:

  • Bank statement imports, import details, import transactions, quality report, and duplicate decisions are scoped to the import organization/location.
  • Name matching summary/list, counterparty match list, counterparty rule list, manual match, and rule creation are scoped to the match or source bank transaction organization/location.
  • Dashboard counters are scoped to the current user's accessible organization/location resources.
  • Payment allocations list/detail and preview/confirm/approve/cancel validate the allocation or source bank transaction scope.
  • Payment candidates build/get validate the source bank transaction scope and only use documents from the same organization/location.
  • Syrve connections list/detail/mutations validate connection organization scope; default connection selection for user-facing Syrve references/debts is scoped to the current user.
  • Syrve documents, document sync runs, document refresh, postings, posting evidence, posting attempts, and posting mutations validate organization/location scope.
  • Payment anomalies list/detail/mutations are scoped to anomaly organization/location.
  • Audit log is append-only through normal EF/API paths: entries can be added, but tracked audit entries cannot be updated or deleted via SaveChanges / SaveChangesAsync. No public audit update/delete endpoint exists.
  • Audit log is platform-wide only for MegaAdmin/MegaManager. OrganizationAdmin, Accountant, and Viewer read audit entries only inside their allowed organization/location scope; optional organizationId and locationId filters can narrow results but cannot expand visibility. Cross-org or cross-location filters return a safe empty result.
  • Cross-tenant access returns 403 when denied after locating the resource, or 404 when the endpoint conceals inaccessible detail records.

Syrve orchestration (Phase 2) ​

POST /api/syrve/connections ​

Purpose: Create an operator-managed Syrve/iiko connection and synchronize the IikoConnector bridge.

Permission: perm:syrve.connections.manage

Request example:

json
{
  "name": "Main Syrve",
  "environment": "Test",
  "baseUrl": "iiko.example.local",
  "login": "operator",
  "password": "<operator-entered-password>",
  "connectionScope": "RMS",
  "isEnabled": true,
  "isDefault": true,
  "organizationId": "01901234-5678-7abc-def0-123456789abc",
  "locationId": "01901234-5678-7abc-def0-123456789abd",
  "provisionServiceAccount": true
}

Response: SyrveConnectionDto; does not include password, token, or secret value/reference.

Connection scope: connectionScope is additive and accepts RMS or CHAIN.

  • RMS requires locationId and represents one Outlet. Multiple RMS connections for the same Outlet are allowed.
  • CHAIN requires locationId: null and represents organization/chain-level scope.
  • Missing connectionScope is normalized for backward compatibility: non-null locationId becomes RMS, null locationId becomes CHAIN.
  • Invalid scope/location combinations return safe 400 errors (invalid_connection_scope, rms_location_required, chain_location_forbidden).

With provisionServiceAccount=true (default), login and password are one-time iiko administrator credentials. Pay creates or restores MyHorecaPayUser, generates a 16-character password, applies the confirmed permission catalog, and persists only the service login plus its SHA1 in the encrypted secret store. Set the flag to false for the legacy manual-account flow.

Security: administrator credentials are write-only and remain in memory for the remote bootstrap call; they are never stored in SecretRecords. API responses, audit details, and logs never expose administrator or generated service-account secrets.


POST /api/syrve/connections/{id}/credentials/rotate ​

Purpose: Rotate Syrve/iiko login/password credentials for an existing connection without allowing server address changes.

Permission: perm:syrve.credentials.rotate / syrve.credentials.rotate.

Allowed MVP roles: MegaAdmin, MegaManager, OrganizationAdmin. Accountant and Viewer are denied.

Request example:

json
{
  "login": "operator",
  "password": "<new-operator-entered-password>",
  "provisionServiceAccount": true
}

baseUrl is intentionally not part of the contract. Rotation uses the persisted SyrveConnection.BaseUrl; clients must use the normal connection update flow for non-secret metadata changes.

Response: SyrveCredentialsRotationResult with safe connection and testResult fields. It does not include password, old secret, new plaintext secret, secret reference, or runtime token/session data.

With provisionServiceAccount=true (default), the supplied credentials are administrator bootstrap credentials and rotation reuses the durable ensure flow. With false, the endpoint keeps the legacy manual login/password behavior.

Behavior:

  • Stores the new password through ISyrveConnectionSecretStore.StorePasswordAsync in the existing encrypted local SecretRecord flow (pay-secret:{secretRecordId}).
  • Deletes Redis runtime token/session key mhpay:{env}:org:{organizationId}:syrve:runtime-token:{connectionId}.
  • Synchronizes the IikoConnector bridge after saving the new secret reference.
  • Executes connection test after save and bridge sync.
  • Writes success/failure audit details with status/credential mode only; plaintext credentials and runtime tokens are forbidden in audit/logs.

POST /api/syrve/connections/{id}/service-account/ensure ​

Purpose: Idempotently create or restore the durable MyHorecaPayUser integration account.

Permission: syrve.credentials.rotate.

json
{
  "adminLogin": "administrator",
  "adminPassword": "<write-only>"
}

The workflow authenticates with the administrator credentials after live get_server_info (card/DTO version is not the source of truth), finds or creates the fixed employee, assigns the confirmed administrator role, applies the permission catalog (ALLOW, or WHERE_RESPONSIBLE for responsibility-scoped permissions), rotates to a generated 16-character password, verifies authentication via service-account auth readback, and then stores Login=MyHorecaPayUser with CredentialMode=PasswordHash. Exact catalog match is preferred; if the live BACK version has no embedded catalog, Pay soft-falls back to a known catalog of the same major.minor line (e.g. 9.6.6071.0 → 9.6.4007.0). If the permission batch is denied (employee_rpc_permission_denied / catalog-not-exact), ensure may still confirm when the administrator role is already assigned and the subsequent service-login auth readback succeeds. Administrator credentials and generated plaintext are never persisted or returned.

Response: 200 + EnsureSyrveServiceAccountResult only when ensure and post-ensure connection test both succeed. Provision/catalog/auth failures and failed post-ensure test return 409 Conflict with ApiError.code (not 200 with a false success).


DELETE /api/syrve/connections/{id} ​

Purpose: Delete a Pay-owned Syrve/iiko connection record using the lifecycle deletion policy.

Permission: scoped organization admin delete semantics. MegaAdmin and scoped OrganizationAdmin may delete within scope; MegaManager, Accountant, and Viewer are denied for destructive operations.

Behavior:

  • performs soft delete only (DeletionState = SoftDeleted);
  • disables the local Pay connection record;
  • writes audit with previous/new deletion state, reason/reference, scope, and syrveIikoMutation=false;
  • normal connection list/detail queries hide the soft-deleted record through EF query filters;
  • does not delete, update, roll back, void, or otherwise mutate any Syrve/iiko data;
  • does not remove SecretRecord rows or tombstone/audit history.

This legacy endpoint has no request body, so the backend uses a fixed compatibility delete reason. New destructive endpoints should require an explicit delete reason and correlation/request id in the request contract.

Deletion lifecycle service foundation ​

IDeletionLifecycleService supports soft delete, restore, mark pending hard delete, final hard delete, and hard-delete preflight for Pay-owned lifecycle entities. The final HardDeleteAsync and PreflightHardDeleteAsync paths are backend service foundation only; no public hard-delete API endpoint is exposed.

Final hard delete rules:

  • requires the entity to already be PendingHardDelete;
  • allowed only for MegaAdmin;
  • deletes only Pay-owned data and never mutates Syrve/iiko;
  • cancels unfinished Pay jobs/outbox work before removal;
  • checks related SyrvePaymentPosting rows for unreconciled external posting risk when the lifecycle entity is a PaymentAllocation or SyrvePaymentPosting;
  • requires exact typed confirmation DELETE PAY DATA ONLY - SYRVE IIKO WILL NOT BE CHANGED when unreconciled external posting risk exists;
  • rejects missing or wrong confirmation with safe code hard_delete.unreconciled_external_posting_confirmation_required;
  • writes an AuditAction.HardDeleted tombstone that remains queryable after the Pay entity is removed;
  • tombstone details include actor, effective scope, org/location, entity type/id, previous/final state, reason, correlation id, warning/confirmation flags, safe posting counts/status names/ids, and minimal preflight/job-cancellation summary;
  • tombstone details must not include raw bank lines, full account numbers, raw bank file contents, raw bank RawFieldsJson, secrets, Syrve/iiko credentials, or full private customer payloads.

POST /api/syrve/connections/probe ​

Purpose: Check Syrve/iiko address and credentials before creating a SyrveConnection.

Permission: perm:syrve.connections.test

Request example:

json
{
  "baseUrl": "iiko.example.local",
  "login": "operator",
  "password": "<operator-entered-password>",
  "environment": "Test",
  "credentialMode": "Password"
}

Response example:

json
{
  "success": true,
  "status": "connected",
  "message": "Подключение к Syrve/iiko успешно проверено.",
  "safeMessage": "Подключение к Syrve/iiko успешно проверено.",
  "normalizedUrl": "https://iiko.example.local",
  "serverVersion": "8.9.1",
  "serverEdition": "BackOffice",
  "suggestedConnectionScope": null,
  "diagnosticCode": null,
  "authModeAttempted": null,
  "sessionTransport": null,
  "httpStatusCode": null,
  "iikoErrorCode": null
}

Behavior:

  • Does not create SyrveConnection in Pay DB.
  • Uses backend address normalization; operators may enter host-only addresses without /resto or http/https.
  • Returns suggestedConnectionScope as RMS, CHAIN, or null based on detected deployment type; create still requires explicit connectionScope and validates Outlet rules.
  • Returns only safe diagnostics and aggregate connection metadata.
  • Must not return password, token, secret reference, raw session data, or raw iiko payload.

Business statuses: connected, invalid_base_url, auth_failed, credentials_missing, iiko_unavailable, timeout


POST /api/syrve/connections/{id}/bridge ​

Purpose: Create or update the link between Pay SyrveConnection and IikoConnector IikoRestaurantConnection.

Permission: perm:syrve.connections.manage

Request: no body.

Response example:

json
{
  "syrveConnectionId": "01901234-5678-7abc-def0-123456789abc",
  "iikoRestaurantConnectionId": "01901234-5678-7abc-def0-123456789abd",
  "status": "bridge_created",
  "message": "Связь с IikoConnector создана."
}

Business errors: bridge_failed, credentials_missing, invalid_base_url


GET /api/syrve/connections/{id}/health ​

Purpose: Operational health snapshot: bridge status and last connection check. Legacy capability fields may remain in the response for compatibility.

Permission: perm:syrve.connections.view

Response example:

json
{
  "connectionId": "01901234-5678-7abc-def0-123456789abc",
  "iikoRestaurantConnectionId": "01901234-5678-7abc-def0-123456789abd",
  "environment": "Test",
  "isEnabled": true,
  "runtimeCallsEnabled": true,
  "paymentWriteEnabled": true,
  "documentSyncEnabled": true,
  "lastCheckStatus": "connected",
  "lastCheckMessage": "Подключение успешно.",
  "lastCheckAtUtc": "2026-06-19T12:00:00Z",
  "serverVersion": "9.0.0",
  "serverEdition": "iikoChain",
  "bridgeStatus": "bridge_linked"
}

runtimeCallsEnabled, paymentWriteEnabled, documentSyncEnabled and matching reason fields are deprecated compatibility fields. Registered Pay-host capabilities are always on; clients must not use these fields as configurable switches.

Business errors: connection not found (404)


GET /api/syrve/connections/operational-flags ​

Purpose: Deprecated compatibility response for older list pages. It is not a runtime control surface.

Permission: perm:syrve.connections.view

Response example:

json
{
  "runtimeCallsEnabled": true,
  "paymentWriteEnabled": true,
  "documentSyncEnabled": true
}

All registered capabilities are reported as available. Removed env/config gates and deprecated DB capability columns do not affect these values.


POST /api/syrve/documents/{id}/refresh ​

Purpose: Re-read a single document snapshot from Syrve/iiko via IikoConnector and update local payment status.

Permission: perm:syrve.documents.sync

Request: no body.

Response example: SyrveDocumentSnapshot DTO with updated paymentStatus, lastSyncedAtUtc, unpaidAmount, isProcessed, isDeleted, processingStateCheckedAtUtc, and processingStateSource.

Business errors: bridge_missing, readback_document_unavailable, iiko_unavailable, timeout, auth_failed, document_processing_state_unknown, document_not_processed, document_deleted, document_not_payable


POST /api/syrve/postings/{id}/dry-run ​

Purpose: Run payment write safety checks, build idempotency/request hash and evidence preview without calling iiko write.

Permission: perm:syrve.postings.view

Response: SyrvePostingDryRunResult with isAllowed, errorCode, requestHash, optional evidence.

Business errors: write_preflight_required, connection_not_connected_for_write, approval_required, write_contract_not_verified, account_required, cash_flow_article_required, idempotency_conflict, posting_type_document_type_mismatch, plus payable/amount/allocation/anomaly and server-applicability failures.

Dry-run is read-only. It does not block only because an open Critical anomaly exists, but it returns warning payment_anomaly_open so UI/operator flows can require manual review before any write/retry path.


GET /api/syrve/postings/{id}/preflight ​

Purpose: Method-specific payment write eligibility (document type → setPaymentsForInvoices / setPaymentsForOutgoingDocuments).

Permission: perm:syrve.postings.view

Response: SyrvePaymentWritePreflightResult with eligible, status, method, contractStatus, genericRpcVerifyStatus, diagnosticCode.


PUT /api/syrve/postings/{id}/write-target ​

Purpose: Set payment account and cash-flow article before dry-run / worker execute.

Permission: perm:syrve.postings.queue

Request body: { "accountId", "accountName?", "cashFlowArticleId", "cashFlowArticleName?" }


Syrve posting rule API contracts ​

SyrvePostingRule v1 is Pay-owned posting configuration. It does not change IikoConnector contracts and does not execute Syrve/iiko writes from the API. Rules are applied only when queueing a SyrvePaymentPosting; existing worker-only execution, approval, preflight, idempotency, anomaly, and reconciliation gates still apply.

Supported v1 behavior:

  • supported document families only: IncomingInvoice, OutgoingInvoice, ServiceReceipt, ServiceProvision;
  • explicit accountId and cashFlowArticleId are required;
  • rules are scoped to organizationId and optionally locationId / syrveConnectionId;
  • platform-global rules are not supported;
  • org-wide document-only rules without location, connection, or counterparty are rejected as too broad;
  • no multi-document, multi-account, multi-cash-flow split;
  • no correspondent account or concept inference;
  • equal-priority matching rules return conflict/review instead of silently picking one.

GET /api/syrve/posting-rules ​

Permission: perm:syrve.postings.view

Query params: organizationId, locationId, syrveConnectionId, documentType, includeInactive, page, pageSize.

Response: PagedResult<SyrvePostingRuleDto>.

json
{
  "items": [
    {
      "id": "019edf00-0000-7000-8000-000000077001",
      "organizationId": "019edf00-0000-7000-8000-000000070001",
      "locationId": "019edf00-0000-7000-8000-000000070002",
      "syrveConnectionId": null,
      "documentType": "IncomingInvoice",
      "counterpartyExternalId": "supplier-demo-001",
      "counterpartyName": "PT DEMO SUPPLIER",
      "priority": 100,
      "postingType": "InvoicePayment",
      "accountId": "account-demo-001",
      "accountName": "Demo settlement account",
      "cashFlowArticleId": "cashflow-demo-001",
      "cashFlowArticleName": "Demo supplier payments",
      "isActive": true,
      "description": "Demo supplier invoice payments",
      "matchExplanationTemplate": "Supplier-specific invoice payment rule",
      "rowVersion": 1
    }
  ],
  "total": 1,
  "page": 1,
  "pageSize": 20
}

GET /api/syrve/posting-rules/{id} ​

Permission: perm:syrve.postings.view

Returns SyrvePostingRuleDto in the caller's allowed scope, otherwise 404/403 according to scoped RBAC path.

POST /api/syrve/posting-rules ​

Permission: perm:syrve.postings.queue

Accountant/admin operation. Creates an active rule and writes audit details with safe metadata only.

json
{
  "organizationId": "019edf00-0000-7000-8000-000000070001",
  "locationId": "019edf00-0000-7000-8000-000000070002",
  "syrveConnectionId": null,
  "documentType": "IncomingInvoice",
  "counterpartyExternalId": "supplier-demo-001",
  "counterpartyName": "PT DEMO SUPPLIER",
  "priority": 100,
  "accountId": "account-demo-001",
  "accountName": "Demo settlement account",
  "cashFlowArticleId": "cashflow-demo-001",
  "cashFlowArticleName": "Demo supplier payments",
  "description": "Demo supplier invoice payments",
  "matchExplanationTemplate": "Supplier-specific invoice payment rule"
}

postingType is derived by the backend from documentType: invoice-like documents use InvoicePayment; outgoing documents use OutgoingDocumentPayment.

Validation errors use syrve_posting_rule_validation_failed or syrve_posting_rule_unsupported.

PUT /api/syrve/posting-rules/{id} ​

Permission: perm:syrve.postings.queue

Updates scope, match fields, target account/cash-flow fields, priority, and explanation text. Include rowVersion when available. Duplicate active rules are rejected with syrve_posting_rule_validation_failed.

POST /api/syrve/posting-rules/{id}/disable ​

Permission: perm:syrve.postings.queue

Idempotently disables a rule; it does not delete historical postings or allocations.

json
{
  "reason": "Replaced by supplier-specific demo rule",
  "rowVersion": 1
}

POST /api/syrve/posting-rules/preview ​

Permission: perm:syrve.postings.view

Evaluates active rules for a payment allocation without persisting and without queueing a posting.

json
{
  "allocationId": "019edf00-0000-7000-8000-000000077101"
}

Response:

json
{
  "allocationId": "019edf00-0000-7000-8000-000000077101",
  "bankTransactionId": "019edf00-0000-7000-8000-000000077102",
  "result": "matched",
  "canApply": true,
  "postingType": "InvoicePayment",
  "accountId": "account-demo-001",
  "cashFlowArticleId": "cashflow-demo-001",
  "candidates": [
    {
      "ruleId": "019edf00-0000-7000-8000-000000077001",
      "priority": 100,
      "documentType": "IncomingInvoice",
      "counterpartyExternalId": "supplier-demo-001",
      "postingType": "InvoicePayment",
      "accountId": "account-demo-001",
      "cashFlowArticleId": "cashflow-demo-001",
      "explanation": "Supplier-specific invoice payment rule"
    }
  ],
  "warnings": [],
  "conflicts": [],
  "explanation": "Supplier-specific invoice payment rule"
}

Conflict response uses result = "conflict", canApply = false, and conflicts[] with code = "syrve_posting_rule_conflict". Queueing with a conflict keeps the allocation in review and does not create a safe-to-run posting.


GET /api/syrve/postings/{id}/attempts ​

Purpose: List worker posting attempts (attempt number, status, error code).

Permission: perm:syrve.postings.view


GET /api/syrve/postings/{id}/evidence ​

Purpose: Evidence report for a payment posting: document state before/after, request preview, write result, reconciliation status.

Permission: perm:syrve.postings.view

Response example:

json
{
  "postingId": "01901234-5678-7abc-def0-123456789abe",
  "before": [
    {
      "externalDocumentId": "a1b2c3d4-...",
      "documentType": "IncomingInvoice",
      "unpaidAmount": 100000,
      "paidAmount": null,
      "totalAmount": 100000,
      "paymentStatus": "Unpaid"
    }
  ],
  "request": {
    "postingType": "InvoicePayment",
    "idempotencyKey": "alloc-...",
    "accountId": "acc-001",
    "cashFlowArticleId": "cfa-001",
    "amount": 100000
  },
  "writeResult": {
    "status": "write_not_implemented",
    "externalPaymentId": null,
    "rawResultHash": "abc123...",
    "message": null
  },
  "after": [],
  "optimisticAfter": [],
  "liveReadback": {
    "source": "IikoLiveReadback",
    "checkedAtUtc": "2026-06-19T12:30:02Z",
    "documents": [{ "unpaidAmount": 200, "paymentStatus": "Overpaid" }]
  },
  "reconciliation": {
    "status": "Failed",
    "checkedAtUtc": "2026-06-19T12:30:02Z",
    "message": null,
    "diagnosticCode": "overpaid_after_write",
    "anomalyId": "019edfdc-1153-798d-88aa-a347285bfb2c"
  },
  "rawResultHash": "abc123..."
}

Business errors: posting not found (404), reconciliation_amount_mismatch, overpaid_after_write, payment_anomaly_open


POST /api/syrve/postings/{id}/reconcile ​

Purpose: Run live readback reconciliation for a posted payment; creates PaymentAnomaly and notifications on mismatch.

Permission: perm:syrve.postings.reconcile

Response: Updated SyrvePaymentPosting DTO (openAnomalyId, reconciliationAttemptCount, nextReconciliationAtUtc).

Business errors: readback_document_unavailable, overpaid_after_write, reconciliation_amount_mismatch, write_posted_but_not_visible_in_readback


GET /api/payment-anomalies ​

Purpose: List payment anomalies (filters: status, severity, postingId, connectionId, paging).

Permission: perm:payment_anomalies.view

Response: Paged PaymentAnomaly items.

GET /api/payment-anomalies/{id} ​

Permission: perm:payment_anomalies.view

POST /api/payment-anomalies/{id}/acknowledge ​

Permission: perm:payment_anomalies.manage

POST /api/payment-anomalies/{id}/resolve ​

Permission: perm:payment_anomalies.manage — body { "resolutionNote" }

POST /api/payment-anomalies/{id}/dismiss ​

Permission: perm:payment_anomalies.manage


Manual corrections (MHPAY-73 / MHPAY-75) ​

Persisted audit records for operator/accountant manual fixes on bank/matching data. No rollback endpoint in MHPAY-73 — UI history/rollback is MHPAY-74.

New corrections are stored with status Applied (immediate audit record). Payloads use typed ManualCorrectionJsonPayload serialized via PayJsonSerializer; sensitive keys (password, token, secret, credential) are stripped server-side.

Create/apply and cancel are dangerous auditable actions and require a policy-compliant reason:

  • required after trim;
  • normalized before persistence/audit (trim + repeated whitespace collapsed to one space);
  • 10..500 characters after normalization;
  • generic placeholders are rejected (test, todo, n/a, na, -, ., reason, fix, cancel, other);
  • secrets/raw credentials are rejected (password, secret, token, credential, apiKey, authorization, bearer).

Rejected reasons return 400 validation.failed with fieldErrors.reason; the raw rejected reason is not returned in error details. Audit details contain only the normalized accepted reason.

POST /api/manual-corrections ​

Purpose: Create a manual correction record (does not mutate payment allocation or bank adapter pipelines in this iteration).

Permission: perm:manual_corrections.manage (scoped: location.manual_corrections.manage)

Body (CreateManualCorrectionRequest):

FieldRequiredNotes
organizationIdyesTenant scope
locationIdnoOutlet scope when applicable
bankStatementImportIdnoMust belong to same org/location when set
bankTransactionIdnoMust belong to same org/location when set
counterpartyMatchIdnoMust belong to same org/location when set
paymentAllocationIdnoMust belong to same org/location when set
correctionTypeyesCounterpartyMatch, BankTransactionMetadata, ImportQualityOverride, PaymentAllocationNote, Other
targetEntityTypeyese.g. CounterpartyMatch, BankTransaction
targetEntityIdyesUUID of corrected entity
reasonyesPolicy-compliant operator justification (10..500 chars; secrets/raw credentials forbidden)
before / after / metadatanoManualCorrectionJsonPayload with optional fields map

Response: ManualCorrectionDto (status Applied, appliedByUserId, appliedAtUtc set).

GET /api/manual-corrections ​

Purpose: Paginated list with tenant/location scope filter.

Permission: perm:manual_corrections.view (scoped: location.manual_corrections.view or org audit view)

Query: organizationId, locationId, correctionType, targetEntityType, targetEntityId, bankTransactionId, status, page, pageSize

Response: PagedResultDto<ManualCorrectionDto>

GET /api/manual-corrections/{id} ​

Permission: perm:manual_corrections.view — scoped to correction organization/location.

Response: ManualCorrectionDto or 404.

POST /api/manual-corrections/{id}/cancel ​

Purpose: Mark an applied manual correction as cancelled (MVP: audit + status change; does not auto-revert target entity side effects).

Permission: perm:manual_corrections.manage (scoped: location.manual_corrections.manage)

Body (CancelManualCorrectionRequest):

FieldRequiredNotes
reasonyesPolicy-compliant operator justification for cancellation (10..500 chars; secrets/raw credentials forbidden)
rowVersionnoOptimistic concurrency; stale value → 409 concurrency.conflict

Response: updated ManualCorrectionDto (status Cancelled, cancelledByUserId, cancelledAtUtc, cancelReason).

Errors:

StatusCodeWhen
404—Correction not found
403forbiddenMissing scoped manage permission
409workflow.invalid_transitionStatus is not Applied
409concurrency.conflictSupplied rowVersion is stale
400validation.failedMissing/non-compliant cancel reason (fieldErrors.reason)

Writes audit entry (AuditAction.Cancel).

GET /api/notifications ​

Purpose: In-app notifications for the current user. Critical notifications include owner/SLA fields for the 2-hour escalation workflow.

Query params:

FieldNotes
isReadOptional read/unread filter. Read state is separate from SLA acknowledgement.
typeOptional notification type filter.
severityOptional severity filter, for example Critical.
slaStatusOptional SLA state: Open, Acknowledged, Escalated, Resolved, Dismissed.
ownerUserIdOptional owner filter.
criticalOnlyOptional boolean shortcut for critical notifications.
page, pageSizePaging.

AppNotificationDto fields:

  • id
  • type
  • severity
  • title
  • message
  • entityType
  • entityId
  • actionUrl
  • isRead
  • readAtUtc
  • ownerUserId
  • slaStatus
  • slaDueAtUtc
  • acknowledgedAtUtc
  • acknowledgedByUserId
  • resolvedAtUtc
  • resolvedByUserId
  • dismissedAtUtc
  • dismissedByUserId
  • escalatedAtUtc
  • escalatedByWorker
  • createdAtUtc

Critical notification SLA behavior:

  • New critical notifications start with slaStatus = Open.
  • slaDueAtUtc is set to createdAtUtc + 2 hours.
  • The notification escalation worker marks due critical Open notifications as Escalated.
  • IsRead remains only a UI read/unread flag and does not acknowledge SLA.
  • Email, Telegram, and WhatsApp delivery channels are safe MVP stubs; no real external message is sent.

Permission: perm:notifications.view

GET /api/notifications/unread-count ​

Returns NotificationUnreadCountDto ({ count }).

Permission: perm:notifications.view

POST /api/notifications/{id}/read ​

Marks notification as read. This does not change slaStatus.

Permission: perm:notifications.view

POST /api/notifications/{id}/assign-owner ​

Assigns notification ownership. Body is optional:

json
{
  "ownerUserId": "019edf00-0000-7000-8000-000000088003"
}

If ownerUserId is omitted or null, backend assigns the current user.

Permission: perm:notifications.view

POST /api/notifications/{id}/acknowledge ​

Sets slaStatus = Acknowledged, stores acknowledgedAtUtc and acknowledgedByUserId, and assigns the current user as owner when no owner exists.

Permission: perm:notifications.view

POST /api/notifications/{id}/resolve ​

Sets slaStatus = Resolved, stores resolvedAtUtc and resolvedByUserId, and assigns the current user as owner when no owner exists.

Permission: perm:notifications.view

POST /api/notifications/{id}/dismiss ​

Sets slaStatus = Dismissed, stores dismissedAtUtc and dismissedByUserId, and assigns the current user as owner when no owner exists.

Permission: perm:notifications.view


Admin workers dashboard ​

GET /api/admin/workers ​

Read-only worker dashboard for platform support. Visible only to MegaAdmin, MegaManager, and legacy Admin compatibility role. OrganizationAdmin, Accountant, Viewer, PaymentOperator, and LocationAdmin receive 403 admin_workers.forbidden.

Response: array of WorkerStatusDto.

FieldMeaning
workerKeyStable dashboard key, for example syrve-document-sync, payment-posting, hard-delete-purge.
displayNameOperator-facing worker name.
categoryOne of read_sync, reconciliation, file_cleanup, payment_posting, hard_delete, notification_escalation, outbox.
isEnabledCurrent configured/enabled state where available.
configuredIntervalSecondsWorker interval when configured.
canRetryTrue only for explicitly allowlisted safe manual runs.
retryForbiddenReasonSafe reason when manual run is unavailable or forbidden.
isDangerousTrue for payment posting and hard-delete categories.
lastRunAtUtc, lastErrorAtUtc, lastErrorCode, lastErrorMessageSafe status metadata only; no raw payloads.
queueCounts[]Safe aggregate counts such as posting status counts or expired local bank files.

Manual run allowlist is conservative:

  • allowed: syrve-document-sync, syrve-supplier-sync, file-retention-cleanup;
  • unavailable/status-only: syrve-reconciliation, notification-escalation, outbox;
  • forbidden: payment-posting, hard-delete-purge.

The dashboard never exposes pause/resume/cancel/dead-letter controls and never exposes generic "run any worker" behavior.

POST /api/admin/workers/{workerKey}/retry ​

Executes a safe manual run only for allowlisted worker keys.

Request body is optional:

json
{
  "reason": "manual_run_from_admin_workers_dashboard"
}

Response: WorkerRetryResult.

FieldMeaning
workerKeyRequested worker key.
statuscompleted, unavailable, forbidden, or not_found.
messageSafe operator-facing result.
ranAtUtcTimestamp when a completed manual run was requested/executed.
queueCounts[]Optional safe aggregate counts.

Forbidden retry returns 403 admin_worker.retry_forbidden. Unknown worker keys return 404 admin_worker.not_found. The endpoint does not call BackgroundService.ExecuteAsync, does not mutate payment posting rows, and does not mutate hard-delete rows.


Common Syrve business error codes ​

CodeMeaning
bridge_missingNo IikoConnector bridge link
credentials_missingSecret reference not resolved
invalid_base_urlInvalid Syrve/iiko server URL
auth_failedAuthentication rejected
timeoutRemote timeout
iiko_unavailableServer unreachable
write_not_implementedSafety checks passed but live write API is not connected
readback_document_unavailableDocument readback failed after posting
reconciliation_amount_mismatchReconciliation amount mismatch
overpaid_after_writeLive readback shows overpaid balance after write
payment_anomaly_openCritical open anomaly blocks queue/approve/retry/write
posting_type_document_type_mismatchPersisted posting type does not match the document-type write method
write_posted_but_not_visible_in_readbackRPC success but readback unchanged

See also docs/docs/architecture/syrve-payment-orchestration.md, docs/docs/architecture/payment-anomaly-control.md, and docs/docs/integrations/syrve-live-test-playbook.md.

OpenAPI document (Development): /openapi/v1.json on API (20001). UI calls Gateway (5010) which proxies /api/*.

Bank document recognition ​

POST /api/bank-statements/import remains the only upload endpoint. PDF, JPEG, PNG, and WEBP are streamed to staged local storage, verified by extension, MIME and magic bytes, SHA-256 checked, and return 202 Accepted. The response includes importId, import status, nullable recognitionStatus, duplicate metadata, and a safe message. A duplicate returns the existing import and does not enqueue another run.

MethodRoutePurpose
GET/api/bank-statements/imports/{id}/recognitionRun status, global model/prompt/schema versions, usage, timing and safe error.
GET/api/bank-statements/imports/{id}/recognition/rowsTenant-scoped drafts with immutable recognized and editable current values.
GET/api/bank-statements/imports/{id}/file/previewAuthorized inline original with MIME and HTTP Range; never exposes StoragePath.
PUT/api/bank-statements/imports/{id}/recognition/rows/{rowId}Whitelisted typed changes with reason and rowVersion.
POST/api/bank-statements/imports/{id}/recognition/confirmAtomically creates normalized BankTransaction rows from persisted current values.
POST/api/bank-statements/imports/{id}/recognition/retryRequeues failed/lease-expired recognition or an uncorrected NeedsReview result. Returns 409 recognition_retry_not_allowed after any manual correction.
POST/api/bank-statements/imports/{id}/recognition/rejectRejects with reason; creates no transactions or learning candidates.

Confirmation accepts only run/row versions and an optional comment; the frontend never submits transactions as source of truth. Repeated confirmation is idempotent. Platform-only approval, dataset freeze, evaluation, canary, activation and rollback endpoints are under /api/platform/recognition-learning.

Stable errors include recognition_provider_not_configured, recognition_provider_auth_failed, recognition_provider_request_invalid, recognition_provider_rate_limited, recognition_file_type_unsupported, recognition_original_file_unavailable, recognition_timeout, recognition_cancelled, recognition_provider_unavailable, recognition_schema_invalid, recognition_row_version_conflict, recognition_confirmation_blocked, training_candidate_privacy_failed, dataset_not_frozen, model_evaluation_required, and model_activation_not_allowed. Raw provider errors are never returned.