Skip to content

Syrve Payment Orchestration ​

Foundation pipeline for reconciling Indonesian bank payments with Syrve/iiko documents.

Flow ​

text
SyrveConnection (Pay DB)
  → SyrveDocumentSyncWorker / manual sync
  → SyrveDocumentSnapshot (local read model)
  → PaymentCandidate (auto-suggest allocation)
  → PaymentAllocation (+ lines)
  → SyrvePaymentPosting (current document-settlement outbox)
  → PaymentPostingWorker
  → SyrveReconciliationWorker

The current production relation remains PaymentAllocation 1:1 SyrvePaymentPosting and is runnable only when the whole bank amount is assigned to document settlement. The pure SupplierSettlementPostingOperationPlanner models the future SupplierSettlementPlan 1:N PostingOperations boundary: document settlement and supplier advance receive separate operation type, amount, account/cash-flow, idempotency key, stable request hash, external/evidence/readback/reversal fields. Advance operations are always emitted as BlockedTransportUnavailable and never enter the production queue until the IikoConnector contract is traced. A posted/unknown document operation plus an unconfirmed advance is explicitly partial execution and requires readback before any document-write retry.

Boundaries ​

  • IikoConnector — only low-level transport (sessions, catalog, document read, payment write).
  • MyHoreca.Pay.Application — use cases and DTOs; no IikoConnector references.
  • MyHoreca.Pay.Infrastructure — adapters (SyrveOrchestrationServices, IikoSyrveMapper) and workers.
  • IikoConnector.Persistence — IikoRestaurantConnection remains the connector's connection store; Pay uses SyrveConnection with optional IikoRestaurantConnectionId bridge field.

Syrve connection scope ​

SyrveConnection.ConnectionScope is a Pay-owned string scope marker with two allowed values:

  • RMS — a single-Outlet connection. LocationId is required. One RMS connection represents exactly one Outlet, while several RMS connections may exist for the same Outlet.
  • CHAIN — an organization/chain-level connection. LocationId must be null.

Scope is selected when the Pay connection is created and is immutable in the current update flow. The default connection flag remains scoped by OrganizationId + LocationId: CHAIN defaults are organization-level (LocationId=null), RMS defaults are Outlet-level.

IikoConnector remains a low-level transport and is not changed by the RMS/CHAIN mapping. The bridge continues to synchronize the Pay-owned SyrveConnection to the existing connector runtime path without adding posting behavior changes.

Document snapshots ​

SyrveDocumentSnapshot stores payment state and document processing state separately. PaymentStatus/UnpaidAmount must never be used as proof that an iiko document was posted.

Payment eligibility requires all of the following:

  • IsProcessed == true;
  • IsDeleted == false;
  • PaymentStatus is Unpaid or PartiallyPaid;
  • UnpaidAmount > 0;
  • document type, bank direction, currency, organization/connection and Outlet scope match the operation.

IsProcessed = null means that the processing state was not reliably obtained. It is fail-closed everywhere. ProcessingStateCheckedAtUtc and ProcessingStateSource (document_list, document_list_absent, live_refresh) record where the last decision came from.

PaymentStatusMeaning
UnpaidNo payment recorded
PartiallyPaidSome payment recorded
PaidFully paid
StaleNot seen in a complete list sync. Hidden from the default /syrve/documents catalogue; query with paymentStatus=Stale or GET /api/syrve/documents/stale for audit. Deletion is represented separately by IsDeleted.

Production document sync read profile ​

Bulk sync always reads the configured Incoming/Outgoing list indexes for catalogue presence and Stale detection. Its primary unpaid sources are:

  • Incoming: UnpaidDocuments.getUnpaidInvoicesByDepartments
  • Outgoing: UnpaidDocuments.getUnpaidOutgoingDocuments (live-proven batch; absence ≠ Paid)

Known department IDs can be chunked for the Incoming unpaid batch with UnpaidBatchDepartmentChunkSize. Outgoing unpaid batch uses All counteragent/departments (or the configured department list when present). List and unpaid-batch RPC cost therefore grows by configured types/chunks, not by the number of documents.

The reader selects one explainable mode:

ModeMeaning
batchIncoming and/or Outgoing unpaid batches are usable and cover every configured document type.
hybridAt least one usable batch covers part of the list; uncovered types use fresh snapshot reuse or bounded per-document unpaid reads.
fallbackBatches are disabled, failed, empty-suspicious (Incoming), or unusable; bounded per-document reads preserve correctness.

