# 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`).
