# Refinement Phase 8 — External rails go real (SMS → trust/identity → money) — Report (2026-07-13) **Track:** backend (integrations) · **Depends on:** phase 6 (money-correctness), phase 7 (scheduler) · **Gate:** `dotnet build` **0 new warnings** · `dotnet test` **402 pass** (unchanged — the mocks stay the default, so no existing test changed behaviour). ## The shape of the phase — an adapter behind every seam, config-selected Every vendor dependency was a deterministic in-process mock. This phase ships a **real HTTP adapter behind each seam**, selected by a per-rail **`Seams:*:Provider`** selector. The mock is the **default** (an unconfigured or typo'd provider falls closed to it), so a **partial rollout is the normal case** — real SMS + real geocoder while payments stay mocked in a pre-launch environment is three config keys. Swapping is a **registration change in `AddCrossCuttingSeams`; no handler changed** (the DoD's "handler is unchanged" holds for every rail). **Zero new NuGet packages.** The CrossCutting project already framework-references `Microsoft.AspNetCore.App`, so every adapter is `HttpClient` (typed via `IHttpClientFactory`) + `System.Text.Json` + BCL crypto — no vendor SDK, no restore risk. Credentials come from `Seams:*` (user-secrets/env), never committed. New adapters live in `Baya.Infrastructure.CrossCutting/Seams/Real/`. ## 3.1 Trust & identity rails - **5.1 SMS — `KavenegarSmsSender` (launch-critical).** OTP via Kavenegar's `verify/lookup` template API; free-form via `sms/send`. A non-`200` `return.status` is surfaced as a delivery failure (the OTP command reports it, never a silent "success"). **The OTP is never logged:** `Program.cs` now runs the Development OTP-in-logs capture bridge **only while the mock SMS sender is selected** (`Seams:Sms:Provider` empty/`mock`) — the moment a real gateway is configured the code leaves the process only over the SMS wire. - **5.2 Shahkar + e-KYC — `FinnotechShahkarVerifier`, `FinnotechIdentityKycProvider`.** A shared `FinnotechClient` (base URL, bearer auth, per-call `trackId`) fronts both; creds in `Seams:Finnotech`. Shahkar can't distinguish a shared-SIM from a plain mismatch (the registry only asserts bound/not-bound), so a real no-match is reported as a plain mismatch — the explicit shared-SIM branch stays reachable through the mock. The raw vendor response is persisted as `external_response_json`. - **5.3 استعلام شبا — `FinnotechBankAccountOwnershipVerifier`** (the b13 first-payout money-mule gate). Matches the IBAN's registered national code against the nurse's; **fails closed** (no national code returned ⇒ no match). - **5.4 Geocoder — `NeshanGeocoder`.** `x`=lng/`y`=lat parsed to `decimal` (exact EVV haversine downstream). A Neshan outage **degrades to the null-pin state** — it never blocks saving an address. - **5.5 Object storage — `S3ObjectStorage`.** MinIO / S3 / ArvanCloud with **manual AWS SigV4** (HMAC-SHA256, all BCL — no AWS SDK). Server-side put/get/delete are SigV4-header-authed (`UNSIGNED-PAYLOAD` so a blob stream is never buffered to hash it); `GetUrl` returns a **presigned GET** = the real form of the b6 signed-URL contract. Path-style default (MinIO/ArvanCloud); virtual-host supported. - **5.6 MoH/INO/eNamad — kept manual (intended MVP).** `ICredentialVerifier` / `ILicenseVerificationService` stay mock — there is **no public B2B API**, so the manual admin review *is* the mechanism, not debt. The registry rows are marked "manual = intended MVP". ## 3.2 Money rails - **6.1 PSP + webhook signature + تسهیم — `ZarinPalPaymentProvider` + `HmacWebhookVerifier` + `ProviderSettlementSplitProvider`** (swap together on `Payments:Provider`). ZarinPal v4 request/verify/refund; the **mandatory server-side verify** re-checks amount + reference (never trusts the callback). The webhook verifier does **per-provider HMAC over the raw body** (`Seams:Payments:WebhookSigningSecrets[{provider}]`, constant-time compare, tolerates a `sha256=` prefix); **no secret ⇒ the handler's server-side verify re-check is the guard** (the contract's signatureless fallback). تسهیم registers a split-by-ratio to registered IBANs. - **6.2 BNPL — `SnappPayBnplProvider` + `DigipayBnplProvider` + `ConfiguredBnplProviderResolver`** (`Bnpl:Provider=real`). One adapter per `provider_code`; the SnappPay verb set is the canonical superset the seam was designed around (OAuth-token cached → eligible → token → verify → settle → status → cancel/revert/update). **Currency crosses the wire only at the adapter boundary** via a shared `HttpBnplProviderBase.ToWire/FromWire` over `ICurrencyNormalizer` (`Seams:Bnpl:WireCurrency`, Rial pass-through by default). The **merchant commission is read from the settle response, never hardcoded.** **REQ-022 / balinyaar decision:** `balinyaar` is the in-house plan — no external API — so it **resolves to the deterministic net-of-fee model** (the distinction is the financing entity, not the money mechanics); `tara`/`torobpay` resolve to `null` (unbuilt) so the handler rejects them cleanly. The b11 `bnpl_revert` refund path injects `IBnplProvider` directly (not per-code) → SnappPay is the default revert provider (per-code revert resolution is a documented follow-up). - **6.3 PAYA/SATNA payout — `JibitBankTransferProvider` + the async reconciliation callback.** The real rail is **async**: an accepted transfer comes back `submitted` (a track id, money not yet confirmed). The existing `ExecutePayoutBatch` handler already `MarkSubmitted`s first and posts **no ledger** until paid, so it needed no change. New: **`ReconcilePayoutBatchCommand`** + **`WebhooksPayoutsController` (`POST webhooks/payouts/{provider}`, anonymous, `webhook` rate policy)** — HMAC-verified (an invalid signature mutates nothing), parses the per-transfer outcomes, matches `submitted` payouts by `transfer_reference`, and flips `paid` (posts the payout ledger + nets clawbacks via `PayoutSettlement`) / `failed`. Idempotent by the forward-only status machine + the ledger-exists guard — a replayed callback is a no-op. - **6.4 `IPaymentCaptureSimulator` out of production.** Prod registers the fail-closed `DisabledPaymentCaptureSimulator` (never fabricates a capture); Dev/Testing re-register the succeeding `MockPaymentCaptureSimulator` via `AddDevelopmentPaymentCapture` (last-wins). The `bookings/convert` path is a Dev/Testing affordance — production converts via the b10 webhook confirm calling `ConvertRequestToBooking` directly. (Testing must keep the mock: Mediator constructs the handler before validation runs, so the API tests that expect `400`/`401` on `bookings/convert` would otherwise `500`.) - **6.5 Moadian — `MoadianClient` + `MoadianReconciliationJob`.** Submit posts the invoice and maps the outcome (22-digit ref ⇒ `registered`; accepted-not-yet ⇒ `submitted`; reject ⇒ `failed`; a transient error stays `submitted` so the next tick retries — never permanently failed on a transient fault). The **reconciliation poll** is a new `IRecurringJob` (fixed **6 h** cadence — no seeded config key, so **no migration**) running `ReconcileMoadianInvoicesCommand`, which re-submits every `pending`/`submitted` invoice until it registers (Moadian dedups on the invoice number, so a re-submit doubles as the status poll — the seam keeps its one verb). New repo read: `IInvoiceRepository.GetUnregisteredMoadianInvoicesAsync`. - **6.6 Partner-center settlement rail — decision (product).** **No new center-payout money path is built this phase.** The MoR resolver already routes the invoice issuer; the settlement decision is: a **merchant-of-record** center is settled at capture time by **adding its registered `settlement_iban` as a تسهیم split leg** (the acquirer credits it directly — reusing 6.1, no new batch), and a **non-MoR** center has **no separate money path** (the nurse is paid via the normal b13 payout; the center's cut is an off-platform arrangement). A dedicated center-settlement ledger account + payout reusing the b13 machinery is **deferred** until center volume justifies it. Documented; no code beyond the existing تسهیم leg. ## Config-selection mechanics (how the swap works) `AddCrossCuttingSeams` reads the bound `SeamOptions` once and, per rail, registers the real adapter **or** the mock. Real HTTP adapters get a **named `IHttpClientFactory` client**; because the seams are singletons injected into scoped handlers (and the BNPL resolver holds its adapters), the adapters are singletons resolving one client — the standard minor SigV4/handler-rotation caveat against these stable vendor hosts is acceptable for the MVP. A `SeamProviders` token class keeps the selectors typo-safe. `SeamOptions` gained a `Provider` selector on every rail + credential blocks (`Sms`, `Finnotech`, `ObjectStorage` S3, `Payments`, `Bnpl.Providers`, `BankTransfer`, `Moadian`). ## What is testable and how (no live vendors here) The adapters can't be exercised against live Iranian vendors in this environment; that is deploy-time credentialing/certification (Shaparak lead time for the PSP especially). What **is** verified now: build + the full 402-test suite stay green with the mocks as default (proving the config-selection default preserves every existing behaviour). To exercise a real rail: provision the vendor account + credential, set `Seams:{rail}:Provider` + creds, and run the flow (request OTP → real SMS → login; sandbox card → verify + signed webhook; payout batch → `submitted` → `POST webhooks/payouts/jibit` → `paid`; invoice → `MoadianReconciliationJob` → `registered`). ## Follow-ups (documented, not forgotten) - **Per-code BNPL revert** — the b11 refund path injects `IBnplProvider` directly; SnappPay is the default. Route the revert through `IBnplProviderResolver` by the transaction's `provider_code`. - **SMS.ir / Ghasedak** adapters — only Kavenegar is implemented; selecting the others throws a clear `NotSupportedException` at registration (fail fast, never a silent mock). - **Finnotech token exchange** — the adapters use a pre-issued `AccessToken`; the client-credential refresh is a deploy-time concern. Same for the Moadian signing certificate. - **Refund-settlement poll** (BNPL `processing → succeeded`) — the phase-7 note paired it with Moadian; the settlement-confirm command exists (phase 6 `ConfirmRefundSettlement`), the poll job over "processing refunds" is the remaining wiring (needs a repo read of pending settlements). - **Center-settlement payout** — deferred per 6.6. - **Redis / Elasticsearch** — unchanged scale-out gates, no adapter (correctly single-instance today). ## Files New (`CrossCutting/Seams/Real/`): `KavenegarSmsSender`, `FinnotechClient`, `FinnotechShahkarVerifier`, `FinnotechIdentityKycProvider`, `FinnotechBankAccountOwnershipVerifier`, `NeshanGeocoder`, `S3ObjectStorage`, `ZarinPalPaymentProvider`, `HmacWebhookVerifier`, `ProviderSettlementSplitProvider`, `HttpBnplProviderBase`, `SnappPayBnplProvider`, `DigipayBnplProvider`, `ConfiguredBnplProviderResolver`, `JibitBankTransferProvider`, `MoadianClient`. Plus `CrossCutting/Seams/DisabledPaymentCaptureSimulator`; `Features/Payouts/Commands/ReconcilePayoutBatch/*`; `Features/Invoices/Commands/ReconcileMoadianInvoices/*`; `Persistence/Services/Scheduling/Jobs/MoadianReconciliationJob`; `Controllers/V1/WebhooksPayoutsController`. Changed: `SeamOptions` (+ provider selectors/creds), `AddCrossCuttingSeams` (config-selected rewrite), `DevelopmentSeamExtensions` (+ `AddDevelopmentPaymentCapture`), `Program.cs` (OTP-capture gated on mock SMS + Dev/Testing payment-capture), `AddPersistenceServices` (register `MoadianReconciliationJob`), `IInvoiceRepository`/`InvoiceRepository` (+ `GetUnregisteredMoadianInvoicesAsync`).