Correctness rules:

  • list records are the authoritative bulk source for processed and deleted; no per-document getDocument call is made to infer processing state;
  • list processed=true and deleted=false are copied to the snapshot on insert and every update;
  • missing list confirmation produces IsProcessed=null; non-processed/deleted list rows are persisted without scalar unpaid or full-document enrichment;
  • safety transitions are propagated even when financial enrichment reached MaxBatchSize;
  • documents present in a successful unpaid batch receive Unpaid/Partial amounts from that batch;
  • documents listed but absent from a successful unpaid batch become Unknown (prior unpaid amounts preserved; PaymentStateReliable=false) — never Paid from absence alone;
  • Outgoing is covered only by a successful Outgoing unpaid batch (Complete / EmptyComplete), never by Incoming-batch success;
  • batch failure/unusable response never mass-clears unpaid amounts or marks documents Paid;
  • Stale is determined only by a complete list index, independently of the unpaid map;
  • fallback is capped by MaxBatchSize, runs at MaxConcurrentDocumentFetches, and calls getDocument only for missing metadata/details;
  • scheduled hybrid sync may reuse an Outgoing snapshot for OutgoingUnpaidRefreshMinutes only when the Outgoing unpaid batch is unavailable and total/counterparty/known payment status/details are present and list DateModified is not newer than the last exact financial refresh. Reuse does not advance LastSyncedAtUtc;
  • expired/modified/cold snapshots and explicit forceRefresh=true bypass snapshot reuse; batches remain primary;
  • authentication identity is not a refresh-policy signal: the normal connection-detail button passes userId for permission/audit but remains soft. The separate confirmed hard-refresh action adds the explicit query flag;
  • bulk sync never calls accounts.getTransactionsByDocumentId; supplier names come from the local catalogue/resolver;
  • targeted RefreshDocumentAsync keeps exact per-document unpaid/document/account readback for UI and reconciliation.

Connector boundary: IikoConnector 1.2.7 currently exposes list-record Processed and Deleted as non-nullable booleans. A missing/invalid processed element is mapped to false and therefore remains blocked, but the Pay runtime cannot distinguish an absent deleted element from explicit deleted=false when processed=true. The confirmed live response contains both elements. A future presence-aware connector contract should expose nullable/raw-presence metadata so malformed list XML is classified explicitly as unknown.

Each immediate sync outcome exposes mode, per-method RPC counters (rpc_unpaid_batch, rpc_unpaid_outgoing_batch), batch/list sizes, snapshotReuseCount, warnings and fallback reason. The same structured summary is retained in the sync-run message without a telemetry-only EF migration. One summary log line replaces the need to infer the profile from the HTTP request stream.

Target soft-sync profile after Outgoing unpaid batch wiring: ~4 RPC (list=2, unpaid_batch=1, unpaid_outgoing_batch=1, unpaid_sum=0, accounts=0).

Connector evidence for Outgoing unpaid batch: EnterCo-Zakup live closure 2026-07-16-iikoconnector-outgoing-unpaid-batch-live-closure.md (RMS 9.6.4007.0 / Chain 9.5.6087.0).

Previous mid-iteration evidence (Incoming batch only + Outgoing hybrid/reuse) retained for history: at 17:35 on 2026-07-16 the existing 200-137-733.iiko.it connection proved Incoming coverage with Outgoing absent from that Incoming map (list=2, unpaid_batch=1, unpaid_sum=37 cold / warm reuse to 6 RPC). That profile is superseded by the dual-batch soft sync above.

Payment write safety controls ​

All writes to Syrve/iiko are:

  • available whenever the Pay-host runtime capability is registered;
  • queued via outbox/worker (never synchronous from UI);
  • created in ApprovalRequired and released only by explicit manual approval;
  • idempotent (SyrvePaymentPosting.IdempotencyKey unique);
  • auditable (AuditLogEntry);
  • checked for RBAC/scope, payable/amount/allocation/anomaly constraints, server applicability, credentials, HTTP/RPC and method-specific readiness, and technical limits.

The shared SyrveDocumentPaymentEligibilityPolicy is applied when candidates are built/read, manual allocation lines are previewed/confirmed, allocations/postings are approved or queued, workbench proposals are prepared/replayed, and retries are scheduled. Boundary checks are intentionally repeated: PaymentPostingWorker reloads allocation lines and snapshots, and IikoSyrvePaymentWriteTransport performs a targeted list-by-id live refresh for the selected documents immediately before IIikoPaymentWriteFacade.ExecuteAsync. A refresh failure or unconfirmed state records a diagnostic code and prevents the transport call.

