Appearance
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
→ SyrveReconciliationWorkerThe 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 —
IikoRestaurantConnectionremains the connector's connection store; Pay usesSyrveConnectionwith optionalIikoRestaurantConnectionIdbridge field.
Syrve connection scope
SyrveConnection.ConnectionScope is a Pay-owned string scope marker with two allowed values:
RMS— a single-Outlet connection.LocationIdis required. One RMS connection represents exactly one Outlet, while several RMS connections may exist for the same Outlet.CHAIN— an organization/chain-level connection.LocationIdmust benull.
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;PaymentStatusisUnpaidorPartiallyPaid;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.
| PaymentStatus | Meaning |
|---|---|
| Unpaid | No payment recorded |
| PartiallyPaid | Some payment recorded |
| Paid | Fully paid |
| Stale | Not 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:
| Mode | Meaning |
|---|---|
batch | Incoming and/or Outgoing unpaid batches are usable and cover every configured document type. |
hybrid | At least one usable batch covers part of the list; uncovered types use fresh snapshot reuse or bounded per-document unpaid reads. |
fallback | Batches are disabled, failed, empty-suspicious (Incoming), or unusable; bounded per-document reads preserve correctness. |
Correctness rules:
- list records are the authoritative bulk source for
processedanddeleted; no per-documentgetDocumentcall is made to infer processing state; - list
processed=trueanddeleted=falseare 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) — neverPaidfrom 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;
Staleis determined only by a complete list index, independently of the unpaid map;- fallback is capped by
MaxBatchSize, runs atMaxConcurrentDocumentFetches, and callsgetDocumentonly for missing metadata/details; - scheduled hybrid sync may reuse an Outgoing snapshot for
OutgoingUnpaidRefreshMinutesonly when the Outgoing unpaid batch is unavailable and total/counterparty/known payment status/details are present and listDateModifiedis not newer than the last exact financial refresh. Reuse does not advanceLastSyncedAtUtc; - expired/modified/cold snapshots and explicit
forceRefresh=truebypass snapshot reuse; batches remain primary; - authentication identity is not a refresh-policy signal: the normal connection-detail button passes
userIdfor 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
RefreshDocumentAsynckeeps 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
ApprovalRequiredand released only by explicit manual approval; - idempotent (
SyrvePaymentPosting.IdempotencyKeyunique); - 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 (
DraftorApprovalRequired); - retry is allowed only from
FailedorRetryScheduled; - forced reconciliation is allowed only from
Posted,ReadbackPending, orFailed; - 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
AccountIdandCashFlowArticleIdare 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
ApprovalRequiredbefore 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:
| Check | API / UI touchpoint |
|---|---|
| API alive | GET /api/health |
| Always-on capability contract | registered Pay-host services + compatibility response |
| Deprecated DB capabilities ignored | GET /api/platform/settings, organization quota |
| REST session probe | POST /api/syrve/connections/{id}/test or POST /api/syrve/connections/probe |
| Connection health snapshot | GET /api/syrve/connections/{id}/health |
| References readability | GET /api/syrve/references/accounts, GET /api/syrve/references/cash-flow-articles |
| Write safety checks without live RPC | GET /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 inSecretRecords, encrypted with AES-GCM (Ciphertext,Nonce,Tag,KeyId,Algorithm).env:VARIABLE— deployment-managed secret resolved from process environment.secret:VARIABLE— legacy alias normalized toenv:VARIABLEby 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/SecretRecordpath; - 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 toWHERE_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 usesgetUnpaidSumForAGivenInvoiceand conditionalgetDocument, with no bulk accounts readsRefreshDocumentAsync— 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;Postedhere is write evidence only.optimisticAfter: Pay local calculation withsource=OptimisticLocalCalculation.liveReadback: verified live readback withsource=IikoLiveReadbackand optional secondary probes.reconciliation:NotStarted,ReadbackPending,Reconciled, orFailed.
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.