Appearance
API Endpoints (MVP)
| Method | Path | Description |
|---|---|---|
| POST | /api/auth/login | Login |
| POST | /api/auth/logout | Logout |
| GET | /api/auth/me | Current user (scoped roles, orgs, locations, permissions) |
| GET | /api/auth/context | Available active organization/Outlet contexts for current user |
| POST | /api/auth/context | Validate and set active organization/Outlet context (stateless, audit-friendly) |
| POST | /api/auth/invites/check | Validate invitation token and return safe invite metadata |
| POST | /api/auth/invites/accept | Accept invitation, set password, activate/create user, assign role |
| GET | /api/platform/organizations | List organizations for platform registry/settings workflows (platform.organizations.view) |
| POST | /api/platform/organizations | Create 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}/quota | Organization quota (organization.quotas.view) |
| PUT | /api/platform/organizations/{id}/quota | Update quota (platform.quotas.manage, audit log) |
| GET | /api/platform/settings | Platform operational settings overrides + env defaults (platform.settings.view) |
| PUT | /api/platform/settings | Update platform settings (platform.settings.manage, audit log) |
| GET | /api/telegram/link | Telegram link status for current user + active Outlet (location.bank_statements.upload); returns enabled=false when feature off |
| POST | /api/telegram/link-challenges | Create one-time personal activation code (~10 min) + deep link |
| DELETE | /api/telegram/link | Revoke Active Telegram link for current user + org |
| POST | /api/telegram/webhook | Telegram 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/users | List platform users and role assignments (platform.users.manage) |
| POST | /api/platform/users | Create platform-managed user without invite (platform.users.manage, audit log) |
| POST | /api/platform/users/invites | Create 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/roles | Assignable MVP roles for direct platform assignment (platform.users.manage) |
| POST | /api/platform/users/{targetUserId}/roles | Assign 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/current | Current 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}/children | Create child organization / sub-organization under an organization admin scope |
| GET | /api/organizations/{id}/locations | List locations |
| POST | /api/organizations/{id}/locations | Create location |
| PUT | /api/organizations/{id}/locations/{locationId} | Update location |
| GET | /api/organizations/{id}/users | Organization users / role assignments |
| POST | /api/organizations/{id}/users/{userId}/roles | Assign scoped role |
| DELETE | /api/organizations/{id}/users/{userId}/roles/{assignmentId} | Remove role assignment |
| GET | /api/locations/{id} | Location details |
| GET | /api/locations/{id}/users | Location users |
| POST | /api/locations/{id}/users/{userId}/roles | Assign 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}/file | Download original bank file before retention expiry |
| GET | /api/bank-statements/imports/{id}/transactions | Import transactions |
| GET | /api/bank-statements/transactions/{id} | Single bank transaction (allocation header) |
| GET | /api/bank-statements/supported-banks | SupportedBankFormatDto[] — registered bank adapters (public, no auth required) |
| GET | /api/bank-statements/quality-summary | Aggregated 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-report | Import quality report (confidence, warnings, balance) |
| POST | /api/bank-statements/imports/{id}/quality-review/override | Override 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}/duplicates | Duplicate transaction candidates for import |
| POST | /api/bank-statements/imports/{id}/reprocess-preview | Run stored reprocess preview (MegaAdmin/MegaManager only) |
| GET | /api/bank-statements/imports/{id}/reprocess-previews | List 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}/confirm | BankDuplicateCandidateMutationResultDto |
| POST | /api/bank-statements/duplicates/{candidateId}/dismiss | BankDuplicateCandidateMutationResultDto |
| GET | /api/counterparties | Match rules (active, legacy list for matching page) |
| GET | /api/counterparties/rules | Counterparty rules directory list |
| PUT | /api/counterparties/rules/{id} | Update counterparty match rule |
| POST | /api/counterparties/rules/{id}/deactivate | Soft-deactivate counterparty match rule |
| POST | /api/counterparties/rules/{id}/activate | Reactivate counterparty match rule |
| POST | /api/counterparties/rules/conflict-preview | Preview counterparty rule conflicts before create |
| POST | /api/counterparties/rules | Create match rule from manual match |
| POST | /api/counterparty-matches | Manual match |
| POST | /api/counterparty-matches/revert | Revert manual match |
| POST | /api/counterparty-matches/exclude | Exclude payment from matching queue |
| POST | /api/counterparty-matches/unexclude | Restore excluded payment to matching queue |
| GET | /api/counterparty-matches | List 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/preview | Allocation preview |
| POST | /api/payment-allocations/confirm | Confirm 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}/approve | Approve allocation; accepts optional { "rowVersion": number } |
| POST | /api/payment-allocations/{id}/cancel | Cancel 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 returnsPaymentCandidateDto[]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.documentisSyrveDocumentSnapshotDtoand includesorganizationIdplus nullablelocationId. Do not treatlocationId = nullas a default Outlet.SyrveDocumentSnapshotDtoexposes nullableisProcessed,isDeleted, nullableprocessingStateCheckedAtUtc, and nullableprocessingStateSource. OnlyisProcessed=truewithisDeleted=falsecan be used for payment.PaymentCandidateDtoalso exposesreservedAmountandavailableAmount;suggestedAmountnever exceedsavailableAmount.- 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
| Method | Path | Purpose |
|---|---|---|
| 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/preview | Calculate allocation outcome without writing. |
| POST | /api/payment-allocations/confirm | Create or return an idempotent allocation. |
| POST | /api/payment-allocations/{id}/approve | Approve an allocation; optional body { "rowVersion": number }. |
| POST | /api/payment-allocations/{id}/cancel | Cancel 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:
bankTransactionIdpaymentAmountallocationMethod:ManualorAutomaticFifolines[].documentIdlines[].documentNumberlines[].documentAmountlines[].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 paymentAmountin the result is canonicalized from persistedBankTransaction.Amount; reducing the request amount cannot make an incomplete allocation confirmablehasOverpayment,hasUnderpayment,hasPartialPaymentscanConfirm,blockReasonlines[].documentId,documentNumber,documentAmount,allocatedAmountlines[].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,warningCodeswarnings[]as{ code, message }
Operator meaning:
- A partial document payment is allowed when the complete bank amount is assigned; positive
invoiceDebtRemainingis not unallocated bank money. - Any non-zero
unallocatedPaymentAmountblocks confirm withpayment_not_fully_distributed. - A positive
requiredSupplierAdvanceAmountdescribes the advance required to consume an excess bank payment. Because advance transport/readback is not verified,supplierAdvanceAmountremains 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
idempotencyKeyfor 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 linesequence, current unpaid values, and expected unpaid values. Worker/retry use these saved lines and never rebuild FIFO. - Multiple
linesare supported, but adocumentIdmay appear only once in one request. - The request is checked in a PostgreSQL
Serializabletransaction. Active allocation lines reserve the document remainder and the bank transaction amount. NeedsReview,Confirmed,ApprovalRequired,Approved,PostingQueued,Posted, andFailedreserve amounts;CancelledandReconcileddo not.- Exceeding the document remainder, bank transaction amount, or selecting an opposite-direction document returns an explainable
409. ConfirmedandNeedsReviewallocation 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:
rowVersionis optimistic concurrency. Stale values return409 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:
idbankTransactionIdsyrveConnectionIdstatustotalAmountallocatedAmountremainingAmountdocumentSettlementAmountsupplierAdvanceAmountunallocatedPaymentAmountinvoiceDebtRemainingallocationMethodcurrencycreatedAtUtcrowVersionlines[]
PaymentAllocationLineDto includes:
iddocumentIddocumentNumberdocumentAmountallocatedAmountisPartialPaymentisOverpaymentpaymentStatusBeforeexpectedPaymentStatusAfterunpaidAmountBeforeexpectedUnpaidAmountAftersequencewarningCodes
Syrve/iiko posting endpoints
| Method | Path | Purpose |
|---|---|---|
| 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}/approve | Approve a posting; optional body { "rowVersion": number? }. |
| POST | /api/syrve/postings/{id}/retry | Retry a failed/retry-scheduled posting; optional body { "rowVersion": number? }. |
| POST | /api/syrve/postings/{id}/reconcile | Force reconciliation; optional body { "rowVersion": number? }. |
| PUT | /api/syrve/postings/{id}/write-target | Configure account and cash-flow target before execution. |
| POST | /api/syrve/postings/{id}/dry-run | Read-only write simulation and gate report. |
| GET | /api/syrve/postings/{id}/preflight | Read-only gate evaluation. |
| GET | /api/syrve/postings/{id}/attempts | Posting attempt history. |
| GET | /api/syrve/postings/{id}/evidence | Safe 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:
allocationIdbankTransactionIdtotalAmountcurrencycounterpartyNamelines[]warnings[]canQueueblockReason
Queue posting
Request body is optional:
json
{
"allocationRowVersion": 12
}Rules:
- Queueing moves the allocation to
PostingQueuedwhen 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
unallocatedPaymentAmountcreate 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; connectorSplitItemscarries 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
DraftorApprovalRequired. - Retry only from
FailedorRetryScheduled.WriteOutcomeUnknownrequires readback and rejects retry withwrite_outcome_unknown_readback_required. - Reconcile only from
Posted,ReadbackPending,WriteOutcomeUnknown, orFailed.
Posting DTOs and evidence
SyrvePaymentPostingDto includes:
idpaymentAllocationIdsyrveConnectionIdstatuspostingTypeaccountIdaccountNamecashFlowArticleIdcashFlowArticleNamerequestHashattemptCountlastAttemptAtUtcnextRetryAtUtcreconciledAtUtcerrorCodeerrorMessageidempotencyKeyrequiresApprovalapprovedByUserIdpostedAtUtccreatedAtUtcrowVersionreconciliationAttemptCountnextReconciliationAtUtcopenAnomalyIdpostingDate(stable date persisted before worker execution)workerClaimedAtUtcworkerLeaseExpiresAtUtc
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.
Postedwrite evidence is not the same asReconciled.WriteOutcomeUnknownmeans 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:
organizationIdlocationIdpostingIdallocationIdbankTransactionIdstatusfromUtctoUtc
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,errorMessageSafeScope 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
organizationIdorlocationIdfilters 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=Postedis not treated asreconciliationStatus=Reconciled.
Audit behavior:
- Export writes a safe
AuditAction.Exportevent forReconciliationEvidence. - 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
| Code | HTTP class | Safe meaning | Operator/UI action | Retry guidance |
|---|---|---|---|---|
workflow.invalid_transition | 409 | Current 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.locked | 409 | Entity 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.conflict | 409 | Supplied rowVersion is stale. | Refresh affected entity/list and ask operator to repeat the action. | Retry with latest rowVersion. |
outlet_ambiguous | Warning/review result | Visible payable documents span multiple Outlets. | Require explicit Outlet/document selection. | Retry after manual review/selection. |
outlet_unresolved | Warning/review result | Transaction or document Outlet is missing. | Resolve Outlet context before posting. | Retry after data/context correction. |
manual_outlet_review_required | Warning/review result | Operator must review Outlet/document scope. | Block automatic posting path. | Retry after review confirms safe scope. |
bank_import_balance_review_required | 409 | Source bank import balance quality gate is not cleared. | Review import quality and apply allowed override if appropriate. | Retry after quality review/override. |
payment_anomaly_open | 409 or preflight/dry-run gate | Open 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_unknown | Warning/409 | Syrve/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_processed | Warning/409 | Selected document is not processed. | Process the document in Syrve or remove it from allocation. | Retry after a reliable processed-state refresh. |
document_deleted | Warning/409 | Selected document is deleted. | Remove it from allocation. | Do not retry against the deleted document. |
document_not_payable | Warning/409 | Document type/payment/currency/amount is no longer payable. | Refresh and review current debt. | Retry only after the payable state is valid. |
payment_not_fully_distributed | Preview block/409 | Part 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_unavailable | Preview/plan block/409 | A 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_unknown | Worker diagnostic | A transport timeout/failure may have occurred after external write. | Run readback/reconciliation. | No blind retry. |
write_outcome_unknown_readback_required | 409 | Retry was requested before resolving an unknown write outcome. | Reconcile/read back first. | Retry only after evidence resolves the outcome. |
posting_type_document_type_mismatch | Preflight/dry-run gate; conflict where surfaced by mutation | Posting 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_unsupported | 409 on queue | Current 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_directions | Preview block / 409 on queue | Allocation 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-logGET /api/audit-log/export.csvGET /api/audit-log/export.pdf
Supported query parameters:
organizationIdlocationIdfromUtctoUtcactionuserEmailentityTypeentityIdpage/pageSizefor list endpoint only
Scope behavior:
MegaAdminandMegaManagercan query/export platform-wide audit or apply explicit organization/location filters.OrganizationAdmin,Accountant, andViewercan query/export only entries inside their effective organization/location scope.- Explicit foreign
organizationIdorlocationIdfilters for non-platform users return a safe empty result, matching the list endpoint behavior. - Export generation writes a safe
AuditAction.Exportaudit 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:
- Transaction review:
GET /api/name-matches/summary, thenGET /api/name-matches?status=&organizationId=&locationId=. - Select Syrve/iiko counterparty:
GET /api/syrve/counterparties. - Manual match:
POST /api/counterparty-matches. - Optional rule conflict preview:
POST /api/counterparties/rules/conflict-preview. - Optional rule create:
POST /api/counterparties/rules. - Completion and cache refresh: refresh
name-matches,counterparty-matches,counterparties, and dashboard queries.
Rules directory endpoints:
GET /api/counterpartiesGET /api/counterparties/rules?organizationId=&locationId=&includeInactive=PUT /api/counterparties/rules/{id}POST /api/counterparties/rules/{id}/deactivatePOST /api/counterparties/rules/{id}/activateGET /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:
organizationIdlocationIdincludeInactive
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/counterpartiesreturns active rules for the matching page.GET /api/counterparties/rulesexcludes inactive rules by default.includeInactive=trueincludes inactive rules in the directory list.- Deactivation does not delete rules and does not rewrite historical matches.
Statuses and filters
Domain match statuses:
UnmatchedAutoMatchedManualMatchedExcluded— 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 ofAutoMatched.needsReviewCount: count ofAutoMatchedwithconfidenceScore < 0.85.notFoundCount: count ofUnmatched.manualMatchedCount: count ofManualMatched.excludedCount: count ofExcluded.accuracyPercent:autoMatchedCount / total * 100, rounded to one decimal.
GET /api/name-matches/summary
Query params:
importId— optional; limit counts to one bank statement importorganizationIdlocationId
Response:
json
{
"autoMatchedCount": 12,
"needsReviewCount": 3,
"notFoundCount": 5,
"manualMatchedCount": 7,
"accuracyPercent": 63.2,
"excludedCount": 2
}GET /api/name-matches/import-summaries
Query params:
organizationIdlocationId
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, orLowConfidence.importId— optional; filter to one bank statement importorganizationIdlocationId
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
CounterpartyMatchforbankTransactionId. - 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
400with legacyApiError.code = bank_transaction_required. - Missing Syrve/iiko id returns
400with legacyApiError.code = syrve_counterparty_required. - Missing Syrve/iiko name returns
400with legacyApiError.code = syrve_counterparty_name_required. - Match not found returns plain
404. - Forbidden scope returns
403with legacyApiError.codefrom 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
UnmatchedorAutoMatched. - Blocked when an active (non-cancelled) payment allocation exists for the transaction.
- Upserts an active
CounterpartyMatchRulewithruleKind = ExcludeNonSupplier, the selectedexcludeCategory, 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
matchedSignalsand a human-readablereason. - Skipped by active-rule reapply (same protection as
ManualMatched). - Allocation preview/confirm returns
409withcounterparty_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
priorityis omitted,effectivePriorityismax(priority) + 10; first backend priority is100. - 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:
ExactDuplicateDuplicateScopeDifferentCounterpartyOverlappingPatternPriorityShadowingDifferentSyrveMapping
Severity values:
InfoWarningBlocking
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, andsyrveCounterpartyName. - Normalizes
patternusing 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 OKwith existing rule andalreadyExists = 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:
organizationIdlocationId
Returns active CounterpartyRuleDto[]. Ordering is scope closeness for requested organization, then ascending priority.
GET /api/counterparties/rules
Directory list for rule management.
Query params:
organizationIdlocationIdincludeInactive: defaultfalse.
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).LowConfidenceis not handled by this endpoint.organizationIdlocationId
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_requiredsyrve_counterparty_requiredsyrve_counterparty_name_requiredbank_transaction_requiredbank_transaction_not_foundvalidation.failedforbiddennot_found- scoped RBAC denial codes from
ScopeAccessDeniedException.Code
Reason behavior:
POST /api/counterparty-matchesdoes not require a free-text operator reason.POST /api/counterparties/rulesdoes not require a free-text reason.PUT /api/counterparties/rules/{id}does not require a free-text reason.activate/deactivatedo 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
| Code | HTTP status | Params | When it occurs | UI localization note |
|---|---|---|---|---|
invalid_credentials | 401 | none | POST /api/auth/login invalid email/password | Show login failure text |
empty_file | 400 | none | POST /api/bank-statements/import empty upload | Show upload file validation text |
pattern_required | 400 | none | Counterparty rule pattern is empty or normalizes to empty | Highlight rule pattern |
syrve_counterparty_required | 400 | none | Counterparty rule/manual match lacks Syrve/iiko counterparty id | Ask user to choose counterparty |
syrve_counterparty_name_required | 400 | none | Counterparty rule/manual match lacks Syrve/iiko counterparty name | Ask user to choose counterparty again |
bank_transaction_required | 400 | none | Counterparty rule/manual match lacks bank transaction id | Ask user to select transaction |
bank_transaction_not_found | 404 | none | Referenced bank transaction does not exist | Refresh data or select another transaction |
request.failed | 400 | none | Generic request failure fallback | Generic retry/support text |
unauthorized | 401 | none | Missing/expired authentication | Redirect/login text |
forbidden | 403 | none | Permission or scoped RBAC gate denies action | Explain missing access |
not_found | 404 | none | Resource missing with no more specific business code | Explain stale/missing data |
validation.failed | 400 | fieldErrors | Model binding/validation failed | Map field keys to form errors |
concurrency.conflict | 409 | entity, id | Client supplied stale rowVersion for guarded write | Refresh entity and ask operator to retry |
workflow.invalid_transition | 409 | entity, state, operation | Requested workflow transition is not allowed for current state | Disable action or show state-specific conflict text |
workflow.locked | 409 | entity, state, operation | Entity is soft-deleted, pending hard delete, or hard-deleted and cannot be used in active workflow | Refresh list/detail; explain lifecycle lock |
allocation.not_editable_after_approve | 409 | allocationId, state, operation | Allocation edit/cancel/confirm conflicts with an already approved allocation | Show approved-allocation lock text |
allocation.not_editable_after_queued | 409 | allocationId, state, operation | Allocation edit/cancel/confirm conflicts with an allocation already queued for posting | Show posting-queue lock text |
allocation.already_in_flight | 409 | bankTransactionId, allocationId | POST /api/payment-allocations/confirm rejected: bank transaction already has Approved/PostingQueued/Posted/Reconciled allocation or an in-flight/completed Syrve posting | Do not open allocate; resume posting result; show already-in-flight message |
bank_import_balance_review_required | 409 | importId | Legacy balance-review gate (no longer blocks posting; bank statement is source of truth) | Informational only |
internal_error | 500 | none | Unhandled server exception | Show safe support text with traceId |
Existing ScopeAccessDeniedException.Code values | 403 | none unless endpoint defines them | Scoped RBAC/application permission denial | Localize exact code when known, fallback to forbidden |
Existing QuotaExceededException.Code values | 403 or existing endpoint conflict status | none unless endpoint defines them | Quota gate denial; invite accept keeps 409 conflict semantics | Localize 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
locationIdis provided, the Outlet/location must belong toorganizationId; - selected Outlet/location must be directly allowed by scope or available via org-wide access;
locationId = nullis allowed only for org-wide access (or platform users);- cross-org/cross-outlet attempts return
403withErrorResponse; - 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/dashboardGET /api/bank-statements/importsGET /api/bank-statements/quality-summaryGET /api/bank-statements/import-health-summaryGET /api/payment-allocationsGET /api/payment-anomalies
Context errors use ErrorResponse:
| Code | HTTP | Meaning |
|---|---|---|
context.organization.required | 400 | Active organization is missing for an Outlet operational endpoint |
context.location.required | 400 | Active Outlet is missing for an Outlet operational endpoint |
context.organization.invalid | 400 | X-Organization-Id is not a UUID |
context.location.invalid | 400 | X-Location-Id is not a UUID |
context.organization.denied | 403 | Selected organization is outside the caller's effective scope |
context.location.denied | 403 | Selected Outlet is outside the caller's effective scope |
context.location.organization_mismatch | 403 | Selected Outlet does not belong to selected organization |
context.organization.not_found | 403 | Selected organization cannot be used as active context |
context.location.not_found | 403 | Selected 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/MegaManagerare platform-scope assignments only;OrganizationAdmin,Accountant, andViewerrequire organization or Outlet scope;locationIdis 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:
importIdalways points to the import the UI should open (existingImportIdwhen 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, defaultdata/bank-files;BankFiles__RetentionHours: original-file TTL, default24;- 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
organizationIdandlocationId; - allowed MVP profiles:
MegaAdmin,MegaManager, scopedOrganizationAdmin, scopedAccountant; - denied MVP profile:
Viewer.
Response:
http
HTTP/1.1 200 OK
Content-Type: text/csv
Content-Disposition: attachment; filename="permata.csv"
Content-Length: 12345The response body is the file stream. The filename is sanitized metadata from the original upload; raw filesystem paths are not returned.
Errors:
| Status | Code | When |
|---|---|---|
| 401 | unauthorized | Missing/expired authentication. |
| 403 | forbidden or scoped RBAC denial code | Missing bankFiles.download or cross-tenant/cross-location access. |
| 404 | not_found | Import/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
BankStatementImportremoves 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, scopedOrganizationAdmin, scopedAccountant; - denied MVP profile:
Viewer.
Request:
json
{
"reason": "Checked bank statement totals manually",
"rowVersion": 123
}Response: updated BankStatementImportQualityReportDto including balanceReviewOverridden, reviewOverrideReason, reviewOverriddenByUserId, and reviewOverriddenAtUtc.
Errors:
| Status | Code | When |
|---|---|---|
| 400 | review_override_reason_required | Empty or whitespace-only reason. |
| 403 | scoped RBAC denial | Missing override permission or cross-scope access. |
| 404 | not_found | Import or quality report missing. |
| 409 | concurrency.conflict | Stale rowVersion. |
| 409 | workflow.invalid_transition | Balance review not required or already overridden. |
Posting gate:
- while
balanceMatches === falseandbalanceReviewOverridden === false,POST /api/syrve/postings/queue/{allocationId}returns409with codebank_import_balance_review_required; GET /api/syrve/postings/preview/{allocationId}returnscanQueue=falseandblockReason=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, scopedOrganizationAdmin, scopedAccountant.
Request:
json
{
"reason": "Wrong file uploaded, need to re-import"
}Response: 204 No Content.
Errors:
| Status | Code | When |
|---|---|---|
| 400 | import_delete_reason_required | Empty or whitespace-only reason. |
| 403 | scoped RBAC denial | Missing upload permission or cross-scope access. |
| 404 | not_found | Import missing. |
| 409 | bank_import_has_allocations | At 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:
| Param | Notes |
|---|---|
state | CSV of Attention, ReadyForApproval, Processing, Completed, Excluded, NeedsPreparation |
reason | CSV of stable reason codes (e.g. balance_review_required, posting_failed) |
importId | Filter by import |
dateFrom / dateTo | DateOnly on transaction date |
search | Counterparty / description / purpose / bank reference / import file name |
documentContains | Case-insensitive import file name filter |
matchStatus | e.g. Unmatched, Excluded |
page / pageSize | Default 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
ReadyForApprovalwhen 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:
| Field | Notes |
|---|---|
idempotencyKey | Required; replay returns AlreadyQueued / Processing / AlreadyCompleted / mapped posting outcome |
expectedTransactionRowVersion | Optimistic concurrency on bank transaction |
expectedAllocationRowVersion / expectedPostingRowVersion | Optional concurrency for follow-up steps |
proposalHash | Optional; mismatch → NeedsAttention |
lines | Optional; when omitted, resolves from safe single full-amount candidate |
writeTarget | Optional account / cash-flow article; otherwise uses posting preview suggestions |
acceptRisk | Default 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/Blockedpath; 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.
| Block | Link |
|---|---|
| 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:
- Permission matrix
- Workflow state machines
- ADR: Organization / Sub-organization / Outlet ownership v1
- Scoped RBAC and Tenancy
Endpoint table
| Method | Path | Product concept | Action | Required permission / policy | Success response | Important error codes |
|---|---|---|---|---|---|---|
| GET | /api/platform/organizations | Organization / Sub-organization | Platform-scoped list used by /platform/organizations registry and settings/quota context selection | platform.organizations.view | 200 OrganizationDto[] | unauthorized, forbidden |
| POST | /api/platform/organizations | Organization / Sub-organization | Create root organization, or child when parentOrganizationId is set | platform.organizations.manage | 201 OrganizationDto | unauthorized, forbidden, validation.failed, not_found / organization_not_found |
| GET | /api/platform/organizations/{id} | Organization / Sub-organization | Platform route for organization detail | platform.organizations.view policy, then scoped organization.view service guard | 200 OrganizationDto | unauthorized, forbidden, not_found, scoped denial codes |
| PUT | /api/platform/organizations/{id} | Organization / Sub-organization | Platform route for details/status update | platform.organizations.manage policy; service allows platform manage or scoped organization.users.manage | 200 OrganizationDto | unauthorized, forbidden, validation.failed, not_found, workflow.invalid_transition |
| DELETE | /api/platform/organizations/{id} | Organization / Sub-organization | Hard delete archived organization without operational data | platform.organizations.manage policy; service requires MegaAdmin | 204 No Content | unauthorized, forbidden, not_found, workflow.invalid_transition, workflow.locked, confirmation/reason errors |
| GET | /api/platform/organizations/{id}/quota | Organization quota | Read quota limits and flags | organization.quotas.view | 200 OrganizationQuotaDto | unauthorized, forbidden, not_found, scoped denial codes |
| PUT | /api/platform/organizations/{id}/quota | Organization quota | Update quota limits and flags | platform.quotas.manage | 200 OrganizationQuotaDto | unauthorized, forbidden, validation.failed, not_found, quota/scoped denial codes |
| GET | /api/organizations/current | Organization | Get first organization from current user's scope | Authenticated user | 200 OrganizationDto or 200 null | unauthorized, forbidden |
| GET | /api/organizations/{id} | Organization / Sub-organization | Scoped organization detail | organization.view | 200 OrganizationDto | unauthorized, forbidden, not_found, scoped denial codes |
| PUT | /api/organizations/{id} | Organization / Sub-organization | Scoped details/status update | organization.users.manage policy; service also accepts platform.organizations.manage | 200 OrganizationDto | unauthorized, forbidden, validation.failed, not_found, workflow.invalid_transition |
| POST | /api/organizations/{id}/children | Sub-organization | Create child organization under parent {id} | organization.locations.manage on parent | 201 OrganizationDto | unauthorized, forbidden, validation.failed, not_found / organization_not_found |
| GET | /api/organizations/{id}/locations | Outlet | List outlets/locations for organization | organization.view | 200 LocationDto[] | unauthorized, forbidden, scoped denial codes |
| POST | /api/organizations/{id}/locations | Outlet | Create outlet/location | organization.locations.manage; quota gate EnsureCanCreateLocationAsync | 201 LocationDto | unauthorized, forbidden, validation.failed, quota codes |
| PUT | /api/organizations/{id}/locations/{locationId} | Outlet | Update outlet/location details or active flag | organization.locations.manage | 200 LocationDto | unauthorized, forbidden, validation.failed, not_found |
| GET | /api/locations/{id} | Outlet | Get outlet/location detail | location.view | 200 LocationDto | unauthorized, 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/jsonjson
{
"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/organizationsjson
[
{
"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-0f89944b5a01json
{
"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/jsonjson
{
"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/jsonjson
{
"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/locationsjson
[
{
"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/jsonjson
{
"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/jsonjson
{
"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.
Activeallows normal scoped operations subject to permissions and quotas.Suspendedis an organization-level operational hold. Backend guards block writes where workflow rules apply.Archivedis 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. OnlyMegaAdminmay 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.Codevalues are returned for scoped RBAC/application denials where the service raises them, for examplepermission_denied_for_scope,organization_not_found, orlocation_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
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
organizationIdandlocationIdfilters can narrow results but cannot expand visibility. Cross-org or cross-location filters return a safe empty result. - Cross-tenant access returns
403when denied after locating the resource, or404when 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.
RMSrequireslocationIdand represents one Outlet. Multiple RMS connections for the same Outlet are allowed.CHAINrequireslocationId: nulland represents organization/chain-level scope.- Missing
connectionScopeis normalized for backward compatibility: non-nulllocationIdbecomesRMS, nulllocationIdbecomesCHAIN. - Invalid scope/location combinations return safe
400errors (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.StorePasswordAsyncin the existing encrypted localSecretRecordflow (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
SecretRecordrows 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
SyrvePaymentPostingrows for unreconciled external posting risk when the lifecycle entity is aPaymentAllocationorSyrvePaymentPosting; - requires exact typed confirmation
DELETE PAY DATA ONLY - SYRVE IIKO WILL NOT BE CHANGEDwhen unreconciled external posting risk exists; - rejects missing or wrong confirmation with safe code
hard_delete.unreconciled_external_posting_confirmation_required; - writes an
AuditAction.HardDeletedtombstone 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
SyrveConnectionin Pay DB. - Uses backend address normalization; operators may enter host-only addresses without
/restoorhttp/https. - Returns
suggestedConnectionScopeasRMS,CHAIN, ornullbased on detected deployment type; create still requires explicitconnectionScopeand 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
accountIdandcashFlowArticleIdare required; - rules are scoped to
organizationIdand optionallylocationId/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):
| Field | Required | Notes |
|---|---|---|
organizationId | yes | Tenant scope |
locationId | no | Outlet scope when applicable |
bankStatementImportId | no | Must belong to same org/location when set |
bankTransactionId | no | Must belong to same org/location when set |
counterpartyMatchId | no | Must belong to same org/location when set |
paymentAllocationId | no | Must belong to same org/location when set |
correctionType | yes | CounterpartyMatch, BankTransactionMetadata, ImportQualityOverride, PaymentAllocationNote, Other |
targetEntityType | yes | e.g. CounterpartyMatch, BankTransaction |
targetEntityId | yes | UUID of corrected entity |
reason | yes | Policy-compliant operator justification (10..500 chars; secrets/raw credentials forbidden) |
before / after / metadata | no | ManualCorrectionJsonPayload 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):
| Field | Required | Notes |
|---|---|---|
reason | yes | Policy-compliant operator justification for cancellation (10..500 chars; secrets/raw credentials forbidden) |
rowVersion | no | Optimistic concurrency; stale value → 409 concurrency.conflict |
Response: updated ManualCorrectionDto (status Cancelled, cancelledByUserId, cancelledAtUtc, cancelReason).
Errors:
| Status | Code | When |
|---|---|---|
404 | — | Correction not found |
403 | forbidden | Missing scoped manage permission |
409 | workflow.invalid_transition | Status is not Applied |
409 | concurrency.conflict | Supplied rowVersion is stale |
400 | validation.failed | Missing/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:
| Field | Notes |
|---|---|
isRead | Optional read/unread filter. Read state is separate from SLA acknowledgement. |
type | Optional notification type filter. |
severity | Optional severity filter, for example Critical. |
slaStatus | Optional SLA state: Open, Acknowledged, Escalated, Resolved, Dismissed. |
ownerUserId | Optional owner filter. |
criticalOnly | Optional boolean shortcut for critical notifications. |
page, pageSize | Paging. |
AppNotificationDto fields:
idtypeseveritytitlemessageentityTypeentityIdactionUrlisReadreadAtUtcownerUserIdslaStatusslaDueAtUtcacknowledgedAtUtcacknowledgedByUserIdresolvedAtUtcresolvedByUserIddismissedAtUtcdismissedByUserIdescalatedAtUtcescalatedByWorkercreatedAtUtc
Critical notification SLA behavior:
- New critical notifications start with
slaStatus = Open. slaDueAtUtcis set tocreatedAtUtc + 2 hours.- The notification escalation worker marks due critical
Opennotifications asEscalated. IsReadremains 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.
| Field | Meaning |
|---|---|
workerKey | Stable dashboard key, for example syrve-document-sync, payment-posting, hard-delete-purge. |
displayName | Operator-facing worker name. |
category | One of read_sync, reconciliation, file_cleanup, payment_posting, hard_delete, notification_escalation, outbox. |
isEnabled | Current configured/enabled state where available. |
configuredIntervalSeconds | Worker interval when configured. |
canRetry | True only for explicitly allowlisted safe manual runs. |
retryForbiddenReason | Safe reason when manual run is unavailable or forbidden. |
isDangerous | True for payment posting and hard-delete categories. |
lastRunAtUtc, lastErrorAtUtc, lastErrorCode, lastErrorMessage | Safe 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.
| Field | Meaning |
|---|---|
workerKey | Requested worker key. |
status | completed, unavailable, forbidden, or not_found. |
message | Safe operator-facing result. |
ranAtUtc | Timestamp 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
| Code | Meaning |
|---|---|
bridge_missing | No IikoConnector bridge link |
credentials_missing | Secret reference not resolved |
invalid_base_url | Invalid Syrve/iiko server URL |
auth_failed | Authentication rejected |
timeout | Remote timeout |
iiko_unavailable | Server unreachable |
write_not_implemented | Safety checks passed but live write API is not connected |
readback_document_unavailable | Document readback failed after posting |
reconciliation_amount_mismatch | Reconciliation amount mismatch |
overpaid_after_write | Live readback shows overpaid balance after write |
payment_anomaly_open | Critical open anomaly blocks queue/approve/retry/write |
posting_type_document_type_mismatch | Persisted posting type does not match the document-type write method |
write_posted_but_not_visible_in_readback | RPC 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.
| Method | Route | Purpose |
|---|---|---|
| GET | /api/bank-statements/imports/{id}/recognition | Run status, global model/prompt/schema versions, usage, timing and safe error. |
| GET | /api/bank-statements/imports/{id}/recognition/rows | Tenant-scoped drafts with immutable recognized and editable current values. |
| GET | /api/bank-statements/imports/{id}/file/preview | Authorized 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/confirm | Atomically creates normalized BankTransaction rows from persisted current values. |
| POST | /api/bank-statements/imports/{id}/recognition/retry | Requeues 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/reject | Rejects 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.