Workflow guards and RowVersion (MHPAY-92) ​

Canonical posting and allocation states, transitions, terminal states, locks, operation names, and UI mapping rules live in Workflow State Machines. This section keeps only Syrve orchestration implementation notes.

SyrvePaymentPosting and PaymentAllocation use app-managed rowVersion optimistic concurrency for guarded write actions. API clients should pass allocationRowVersion when queueing a posting and rowVersion when approving, retrying, reconciling, or configuring write target.

The posting worker persists an atomic Posting claim, attempt number, claimed timestamp, and bounded lease before calling transport. The posting date is captured from bank ValueDate ?? TransactionDate; request hashes use that persisted date plus allocation method and saved line sequence, never the current clock. A timeout, exception, or expired in-flight lease becomes WriteOutcomeUnknown, clears normal retry scheduling, queues readback, and blocks all blind writes with write_outcome_unknown_readback_required. Only readback/reconciliation can resolve that state.

Central guards enforce:

  • queueing a posting moves the allocation to PostingQueued, which blocks later allocation edits/cancel;
  • duplicate posting queue for the same allocation is rejected;
  • posting approval is allowed only from ApprovalRequired;
  • write target configuration is allowed only before the posting is queued/posted/reconciled/cancelled (Draft or ApprovalRequired);
  • retry is allowed only from Failed or RetryScheduled;
  • forced reconciliation is allowed only from Posted, ReadbackPending, or Failed;
  • soft-deleted, pending-hard-delete, and hard-deleted allocations/postings are locked out of active write workflow.

Invalid workflow transitions and stale row versions return canonical 409 ErrorResponse codes; server responses do not expose raw exceptions or stacktraces.

SyrvePostingRule v1 ​

SyrvePostingRule is Pay-owned business configuration. It lives in MyHoreca.Pay.*; IikoConnector remains transport-only and is not changed by rule matching.

V1 rules are intentionally narrow:

  • scoped to organization and optionally location / Syrve connection;
  • supported document families only (IncomingInvoice, OutgoingInvoice, ServiceReceipt, ServiceProvision);
  • explicit AccountId and CashFlowArticleId are required;
  • posting type is derived from document type by the existing Pay resolver;
  • no platform-global rules and no org-wide document-only catch-all rules;
  • no multi-document, multi-account, or multi-cash-flow split;
  • no correspondent account or concept inference.

When queueing a posting, Pay uses configured test/default account and cash-flow values if present. If either target is missing, exactly one active matching rule must resolve the target. If no rule matches, the document type is unsupported, or multiple equally applicable rules match, the allocation is left in NeedsReview and no runnable posting is queued.

Rule create/update/disable and conflict detection are audited with safe metadata only. Worker-only execution and the existing approval, preflight, idempotency, anomaly, and reconciliation gates remain unchanged.

Always-on runtime ​

See docs/deployment/environment-variables.md — section Syrve orchestration. Registered Pay-host reads, sync and payment-write capabilities have no env/config enablement or verification-status bypass flags. AddIikoConnectorForPayHost() translates the portable connector's compatibility options into this always-on Pay contract and omits only runtimes whose persistence ports are owned by EnterCo rather than Pay.

Live test procedure: docs/docs/integrations/syrve-live-test-playbook.md.

For paym production acceptance, verify active registration and safety evidence:

  • connection ownership and scoped RBAC;
  • every new posting is ApprovalRequired before explicit approval;
  • idempotency and payable/amount/allocation/anomaly checks;
  • server applicability, credentials and HTTP/RPC/method-specific preflight;
  • technical retry, payload and response limits;
  • deprecated platform/quota capability columns do not affect runtime behavior.

Per-connection /api/syrve/connections/{id}/health remains the source of safe connectivity diagnostics. Legacy capability fields returned by /api/syrve/connections/operational-flags, if retained for compatibility, are informational/deprecated and must not be treated as runtime switches.

paym acceptance verification (MHPAY-39) ​

After redeploy on paym.ru.tuna.am, operators run PAYM-01..08:

CheckAPI / UI touchpoint
API aliveGET /api/health
Always-on capability contractregistered Pay-host services + compatibility response
Deprecated DB capabilities ignoredGET /api/platform/settings, organization quota
REST session probePOST /api/syrve/connections/{id}/test or POST /api/syrve/connections/probe
Connection health snapshotGET /api/syrve/connections/{id}/health
References readabilityGET /api/syrve/references/accounts, GET /api/syrve/references/cash-flow-articles
Write safety checks without live RPCGET /api/syrve/postings/{id}/preflight, POST /api/syrve/postings/{id}/dry-run

Safety evaluation in worker/API keeps scoped authorization, posting state/manual approval, idempotency, business checks, server applicability and required method-specific preflight. Dry-run remains read-only and may report approval as pending; it never releases or executes a posting.

UUID v7 ​

All new entity IDs use IUuidGenerator.NewUuidV7(). External Syrve document IDs are stored in ExternalDocumentId.

Live IikoConnector bridge ​

SyrveConnection (Pay DB) is the operational portal registry. IikoRestaurantConnection (IikoConnector.Persistence) is the runtime entity used by IikoConnector sessions and RPC.

Bridge service (ISyrveIikoConnectionBridge):

  • POST /api/syrve/connections/{id}/bridge — create/update link
  • fills SyrveConnection.IikoRestaurantConnectionId
  • maps PasswordSecretRef → runtime secret reference (no raw password in Pay DB/API)
  • audit events: bridge_created, bridge_updated, bridge_failed

Secrets ​

SyrveConnection.PasswordSecretRef stores only a reference:

  • pay-secret:{secretRecordId} — Pay-managed local secret in SecretRecords, encrypted with AES-GCM (Ciphertext, Nonce, Tag, KeyId, Algorithm).
  • env:VARIABLE — deployment-managed secret resolved from process environment.
  • secret:VARIABLE — legacy alias normalized to env:VARIABLE by the bridge.

The local encrypted store accepts plaintext only on create/update, encrypts before DB persistence, and returns only the reference. SyrveConnectionDto does not expose password or secret reference. Bridge, live checker, and IikoCredentialsProvider resolve references through ISecretResolver; audit details/messages must not contain raw passwords.

Local encrypted storage requires PaySecrets__EncryptionKey (32-byte AES key) on the API host.

Credential rotation ​

POST /api/syrve/connections/{id}/credentials/rotate rotates login/password credentials through a dedicated flow, not through the generic connection update method. The request contract intentionally has no BaseUrl; rotation preserves the persisted server address.

Required permission: syrve.credentials.rotate. MVP roles with this permission are MegaAdmin, MegaManager, and OrganizationAdmin; Accountant and Viewer are denied.

Rotation sequence:

  • verify scoped permission against the connection organization;
  • store the new password through the existing encrypted ISyrveConnectionSecretStore.StorePasswordAsync / SecretRecord path;
  • delete Redis runtime token/session key mhpay:{env}:org:{organizationId}:syrve:runtime-token:{connectionId};
  • sync the IikoConnector bridge so runtime handles use the new secret reference;
  • execute the existing connection test flow after save;
  • write success/failure audit without plaintext password, secret reference, or runtime token/session value.

Durable iiko service account ​

New connections provision MyHorecaPayUser by default. The operator supplies administrator credentials only for the bootstrap request; Pay does not persist them.

The service-account identity, password generation, permission policy, and ensure orchestration are owned by MyHoreca.Pay.Infrastructure. IikoConnector exposes only reusable employee/session capabilities; it contains no Pay or EnterCo service-account identity and no managed-service-account workflow.

Pay's IIikoServiceAccountEnsureRuntime performs an idempotent remote workflow:

  • discovers live server edition/version via get_server_info (persisted card version is not authoritative);
  • finds MyHorecaPayUser, or creates it as an employee with the confirmed administrator role;
  • normalizes its login/code/display name and restores its administrator role;
  • resolves the permission catalog by exact BACK version, otherwise soft-falls back to a known catalog of the same major.minor line;
  • applies catalog entries (ALLOW, with responsibility-scoped entries set to WHERE_RESPONSIBLE); on permission-batch 403 soft paths, keeps ensure alive when admin role is present and service-login auth readback succeeds;
  • generates a cryptographically random 16-character password, changes it through permission.setPasswordForUser, and verifies auth + a protected read under the service account.

Only Login=MyHorecaPayUser and the generated password SHA1 are written through ISyrveConnectionSecretStore; plaintext service and administrator passwords never enter API responses, logs, audit details, or persisted entities. POST /api/syrve/connections/{id}/service-account/ensure repeats the same workflow for recovery and returns 409 on failed ensure/test (UI treats success only when the post-ensure check succeeds). The legacy manual credential path remains available with provisionServiceAccount=false.

Runtime reads ​

When the Pay-host runtime is registered:

  • TestConnectionAsync — server info + session verify (no fake success)
  • SyrveDocumentSyncService — unpaid batch plus Incoming/Outgoing list indexes; bounded hybrid/fallback uses getUnpaidSumForAGivenInvoice and conditional getDocument, with no bulk accounts reads
  • RefreshDocumentAsync — targeted readback for one snapshot
  • references/counterparties via bridged default connection

The targeted refresh reads the list record first. It stops before unpaid/full-document reads when the list row is missing, not processed, or deleted. Full-document enrichment never overwrites the list-derived processing state.

Payment write safety controls ​

There are no environment capability switches. Every new posting is created in ApprovalRequired. The worker checks manual approval, scoped access captured by the workflow, idempotency, payable/amount/allocation/anomaly constraints, server applicability, credentials, HTTP/RPC and method-specific readiness before calling ISyrvePaymentWriteTransport. Evidence is stored in SyrvePaymentPosting.RawResultJson; API: GET /api/syrve/postings/{id}/evidence.

Immediately before the iiko write facade executes, the transport performs a targeted list-by-id refresh in an isolated EF scope and rechecks processing state, deletion, payment state, currency/direction and the aggregate allocation amount against the fresh available remainder. Any failure returns a deterministic non-retryable diagnostic and the facade ExecuteAsync call is not made.

PASS_WRITE means the iiko write path accepted the payment and Pay stored write evidence (PostedAtUtc, optional ExternalPaymentId, request/result hash). It does not mean the document was verified by live readback. On worker write success, Pay moves the posting to ReadbackPending and schedules reconciliation.

Write preflight resolves posting type from the allocation document type. If a persisted posting type does not match the document-derived write method, preflight blocks with posting_type_document_type_mismatch before any external write attempt. Open Critical payment anomalies for the same external document block queue, approve, retry, and worker execution with payment_anomaly_open; dry-run remains read-only and returns a payment_anomaly_open warning for manual review.

Reconciliation ​

SyrveReconciliationWorker and POST /api/syrve/postings/{id}/reconcile re-read documents via IikoConnector and compare ExpectedUnpaidAmountAfter. Posted ≠ Reconciled.

Evidence distinguishes four states:

  • writeResult: write attempt result and hash; Posted here is write evidence only.
  • optimisticAfter: Pay local calculation with source=OptimisticLocalCalculation.
  • liveReadback: verified live readback with source=IikoLiveReadback and optional secondary probes.
  • reconciliation: NotStarted, ReadbackPending, Reconciled, or Failed.

Operator UI surfaces the same separation on allocation/posting detail pages: allocation actions are disabled outside backend guard source states, queue blockers are shown from preflight/business codes, posting detail exposes method-specific preflight, write target, worker attempts, safe write evidence, and reconciliation status. Frontend mutations must invalidate the affected allocation/posting/preflight/evidence/attempt queries rather than assuming a local status transition.

PASS_RECONCILE requires live readback to match ExpectedUnpaidAmountAfter. Warning-only readback lag stays ReadbackPending with deterministic configured backoff; empty ReadbackRetryDelaysSeconds falls back to 30 seconds. Critical mismatch/anomaly transitions posting to Failed and stores OpenAnomalyId.

The comparison is performed independently for every saved allocation line, including partially paid documents: for example, UnpaidAmountBefore=200000, AllocatedAmount=150000 requires live UnpaidAmount=50000. All lines must match before the posting and allocation become Reconciled. ReconciledAtUtc records successful reconciliation only; a mismatch leaves it null, persists an open payment anomaly, and cannot make the settlement complete. WriteOutcomeUnknown enters this same readback path without repeating the transport write. A future supplier-advance operation requires its own independent readback; none is implemented or inferred from local remaining amounts.

GET /api/reports/reconciliation-evidence/export.csv provides a scoped, read-only evidence export for operators and auditors. The export reads Pay-owned local evidence only, keeps writeResult, optimisticAfter, liveReadback, and reconciliation as separate CSV column groups, masks sensitive external identifiers, and writes only metadata to audit. It does not perform live readback, retry, queue, write, delete, roll back, void, or otherwise mutate Syrve/iiko state.

Hard-delete lifecycle preflight checks Pay-owned posting evidence for unreconciled external posting risk before final Pay data removal. This preflight uses safe Pay metadata only and never deletes, rolls back, voids, updates, or writes Syrve/iiko data; IikoConnector remains transport-only and unchanged by deletion flows.