diff --git a/dev/post-phase/server/runtime-services.md b/dev/post-phase/server/runtime-services.md
index b075ea2..fd04188 100644
--- a/dev/post-phase/server/runtime-services.md
+++ b/dev/post-phase/server/runtime-services.md
@@ -9,6 +9,13 @@ is invented; "not needed" claims are backed by the absence of the package/code.
(app DB + log DB); everything else (18 seams) is an in-process mock, so "deployment" today is one container
+ one database — and the table below is the roadmap of what must exist as each seam goes real.
+> **Refinement-phase-8 update (2026-07-13):** services **8–14** and **16** below now have a **real HTTP adapter
+> shipped behind their seam**, config-selected by a per-rail `Seams:*:Provider` selector (mock stays the default).
+> "Depends via" now points at a real client, not just a planned one — provisioning the vendor account + credential
+> and flipping the selector turns each on, no code change. What is still genuinely absent (no adapter): **Redis**
+> (5), **Elasticsearch** (17), and the LLM **review-moderation classifier** (15, optional). MoH/INO/eNamad (16)
+> stay **manual by design**. See the mocks-registry refinement-phase-8 banner for the provider tokens per rail.
+
## Service inventory
| # | Service | Purpose | Depends via (seam / config) | MVP? | Registry row |
diff --git a/dev/shared-working-context/backend/handoff/after-refinement-phase-8.md b/dev/shared-working-context/backend/handoff/after-refinement-phase-8.md
new file mode 100644
index 0000000..1e679d1
--- /dev/null
+++ b/dev/shared-working-context/backend/handoff/after-refinement-phase-8.md
@@ -0,0 +1,56 @@
+# After refinement-phase-8 — External rails go real (config-selected vendor adapters)
+
+**For the frontend / next backend phase. Backend-owned; frontend reads.**
+
+## What changed for a client
+
+**Nothing user-facing changed by default** — the mocks stay the default registration, so every existing flow
+behaves exactly as before. This phase makes each vendor rail *swappable to real by config*, not on by default.
+
+One **new endpoint** (backend-to-backend, not for the browser): `POST /api/v1/webhooks/payouts/{provider}` — the
+async PAYA/SATNA reconciliation callback (signature-authenticated, anonymous, `webhook` rate policy). It flips a
+`submitted` payout to `paid`/`failed`. No client calls it.
+
+## How a rail goes real (ops)
+
+Set the rail's **`Seams:{rail}:Provider`** + its credentials (user-secrets/env) and restart — no code change,
+no deploy of new binaries. Provider tokens (mock stays default; a typo falls closed to mock):
+
+| Rail | Key | Real value | Also needs |
+| --- | --- | --- | --- |
+| SMS (**launch-critical**) | `Seams:Sms:Provider` | `kavenegar` | `Seams:Sms:{ApiKey,SenderLine,OtpTemplate}` |
+| Shahkar / e-KYC / شبا | `Seams:{Shahkar,IdentityKyc,BankOwnership}:Provider` | `finnotech` | `Seams:Finnotech:{BaseUrl,ClientId,AccessToken}` |
+| Geocoding | `Seams:Geocoding:Provider` | `neshan` | `Seams:Geocoding:{ApiKey}` |
+| Object storage | `Seams:ObjectStorage:Provider` | `s3` | `Seams:ObjectStorage:{ServiceUrl,Bucket,Region,AccessKey,SecretKey}` |
+| Card PSP (+ HMAC webhook + تسهیم) | `Seams:Payments:Provider` | `zarinpal` | `Seams:Payments:{MerchantId,CallbackUrl,WebhookSigningSecrets}` |
+| BNPL | `Seams:Bnpl:Provider` | `real` | `Seams:Bnpl:Providers:{snapppay,digipay}:*` (creds via gateway config) |
+| Payout rail | `Seams:BankTransfer:Provider` | `jibit` | `Seams:BankTransfer:{ApiKey,SourceSettlementAccount}` + webhook secret |
+| Moadian | `Seams:Moadian:Provider` | `moadian` | `Seams:Moadian:{MemoryId,AccessToken}` + signing cert |
+
+**Two hard rules baked in:**
+- **SMS real ⇒ the OTP is never logged.** The Development OTP-in-logs bridge (`GET dev/last_otp`) runs **only while
+ the mock SMS sender is selected**. Once `Seams:Sms:Provider=kavenegar`, the code only leaves the process over the
+ SMS wire.
+- **Money callbacks fail closed.** The payout reconciliation + PSP webhook verify a per-provider HMAC over the raw
+ body; an invalid signature mutates nothing. The confirm path still re-verifies the amount server-side.
+
+## Behavioural notes the next phase should know
+
+- **Payout rail is async now (when real).** A real `JibitBankTransferProvider` accepts a transfer as `submitted`;
+ the ledger posts only when the reconciliation callback confirms `paid`. The `ExecutePayoutBatch` handler already
+ handled this (`MarkSubmitted` first, ledger on `paid`) — it was unchanged.
+- **`bookings/convert` is Dev/Testing only.** `IPaymentCaptureSimulator` is out of the production registration
+ (prod = fail-closed `DisabledPaymentCaptureSimulator`). Production converts via the b10 payment **webhook confirm**
+ calling `ConvertRequestToBooking` directly — do not build a client convert flow.
+- **`balinyaar` BNPL = in-house.** In real BNPL mode `provider_code=balinyaar` resolves to the deterministic
+ net-of-fee model (no external API); `tara`/`torobpay` are unbuilt and rejected cleanly.
+- **New cron:** `MoadianReconciliationJob` (6 h) walks `pending/submitted` invoices to `registered` — registered
+ the phase-7 way (`IRecurringJob` + one `AddSingleton`), no migration.
+
+## Follow-ups carried forward
+
+- Per-code BNPL revert (the b11 refund path uses the SnappPay default); SMS.ir/Ghasedak adapters; Finnotech/Moadian
+ token exchange + Moadian signing cert; the **refund-settlement poll** (BNPL `processing → succeeded`, pairs with
+ Moadian — the confirm command exists, the poll job is the remaining wiring); a dedicated **center-settlement
+ payout** (deferred per 6.6 — MoR centers settle via a تسهیم split leg, non-MoR have no separate money path).
+- **Redis** stays the >1-instance gate; **Elasticsearch** is never MVP.
diff --git a/dev/shared-working-context/reports/mocks-registry.md b/dev/shared-working-context/reports/mocks-registry.md
index 150f055..dbe116e 100644
--- a/dev/shared-working-context/reports/mocks-registry.md
+++ b/dev/shared-working-context/reports/mocks-registry.md
@@ -5,7 +5,34 @@ exact steps to make each one real. Backend lane owns this file; every phase that
seam updates its row. This is the checklist the team works through to go from "MVP with mocks" to
"production with real providers".
-Status legend: 🔴 not built · 🟡 mocked (seam + fake impl in place) · 🟢 real integration live.
+Status legend: 🔴 not built · 🟡 mocked (seam + fake impl in place) · 🟢 real integration live · 🟢◐ **real
+adapter shipped, config-selected (mock remains the default/fallback)** — the refinement-phase-8 state.
+
+> **Refinement-phase-8 — external rails go real (2026-07-13).** Every vendor rail below now has a **real HTTP
+> adapter behind the same seam**, config-selected via a per-rail **`Seams:*:Provider`** selector (default = the
+> mock, so an unconfigured environment is unchanged; a typo falls closed to the mock). Selecting a real provider
+> swaps the adapter by a **registration change only — no handler changed**. All adapters are built on
+> `HttpClient` + `System.Text.Json` + BCL crypto (**zero new NuGet packages**); credentials come from `Seams:*`
+> (user-secrets/env). `dotnet build` 0 new warnings · `dotnet test` **402 pass**. The rails made real, with their
+> provider token and adapter (`CrossCutting/Seams/Real/`):
+>
+> | Seam | `Seams:*:Provider` | Real adapter | Notes |
+> | --- | --- | --- | --- |
+> | `ISmsSender` | `Sms:Provider=kavenegar` | `KavenegarSmsSender` | **launch-critical.** OTP via verify/lookup template; the Dev OTP-in-logs bridge is **disabled** when a real provider is selected (OTP never logged). |
+> | `IShahkarVerifier` | `Shahkar:Provider=finnotech` | `FinnotechShahkarVerifier` | shared `Seams:Finnotech` creds; شاهکار can't distinguish shared-SIM from mismatch (reported as plain mismatch). |
+> | `IIdentityKycProvider` | `IdentityKyc:Provider=finnotech` | `FinnotechIdentityKycProvider` | nid+name inquiry; liveness extends the same adapter. |
+> | `IBankAccountOwnershipVerifier` | `BankOwnership:Provider=finnotech` | `FinnotechBankAccountOwnershipVerifier` | استعلام شبا owner↔nid; fails closed (no nid returned = no match) — it is the first-payout gate. |
+> | `IGeocoder` | `Geocoding:Provider=neshan` | `NeshanGeocoder` | outage degrades to the null-pin state, never blocks saving an address. |
+> | `IObjectStorage` | `ObjectStorage:Provider=s3` | `S3ObjectStorage` | MinIO/S3/ArvanCloud; **manual AWS SigV4** (no SDK) — presigned GET = the real b6 signed-URL contract. |
+> | `IPaymentProvider` | `Payments:Provider=zarinpal` | `ZarinPalPaymentProvider` | v4 request/verify/refund; mandatory server-side verify. |
+> | `IWebhookVerifier` | (with `Payments:Provider`) | `HmacWebhookVerifier` | per-provider HMAC over the raw body; no-secret ⇒ the server-side verify re-check is the guard. |
+> | `ISettlementSplitProvider` | (with `Payments:Provider`) | `ProviderSettlementSplitProvider` | تسهیم split-by-ratio to registered IBANs. |
+> | `IBnplProvider`/`IBnplProviderResolver` | `Bnpl:Provider=real` | `SnappPayBnplProvider` + `DigipayBnplProvider` + `ConfiguredBnplProviderResolver` | one adapter per code; **`balinyaar` = in-house (no external API), resolves to the net-of-fee model**; `tara`/`torobpay` → null (unbuilt). |
+> | `ICurrencyNormalizer` | `Currency:TomanToIrrMultiplier` | `MockCurrencyNormalizer` (config-driven = **the real impl**) | conversion only at the adapter boundary. |
+> | `IBankTransferProvider` | `BankTransfer:Provider=jibit` | `JibitBankTransferProvider` | **async rail** — accepts as `submitted`; the reconciliation callback (`POST webhooks/payouts/{provider}`, `ReconcilePayoutBatchCommand`, HMAC-verified) flips `submitted → paid/failed`. |
+> | `IMoadianClient` | `Moadian:Provider=moadian` | `MoadianClient` | submit + the `MoadianReconciliationJob` (`IRecurringJob`, 6 h) walks `pending/submitted → registered`. |
+> | `IPaymentCaptureSimulator` | — | `DisabledPaymentCaptureSimulator` (prod) / `MockPaymentCaptureSimulator` (Dev/Testing) | **6.4:** removed from prod; the `bookings/convert` path is a Dev/Testing affordance (b10's webhook confirm is the real conversion). |
+> | `ICredentialVerifier`, `ILicenseVerificationService` | — | mock (unchanged) | **5.6: manual = intended MVP** — MoH/INO/eNamad have no public B2B API; the manual admin review is the mechanism, not debt. |
| Seam (interface) | Introduced in | What it fakes | Config keys | Make it real → | Status |
| --- | --- | --- | --- | --- | --- |
diff --git a/dev/shared-working-context/reports/refinement-phase-8-report.md b/dev/shared-working-context/reports/refinement-phase-8-report.md
new file mode 100644
index 0000000..4c7de0c
--- /dev/null
+++ b/dev/shared-working-context/reports/refinement-phase-8-report.md
@@ -0,0 +1,136 @@
+# 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`).
diff --git a/server/CLAUDE.md b/server/CLAUDE.md
index f0bf68c..6093398 100644
--- a/server/CLAUDE.md
+++ b/server/CLAUDE.md
@@ -124,6 +124,30 @@ Application reference Infrastructure or the API — this is a hard rule.
real provider is a registration change — handlers depend only on the contract. Audit fields are
stamped by `AuditFieldInterceptor` (Persistence), not in handlers.
+**External rails go real — config-selected vendor adapters (refinement-phase-8).** Every vendor rail now has a
+**real HTTP adapter** in `Baya.Infrastructure.CrossCutting/Seams/Real/`, **config-selected** by a per-rail
+`Seams:*:Provider` selector in `AddCrossCuttingSeams` (default = the mock, so an unconfigured env is unchanged;
+a typo falls closed to the mock). Real adapters use `HttpClient` (typed via `IHttpClientFactory`) +
+`System.Text.Json` + BCL crypto — **no new NuGet packages**; credentials come from `Seams:*` (user-secrets/env,
+never committed). Swapping is a registration change; **no handler is touched**. The adapters:
+`KavenegarSmsSender` (`Sms:Provider=kavenegar` — **launch-critical**; when a real provider is selected the
+Development OTP-in-logs bridge is **disabled**, so the OTP is never logged), `Finnotech{Shahkar,IdentityKyc,
+BankAccountOwnership}` (`{Shahkar,IdentityKyc,BankOwnership}:Provider=finnotech`, shared `Seams:Finnotech`
+creds), `NeshanGeocoder` (`Geocoding:Provider=neshan`), `S3ObjectStorage` (`ObjectStorage:Provider=s3` — MinIO/
+S3/ArvanCloud via **manual AWS SigV4**, presigned GET = the real b6 signed-URL contract), `ZarinPalPaymentProvider`
++ `HmacWebhookVerifier` (per-provider HMAC over the raw body) + `ProviderSettlementSplitProvider`
+(`Payments:Provider=zarinpal`), `SnappPayBnplProvider`/`DigipayBnplProvider` + `ConfiguredBnplProviderResolver`
+(`Bnpl:Provider=real`; **`balinyaar` = in-house, resolves to the net-of-fee model, no external API**),
+`JibitBankTransferProvider` (`BankTransfer:Provider=jibit` — **async rail**: accepts as `submitted`, the
+reconciliation callback `POST webhooks/payouts/{provider}` → `ReconcilePayoutBatchCommand` [HMAC-verified] flips
+`submitted → paid/failed`), and `MoadianClient` (`Moadian:Provider=moadian`) with the `MoadianReconciliationJob`
+`IRecurringJob` (6 h, walks `pending/submitted → registered`). **6.4:** `IPaymentCaptureSimulator` is out of the
+production registration — prod gets the fail-closed `DisabledPaymentCaptureSimulator`; Dev/Testing re-register the
+succeeding `MockPaymentCaptureSimulator` (the `bookings/convert` path is a Dev/Testing affordance — prod converts
+via the b10 webhook confirm). **5.6:** `ICredentialVerifier`/`ILicenseVerificationService` stay mock —
+**manual MoH/INO/eNamad review is the intended MVP** (no public B2B API). `ICurrencyNormalizer` is already
+config-driven (the real impl). See the mocks-registry for the per-rail config keys.
+
**Platform-signal facades (backend-phase-1).** The cross-cutting marketplace tables live in a dedicated
**`ops` schema** (mirroring how Identity uses `usr`): `PlatformConfigs`, `AuditLogs`, `SystemEvents`,
`IranianHolidays`, `Notifications`, `SupportAlerts`. Because they are DB-backed, their Application
diff --git a/server/src/API/Baya.Web.Api/Controllers/V1/WebhooksPayoutsController.cs b/server/src/API/Baya.Web.Api/Controllers/V1/WebhooksPayoutsController.cs
new file mode 100644
index 0000000..55416eb
--- /dev/null
+++ b/server/src/API/Baya.Web.Api/Controllers/V1/WebhooksPayoutsController.cs
@@ -0,0 +1,44 @@
+using System.ComponentModel.DataAnnotations;
+using System.IO;
+using System.Linq;
+using System.Text;
+using Asp.Versioning;
+using Baya.Application.Features.Payouts.Commands.ReconcilePayoutBatch;
+using Baya.WebFramework.Attributes;
+using Baya.WebFramework.BaseController;
+using Baya.WebFramework.ServiceConfiguration;
+using Mediator;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.RateLimiting;
+
+namespace Baya.Web.Api.Controllers.V1;
+
+///
+/// The async PAYA/SATNA payout reconciliation callback (refinement-phase-8, 6.3). The real bank rail
+/// accepts a payout as submitted and calls back later with the settled outcome, flipping each payout
+/// submitted → paid/failed (the mock rail collapsed this into the submit). Authenticated by
+/// signature, not a user session, so it is anonymous to the auth pipeline; the callback is HMAC-verified and
+/// idempotent (a replayed callback re-driving an already-settled payout is a no-op). Shares the deliberate
+/// bursty-tolerant webhook rate policy with the PSP/BNPL callbacks.
+///
+[ApiVersion("1")]
+[ApiController]
+[Route("api/v{version:apiVersion}/webhooks")]
+[AllowAnonymous]
+[EnableRateLimiting(RateLimitingServiceExtension.WebhookPolicy)]
+[Display(Description = "PAYA/SATNA payout reconciliation callbacks (signature-authenticated, idempotent)")]
+public sealed class WebhooksPayoutsController(ISender sender) : BaseController
+{
+ [HttpPost("payouts/{provider}")]
+ [ProducesOkApiResponseType]
+ public async Task Payouts(string provider, CancellationToken cancellationToken)
+ {
+ using var reader = new StreamReader(Request.Body, Encoding.UTF8, leaveOpen: true);
+ var rawBody = await reader.ReadToEndAsync(cancellationToken);
+
+ var headers = Request.Headers.ToDictionary(h => h.Key, h => h.Value.ToString(), StringComparer.OrdinalIgnoreCase);
+
+ return OperationResult(await sender.Send(new ReconcilePayoutBatchCommand(provider, headers, rawBody), cancellationToken));
+ }
+}
diff --git a/server/src/API/Baya.Web.Api/Program.cs b/server/src/API/Baya.Web.Api/Program.cs
index 9f34f89..4f5b739 100644
--- a/server/src/API/Baya.Web.Api/Program.cs
+++ b/server/src/API/Baya.Web.Api/Program.cs
@@ -86,10 +86,19 @@ builder.Services.AddApplicationServices()
.AddRateLimitingPolicies();
// Development-only: capture each OTP in-memory so GET /api/v1/dev/last_otp/{phone} can complete a login
-// without an SMS gateway. Nothing here is wired in any other environment.
-if (builder.Environment.IsDevelopment())
+// without an SMS gateway. refinement-phase-8: the capture bridge runs ONLY while the log-only mock SMS sender is
+// selected — once a real gateway (Seams:Sms:Provider) ships, the OTP is delivered over the wire and never logged
+// or captured. Nothing here is wired in any other environment.
+var smsProvider = configuration["Seams:Sms:Provider"];
+var usingMockSms = string.IsNullOrWhiteSpace(smsProvider) || smsProvider.Equals("mock", StringComparison.OrdinalIgnoreCase);
+if (builder.Environment.IsDevelopment() && usingMockSms)
builder.Services.AddDevelopmentOtpCapture();
+// The IPaymentCaptureSimulator + bookings/convert path is a Development/Testing affordance (b10's real webhook
+// confirm supersedes it in production). Re-register the succeeding mock over the production fail-closed stand-in.
+if (builder.Environment.IsDevelopment() || builder.Environment.IsEnvironment("Testing"))
+ builder.Services.AddDevelopmentPaymentCapture();
+
builder.Services.RegisterValidatorsAsServices();
builder.Services.AddExceptionHandler();
diff --git a/server/src/Core/Baya.Application/Contracts/Persistence/IInvoiceRepository.cs b/server/src/Core/Baya.Application/Contracts/Persistence/IInvoiceRepository.cs
index c8a8b77..9a106ba 100644
--- a/server/src/Core/Baya.Application/Contracts/Persistence/IInvoiceRepository.cs
+++ b/server/src/Core/Baya.Application/Contracts/Persistence/IInvoiceRepository.cs
@@ -22,6 +22,11 @@ public interface IInvoiceRepository
/// Null when none is issued yet.
Task GetByBookingIdAsync(long bookingId, CancellationToken cancellationToken);
+ /// Tracked invoices still walking the مودیان state (pending/submitted), oldest first,
+ /// capped at — the refinement-phase-8 reconciliation poll re-submits each until مودیان
+ /// returns the 22-digit reference (or rejects it).
+ Task> GetUnregisteredMoadianInvoicesAsync(int max, CancellationToken cancellationToken);
+
Task AddInvoiceAsync(Invoice invoice, CancellationToken cancellationToken);
/// Reads and increments the tracked counter row and returns the reserved value. The increment is
diff --git a/server/src/Core/Baya.Application/Features/Invoices/Commands/ReconcileMoadianInvoices/ReconcileMoadianInvoicesCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Invoices/Commands/ReconcileMoadianInvoices/ReconcileMoadianInvoicesCommand.Handler.cs
new file mode 100644
index 0000000..6b3bebd
--- /dev/null
+++ b/server/src/Core/Baya.Application/Features/Invoices/Commands/ReconcileMoadianInvoices/ReconcileMoadianInvoicesCommand.Handler.cs
@@ -0,0 +1,46 @@
+#nullable enable
+using Baya.Application.Contracts.Invoices;
+using Baya.Application.Contracts.Persistence;
+using Baya.Application.Models.Common;
+using Baya.Domain.Entities.Invoices;
+using Mediator;
+
+namespace Baya.Application.Features.Invoices.Commands.ReconcileMoadianInvoices;
+
+///
+/// Loads the unregistered invoices (tracked), (re)submits each through , and applies
+/// the returned status/reference via the entity's guarded ApplyMoadianResult. One commit at the end. A
+/// transient مودیان error leaves the invoice submitted so the next tick retries — it is never marked failed
+/// on a transient fault.
+///
+internal sealed class ReconcileMoadianInvoicesCommandHandler(
+ IUnitOfWork unitOfWork,
+ IMoadianClient moadianClient)
+ : IRequestHandler>
+{
+ private const int BatchSize = 100;
+
+ public async ValueTask> Handle(
+ ReconcileMoadianInvoicesCommand request, CancellationToken cancellationToken)
+ {
+ var invoices = await unitOfWork.InvoiceRepository.GetUnregisteredMoadianInvoicesAsync(BatchSize, cancellationToken);
+
+ var registered = 0;
+ foreach (var invoice in invoices)
+ {
+ var submission = new InvoiceSubmission(
+ invoice.InvoiceNumber, invoice.BookingId, invoice.GrossIrr, invoice.PlatformCommissionIrr, invoice.VatIrr);
+
+ var result = await moadianClient.SubmitAsync(submission, cancellationToken);
+ invoice.ApplyMoadianResult(result.Status, result.ReferenceNumber);
+
+ if (result.Status == MoadianStatus.Registered)
+ registered++;
+ }
+
+ if (invoices.Count > 0)
+ await unitOfWork.CommitAsync();
+
+ return OperationResult.SuccessResult(new ReconcileMoadianResult(invoices.Count, registered));
+ }
+}
diff --git a/server/src/Core/Baya.Application/Features/Invoices/Commands/ReconcileMoadianInvoices/ReconcileMoadianInvoicesCommand.cs b/server/src/Core/Baya.Application/Features/Invoices/Commands/ReconcileMoadianInvoices/ReconcileMoadianInvoicesCommand.cs
new file mode 100644
index 0000000..6634043
--- /dev/null
+++ b/server/src/Core/Baya.Application/Features/Invoices/Commands/ReconcileMoadianInvoices/ReconcileMoadianInvoicesCommand.cs
@@ -0,0 +1,18 @@
+#nullable enable
+using Baya.Application.Models.Common;
+using Mediator;
+
+namespace Baya.Application.Features.Invoices.Commands.ReconcileMoadianInvoices;
+
+///
+/// Walks every invoice still pending/submitted with سامانه مودیان toward its registered 22-digit
+/// reference (refinement-phase-8, 6.5). Run by the Moadian reconciliation IRecurringJob (and available as an
+/// admin override); idempotent — a (re)submission of an already-registered invoice is a no-op, and the real
+/// IMoadianClient dedups on the invoice number so re-submitting a still-submitted one doubles as the
+/// status poll.
+///
+public sealed record ReconcileMoadianInvoicesCommand : IRequest>;
+
+/// How many unregistered invoices were examined this run.
+/// How many reached registered this run.
+public sealed record ReconcileMoadianResult(int Scanned, int Registered);
diff --git a/server/src/Core/Baya.Application/Features/Payouts/Commands/ReconcilePayoutBatch/ReconcilePayoutBatchCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Payouts/Commands/ReconcilePayoutBatch/ReconcilePayoutBatchCommand.Handler.cs
new file mode 100644
index 0000000..0af2570
--- /dev/null
+++ b/server/src/Core/Baya.Application/Features/Payouts/Commands/ReconcilePayoutBatch/ReconcilePayoutBatchCommand.Handler.cs
@@ -0,0 +1,106 @@
+#nullable enable
+using System.Text.Json;
+using Baya.Application.Contracts.Common;
+using Baya.Application.Contracts.Payments;
+using Baya.Application.Contracts.Persistence;
+using Baya.Application.Models.Common;
+using Baya.Domain.Entities.Payouts;
+using Mediator;
+
+namespace Baya.Application.Features.Payouts.Commands.ReconcilePayoutBatch;
+
+///
+/// Verifies the callback signature (an invalid signature mutates nothing — an irreversible-money callback
+/// fails closed), parses the per-transfer outcomes, and under lock(payout:batch) flips each matched
+/// submitted payout: paid posts the payout ledger + nets clawbacks (reusing ),
+/// failed records the reason. Matched by transfer_reference (the bank track id the submit persisted).
+/// Idempotent by the forward-only status machine + the ledger-exists guard, so a replayed callback never
+/// double-pays or double-posts.
+///
+internal sealed class ReconcilePayoutBatchCommandHandler(
+ IUnitOfWork unitOfWork,
+ IDistributedLock distributedLock,
+ IWebhookVerifier webhookVerifier,
+ IDateTimeProvider dateTimeProvider)
+ : IRequestHandler>
+{
+ public async ValueTask> Handle(ReconcilePayoutBatchCommand request, CancellationToken cancellationToken)
+ {
+ var verification = webhookVerifier.Verify(request.Provider, request.Headers, request.RawBody);
+ if (!verification.SignatureValid)
+ return OperationResult.FailureResult("signature", "Invalid payout callback signature; nothing was reconciled.");
+
+ if (!TryParse(request.RawBody, out var batchId, out var outcomes))
+ return OperationResult.FailureResult("body", "Malformed payout reconciliation callback.");
+
+ var now = dateTimeProvider.UtcNow.UtcDateTime;
+
+ await using var _ = await distributedLock.AcquireAsync("payout:batch", cancellationToken);
+
+ var batch = await unitOfWork.PayoutRepository.GetTrackedBatchAsync(batchId, cancellationToken);
+ if (batch is null)
+ return OperationResult.NotFoundResult("Payout batch not found.");
+
+ foreach (var outcome in outcomes)
+ {
+ var payout = batch.Payouts.FirstOrDefault(p =>
+ p.TransferReference is not null &&
+ string.Equals(p.TransferReference, outcome.TransferReference, StringComparison.Ordinal));
+
+ // Only a still-submitted payout is actionable; an already-paid/failed row (replay) is skipped.
+ if (payout is null || payout.Status != PayoutStatus.Submitted)
+ continue;
+
+ if (outcome.Paid)
+ {
+ payout.MarkPaid(now);
+ await PayoutSettlement.PostPayoutLedgerAsync(unitOfWork, payout, now, cancellationToken);
+ await PayoutSettlement.RecoverClawbacksAsync(unitOfWork, payout, now, cancellationToken);
+ }
+ else
+ {
+ payout.MarkFailed(outcome.FailureReason ?? "provider_declined");
+ }
+ }
+
+ batch.RecomputeSettlement(now);
+ await unitOfWork.CommitAsync();
+
+ return OperationResult.SuccessResult(true);
+ }
+
+ private static bool TryParse(string rawBody, out long batchId, out List outcomes)
+ {
+ batchId = 0;
+ outcomes = [];
+ try
+ {
+ using var doc = JsonDocument.Parse(rawBody);
+ var root = doc.RootElement;
+ if (!root.TryGetProperty("batch_id", out var batchEl) || !batchEl.TryGetInt64(out batchId))
+ return false;
+
+ if (root.TryGetProperty("transfers", out var transfers) && transfers.ValueKind == JsonValueKind.Array)
+ {
+ foreach (var t in transfers.EnumerateArray())
+ {
+ var reference = t.TryGetProperty("transfer_reference", out var r) ? r.GetString() : null;
+ if (string.IsNullOrEmpty(reference))
+ continue;
+
+ var status = t.TryGetProperty("status", out var s) ? s.GetString() : null;
+ var reason = t.TryGetProperty("failure_reason", out var fr) ? fr.GetString() : null;
+ outcomes.Add(new TransferOutcome(reference, string.Equals(status, "paid", StringComparison.OrdinalIgnoreCase), reason));
+ }
+ }
+
+ return true;
+ }
+ catch (JsonException)
+ {
+ return false;
+ }
+ }
+
+ private readonly record struct TransferOutcome(string TransferReference, bool Paid, string? FailureReason);
+}
diff --git a/server/src/Core/Baya.Application/Features/Payouts/Commands/ReconcilePayoutBatch/ReconcilePayoutBatchCommand.cs b/server/src/Core/Baya.Application/Features/Payouts/Commands/ReconcilePayoutBatch/ReconcilePayoutBatchCommand.cs
new file mode 100644
index 0000000..27e8de2
--- /dev/null
+++ b/server/src/Core/Baya.Application/Features/Payouts/Commands/ReconcilePayoutBatch/ReconcilePayoutBatchCommand.cs
@@ -0,0 +1,21 @@
+#nullable enable
+using Baya.Application.Models.Common;
+using Mediator;
+
+namespace Baya.Application.Features.Payouts.Commands.ReconcilePayoutBatch;
+
+///
+/// The async PAYA/SATNA reconciliation callback (refinement-phase-8, 6.3). A real payout rail accepts a
+/// transfer as submitted (a track id, but the money hasn't confirmed) and later calls back with the settled
+/// outcome; this command flips each submitted payout paid/failed, posting the payout ledger +
+/// netting clawbacks on a paid one (the same settlement the batch execute would have done had the mock rail
+/// collapsed the step). Authenticated by signature (not a user session) and idempotent — a replayed callback
+/// re-driving an already-paid/failed payout is a no-op.
+///
+/// The rail's provider_code (e.g. jibit) — selects the signing secret.
+/// The raw callback headers (carry the signature).
+/// The verbatim callback body (HMAC-verified, then parsed for the per-transfer outcomes).
+public sealed record ReconcilePayoutBatchCommand(
+ string Provider,
+ IReadOnlyDictionary Headers,
+ string RawBody) : IRequest>;
diff --git a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/DisabledPaymentCaptureSimulator.cs b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/DisabledPaymentCaptureSimulator.cs
new file mode 100644
index 0000000..b121fc9
--- /dev/null
+++ b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/DisabledPaymentCaptureSimulator.cs
@@ -0,0 +1,20 @@
+#nullable enable
+using Baya.Application.Contracts.Common;
+
+namespace Baya.Infrastructure.CrossCutting.Seams;
+
+///
+/// The production registration for (refinement-phase-8, 6.4). b10's real
+/// card capture superseded the simulator: in a deployed environment a booking is converted by the payment webhook
+/// confirm path calling ConvertRequestToBooking directly on a real payment_transactions.succeeded —
+/// never by fabricating a capture. So the simulator itself is a Development/Testing affordance
+/// (the bookings/convert endpoint). This production stand-in fails closed — it never fabricates a
+/// capture, so if the dev-only convert endpoint is somehow reached in production it cleanly creates no booking.
+/// The real is re-registered only under Development/Testing via
+/// AddDevelopmentPaymentCapture.
+///
+public sealed class DisabledPaymentCaptureSimulator : IPaymentCaptureSimulator
+{
+ public ValueTask ConfirmCaptureAsync(long bookingRequestId, CancellationToken cancellationToken = default)
+ => ValueTask.FromResult(new PaymentCaptureResult(false, string.Empty, null));
+}
diff --git a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/Real/ConfiguredBnplProviderResolver.cs b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/Real/ConfiguredBnplProviderResolver.cs
new file mode 100644
index 0000000..49982a4
--- /dev/null
+++ b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/Real/ConfiguredBnplProviderResolver.cs
@@ -0,0 +1,36 @@
+#nullable enable
+using Baya.Application.Contracts.Payments;
+using Baya.Domain.Entities.Bnpl;
+
+namespace Baya.Infrastructure.CrossCutting.Seams.Real;
+
+///
+/// Real (refinement-phase-8, 6.2) — maps each provider_code to its
+/// concrete adapter, config-selected, never an if (mock) in a handler. Selected by
+/// Seams:Bnpl:Provider = real.
+///
+///
+/// - snapppay → .
+/// - digipay → .
+/// - balinyaar → the in-house plan (). REQ-022 decision:
+/// Balinyaar's own installment plan has no external provider API — it is financed in-house and modelled
+/// identically to the external rails (a card payment landing net-of-fee, provider-financed). So it resolves
+/// to the deterministic net-of-fee adapter, not an HTTP call. The distinction is the financing entity, not
+/// the money mechanics.
+/// - tara / torobpay → null (no adapter built yet) so the handler rejects them cleanly
+/// rather than silently financing through the wrong provider.
+///
+///
+public sealed class ConfiguredBnplProviderResolver(
+ SnappPayBnplProvider snappPay,
+ DigipayBnplProvider digipay,
+ Seams.MockBnplProvider inHouse) : IBnplProviderResolver
+{
+ public IBnplProvider? Resolve(string providerCode) => providerCode switch
+ {
+ BnplProviderCodes.SnappPay => snappPay,
+ BnplProviderCodes.Digipay => digipay,
+ BnplProviderCodes.Balinyaar => inHouse,
+ _ => null,
+ };
+}
diff --git a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/Real/DigipayBnplProvider.cs b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/Real/DigipayBnplProvider.cs
new file mode 100644
index 0000000..89637c5
--- /dev/null
+++ b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/Real/DigipayBnplProvider.cs
@@ -0,0 +1,175 @@
+#nullable enable
+using System.Net.Http.Headers;
+using System.Net.Http.Json;
+using System.Text;
+using System.Text.Json;
+using Baya.Application.Contracts.Payments;
+using Baya.Domain.Entities.Bnpl;
+using Microsoft.Extensions.Logging;
+
+namespace Baya.Infrastructure.CrossCutting.Seams.Real;
+
+///
+/// Real for Digipay (refinement-phase-8, 6.2) over the UPG installment flow
+/// (type=13): OAuth → tickets/business (create) → purchases/verify → purchases/deliver
+/// (settle) → refunds (revert). Resolved for provider_code = digipay. Same currency boundary as the
+/// SnappPay adapter (conversion only via the base's ToWire/FromWire); the merchant commission on
+/// settle is read from the deliver response, never hardcoded. Credentials come from the encrypted
+/// payment_gateways.config_json in a full deployment; base URL + non-secret facts from
+/// Seams:Bnpl:Providers[digipay].
+///
+public sealed class DigipayBnplProvider : HttpBnplProviderBase, IBnplProvider
+{
+ private const string DefaultBaseUrl = "https://uat.mydigipay.info";
+ private readonly BnplProviderConnection _connection;
+ private readonly ILogger _logger;
+
+ private string? _token;
+ private DateTime _tokenExpiresAt;
+ private readonly SemaphoreSlim _tokenGate = new(1, 1);
+
+ public DigipayBnplProvider(
+ HttpClient httpClient,
+ ICurrencyNormalizer currency,
+ BnplProviderConnection connection,
+ string wireCurrency,
+ ILogger logger)
+ : base(httpClient, currency, wireCurrency)
+ {
+ _connection = connection;
+ _logger = logger;
+ }
+
+ private string BaseUrl => string.IsNullOrWhiteSpace(_connection.BaseUrl) ? DefaultBaseUrl : _connection.BaseUrl.TrimEnd('/');
+
+ // Digipay decides eligibility on its own hosted page; the UPG has no standalone pre-check, so the order is
+ // offered and the provider declines at the page if the customer is ineligible.
+ public ValueTask CheckEligibilityAsync(string customerMobile, long orderAmountIrr, CancellationToken cancellationToken = default)
+ => ValueTask.FromResult(new BnplEligibilityResult(
+ BnplEligibilityStatus.Eligible, InstallmentCount: 4, CreditCeilingIrr: null,
+ PlanSummary: "Interest-free installments, provider-financed (Digipay)."));
+
+ public async ValueTask CreatePaymentTokenAsync(string customerMobile, long orderAmountIrr, string idempotencyKey, CancellationToken cancellationToken = default)
+ {
+ var body = new { amount = ToWire(orderAmountIrr), cellNumber = customerMobile, providerId = idempotencyKey, callbackUrl = string.Empty };
+ using var doc = await SendAsync(HttpMethod.Post, "digipay/api/tickets/business?type=13", body, cancellationToken);
+ if (doc is null)
+ return new BnplTokenResult(PaymentProviderStatus.Failed, string.Empty, string.Empty, null);
+
+ var root = doc.RootElement;
+ var ticket = root.TryGetProperty("ticket", out var t) ? t.GetString() ?? string.Empty : string.Empty;
+ var redirect = root.TryGetProperty("redirectUrl", out var u) ? u.GetString() ?? string.Empty : string.Empty;
+
+ return string.IsNullOrEmpty(ticket)
+ ? new BnplTokenResult(PaymentProviderStatus.Failed, string.Empty, string.Empty, null)
+ : new BnplTokenResult(PaymentProviderStatus.Succeeded, ticket, redirect, idempotencyKey);
+ }
+
+ public async ValueTask VerifyAsync(string externalPaymentToken, long expectedOrderAmountIrr, CancellationToken cancellationToken = default)
+ {
+ using var doc = await SendAsync(HttpMethod.Post, "digipay/api/purchases/verify", new { trackingCode = externalPaymentToken }, cancellationToken);
+ var ok = doc?.RootElement.TryGetProperty("result", out var r) == true
+ && r.TryGetProperty("status", out var st) && st.GetInt32() == 0;
+ return new BnplVerifyResult(ok ? PaymentProviderStatus.Succeeded : PaymentProviderStatus.Failed, expectedOrderAmountIrr, externalPaymentToken);
+ }
+
+ public async ValueTask SettleAsync(string externalPaymentToken, long orderAmountIrr, string idempotencyKey, CancellationToken cancellationToken = default)
+ {
+ using var doc = await SendAsync(HttpMethod.Post, "digipay/api/purchases/deliver?type=13",
+ new { invoiceNumber = externalPaymentToken, deliveryDate = DateTime.UtcNow.ToString("yyyy-MM-dd") }, cancellationToken);
+ var ok = doc?.RootElement.TryGetProperty("result", out var r) == true
+ && r.TryGetProperty("status", out var st) && st.GetInt32() == 0;
+ if (!ok)
+ return new BnplSettleResult(PaymentProviderStatus.Failed, 0, 0, null, externalPaymentToken);
+
+ var commissionWire = doc!.RootElement.TryGetProperty("feeAmount", out var fee) && fee.TryGetInt64(out var f) ? f : 0;
+ var settledWire = doc.RootElement.TryGetProperty("settlementAmount", out var s) && s.TryGetInt64(out var sv)
+ ? sv
+ : ToWire(orderAmountIrr) - commissionWire;
+
+ return new BnplSettleResult(
+ PaymentProviderStatus.Succeeded, FromWire(settledWire), FromWire(commissionWire), DateTime.UtcNow, externalPaymentToken);
+ }
+
+ public async ValueTask GetStatusAsync(string externalPaymentToken, CancellationToken cancellationToken = default)
+ {
+ using var doc = await SendAsync(HttpMethod.Get, $"digipay/api/purchases/track?trackingCode={Uri.EscapeDataString(externalPaymentToken)}", null, cancellationToken);
+ var status = doc?.RootElement.TryGetProperty("status", out var st) == true ? st.GetString() ?? "unknown" : "unknown";
+ return new BnplStatusResult(status);
+ }
+
+ public ValueTask CancelAsync(string externalPaymentToken, string idempotencyKey, CancellationToken cancellationToken = default)
+ => RefundAsync(externalPaymentToken, null, cancellationToken);
+
+ public ValueTask RevertAsync(string providerOrderReference, long amountIrr, string idempotencyKey, CancellationToken cancellationToken = default)
+ => RefundAsync(providerOrderReference, ToWire(amountIrr), cancellationToken);
+
+ public ValueTask UpdateAsync(string providerOrderReference, long newAmountIrr, string idempotencyKey, CancellationToken cancellationToken = default)
+ => RefundAsync(providerOrderReference, ToWire(newAmountIrr), cancellationToken);
+
+ private async ValueTask RefundAsync(string reference, long? wireAmount, CancellationToken cancellationToken)
+ {
+ object body = wireAmount is null
+ ? new { trackingCode = reference }
+ : new { trackingCode = reference, amount = wireAmount.Value };
+ using var doc = await SendAsync(HttpMethod.Post, "digipay/api/refunds", body, cancellationToken);
+ var ok = doc?.RootElement.TryGetProperty("result", out var r) == true
+ && r.TryGetProperty("status", out var st) && st.GetInt32() == 0;
+ if (!ok)
+ return new BnplRevertResult(PaymentProviderStatus.Failed, null, null);
+
+ var refundRef = doc!.RootElement.TryGetProperty("refundTrackingCode", out var rt) ? rt.GetString() : null;
+ return new BnplRevertResult(PaymentProviderStatus.Succeeded, refundRef, null);
+ }
+
+ private async ValueTask SendAsync(HttpMethod method, string path, object? body, CancellationToken cancellationToken)
+ {
+ using var request = new HttpRequestMessage(method, $"{BaseUrl}/{path}");
+ if (body is not null)
+ request.Content = JsonContent.Create(body);
+ request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", await GetTokenAsync(cancellationToken));
+
+ using var response = await Http.SendAsync(request, cancellationToken);
+ var raw = await response.Content.ReadAsStringAsync(cancellationToken);
+ if (!response.IsSuccessStatusCode)
+ {
+ _logger.LogWarning("Digipay {Path} returned http {Http}", path, (int)response.StatusCode);
+ return null;
+ }
+
+ return JsonDocument.Parse(raw);
+ }
+
+ private async ValueTask GetTokenAsync(CancellationToken cancellationToken)
+ {
+ if (_token is not null && DateTime.UtcNow < _tokenExpiresAt)
+ return _token;
+
+ await _tokenGate.WaitAsync(cancellationToken);
+ try
+ {
+ if (_token is not null && DateTime.UtcNow < _tokenExpiresAt)
+ return _token;
+
+ using var request = new HttpRequestMessage(HttpMethod.Post, $"{BaseUrl}/digipay/api/oauth/token")
+ {
+ Content = new FormUrlEncodedContent(new Dictionary { ["grant_type"] = "client_credentials" }),
+ };
+ // Digipay authenticates the token call with HTTP Basic (merchant client id:secret from the gateway config).
+ request.Headers.Authorization = new AuthenticationHeaderValue("Basic",
+ Convert.ToBase64String(Encoding.UTF8.GetBytes($"{_connection.MerchantId}:")));
+
+ using var response = await Http.SendAsync(request, cancellationToken);
+ response.EnsureSuccessStatusCode();
+ using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync(cancellationToken));
+ _token = doc.RootElement.GetProperty("access_token").GetString();
+ var ttl = doc.RootElement.TryGetProperty("expires_in", out var exp) && exp.TryGetInt32(out var seconds) ? seconds : 3600;
+ _tokenExpiresAt = DateTime.UtcNow.AddSeconds(Math.Max(60, ttl - 60));
+ return _token!;
+ }
+ finally
+ {
+ _tokenGate.Release();
+ }
+ }
+}
diff --git a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/Real/FinnotechBankAccountOwnershipVerifier.cs b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/Real/FinnotechBankAccountOwnershipVerifier.cs
new file mode 100644
index 0000000..e025ab2
--- /dev/null
+++ b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/Real/FinnotechBankAccountOwnershipVerifier.cs
@@ -0,0 +1,48 @@
+#nullable enable
+using System.Text.Json;
+using Baya.Application.Contracts.Common;
+
+namespace Baya.Infrastructure.CrossCutting.Seams.Real;
+
+///
+/// Real over the Finnotech استعلام شبا owner ↔ national-id inquiry
+/// (refinement-phase-8, 5.3 — the b13 first-payout money-mule gate). Selected by
+/// Seams:BankOwnership:Provider = finnotech. Resolves the IBAN's registered owner and matches it against
+/// the nurse's national id; persists the vendor track id.
+///
+public sealed class FinnotechBankAccountOwnershipVerifier(FinnotechClient client) : IBankAccountOwnershipVerifier
+{
+ public async Task VerifyOwnershipAsync(string iban, string? nurseNationalId, CancellationToken cancellationToken = default)
+ {
+ var trackId = FinnotechClient.NewTrackId();
+ var vendorRef = $"FINNOTECH-SHEBA-{trackId}";
+ var path = $"oak/v2/clients/{client.ClientId}/ibanToNid" +
+ $"?iban={Uri.EscapeDataString(Normalize(iban))}&trackId={trackId}";
+
+ var (document, _) = await client.GetAsync(path, cancellationToken);
+ using var _doc = document;
+
+ var root = document.RootElement;
+ string ownerName = string.Empty;
+ string? ownerNid = null;
+
+ if (root.TryGetProperty("result", out var result))
+ {
+ if (result.TryGetProperty("name", out var name) && name.ValueKind == JsonValueKind.String)
+ ownerName = name.GetString() ?? string.Empty;
+ if (result.TryGetProperty("nationalCode", out var nid) && nid.ValueKind == JsonValueKind.String)
+ ownerNid = nid.GetString();
+ }
+
+ // Ownership matches when the account's registered national code equals the nurse's. If the vendor did not
+ // return a national code we cannot assert a match — fail closed (matched = false), since this is the gate.
+ var matched = !string.IsNullOrEmpty(ownerNid)
+ && !string.IsNullOrEmpty(nurseNationalId)
+ && string.Equals(ownerNid, nurseNationalId, StringComparison.Ordinal);
+
+ return new OwnershipInquiryResult(matched, ownerName, vendorRef);
+ }
+
+ private static string Normalize(string iban)
+ => string.IsNullOrEmpty(iban) ? string.Empty : iban.Replace(" ", string.Empty).ToUpperInvariant();
+}
diff --git a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/Real/FinnotechClient.cs b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/Real/FinnotechClient.cs
new file mode 100644
index 0000000..6603aed
--- /dev/null
+++ b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/Real/FinnotechClient.cs
@@ -0,0 +1,39 @@
+#nullable enable
+using System.Net.Http.Headers;
+using System.Text.Json;
+using Microsoft.Extensions.Options;
+
+namespace Baya.Infrastructure.CrossCutting.Seams.Real;
+
+///
+/// Thin shared client for the Finnotech-class KYC bridge that fronts three trust rails (شاهکار / e-KYC /
+/// استعلام شبا). Owns the base address, the bearer-token authentication, and a per-call trackId — the
+/// three seam adapters differ only in the resource path + response mapping. Credentials come from
+/// Seams:Finnotech; a deployment supplies a current access token (the client-credential token exchange is
+/// a deploy-time concern, out of the MVP adapter's scope).
+///
+public sealed class FinnotechClient(HttpClient httpClient, IOptions options)
+{
+ private const string DefaultBaseUrl = "https://apibeta.finnotech.ir";
+ private readonly FinnotechOptions _options = options.Value.Finnotech;
+
+ public string ClientId => _options.ClientId;
+
+ /// A fresh, unique inquiry track id (Finnotech requires one per call; kept for audit).
+ public static string NewTrackId() => Guid.NewGuid().ToString("N");
+
+ /// Issues an authenticated GET and returns the parsed body + the raw JSON (persisted as
+ /// external_response_json). The base URL defaults to Finnotech's host when unset.
+ public async Task<(JsonDocument Document, string Raw)> GetAsync(string relativePath, CancellationToken cancellationToken)
+ {
+ var baseUrl = string.IsNullOrWhiteSpace(_options.BaseUrl) ? DefaultBaseUrl : _options.BaseUrl.TrimEnd('/');
+ using var request = new HttpRequestMessage(HttpMethod.Get, $"{baseUrl}/{relativePath.TrimStart('/')}");
+ request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _options.AccessToken);
+
+ using var response = await httpClient.SendAsync(request, cancellationToken);
+ var raw = await response.Content.ReadAsStringAsync(cancellationToken);
+ response.EnsureSuccessStatusCode();
+
+ return (JsonDocument.Parse(raw), raw);
+ }
+}
diff --git a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/Real/FinnotechIdentityKycProvider.cs b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/Real/FinnotechIdentityKycProvider.cs
new file mode 100644
index 0000000..92612a7
--- /dev/null
+++ b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/Real/FinnotechIdentityKycProvider.cs
@@ -0,0 +1,45 @@
+#nullable enable
+using System.Text.Json;
+using Baya.Application.Contracts.Common;
+
+namespace Baya.Infrastructure.CrossCutting.Seams.Real;
+
+///
+/// Real over the Finnotech civil-registry (ثبت احوال) inquiry
+/// (refinement-phase-8, 5.2). Selected by Seams:IdentityKyc:Provider = finnotech. Verifies the national
+/// id is valid and returns the civil-registry full name used for the later credential cross-check.
+///
+/// Liveness scope. This MVP adapter performs the national-id + name inquiry (the deterministic KYC
+/// gate). Photo/video liveness is a distinct vendor product; it is added by extending this same adapter to also
+/// submit the to the liveness endpoint and folding its verdict into
+/// — the seam contract does not change.
+///
+public sealed class FinnotechIdentityKycProvider(FinnotechClient client) : IIdentityKycProvider
+{
+ public async Task VerifyAsync(string nationalId, string? livenessPayload, CancellationToken cancellationToken = default)
+ {
+ var trackId = FinnotechClient.NewTrackId();
+ var vendorRef = $"FINNOTECH-KYC-{trackId}";
+ var path = $"kyc/v2/clients/{client.ClientId}/nidVerification" +
+ $"?nationalCode={Uri.EscapeDataString(nationalId)}&trackId={trackId}";
+
+ var (document, raw) = await client.GetAsync(path, cancellationToken);
+ using var _ = document;
+
+ var root = document.RootElement;
+ var passed = root.TryGetProperty("result", out var result)
+ && result.TryGetProperty("nidVerified", out var verified)
+ && verified.ValueKind == JsonValueKind.True;
+
+ string? matchedName = null;
+ if (passed && result.TryGetProperty("fullName", out var fullName) && fullName.ValueKind == JsonValueKind.String)
+ matchedName = fullName.GetString();
+
+ return new IdentityKycResult(
+ Passed: passed,
+ MatchedName: matchedName,
+ VendorRef: vendorRef,
+ ExternalResponseJson: raw,
+ FailureReason: passed ? null : "Identity could not be verified against the civil registry.");
+ }
+}
diff --git a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/Real/FinnotechShahkarVerifier.cs b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/Real/FinnotechShahkarVerifier.cs
new file mode 100644
index 0000000..add195d
--- /dev/null
+++ b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/Real/FinnotechShahkarVerifier.cs
@@ -0,0 +1,42 @@
+#nullable enable
+using Baya.Application.Contracts.Common;
+
+namespace Baya.Infrastructure.CrossCutting.Seams.Real;
+
+///
+/// Real over the Finnotech شاهکار phone↔national-id inquiry (refinement-phase-8,
+/// 5.2). Selected by Seams:Shahkar:Provider = finnotech. Maps the vendor's boolean match to
+/// and persists the raw response for audit.
+///
+/// Shared-SIM is a handled state, not derivable from Shahkar alone. The شاهکار registry only asserts
+/// whether the SIM is bound to the national id — it does not distinguish "owned by a family member" from a plain
+/// mismatch. So a real no-match is reported as a plain mismatch (IsSharedSim = false) with a non-accusatory
+/// reason; the explicit shared-SIM branch stays reachable through the mock (and any future vendor that surfaces
+/// the ownership relationship). The handler's downstream behaviour is unchanged either way.
+///
+public sealed class FinnotechShahkarVerifier(FinnotechClient client) : IShahkarVerifier
+{
+ public async Task MatchAsync(string? phoneNumber, string? nationalId, CancellationToken cancellationToken = default)
+ {
+ var trackId = FinnotechClient.NewTrackId();
+ var vendorRef = $"FINNOTECH-SHAHKAR-{trackId}";
+ var path = $"mpg/v2/clients/{client.ClientId}/shahkar/verify" +
+ $"?mobile={Uri.EscapeDataString(phoneNumber ?? string.Empty)}" +
+ $"&nationalCode={Uri.EscapeDataString(nationalId ?? string.Empty)}" +
+ $"&trackId={trackId}";
+
+ var (document, raw) = await client.GetAsync(path, cancellationToken);
+ using var _ = document;
+
+ var matched = document.RootElement.TryGetProperty("result", out var result)
+ && result.TryGetProperty("isValid", out var isValid)
+ && isValid.ValueKind == System.Text.Json.JsonValueKind.True;
+
+ return new ShahkarMatchResult(
+ Matched: matched,
+ IsSharedSim: false,
+ VendorRef: vendorRef,
+ ExternalResponseJson: raw,
+ FailureReason: matched ? null : "The phone number is not registered to your national ID.");
+ }
+}
diff --git a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/Real/HmacWebhookVerifier.cs b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/Real/HmacWebhookVerifier.cs
new file mode 100644
index 0000000..f83380d
--- /dev/null
+++ b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/Real/HmacWebhookVerifier.cs
@@ -0,0 +1,71 @@
+#nullable enable
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using Baya.Application.Contracts.Payments;
+using Microsoft.Extensions.Options;
+
+namespace Baya.Infrastructure.CrossCutting.Seams.Real;
+
+///
+/// Real (refinement-phase-8, 6.1). Verifies an inbound PSP/BNPL callback's
+/// authenticity by an HMAC-SHA256 over the raw body against the per-provider signing secret
+/// (Seams:Payments:WebhookSigningSecrets[{provider}]), read from the Seams:Payments:SignatureHeader
+/// header. Selected by Seams:Payments:Provider together with the PSP + settlement adapters. An invalid
+/// signature yields SignatureValid = false so the handler stores the event ignored and no money moves.
+///
+/// Signatureless fallback. When no signing secret is configured for a provider, the signature can't
+/// be checked here — the guard is the handler's mandatory server-side verify re-check on every success
+/// event (IPaymentProvider.VerifyAsync), exactly as the contract prescribes. The event-id/type/reference
+/// extraction is unchanged from the mock, so HandlePaymentWebhook's upsert-first/no-op-on-duplicate
+/// ordering is untouched.
+///
+public sealed class HmacWebhookVerifier(IOptions options) : IWebhookVerifier
+{
+ private readonly PaymentsOptions _options = options.Value.Payments;
+
+ public WebhookVerification Verify(string provider, IReadOnlyDictionary headers, string rawBody)
+ {
+ var signatureValid = VerifySignature(provider, headers, rawBody);
+
+ string externalEventId = string.Empty;
+ string eventType = string.Empty;
+ string? gatewayReferenceCode = null;
+
+ try
+ {
+ using var doc = JsonDocument.Parse(rawBody);
+ var root = doc.RootElement;
+ if (root.TryGetProperty("external_event_id", out var id))
+ externalEventId = id.GetString() ?? string.Empty;
+ if (root.TryGetProperty("event_type", out var type))
+ eventType = type.GetString() ?? string.Empty;
+ if (root.TryGetProperty("gateway_reference_code", out var reference))
+ gatewayReferenceCode = reference.GetString();
+ }
+ catch (JsonException)
+ {
+ // Unparseable body → empty id/type; the handler stores it and no-ops (nothing to confirm).
+ }
+
+ var isSuccessEvent = eventType.Contains("succeed", StringComparison.OrdinalIgnoreCase);
+ return new WebhookVerification(signatureValid, externalEventId, eventType, gatewayReferenceCode, isSuccessEvent);
+ }
+
+ private bool VerifySignature(string provider, IReadOnlyDictionary headers, string rawBody)
+ {
+ if (!_options.WebhookSigningSecrets.TryGetValue(provider, out var secret) || string.IsNullOrEmpty(secret))
+ return true; // no secret configured → the server-side verify re-check is the guard (contract fallback).
+
+ if (!headers.TryGetValue(_options.SignatureHeader, out var provided) || string.IsNullOrEmpty(provided))
+ return false; // a secret is configured but the callback carried no signature → reject.
+
+ var computed = Convert.ToHexStringLower(
+ HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), Encoding.UTF8.GetBytes(rawBody)));
+
+ // Constant-time compare; tolerate a provider that prefixes the scheme (e.g. "sha256=...").
+ var candidate = provided.Contains('=') ? provided[(provided.IndexOf('=') + 1)..] : provided;
+ return CryptographicOperations.FixedTimeEquals(
+ Encoding.UTF8.GetBytes(computed), Encoding.UTF8.GetBytes(candidate.ToLowerInvariant()));
+ }
+}
diff --git a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/Real/HttpBnplProviderBase.cs b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/Real/HttpBnplProviderBase.cs
new file mode 100644
index 0000000..89825a1
--- /dev/null
+++ b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/Real/HttpBnplProviderBase.cs
@@ -0,0 +1,27 @@
+#nullable enable
+using Baya.Application.Contracts.Payments;
+
+namespace Baya.Infrastructure.CrossCutting.Seams.Real;
+
+///
+/// Shared base for the real HTTP BNPL adapters (refinement-phase-8, 6.2). Owns the currency boundary — the
+/// one place Toman↔IRR conversion is allowed — via : every amount leaving the
+/// domain is turned into the provider's wire currency by , and every amount coming back is
+/// normalized to IRR by . Conversion happens only here, never internally. Concrete
+/// adapters (, ) implement the provider's verb
+/// set; a real deployment reads the provider's wire currency from Seams:Bnpl:WireCurrency (SnappPay/Digipay
+/// settle in Rial, so the default is a pass-through).
+///
+public abstract class HttpBnplProviderBase(HttpClient httpClient, ICurrencyNormalizer currency, string wireCurrency)
+{
+ protected HttpClient Http { get; } = httpClient;
+
+ /// Domain IRR → the provider's wire amount (Rial pass-through, or Toman when the provider speaks Toman).
+ protected long ToWire(long amountIrr)
+ => string.Equals(wireCurrency, "TOMAN", StringComparison.OrdinalIgnoreCase)
+ ? currency.ToDisplayToman(amountIrr)
+ : amountIrr;
+
+ /// A provider wire amount → domain IRR (the only inbound conversion point).
+ protected long FromWire(long wireAmount) => currency.ToIrr(wireAmount, wireCurrency);
+}
diff --git a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/Real/JibitBankTransferProvider.cs b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/Real/JibitBankTransferProvider.cs
new file mode 100644
index 0000000..0f441fc
--- /dev/null
+++ b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/Real/JibitBankTransferProvider.cs
@@ -0,0 +1,129 @@
+#nullable enable
+using System.Net.Http.Headers;
+using System.Net.Http.Json;
+using System.Text.Json;
+using Baya.Application.Contracts.Payments;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+
+namespace Baya.Infrastructure.CrossCutting.Seams.Real;
+
+///
+/// Real over the Jibit PAYA/SATNA payout rail (refinement-phase-8, 6.3).
+/// Selected by Seams:BankTransfer:Provider = jibit. Registers each payout as a transfer from the platform's
+/// registered source settlement account to the nurse's verified Sheba, honouring the PAYA/SATNA
+/// the handler chose by the config threshold. The real rail is async:
+/// an accepted transfer comes back (a track id is issued but the money
+/// hasn't confirmed) — the execute handler already leaves such a payout submitted and posts no ledger until
+/// the async reconciliation callback (POST webhooks/payouts/jibit) flips it paid/failed.
+///
+/// The idempotencyKey (payout-batch:{id}) is passed as the client reference so a retried submit
+/// never re-sends an already-accepted transfer. Every amount is IRR long. Credentials from
+/// Seams:BankTransfer; the exact transfer payload is confirmed against the transferor at integration.
+///
+public sealed class JibitBankTransferProvider(
+ HttpClient httpClient,
+ IOptions options,
+ ILogger logger) : IBankTransferProvider
+{
+ private const string DefaultBaseUrl = "https://napi.jibit.ir/trf";
+ private readonly BankTransferOptions _options = options.Value.BankTransfer;
+
+ private string BaseUrl => string.IsNullOrWhiteSpace(_options.BaseUrl) ? DefaultBaseUrl : _options.BaseUrl.TrimEnd('/');
+
+ public async ValueTask SubmitPayoutBatchAsync(
+ long payoutBatchId,
+ IReadOnlyList instructions,
+ string idempotencyKey,
+ CancellationToken cancellationToken = default)
+ {
+ var payload = new
+ {
+ batchID = idempotencyKey,
+ submissionMode = "TRANSFER",
+ transfers = instructions.Select(i => new
+ {
+ transferID = $"{idempotencyKey}:{i.PayoutId}",
+ destination = i.Iban,
+ amount = i.AmountIrr,
+ currency = "IRR",
+ transferMode = MapMode(i.Method),
+ sourceIdentifier = _options.SourceSettlementAccount,
+ }),
+ };
+
+ using var request = new HttpRequestMessage(HttpMethod.Post, $"{BaseUrl}/v1/transfers")
+ {
+ Content = JsonContent.Create(payload),
+ };
+ request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _options.ApiKey);
+
+ using var response = await httpClient.SendAsync(request, cancellationToken);
+ var raw = await response.Content.ReadAsStringAsync(cancellationToken);
+
+ if (!response.IsSuccessStatusCode)
+ {
+ logger.LogWarning("Jibit payout submit returned http {Http} for batch {BatchId}", (int)response.StatusCode, payoutBatchId);
+ // Whole-batch rejection — every instruction fails so the batch reports failed/retryable.
+ return new PayoutBatchSubmitResult(
+ ExternalBatchRef: idempotencyKey,
+ Results: instructions
+ .Select(i => new PayoutInstructionResult(i.PayoutId, BankTransferStatus.Failed, null, i.Method, "provider_rejected_batch"))
+ .ToList());
+ }
+
+ using var doc = JsonDocument.Parse(raw);
+ var perTransfer = ReadTransferStates(doc);
+
+ var results = instructions.Select(i =>
+ {
+ var transferId = $"{idempotencyKey}:{i.PayoutId}";
+ var (accepted, track) = perTransfer.GetValueOrDefault(transferId, (true, transferId));
+ return accepted
+ ? new PayoutInstructionResult(i.PayoutId, BankTransferStatus.Submitted, track, i.Method, null)
+ : new PayoutInstructionResult(i.PayoutId, BankTransferStatus.Failed, null, i.Method, "provider_declined");
+ }).ToList();
+
+ return new PayoutBatchSubmitResult(idempotencyKey, results);
+ }
+
+ public async ValueTask GetPayoutStatusAsync(string externalBatchRef, CancellationToken cancellationToken = default)
+ {
+ using var request = new HttpRequestMessage(HttpMethod.Get, $"{BaseUrl}/v1/transfers?batchID={Uri.EscapeDataString(externalBatchRef)}");
+ request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _options.ApiKey);
+
+ using var response = await httpClient.SendAsync(request, cancellationToken);
+ if (!response.IsSuccessStatusCode)
+ return BankTransferStatus.Submitted; // unknown yet — stay submitted, the callback is authoritative.
+
+ using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync(cancellationToken));
+ return MapState(doc.RootElement.TryGetProperty("state", out var s) ? s.GetString() : null);
+ }
+
+ private static Dictionary ReadTransferStates(JsonDocument doc)
+ {
+ var map = new Dictionary(StringComparer.Ordinal);
+ if (doc.RootElement.TryGetProperty("transfers", out var transfers) && transfers.ValueKind == JsonValueKind.Array)
+ {
+ foreach (var t in transfers.EnumerateArray())
+ {
+ var id = t.TryGetProperty("transferID", out var tid) ? tid.GetString() : null;
+ if (id is null) continue;
+ var accepted = MapState(t.TryGetProperty("state", out var st) ? st.GetString() : null) != BankTransferStatus.Failed;
+ var track = t.TryGetProperty("bankTransferID", out var bt) ? bt.GetString() : id;
+ map[id] = (accepted, track);
+ }
+ }
+ return map;
+ }
+
+ private static BankTransferStatus MapState(string? state) => state?.ToUpperInvariant() switch
+ {
+ "TRANSFERRED" or "SETTLED" or "PAID" => BankTransferStatus.Paid,
+ "FAILED" or "CANCELLED" or "REJECTED" => BankTransferStatus.Failed,
+ _ => BankTransferStatus.Submitted,
+ };
+
+ private static string MapMode(string method)
+ => string.Equals(method, BankTransferMethod.Satna, StringComparison.OrdinalIgnoreCase) ? "SATNA" : "ACH";
+}
diff --git a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/Real/KavenegarSmsSender.cs b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/Real/KavenegarSmsSender.cs
new file mode 100644
index 0000000..c8d3df0
--- /dev/null
+++ b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/Real/KavenegarSmsSender.cs
@@ -0,0 +1,102 @@
+#nullable enable
+using System.Net;
+using System.Text.Json;
+using Baya.Application.Contracts.Common;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+
+namespace Baya.Infrastructure.CrossCutting.Seams.Real;
+
+///
+/// Real over the Kavenegar Iranian SMS gateway (refinement-phase-8, 5.1 —
+/// launch-critical). OTP delivery uses Kavenegar's verify/lookup pattern API (a pre-approved template,
+/// no marketing pre-clearance needed for transactional OTPs); free-form transactional messages use
+/// sms/send from the registered sender line. Selected by Seams:Sms:Provider = kavenegar; the seam
+/// is swapped by a registration change only, so no handler is touched.
+///
+/// The OTP is never logged. Once this ships the Development OTP-in-logs/echo bridge is disabled
+/// (the code only leaves the process over the SMS wire) — only the phone tail + gateway status are logged.
+///
+public sealed class KavenegarSmsSender(
+ HttpClient httpClient,
+ IOptions options,
+ ILogger logger) : ISmsSender
+{
+ private readonly SmsOptions _options = options.Value.Sms;
+
+ public async Task SendOtpAsync(string phone, string code, CancellationToken cancellationToken = default)
+ {
+ // verify/lookup: the code is delivered through the approved OTP template — never a free-form message,
+ // which is what keeps transactional OTPs deliverable without marketing pre-clearance.
+ var query = new Dictionary
+ {
+ ["receptor"] = phone,
+ ["token"] = code,
+ ["template"] = _options.OtpTemplate,
+ };
+
+ await SendAsync($"v1/{_options.ApiKey}/verify/lookup.json", query, phone, cancellationToken);
+ }
+
+ public async Task SendAsync(string phone, string message, CancellationToken cancellationToken = default)
+ {
+ var query = new Dictionary
+ {
+ ["receptor"] = phone,
+ ["sender"] = _options.SenderLine,
+ ["message"] = message,
+ };
+
+ await SendAsync($"v1/{_options.ApiKey}/sms/send.json", query, phone, cancellationToken);
+ }
+
+ private async Task SendAsync(
+ string path, IReadOnlyDictionary query, string phone, CancellationToken cancellationToken)
+ {
+ var url = QueryHelpers(path, query);
+
+ using var response = await httpClient.GetAsync(url, cancellationToken);
+ var body = await response.Content.ReadAsStringAsync(cancellationToken);
+
+ // Kavenegar always returns 200 for a well-formed request and carries the real outcome in `return.status`
+ // (200 = accepted). A non-accepted status (e.g. 411 invalid receptor, 418 credit) is a delivery failure —
+ // surfaced as an exception so the OTP command reports the send failure rather than silently "succeeding".
+ var status = TryReadReturnStatus(body);
+ if (response.StatusCode != HttpStatusCode.OK || status is not 200)
+ {
+ logger.LogWarning(
+ "Kavenegar SMS delivery failed for phone ending {PhoneTail} — http {Http}, return status {ReturnStatus}",
+ Tail(phone), (int)response.StatusCode, status);
+ throw new InvalidOperationException($"Kavenegar SMS delivery failed (return status {status}).");
+ }
+
+ logger.LogInformation("Kavenegar SMS accepted for phone ending {PhoneTail}", Tail(phone));
+ }
+
+ private static int? TryReadReturnStatus(string body)
+ {
+ try
+ {
+ using var doc = JsonDocument.Parse(body);
+ return doc.RootElement.TryGetProperty("return", out var ret)
+ && ret.TryGetProperty("status", out var status)
+ ? status.GetInt32()
+ : null;
+ }
+ catch (JsonException)
+ {
+ return null;
+ }
+ }
+
+ private static string QueryHelpers(string path, IReadOnlyDictionary query)
+ {
+ var pairs = query
+ .Where(kvp => kvp.Value is not null)
+ .Select(kvp => $"{Uri.EscapeDataString(kvp.Key)}={Uri.EscapeDataString(kvp.Value!)}");
+ return $"{path}?{string.Join('&', pairs)}";
+ }
+
+ private static string Tail(string phone) =>
+ string.IsNullOrEmpty(phone) ? "????" : phone[^Math.Min(4, phone.Length)..];
+}
diff --git a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/Real/MoadianClient.cs b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/Real/MoadianClient.cs
new file mode 100644
index 0000000..fbc2edf
--- /dev/null
+++ b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/Real/MoadianClient.cs
@@ -0,0 +1,86 @@
+#nullable enable
+using System.Net.Http.Headers;
+using System.Net.Http.Json;
+using System.Text.Json;
+using Baya.Application.Contracts.Invoices;
+using Baya.Domain.Entities.Invoices;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+
+namespace Baya.Infrastructure.CrossCutting.Seams.Real;
+
+///
+/// Real over سامانه مودیان (refinement-phase-8, 6.5). Selected by
+/// Seams:Moadian:Provider = moadian. Submits the commission invoice (صورتحساب) to the tax-authority API and
+/// maps the outcome: a returned 22-digit reference ⇒ ; an accepted-but-not-
+/// yet-registered submission ⇒ (the reconciliation poll walks it to
+/// registered); a rejection ⇒ .
+///
+/// Idempotent submit. The invoice number is sent as the unique document id, so مودیان dedups a
+/// re-submission — which is what lets the reconciliation job safely re-call this for a still-submitted
+/// invoice (the seam has one verb; a re-submit doubles as the status poll). Enrollment + the signing
+/// certificate (memory/economic id, تعهدنامه) are a deploy-time concern — a deployment supplies the memory id
+/// + a current token via Seams:Moadian; signing the payload with the platform's private key is added inside
+/// this adapter at integration without changing the seam.
+///
+public sealed class MoadianClient(
+ HttpClient httpClient,
+ IOptions options,
+ ILogger logger) : IMoadianClient
+{
+ private const string DefaultBaseUrl = "https://tp.tax.gov.ir";
+ private readonly MoadianOptions _options = options.Value.Moadian;
+
+ private string BaseUrl => string.IsNullOrWhiteSpace(_options.BaseUrl) ? DefaultBaseUrl : _options.BaseUrl.TrimEnd('/');
+
+ public async ValueTask SubmitAsync(InvoiceSubmission submission, CancellationToken cancellationToken = default)
+ {
+ var payload = new
+ {
+ memoryId = _options.MemoryId,
+ invoiceNumber = submission.InvoiceNumber,
+ bookingId = submission.BookingId,
+ totalAmount = submission.GrossIrr,
+ commissionAmount = submission.PlatformCommissionIrr,
+ vatAmount = submission.VatIrr,
+ };
+
+ using var request = new HttpRequestMessage(HttpMethod.Post, $"{BaseUrl}/req/api/self-tsp/sync/normal-enveloped")
+ {
+ Content = JsonContent.Create(payload),
+ };
+ request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _options.AccessToken);
+
+ try
+ {
+ using var response = await httpClient.SendAsync(request, cancellationToken);
+ var raw = await response.Content.ReadAsStringAsync(cancellationToken);
+
+ if (!response.IsSuccessStatusCode)
+ {
+ logger.LogWarning("مودیان submission returned http {Http} for invoice {InvoiceNumber}",
+ (int)response.StatusCode, submission.InvoiceNumber);
+ return new MoadianSubmissionResult(MoadianStatus.Failed, null);
+ }
+
+ using var doc = JsonDocument.Parse(raw);
+ var root = doc.RootElement;
+
+ // A returned reference number registers the invoice; otherwise it was accepted and is awaiting the ref.
+ var reference = root.TryGetProperty("referenceNumber", out var refEl) ? refEl.GetString()
+ : root.TryGetProperty("uid", out var uid) ? uid.GetString()
+ : null;
+
+ return string.IsNullOrEmpty(reference)
+ ? new MoadianSubmissionResult(MoadianStatus.Submitted, null)
+ : new MoadianSubmissionResult(MoadianStatus.Registered, reference);
+ }
+ catch (Exception ex) when (ex is HttpRequestException or JsonException or TaskCanceledException)
+ {
+ // A مودیان outage leaves the invoice submittable again on the next reconciliation tick — never failed
+ // permanently on a transient error.
+ logger.LogWarning(ex, "مودیان submission failed transiently for invoice {InvoiceNumber}", submission.InvoiceNumber);
+ return new MoadianSubmissionResult(MoadianStatus.Submitted, null);
+ }
+ }
+}
diff --git a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/Real/NeshanGeocoder.cs b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/Real/NeshanGeocoder.cs
new file mode 100644
index 0000000..b788754
--- /dev/null
+++ b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/Real/NeshanGeocoder.cs
@@ -0,0 +1,87 @@
+#nullable enable
+using System.Globalization;
+using System.Text.Json;
+using Baya.Application.Contracts.Common;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+
+namespace Baya.Infrastructure.CrossCutting.Seams.Real;
+
+///
+/// Real over the Neshan geocoding API (refinement-phase-8, 5.4) — turns a typed
+/// address into coordinates for the b9 EVV distance check. Selected by Seams:Geocoding:Provider = neshan.
+/// Coordinates are parsed to (never float) so the downstream haversine stays exact. An
+/// address Neshan can't resolve yields null coordinates (the "saved without a map pin" state) rather than an
+/// error — the address is still saved. REQ-008's user-supplied pin reduces, but doesn't remove, the need for this.
+///
+public sealed class NeshanGeocoder(
+ HttpClient httpClient,
+ IOptions options,
+ ILogger logger) : IGeocoder
+{
+ private const string DefaultBaseUrl = "https://api.neshan.org";
+ private readonly GeocodingOptions _options = options.Value.Geocoding;
+
+ public async ValueTask GeocodeAsync(
+ string addressText,
+ string cityName,
+ string? districtName,
+ CancellationToken cancellationToken = default)
+ {
+ var formatted = FormatAddress(addressText, cityName, districtName);
+ var baseUrl = string.IsNullOrWhiteSpace(_options.BaseUrl) ? DefaultBaseUrl : _options.BaseUrl.TrimEnd('/');
+
+ try
+ {
+ using var request = new HttpRequestMessage(HttpMethod.Get,
+ $"{baseUrl}/v1/geocoding?address={Uri.EscapeDataString(formatted)}");
+ request.Headers.Add("Api-Key", _options.ApiKey);
+
+ using var response = await httpClient.SendAsync(request, cancellationToken);
+ var raw = await response.Content.ReadAsStringAsync(cancellationToken);
+ response.EnsureSuccessStatusCode();
+
+ using var doc = JsonDocument.Parse(raw);
+ var root = doc.RootElement;
+
+ var ok = root.TryGetProperty("status", out var status)
+ && string.Equals(status.GetString(), "OK", StringComparison.OrdinalIgnoreCase);
+
+ if (ok && root.TryGetProperty("location", out var location)
+ && location.TryGetProperty("y", out var y)
+ && location.TryGetProperty("x", out var x)
+ && TryDecimal(y, out var lat) && TryDecimal(x, out var lng))
+ {
+ // Neshan: x = longitude, y = latitude.
+ return new GeocodeResult(decimal.Round(lat, 6), decimal.Round(lng, 6), formatted, _options.ResolvedConfidence);
+ }
+
+ logger.LogWarning("Neshan could not resolve an address to coordinates (status {Status})",
+ ok ? "OK-no-location" : "not-OK");
+ }
+ catch (Exception ex) when (ex is HttpRequestException or JsonException or TaskCanceledException)
+ {
+ // A geocoder outage must never block saving an address — degrade to the null-pin state.
+ logger.LogWarning(ex, "Neshan geocoding failed; saving the address without a map pin");
+ }
+
+ return new GeocodeResult(null, null, formatted, 0.2);
+ }
+
+ private static bool TryDecimal(JsonElement element, out decimal value)
+ {
+ if (element.ValueKind == JsonValueKind.Number && element.TryGetDecimal(out value))
+ return true;
+
+ if (element.ValueKind == JsonValueKind.String
+ && decimal.TryParse(element.GetString(), NumberStyles.Float, CultureInfo.InvariantCulture, out value))
+ return true;
+
+ value = 0m;
+ return false;
+ }
+
+ private static string FormatAddress(string addressText, string cityName, string? districtName) =>
+ string.Join("، ", new[] { cityName, districtName, addressText }
+ .Where(part => !string.IsNullOrWhiteSpace(part)));
+}
diff --git a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/Real/ProviderSettlementSplitProvider.cs b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/Real/ProviderSettlementSplitProvider.cs
new file mode 100644
index 0000000..62656a6
--- /dev/null
+++ b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/Real/ProviderSettlementSplitProvider.cs
@@ -0,0 +1,78 @@
+#nullable enable
+using System.Net.Http.Json;
+using System.Text.Json;
+using Baya.Application.Contracts.Payments;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+
+namespace Baya.Infrastructure.CrossCutting.Seams.Real;
+
+///
+/// Real — the تسهیم (settlement-sharing) rail (refinement-phase-8, 6.1).
+/// Selected by Seams:Payments:Provider together with the PSP + webhook adapters. Registers a split-by-ratio
+/// to the beneficiaries' registered IBANs (nurse payout + platform commission); the acquirer credits each
+/// IBAN directly — the platform never custodies funds, the ledger only mirrors what legally sits at the
+/// provider/bank. Amounts are IRR long.
+///
+/// The exact تسهیم endpoint/payload is acquirer-specific (ZarinPal/Zibal/Vandar each differ) and honours the
+/// ~100,000 IRR per-leg minimum; the request shape here is the common split-list form. A deployment confirms the
+/// concrete route + credential source (the platform SHEBA from Seams:Payments:PlatformSheba, each nurse
+/// SHEBA from the b3 matched_national_id-gated bank account) at integration.
+///
+public sealed class ProviderSettlementSplitProvider(
+ HttpClient httpClient,
+ IOptions options,
+ ILogger logger) : ISettlementSplitProvider
+{
+ private readonly PaymentsOptions _options = options.Value.Payments;
+
+ private string BaseUrl => _options.BaseUrl.TrimEnd('/');
+
+ public async ValueTask RegisterSplitAsync(long bookingId, IReadOnlyList legs, CancellationToken cancellationToken = default)
+ {
+ if (legs.Count == 0 || legs.Sum(l => l.AmountIrr) <= 0)
+ return new SettlementResult(SettlementStatus.Failed);
+
+ var payload = new
+ {
+ booking_id = bookingId,
+ wages = legs.Select(l => new { iban = l.Sheba, amount = l.AmountIrr, description = l.Beneficiary }),
+ };
+
+ try
+ {
+ using var response = await httpClient.PostAsJsonAsync($"{BaseUrl}/settlement/split", payload, cancellationToken);
+ response.EnsureSuccessStatusCode();
+ return new SettlementResult(SettlementStatus.Registered);
+ }
+ catch (HttpRequestException ex)
+ {
+ logger.LogWarning(ex, "تسهیم split registration failed for booking {BookingId}", bookingId);
+ return new SettlementResult(SettlementStatus.Failed);
+ }
+ }
+
+ public async ValueTask GetSplitStatusAsync(long bookingId, CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ using var response = await httpClient.GetAsync($"{BaseUrl}/settlement/split/{bookingId}", cancellationToken);
+ response.EnsureSuccessStatusCode();
+ var raw = await response.Content.ReadAsStringAsync(cancellationToken);
+
+ using var doc = JsonDocument.Parse(raw);
+ var status = doc.RootElement.TryGetProperty("status", out var s) ? s.GetString() : null;
+ return new SettlementResult(status?.ToLowerInvariant() switch
+ {
+ "settled" or "done" or "paid" => SettlementStatus.Settled,
+ "failed" or "rejected" => SettlementStatus.Failed,
+ _ => SettlementStatus.Registered,
+ });
+ }
+ catch (HttpRequestException ex)
+ {
+ logger.LogWarning(ex, "تسهیم split status read failed for booking {BookingId}", bookingId);
+ return new SettlementResult(SettlementStatus.Registered);
+ }
+ }
+}
diff --git a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/Real/S3ObjectStorage.cs b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/Real/S3ObjectStorage.cs
new file mode 100644
index 0000000..09b229f
--- /dev/null
+++ b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/Real/S3ObjectStorage.cs
@@ -0,0 +1,190 @@
+#nullable enable
+using System.Security.Cryptography;
+using System.Text;
+using Baya.Application.Contracts.Common;
+using Microsoft.Extensions.Options;
+
+namespace Baya.Infrastructure.CrossCutting.Seams.Real;
+
+///
+/// Real over an S3-compatible store — MinIO / AWS S3 / ArvanCloud
+/// (refinement-phase-8, 5.5). Selected by Seams:ObjectStorage:Provider = s3. Makes the b6 signed-URL
+/// document contract and the REQ-006 avatar path real (local disk + file:// today). Server-side
+/// put/get/delete are AWS-SigV4-authenticated requests; returns a time-limited
+/// presigned GET so a document is fetched directly from storage without proxying bytes through the API.
+///
+/// Signing is implemented directly against SigV4 (HMAC-SHA256, all in the BCL) so no AWS SDK dependency is
+/// pulled in. Uploads use UNSIGNED-PAYLOAD so a blob stream is never buffered to hash it.
+///
+public sealed class S3ObjectStorage : IObjectStorage
+{
+ private const string Algorithm = "AWS4-HMAC-SHA256";
+ private const string UnsignedPayload = "UNSIGNED-PAYLOAD";
+ private const string Service = "s3";
+
+ private readonly HttpClient _httpClient;
+ private readonly ObjectStorageOptions _options;
+ private readonly Uri _serviceUri;
+
+ public S3ObjectStorage(HttpClient httpClient, IOptions options)
+ {
+ _httpClient = httpClient;
+ _options = options.Value.ObjectStorage;
+ _serviceUri = new Uri(_options.ServiceUrl.TrimEnd('/'), UriKind.Absolute);
+ }
+
+ public async ValueTask PutAsync(string key, Stream content, string contentType, CancellationToken cancellationToken = default)
+ {
+ var (uri, host, canonicalUri) = ResolveObject(key);
+ using var request = new HttpRequestMessage(HttpMethod.Put, uri)
+ {
+ Content = new StreamContent(content),
+ };
+ request.Content.Headers.TryAddWithoutValidation("Content-Type", contentType);
+ SignRequest(request, "PUT", host, canonicalUri);
+
+ using var response = await _httpClient.SendAsync(request, cancellationToken);
+ response.EnsureSuccessStatusCode();
+ }
+
+ public async ValueTask GetAsync(string key, CancellationToken cancellationToken = default)
+ {
+ var (uri, host, canonicalUri) = ResolveObject(key);
+ using var request = new HttpRequestMessage(HttpMethod.Get, uri);
+ SignRequest(request, "GET", host, canonicalUri);
+
+ var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
+ if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
+ {
+ response.Dispose();
+ return null;
+ }
+
+ response.EnsureSuccessStatusCode();
+ return await response.Content.ReadAsStreamAsync(cancellationToken);
+ }
+
+ public async ValueTask DeleteAsync(string key, CancellationToken cancellationToken = default)
+ {
+ var (uri, host, canonicalUri) = ResolveObject(key);
+ using var request = new HttpRequestMessage(HttpMethod.Delete, uri);
+ SignRequest(request, "DELETE", host, canonicalUri);
+
+ using var response = await _httpClient.SendAsync(request, cancellationToken);
+ if (response.StatusCode != System.Net.HttpStatusCode.NotFound)
+ response.EnsureSuccessStatusCode();
+ }
+
+ /// A presigned GET URL valid for PresignExpirySeconds — the real form of the b6 signed-URL contract.
+ public string GetUrl(string key) => Presign(key, TimeSpan.FromSeconds(_options.PresignExpirySeconds));
+
+ // ---- URL / object resolution -------------------------------------------------------------------------
+
+ private (Uri Uri, string Host, string CanonicalUri) ResolveObject(string key)
+ {
+ var encodedKey = EncodeKey(key);
+ if (_options.UsePathStyle)
+ {
+ var host = _serviceUri.Authority;
+ var canonicalUri = $"/{_options.Bucket}/{encodedKey}";
+ return (new Uri($"{_serviceUri.Scheme}://{host}{canonicalUri}"), host, canonicalUri);
+ }
+ else
+ {
+ var host = $"{_options.Bucket}.{_serviceUri.Authority}";
+ var canonicalUri = $"/{encodedKey}";
+ return (new Uri($"{_serviceUri.Scheme}://{host}{canonicalUri}"), host, canonicalUri);
+ }
+ }
+
+ // ---- SigV4: authenticated request (header auth) ------------------------------------------------------
+
+ private void SignRequest(HttpRequestMessage request, string method, string host, string canonicalUri)
+ {
+ var now = DateTime.UtcNow;
+ var amzDate = now.ToString("yyyyMMddTHHmmssZ");
+ var dateStamp = now.ToString("yyyyMMdd");
+ var scope = $"{dateStamp}/{_options.Region}/{Service}/aws4_request";
+
+ var canonicalHeaders = $"host:{host}\nx-amz-content-sha256:{UnsignedPayload}\nx-amz-date:{amzDate}\n";
+ const string signedHeaders = "host;x-amz-content-sha256;x-amz-date";
+
+ var canonicalRequest = string.Join('\n',
+ method, canonicalUri, string.Empty, canonicalHeaders, signedHeaders, UnsignedPayload);
+ var stringToSign = string.Join('\n', Algorithm, amzDate, scope, Hex(Sha256(canonicalRequest)));
+ var signature = Hex(Hmac(SigningKey(dateStamp), stringToSign));
+
+ request.Headers.TryAddWithoutValidation("x-amz-date", amzDate);
+ request.Headers.TryAddWithoutValidation("x-amz-content-sha256", UnsignedPayload);
+ request.Headers.TryAddWithoutValidation("Authorization",
+ $"{Algorithm} Credential={_options.AccessKey}/{scope}, SignedHeaders={signedHeaders}, Signature={signature}");
+ }
+
+ // ---- SigV4: presigned URL (query auth) ---------------------------------------------------------------
+
+ private string Presign(string key, TimeSpan expiry)
+ {
+ var (uri, host, canonicalUri) = ResolveObject(key);
+ var now = DateTime.UtcNow;
+ var amzDate = now.ToString("yyyyMMddTHHmmssZ");
+ var dateStamp = now.ToString("yyyyMMdd");
+ var scope = $"{dateStamp}/{_options.Region}/{Service}/aws4_request";
+
+ // Query keys must be sorted; each value URL-encoded (RFC3986). host is the only signed header.
+ var query = new SortedDictionary(StringComparer.Ordinal)
+ {
+ ["X-Amz-Algorithm"] = Algorithm,
+ ["X-Amz-Credential"] = $"{_options.AccessKey}/{scope}",
+ ["X-Amz-Date"] = amzDate,
+ ["X-Amz-Expires"] = ((int)expiry.TotalSeconds).ToString(),
+ ["X-Amz-SignedHeaders"] = "host",
+ };
+ var canonicalQuery = string.Join('&', query.Select(kvp => $"{RfcEncode(kvp.Key)}={RfcEncode(kvp.Value)}"));
+
+ var canonicalHeaders = $"host:{host}\n";
+ var canonicalRequest = string.Join('\n',
+ "GET", canonicalUri, canonicalQuery, canonicalHeaders, "host", UnsignedPayload);
+ var stringToSign = string.Join('\n', Algorithm, amzDate, scope, Hex(Sha256(canonicalRequest)));
+ var signature = Hex(Hmac(SigningKey(dateStamp), stringToSign));
+
+ return $"{uri.Scheme}://{host}{canonicalUri}?{canonicalQuery}&X-Amz-Signature={signature}";
+ }
+
+ // ---- crypto primitives -------------------------------------------------------------------------------
+
+ private byte[] SigningKey(string dateStamp)
+ {
+ var kDate = Hmac(Encoding.UTF8.GetBytes($"AWS4{_options.SecretKey}"), dateStamp);
+ var kRegion = Hmac(kDate, _options.Region);
+ var kService = Hmac(kRegion, Service);
+ return Hmac(kService, "aws4_request");
+ }
+
+ private static byte[] Hmac(byte[] key, string data) => HMACSHA256.HashData(key, Encoding.UTF8.GetBytes(data));
+ private static byte[] Sha256(string data) => SHA256.HashData(Encoding.UTF8.GetBytes(data));
+ private static string Hex(byte[] bytes) => Convert.ToHexStringLower(bytes);
+
+ // Encode an object key preserving '/' as a path separator (each segment RFC3986-encoded).
+ private static string EncodeKey(string key)
+ {
+ var normalized = key.Replace('\\', '/').TrimStart('/');
+ return string.Join('/', normalized
+ .Split('/', StringSplitOptions.RemoveEmptyEntries)
+ .Where(s => s != "." && s != "..")
+ .Select(RfcEncode));
+ }
+
+ // RFC 3986 unreserved set — AWS canonicalization encodes everything else.
+ private static string RfcEncode(string value)
+ {
+ const string unreserved = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.~";
+ var sb = new StringBuilder(value.Length);
+ foreach (var b in Encoding.UTF8.GetBytes(value))
+ {
+ var c = (char)b;
+ if (unreserved.IndexOf(c) >= 0) sb.Append(c);
+ else sb.Append('%').Append(b.ToString("X2"));
+ }
+ return sb.ToString();
+ }
+}
diff --git a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/Real/SnappPayBnplProvider.cs b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/Real/SnappPayBnplProvider.cs
new file mode 100644
index 0000000..4196567
--- /dev/null
+++ b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/Real/SnappPayBnplProvider.cs
@@ -0,0 +1,201 @@
+#nullable enable
+using System.Net.Http.Headers;
+using System.Net.Http.Json;
+using System.Text.Json;
+using Baya.Application.Contracts.Payments;
+using Baya.Domain.Entities.Bnpl;
+using Microsoft.Extensions.Logging;
+
+namespace Baya.Infrastructure.CrossCutting.Seams.Real;
+
+///
+/// Real for SnappPay (refinement-phase-8, 6.2) — the canonical superset the
+/// seam was designed around: OAuth token → offer/eligible → payment/token|verify|settle|revert|
+/// cancel|update|status. Resolved for provider_code = snapppay by .
+/// Amounts cross the wire in the provider's currency (converted only at this boundary via the base's
+/// ToWire/FromWire); the settle reads the merchant commission from the actual response, never
+/// hardcoded, and the settled_at is whatever the provider reports (nullable — never assumed instant).
+/// Money always flows customer ↔ provider ↔ Balinyaar.
+///
+/// Warn: this is the Iranian SnappPay provider-financed BNPL, not the unrelated Canadian
+/// SnapPayInc/open-api-java-sdk. Client id/secret come from the encrypted payment_gateways.config_json
+/// in a full deployment; the base URL + non-secret facts are in Seams:Bnpl:Providers[snapppay].
+///
+public sealed class SnappPayBnplProvider : HttpBnplProviderBase, IBnplProvider
+{
+ private const string DefaultBaseUrl = "https://fms-gateway-staging.apps.public.teh-1.snappcloud.io";
+ private readonly BnplProviderConnection _connection;
+ private readonly ILogger _logger;
+
+ private string? _token;
+ private DateTime _tokenExpiresAt;
+ private readonly SemaphoreSlim _tokenGate = new(1, 1);
+
+ public SnappPayBnplProvider(
+ HttpClient httpClient,
+ ICurrencyNormalizer currency,
+ BnplProviderConnection connection,
+ string wireCurrency,
+ ILogger logger)
+ : base(httpClient, currency, wireCurrency)
+ {
+ _connection = connection;
+ _logger = logger;
+ }
+
+ private string BaseUrl => string.IsNullOrWhiteSpace(_connection.BaseUrl) ? DefaultBaseUrl : _connection.BaseUrl.TrimEnd('/');
+
+ public async ValueTask CheckEligibilityAsync(string customerMobile, long orderAmountIrr, CancellationToken cancellationToken = default)
+ {
+ var body = new { amount = ToWire(orderAmountIrr), mobile = customerMobile };
+ using var doc = await PostAsync("api/online/offer/v1/eligible", body, cancellationToken);
+
+ var eligible = doc?.RootElement.TryGetProperty("successful", out var s) == true && s.ValueKind == JsonValueKind.True;
+ long? ceiling = null;
+ if (doc?.RootElement.TryGetProperty("response", out var resp) == true
+ && resp.TryGetProperty("titleAmount", out var ceilingEl) && ceilingEl.TryGetInt64(out var c))
+ ceiling = FromWire(c);
+
+ return new BnplEligibilityResult(
+ eligible ? BnplEligibilityStatus.Eligible : BnplEligibilityStatus.NotEligible,
+ InstallmentCount: 4, CreditCeilingIrr: ceiling,
+ PlanSummary: "4 interest-free installments, provider-financed (SnappPay).");
+ }
+
+ public async ValueTask CreatePaymentTokenAsync(string customerMobile, long orderAmountIrr, string idempotencyKey, CancellationToken cancellationToken = default)
+ {
+ var body = new
+ {
+ amount = ToWire(orderAmountIrr),
+ mobile = customerMobile,
+ paymentMethodTypeDto = "INSTALLMENT",
+ transactionId = idempotencyKey,
+ returnURL = string.Empty,
+ };
+ using var doc = await PostAsync("api/online/payment/v1/token", body, cancellationToken);
+
+ var response = doc?.RootElement.TryGetProperty("response", out var r) == true ? r : default;
+ var token = response.ValueKind == JsonValueKind.Object && response.TryGetProperty("paymentToken", out var t)
+ ? t.GetString() ?? string.Empty
+ : string.Empty;
+ var redirect = response.ValueKind == JsonValueKind.Object && response.TryGetProperty("paymentPageUrl", out var u)
+ ? u.GetString() ?? string.Empty
+ : string.Empty;
+
+ return string.IsNullOrEmpty(token)
+ ? new BnplTokenResult(PaymentProviderStatus.Failed, string.Empty, string.Empty, null)
+ : new BnplTokenResult(PaymentProviderStatus.Succeeded, token, redirect, idempotencyKey);
+ }
+
+ public async ValueTask VerifyAsync(string externalPaymentToken, long expectedOrderAmountIrr, CancellationToken cancellationToken = default)
+ {
+ using var doc = await PostAsync("api/online/payment/v1/verify", new { paymentToken = externalPaymentToken }, cancellationToken);
+ var ok = doc?.RootElement.TryGetProperty("successful", out var s) == true && s.ValueKind == JsonValueKind.True;
+ return new BnplVerifyResult(ok ? PaymentProviderStatus.Succeeded : PaymentProviderStatus.Failed, expectedOrderAmountIrr, externalPaymentToken);
+ }
+
+ public async ValueTask SettleAsync(string externalPaymentToken, long orderAmountIrr, string idempotencyKey, CancellationToken cancellationToken = default)
+ {
+ using var doc = await PostAsync("api/online/payment/v1/settle", new { paymentToken = externalPaymentToken }, cancellationToken);
+ var ok = doc?.RootElement.TryGetProperty("successful", out var s) == true && s.ValueKind == JsonValueKind.True;
+ if (!ok)
+ return new BnplSettleResult(PaymentProviderStatus.Failed, 0, 0, null, externalPaymentToken);
+
+ var response = doc!.RootElement.GetProperty("response");
+ // The merchant discount is read from the settlement response — never hardcoded.
+ var commissionWire = response.TryGetProperty("feeAmount", out var fee) && fee.TryGetInt64(out var f) ? f : 0;
+ var settledWire = response.TryGetProperty("settleAmount", out var st) && st.TryGetInt64(out var stv)
+ ? stv
+ : ToWire(orderAmountIrr) - commissionWire;
+
+ return new BnplSettleResult(
+ PaymentProviderStatus.Succeeded,
+ SettledAmountIrr: FromWire(settledWire),
+ BnplCommissionIrr: FromWire(commissionWire),
+ SettledAt: DateTime.UtcNow,
+ ExternalTransactionId: externalPaymentToken);
+ }
+
+ public async ValueTask GetStatusAsync(string externalPaymentToken, CancellationToken cancellationToken = default)
+ {
+ using var doc = await PostAsync("api/online/payment/v1/status", new { paymentToken = externalPaymentToken }, cancellationToken);
+ var status = doc?.RootElement.TryGetProperty("response", out var r) == true && r.TryGetProperty("status", out var st)
+ ? st.GetString() ?? "unknown"
+ : "unknown";
+ return new BnplStatusResult(status);
+ }
+
+ public ValueTask CancelAsync(string externalPaymentToken, string idempotencyKey, CancellationToken cancellationToken = default)
+ => ReverseAsync("api/online/payment/v1/cancel", new { paymentToken = externalPaymentToken }, cancellationToken);
+
+ public ValueTask RevertAsync(string providerOrderReference, long amountIrr, string idempotencyKey, CancellationToken cancellationToken = default)
+ => ReverseAsync("api/online/payment/v1/revert", new { paymentToken = providerOrderReference }, cancellationToken);
+
+ public ValueTask UpdateAsync(string providerOrderReference, long newAmountIrr, string idempotencyKey, CancellationToken cancellationToken = default)
+ => ReverseAsync("api/online/payment/v1/update", new { paymentToken = providerOrderReference, amount = ToWire(newAmountIrr) }, cancellationToken);
+
+ private async ValueTask ReverseAsync(string path, object body, CancellationToken cancellationToken)
+ {
+ using var doc = await PostAsync(path, body, cancellationToken);
+ var ok = doc?.RootElement.TryGetProperty("successful", out var s) == true && s.ValueKind == JsonValueKind.True;
+ if (!ok)
+ return new BnplRevertResult(PaymentProviderStatus.Failed, null, null);
+
+ long? reversedCommission = null;
+ if (doc!.RootElement.TryGetProperty("response", out var r)
+ && r.TryGetProperty("reversedFeeAmount", out var rf) && rf.TryGetInt64(out var rfv))
+ reversedCommission = FromWire(rfv);
+
+ var reference = doc.RootElement.TryGetProperty("response", out var rr) && rr.TryGetProperty("trackingCode", out var tc)
+ ? tc.GetString()
+ : null;
+
+ return new BnplRevertResult(PaymentProviderStatus.Succeeded, reference, reversedCommission);
+ }
+
+ private async ValueTask PostAsync(string path, object body, CancellationToken cancellationToken)
+ {
+ using var request = new HttpRequestMessage(HttpMethod.Post, $"{BaseUrl}/{path}")
+ {
+ Content = JsonContent.Create(body),
+ };
+ request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", await GetTokenAsync(cancellationToken));
+
+ using var response = await Http.SendAsync(request, cancellationToken);
+ var raw = await response.Content.ReadAsStringAsync(cancellationToken);
+ if (!response.IsSuccessStatusCode)
+ {
+ _logger.LogWarning("SnappPay {Path} returned http {Http}", path, (int)response.StatusCode);
+ return null;
+ }
+
+ return JsonDocument.Parse(raw);
+ }
+
+ private async ValueTask GetTokenAsync(CancellationToken cancellationToken)
+ {
+ if (_token is not null && DateTime.UtcNow < _tokenExpiresAt)
+ return _token;
+
+ await _tokenGate.WaitAsync(cancellationToken);
+ try
+ {
+ if (_token is not null && DateTime.UtcNow < _tokenExpiresAt)
+ return _token;
+
+ using var response = await Http.PostAsJsonAsync($"{BaseUrl}/api/online/v1/oauth/token",
+ new { grant_type = "client_credentials", scope = "online-merchant" }, cancellationToken);
+ response.EnsureSuccessStatusCode();
+ using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync(cancellationToken));
+ var root = doc.RootElement;
+ _token = root.GetProperty("access_token").GetString();
+ var ttl = root.TryGetProperty("expires_in", out var exp) && exp.TryGetInt32(out var seconds) ? seconds : 3600;
+ _tokenExpiresAt = DateTime.UtcNow.AddSeconds(Math.Max(60, ttl - 60));
+ return _token!;
+ }
+ finally
+ {
+ _tokenGate.Release();
+ }
+ }
+}
diff --git a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/Real/ZarinPalPaymentProvider.cs b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/Real/ZarinPalPaymentProvider.cs
new file mode 100644
index 0000000..3722ef8
--- /dev/null
+++ b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/Real/ZarinPalPaymentProvider.cs
@@ -0,0 +1,107 @@
+#nullable enable
+using System.Net.Http.Json;
+using System.Text.Json;
+using Baya.Application.Contracts.Payments;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+
+namespace Baya.Infrastructure.CrossCutting.Seams.Real;
+
+///
+/// Real over the ZarinPal IPG (refinement-phase-8, 6.1). Selected by
+/// Seams:Payments:Provider = zarinpal. Opens a v4 payment session (returns the Shaparak-routed redirect +
+/// the authority as the gateway reference), performs the mandatory server-side verify (never trusts
+/// a callback alone — the amount is re-checked against the gateway), and reverses a captured payment.
+///
+/// Every amount crossing this seam is IRR long; ZarinPal v4 amounts are Rial, so no conversion
+/// happens here. The merchant id defaults from Seams:Payments:MerchantId; a full multi-gateway deployment
+/// resolves it per gateway row from the encrypted payment_gateways.config_json via a provider factory —
+/// the seam contract is identical either way.
+///
+public sealed class ZarinPalPaymentProvider(
+ HttpClient httpClient,
+ IOptions options,
+ ILogger logger) : IPaymentProvider
+{
+ private const string DefaultBaseUrl = "https://payment.zarinpal.com";
+ private readonly PaymentsOptions _options = options.Value.Payments;
+
+ private string BaseUrl => string.IsNullOrWhiteSpace(_options.BaseUrl) ? DefaultBaseUrl : _options.BaseUrl.TrimEnd('/');
+
+ public async ValueTask InitPaymentAsync(long bookingRequestId, long amountIrr, string idempotencyKey, CancellationToken cancellationToken = default)
+ {
+ var payload = new
+ {
+ merchant_id = _options.MerchantId,
+ amount = amountIrr,
+ callback_url = _options.CallbackUrl,
+ description = $"Balinyaar booking request {bookingRequestId}",
+ metadata = new { idempotency_key = idempotencyKey },
+ };
+
+ using var response = await httpClient.PostAsJsonAsync($"{BaseUrl}/pg/v4/payment/request.json", payload, cancellationToken);
+ var raw = await response.Content.ReadAsStringAsync(cancellationToken);
+ response.EnsureSuccessStatusCode();
+
+ using var doc = JsonDocument.Parse(raw);
+ var authority = doc.RootElement.TryGetProperty("data", out var data)
+ && data.TryGetProperty("authority", out var auth)
+ ? auth.GetString()
+ : null;
+
+ if (string.IsNullOrEmpty(authority))
+ throw new InvalidOperationException("ZarinPal did not return a payment authority.");
+
+ return new PaymentInitResult(
+ RedirectUrl: $"{BaseUrl}/pg/StartPay/{authority}",
+ GatewayReferenceCode: authority);
+ }
+
+ public async ValueTask VerifyAsync(string gatewayReferenceCode, long expectedAmountIrr, CancellationToken cancellationToken = default)
+ {
+ var payload = new { merchant_id = _options.MerchantId, amount = expectedAmountIrr, authority = gatewayReferenceCode };
+
+ using var response = await httpClient.PostAsJsonAsync($"{BaseUrl}/pg/v4/payment/verify.json", payload, cancellationToken);
+ var raw = await response.Content.ReadAsStringAsync(cancellationToken);
+
+ if (!response.IsSuccessStatusCode)
+ {
+ logger.LogWarning("ZarinPal verify returned http {Http} for authority {Authority}", (int)response.StatusCode, gatewayReferenceCode);
+ return new PaymentVerifyResult(PaymentProviderStatus.Failed, 0);
+ }
+
+ using var doc = JsonDocument.Parse(raw);
+ // v4 verify: data.code 100 = paid, 101 = already verified (both confirm). Anything else is a non-confirm.
+ var code = doc.RootElement.TryGetProperty("data", out var data) && data.TryGetProperty("code", out var c)
+ ? c.GetInt32()
+ : -1;
+
+ return code is 100 or 101
+ ? new PaymentVerifyResult(PaymentProviderStatus.Succeeded, expectedAmountIrr)
+ : new PaymentVerifyResult(PaymentProviderStatus.Failed, 0);
+ }
+
+ public async ValueTask RefundAsync(string gatewayReferenceCode, long amountIrr, string idempotencyKey, CancellationToken cancellationToken = default)
+ {
+ // ZarinPal reversals go through the authorized refund API; the idempotency key is carried so a retried
+ // refund is a no-op at the gateway rather than a double reversal.
+ var payload = new { merchant_id = _options.MerchantId, authority = gatewayReferenceCode, amount = amountIrr, idempotency_key = idempotencyKey };
+
+ using var response = await httpClient.PostAsJsonAsync($"{BaseUrl}/pg/v4/payment/refund.json", payload, cancellationToken);
+ var raw = await response.Content.ReadAsStringAsync(cancellationToken);
+
+ if (!response.IsSuccessStatusCode)
+ {
+ logger.LogWarning("ZarinPal refund returned http {Http} for authority {Authority}", (int)response.StatusCode, gatewayReferenceCode);
+ return new PaymentRefundResult(PaymentProviderStatus.Failed, null);
+ }
+
+ using var doc = JsonDocument.Parse(raw);
+ string? refundRef = doc.RootElement.TryGetProperty("data", out var data)
+ && data.TryGetProperty("ref_id", out var refId)
+ ? refId.ToString()
+ : null;
+
+ return new PaymentRefundResult(PaymentProviderStatus.Succeeded, refundRef);
+ }
+}
diff --git a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/SeamOptions.cs b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/SeamOptions.cs
index 54cfc70..2119b32 100644
--- a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/SeamOptions.cs
+++ b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/SeamOptions.cs
@@ -3,12 +3,19 @@ namespace Baya.Infrastructure.CrossCutting.Seams;
///
/// Options bound from the Seams configuration section. The mock seams read non-secret defaults
/// from here; production keys/paths come from environment variables or user-secrets, never committed.
+///
+/// Provider selection (refinement-phase-8). Each vendor rail carries a Provider selector
+/// (default = the mock, so an unconfigured environment behaves exactly as before). Setting it to a real
+/// provider token (e.g. Seams:Sms:Provider = kavenegar) swaps in the real HTTP adapter behind the same
+/// contract — handlers never change. This makes a partial rollout the normal case: real SMS + real
+/// geocoder while payments stay mocked in a pre-launch environment is just three config keys.
///
public sealed class SeamOptions
{
public const string SectionName = "Seams";
public FieldEncryptionOptions FieldEncryption { get; set; } = new();
+ public SmsOptions Sms { get; set; } = new();
public ObjectStorageOptions ObjectStorage { get; set; } = new();
public BankOwnershipOptions BankOwnership { get; set; } = new();
public GeocodingOptions Geocoding { get; set; } = new();
@@ -22,6 +29,85 @@ public sealed class SeamOptions
public BankTransferOptions BankTransfer { get; set; } = new();
public ReviewModerationOptions ReviewModeration { get; set; } = new();
public LicenseVerificationOptions LicenseVerification { get; set; } = new();
+ public FinnotechOptions Finnotech { get; set; } = new();
+}
+
+///
+/// Shared credentials for the Finnotech-class KYC bridge that fronts three trust rails — شاهکار
+/// (IShahkarVerifier), e-KYC (IIdentityKycProvider), and استعلام شبا
+/// (IBankAccountOwnershipVerifier). Each seam opts in with its own Provider = finnotech selector,
+/// but they authenticate against the same tenant, so the connection facts live here once. All values are
+/// secrets — user-secrets / environment, never committed.
+///
+public sealed class FinnotechOptions
+{
+ /// API host (defaults to Finnotech's public sandbox/production host at the adapter).
+ public string BaseUrl { get; set; } = string.Empty;
+
+ /// The tenant's client id (NID) — the Finnotech app identifier.
+ public string ClientId { get; set; } = string.Empty;
+
+ /// A pre-issued bearer access token (client-credential token exchange is out of scope for the MVP
+ /// adapter; a deployment supplies a current token, refreshed out-of-band).
+ public string AccessToken { get; set; } = string.Empty;
+}
+
+/// Stable provider tokens for the Provider selectors, so a typo fails closed to the mock.
+public static class SeamProviders
+{
+ public const string Mock = "mock";
+ public const string LocalDisk = "local";
+
+ // SMS gateways
+ public const string Kavenegar = "kavenegar";
+ public const string SmsIr = "smsir";
+ public const string Ghasedak = "ghasedak";
+
+ // Object storage
+ public const string S3 = "s3";
+
+ // Trust / identity (a Finnotech-class KYC bridge fronts Shahkar / e-KYC / استعلام شبا)
+ public const string Finnotech = "finnotech";
+
+ // Geocoding
+ public const string Neshan = "neshan";
+
+ // Card PSP acquirers
+ public const string ZarinPal = "zarinpal";
+ public const string Sadad = "sadad";
+ public const string Vandar = "vandar";
+ public const string Jibit = "jibit";
+
+ // BNPL
+ public const string SnappPay = "snapppay";
+ public const string Digipay = "digipay";
+
+ // e-invoicing
+ public const string Moadian = "moadian";
+}
+
+///
+/// The outbound SMS rail (ISmsSender). = mock logs the OTP (the b2
+/// LoggingSmsSender); set it to kavenegar / smsir / ghasedak to deliver over a real
+/// Iranian gateway. refinement-phase-8: when a real provider is selected the Development OTP-in-logs/echo
+/// bridge is disabled — the OTP must never be logged once real SMS ships.
+///
+public sealed class SmsOptions
+{
+ /// mock (default) | kavenegar | smsir | ghasedak.
+ public string Provider { get; set; } = SeamProviders.Mock;
+
+ /// Gateway API key / token (secret — user-secrets or environment, never committed).
+ public string ApiKey { get; set; } = string.Empty;
+
+ /// The registered sender line (used by SendAsync free-form messages and non-template sends).
+ public string SenderLine { get; set; } = string.Empty;
+
+ /// Override the gateway base URL (defaults to the provider's public API host).
+ public string BaseUrl { get; set; } = string.Empty;
+
+ /// The approved OTP template/pattern name the gateway sends the code through (verify-lookup APIs).
+ public string OtpTemplate { get; set; } = string.Empty;
}
///
@@ -60,6 +146,19 @@ public sealed class ReviewModerationOptions
///
public sealed class BankTransferOptions
{
+ /// mock (default) | jibit | vandar | sadad — the payout transferor.
+ public string Provider { get; set; } = SeamProviders.Mock;
+
+ /// Transferor API base URL.
+ public string BaseUrl { get; set; } = string.Empty;
+
+ /// Transferor API key / bearer token (secret).
+ public string ApiKey { get; set; } = string.Empty;
+
+ /// The platform's registered source settlement account the batch debits (IBAN/account id the
+ /// transferor recognises). Every PAYA/SATNA transfer originates here.
+ public string SourceSettlementAccount { get; set; } = string.Empty;
+
/// When true, every payout instruction is rejected so the whole-batch-failure path is testable.
public bool ForceFailure { get; set; }
@@ -86,6 +185,19 @@ public sealed class CurrencyOptions
///
public sealed class MoadianOptions
{
+ /// mock (default) | moadian — the real سامانه مودیان submission adapter.
+ public string Provider { get; set; } = SeamProviders.Mock;
+
+ /// مودیان API base URL (the tax-authority endpoint).
+ public string BaseUrl { get; set; } = string.Empty;
+
+ /// The platform's مودیان memory/economic id (memoryId / شناسه یکتای حافظه مالیاتی).
+ public string MemoryId { get; set; } = string.Empty;
+
+ /// A pre-issued bearer token for the مودیان API (the signing-certificate token exchange is a
+ /// deploy-time concern; a deployment supplies a current token). Secret.
+ public string AccessToken { get; set; } = string.Empty;
+
/// When true, a submission returns registered + a deterministic fake 22-digit reference.
public bool ForceRegistered { get; set; }
}
@@ -97,6 +209,19 @@ public sealed class MoadianOptions
///
public sealed class BnplOptions
{
+ /// mock (default) | real — when real, IBnplProviderResolver maps each
+ /// provider_code to its concrete adapter (SnappPay / Digipay) instead of the one mock.
+ public string Provider { get; set; } = SeamProviders.Mock;
+
+ /// Per-provider connection facts, keyed by provider_code (snapppay/digipay).
+ /// Credentials proper (client id/secret) come from the encrypted payment_gateways.config_json in a
+ /// full deployment; the base URL + non-secret facts can be defaulted here.
+ public Dictionary Providers { get; set; } = new(StringComparer.OrdinalIgnoreCase);
+
+ /// The currency the BNPL providers speak on the wire (TOMAN or IRR); conversion to IRR
+ /// happens only at the adapter boundary via ICurrencyNormalizer. SnappPay/Digipay speak Rial.
+ public string WireCurrency { get; set; } = "IRR";
+
/// When true, token/revert/update/cancel all fail so the provider-declined paths are testable.
public bool ForceFailure { get; set; }
@@ -119,6 +244,17 @@ public sealed class BnplOptions
public string NotEligibleMobile { get; set; } = "09120000099";
}
+/// Non-secret connection facts for one BNPL provider (base URL, sandbox flag, merchant handle). The
+/// secret client id/secret live in the encrypted payment_gateways.config_json; a real adapter reads both.
+public sealed class BnplProviderConnection
+{
+ public string BaseUrl { get; set; } = string.Empty;
+ public bool Sandbox { get; set; }
+
+ /// Optional non-secret merchant/terminal identifier the provider expects on requests.
+ public string MerchantId { get; set; } = string.Empty;
+}
+
///
/// Tunes the b10 money-path mocks (PSP acquirer, تسهیم split, webhook verifier). The real adapters ignore
/// these — production merchant ids / signing keys come from payment_gateways.config_json and secrets,
@@ -126,7 +262,30 @@ public sealed class BnplOptions
///
public sealed class PaymentsOptions
{
- /// The platform's own registered IBAN (SHEBA) the mock split credits the commission leg to.
+ /// mock (default) | zarinpal | sadad | vandar | jibit — the card
+ /// acquirer IPaymentProvider + ISettlementSplitProvider + IWebhookVerifier swap together.
+ public string Provider { get; set; } = SeamProviders.Mock;
+
+ /// Acquirer IPG base URL (the payment-request / verify / refund host).
+ public string BaseUrl { get; set; } = string.Empty;
+
+ /// The acquirer merchant id / terminal (non-secret handle). Production reads it from the encrypted
+ /// payment_gateways.config_json; this default enables a single-merchant deployment without the DB row.
+ public string MerchantId { get; set; } = string.Empty;
+
+ /// Where the acquirer sends the customer back after the hosted payment page (the return deep-link the
+ /// adapter passes as the callback URL when opening the IPG session).
+ public string CallbackUrl { get; set; } = string.Empty;
+
+ /// Per-provider webhook signing secret (HMAC key), keyed by provider_code. The real
+ /// IWebhookVerifier verifies the raw callback body against this; a provider with no signature falls back
+ /// to the mandatory server-side verify re-check.
+ public Dictionary WebhookSigningSecrets { get; set; } = new(StringComparer.OrdinalIgnoreCase);
+
+ /// The header the provider carries its signature in (default X-Signature).
+ public string SignatureHeader { get; set; } = "X-Signature";
+
+ /// The platform's own registered IBAN (SHEBA) the split credits the commission leg to.
public string PlatformSheba { get; set; } = "IR000000000000000000000001";
/// A callback whose raw body contains this marker is treated as an invalid signature by the
@@ -156,6 +315,9 @@ public sealed class PaymentCaptureOptions
///
public sealed class ShahkarOptions
{
+ /// mock (default) | finnotech — the real شاهکار bridge (shares Seams:Finnotech creds).
+ public string Provider { get; set; } = SeamProviders.Mock;
+
/// The designated test phone that returns the shared-SIM failure state.
public string SharedSimPhone { get; set; } = "09120000000";
@@ -170,6 +332,9 @@ public sealed class ShahkarOptions
///
public sealed class IdentityKycOptions
{
+ /// mock (default) | finnotech — the real e-KYC bridge (shares Seams:Finnotech creds).
+ public string Provider { get; set; } = SeamProviders.Mock;
+
/// The designated test national id that fails identity KYC.
public string FailNationalId { get; set; } = "0000000000";
@@ -186,6 +351,15 @@ public sealed class IdentityKycOptions
///
public sealed class GeocodingOptions
{
+ /// mock (default) | neshan — the real Neshan geocoding adapter.
+ public string Provider { get; set; } = SeamProviders.Mock;
+
+ /// Neshan API key (secret). The real geocoder sends it as the Api-Key header.
+ public string ApiKey { get; set; } = string.Empty;
+
+ /// Neshan API base URL (defaults to the public host at the adapter).
+ public string BaseUrl { get; set; } = string.Empty;
+
/// When true, every geocode returns null coordinates with low confidence.
public bool ReturnNullCoordinates { get; set; }
@@ -203,6 +377,9 @@ public sealed class GeocodingOptions
///
public sealed class BankOwnershipOptions
{
+ /// mock (default) | finnotech — the real استعلام شبا bridge (shares Seams:Finnotech creds).
+ public string Provider { get; set; } = SeamProviders.Mock;
+
/// The designated test IBAN that returns matched_national_id = false.
public string MismatchIban { get; set; } = "IR000000000000000000000000";
@@ -224,6 +401,31 @@ public sealed class FieldEncryptionOptions
public sealed class ObjectStorageOptions
{
+ /// local (default) | s3 — S3/MinIO/ArvanCloud object storage with presigned PUT/GET.
+ public string Provider { get; set; } = SeamProviders.LocalDisk;
+
/// Filesystem root the local-disk mock writes blobs under.
public string RootPath { get; set; } = string.Empty;
+
+ /// S3-compatible endpoint host, e.g. https://s3.ir-thr-at1.arvanstorage.ir or a MinIO URL.
+ public string ServiceUrl { get; set; } = string.Empty;
+
+ /// The bucket blobs are stored in.
+ public string Bucket { get; set; } = string.Empty;
+
+ /// The S3 region (SigV4 credential scope; MinIO/ArvanCloud commonly use us-east-1 or their own).
+ public string Region { get; set; } = "us-east-1";
+
+ /// S3 access key id (secret).
+ public string AccessKey { get; set; } = string.Empty;
+
+ /// S3 secret access key (secret).
+ public string SecretKey { get; set; } = string.Empty;
+
+ /// Use path-style addressing ({endpoint}/{bucket}/{key}) — required by MinIO/ArvanCloud; AWS
+ /// proper uses virtual-host style. Default true (path-style) since Iranian S3 endpoints expect it.
+ public bool UsePathStyle { get; set; } = true;
+
+ /// How long a presigned GET/PUT URL stays valid (seconds).
+ public int PresignExpirySeconds { get; set; } = 900;
}
diff --git a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/ServiceConfiguration/DevelopmentSeamExtensions.cs b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/ServiceConfiguration/DevelopmentSeamExtensions.cs
index 6afbf1e..083d8d2 100644
--- a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/ServiceConfiguration/DevelopmentSeamExtensions.cs
+++ b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/ServiceConfiguration/DevelopmentSeamExtensions.cs
@@ -28,4 +28,16 @@ public static class DevelopmentSeamExtensions
return services;
}
+
+ ///
+ /// Development/Testing-only re-registration of the real over the
+ /// production (refinement-phase-8, 6.4). The bookings/convert
+ /// simulator path is a dev/test affordance — production converts via the real b10 webhook confirm, not this
+ /// command. Last registration wins, so callers transparently get the succeeding mock in Dev/Testing.
+ ///
+ public static IServiceCollection AddDevelopmentPaymentCapture(this IServiceCollection services)
+ {
+ services.AddSingleton();
+ return services;
+ }
}
diff --git a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/ServiceConfiguration/ServiceCollectionExtension.cs b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/ServiceConfiguration/ServiceCollectionExtension.cs
index f6e7469..90a7086 100644
--- a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/ServiceConfiguration/ServiceCollectionExtension.cs
+++ b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/ServiceConfiguration/ServiceCollectionExtension.cs
@@ -3,96 +3,287 @@ using Baya.Application.Contracts.Invoices;
using Baya.Application.Contracts.Payments;
using Baya.Application.Contracts.Reviews;
using Baya.Infrastructure.CrossCutting.Seams;
+using Baya.Infrastructure.CrossCutting.Seams.Real;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
namespace Baya.Infrastructure.CrossCutting.ServiceConfiguration;
public static class ServiceCollectionExtension
{
///
- /// Registers the cross-cutting seams (time, PII encryption, cache, object storage, SMS) with their
- /// in-memory/local mock implementations. Swapping in a real provider later is a registration change
- /// here — callers depend only on the Application contracts. (The real in-app
- /// INotificationDispatcher needs the database, so it is registered in the Persistence layer.)
+ /// Registers the cross-cutting + vendor-rail seams. Each rail is config-selected (refinement-phase-8):
+ /// the deterministic mock is the default, and setting the rail's Seams:*:Provider to a real provider
+ /// token swaps in the real HTTP adapter behind the same Application contract — callers never change. An
+ /// unconfigured/typo'd provider falls closed to the mock. This makes a partial rollout the normal case (real SMS
+ /// + real geocoder while payments stay mocked in a pre-launch environment). Real adapters read credentials from
+ /// Seams:* (user-secrets/environment) and get an from the
+ /// IHttpClientFactory. (The real in-app INotificationDispatcher needs the database, so it is
+ /// registered in the Persistence layer.)
///
public static IServiceCollection AddCrossCuttingSeams(this IServiceCollection services, IConfiguration configuration)
{
services.Configure(configuration.GetSection(SeamOptions.SectionName));
+ var seams = configuration.GetSection(SeamOptions.SectionName).Get() ?? new SeamOptions();
+ services.AddHttpClient();
services.AddMemoryCache();
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
- services.AddSingleton();
- // OTP/SMS delivery rail (backend-phase-2). The mock logs the code; a real gateway client
- // (Kavenegar/Ghasedak/SMS.ir) replaces this registration only.
- services.AddSingleton();
+ RegisterObjectStorage(services, seams);
+ RegisterSms(services, seams);
+ RegisterTrustRails(services, seams);
+ RegisterGeocoder(services, seams);
+ RegisterPaymentRails(services, seams);
+ RegisterBnpl(services, seams);
+ RegisterPayoutRail(services, seams);
+ RegisterMoadian(services, seams);
- // استعلام شبا IBAN-owner ↔ national-id inquiry (backend-phase-3). The mock returns a deterministic
- // fake match; a real Finnotech/banking-bridge client replaces this registration only.
- services.AddSingleton();
+ // Payment-capture trigger (backend-phase-9). refinement-phase-8 (6.4): the real card capture (b10) supersedes
+ // it, so production registers the fail-closed DisabledPaymentCaptureSimulator (the dev-only bookings/convert
+ // endpoint fabricates nothing in a deployed env); Development/Testing re-register the real MockPaymentCaptureSimulator
+ // over this via AddDevelopmentPaymentCapture (last registration wins).
+ services.AddSingleton();
- // Address geocoding (backend-phase-4). The mock derives deterministic coordinates around the city
- // centroid with no network call; a real Neshan/Google geocoding client replaces this registration.
- services.AddSingleton();
-
- // Nurse-verification vendors (backend-phase-6). All three are deterministic mocks; a real Iranian
- // e-KYC vendor / Shahkar bridge / (future) MoH-INO portal swaps in by a registration change only —
- // no mock behaviour is baked into any handler call site.
- services.AddSingleton();
- services.AddSingleton();
- services.AddSingleton();
-
- // Payment-capture trigger (backend-phase-9). The mock returns a deterministic succeeded capture so
- // ConvertRequestToBooking is testable now; in b10 the real card capture replaces this registration
- // and calls ConvertRequestToBooking directly on a real payment_transactions.succeeded.
- services.AddSingleton();
-
- // Payments money-path seams (backend-phase-10). All four are deterministic mocks; a real card PSP /
- // تسهیم split adapter (config-selected per payment_gateways.config_json), per-provider signature
- // verifier, and StackExchange.Redis lock swap in by a registration change only — no mock behaviour is
- // baked into any handler. The DB uniques/state-machine remain the authoritative money-path backstop.
- services.AddSingleton();
- services.AddSingleton();
- services.AddSingleton();
- services.AddSingleton();
-
- // Refunds/invoices seams (backend-phase-11). سامانه مودیان e-invoicing is mocked (pending/no-ref by
- // default; config can force registered).
- services.AddSingleton();
-
- // BNPL seams (backend-phase-12). The deterministic MockBnplProvider drives the full eligible → settled →
- // reverted state machine with no network; the resolver selects one impl per provider_code (config-driven,
- // never an if(mock) in a handler); ICurrencyNormalizer does Toman↔IRR at the boundary only. A real
- // SnappPay/Digipay adapter + real Redis normalizer swap in by a registration change only. IBnplProvider
- // is still registered directly for the b11 refund path's bnpl_revert channel.
- services.AddSingleton();
- services.AddSingleton(sp => sp.GetRequiredService());
- services.AddSingleton();
- services.AddSingleton();
-
- // Payout bank rail (backend-phase-13). The deterministic MockBankTransferProvider settles every PAYA/SATNA
- // instruction paid with no money movement; a config switch forces whole-batch/single-row failures so the
- // partially_failed + retry paths are testable. A real transferor (Jibit/Vandar/Sadad payout) with a
- // registered source settlement account + reconciliation callback swaps in by a registration change only —
- // the payout status machine + the nurse_payout_booking_links UNIQUE remain the irreversible-transfer backstop.
- services.AddSingleton();
-
- // AI review moderation (backend-phase-14). The mock is a keyword filter / pass-through (clean → human
- // flag by default so the publish gate holds; banned word → reject; config toggle auto-approves clean).
- // A real text classifier / LLM endpoint swaps in by a registration change only — ModerateReviewCommand
- // keeps decision authority + the human override, so the real impl never touches the handler.
+ // Non-vendor mocks that stay as-is (real behaviour is out of this phase's scope / manual is the intended MVP).
+ services.AddSingleton(); // 5.6 manual = intended MVP
services.AddSingleton();
-
- // Partner-center licensing (backend-phase-15). eNamad / MoH establishment-permit registries have no
- // public B2B API, so the mock returns NeedsManualReview (manual admin approval at MVP; config can force
- // auto-approve for tests). A real registry/API client swaps in by a registration change only —
- // VerifyPartnerCenter records the decision and is never touched.
- services.AddSingleton();
+ services.AddSingleton(); // 5.6 manual = intended MVP
return services;
}
+
+ // ---- object storage (5.5) --------------------------------------------------------------------------------
+
+ private static void RegisterObjectStorage(IServiceCollection services, SeamOptions seams)
+ {
+ if (Is(seams.ObjectStorage.Provider, SeamProviders.S3))
+ {
+ services.AddHttpClient(HttpClients.ObjectStorage);
+ services.AddSingleton(sp => new S3ObjectStorage(
+ Client(sp, HttpClients.ObjectStorage),
+ sp.GetRequiredService>()));
+ }
+ else
+ {
+ services.AddSingleton();
+ }
+ }
+
+ // ---- SMS (5.1, launch-critical) --------------------------------------------------------------------------
+
+ private static void RegisterSms(IServiceCollection services, SeamOptions seams)
+ {
+ var provider = seams.Sms.Provider;
+ if (Is(provider, SeamProviders.Kavenegar))
+ {
+ services.AddHttpClient(HttpClients.Sms, c => c.BaseAddress = new Uri(BaseOrDefault(seams.Sms.BaseUrl, "https://api.kavenegar.com/")));
+ services.AddSingleton(sp => new KavenegarSmsSender(
+ Client(sp, HttpClients.Sms),
+ sp.GetRequiredService>(),
+ sp.GetRequiredService>()));
+ }
+ else if (Is(provider, SeamProviders.SmsIr) || Is(provider, SeamProviders.Ghasedak))
+ {
+ throw new NotSupportedException(
+ $"SMS provider '{provider}' is not implemented — only 'kavenegar' has a real adapter (refinement-phase-8). " +
+ "Add its adapter behind ISmsSender, or use 'mock'/'kavenegar'.");
+ }
+ else
+ {
+ // b2 log-only mock; the Development OTP-capture decorator is layered on in Program.cs (Development only,
+ // and only while this mock is selected — a real SMS provider disables it so the OTP is never logged).
+ services.AddSingleton();
+ }
+ }
+
+ // ---- trust & identity (5.2, 5.3) — Finnotech-class KYC bridge --------------------------------------------
+
+ private static void RegisterTrustRails(IServiceCollection services, SeamOptions seams)
+ {
+ var anyFinnotech =
+ Is(seams.Shahkar.Provider, SeamProviders.Finnotech) ||
+ Is(seams.IdentityKyc.Provider, SeamProviders.Finnotech) ||
+ Is(seams.BankOwnership.Provider, SeamProviders.Finnotech);
+
+ if (anyFinnotech)
+ {
+ services.AddHttpClient(HttpClients.Finnotech);
+ services.AddSingleton(sp => new FinnotechClient(
+ Client(sp, HttpClients.Finnotech),
+ sp.GetRequiredService>()));
+ }
+
+ if (Is(seams.Shahkar.Provider, SeamProviders.Finnotech))
+ services.AddSingleton(sp => new FinnotechShahkarVerifier(sp.GetRequiredService()));
+ else
+ services.AddSingleton();
+
+ if (Is(seams.IdentityKyc.Provider, SeamProviders.Finnotech))
+ services.AddSingleton(sp => new FinnotechIdentityKycProvider(sp.GetRequiredService()));
+ else
+ services.AddSingleton();
+
+ if (Is(seams.BankOwnership.Provider, SeamProviders.Finnotech))
+ services.AddSingleton(sp => new FinnotechBankAccountOwnershipVerifier(sp.GetRequiredService()));
+ else
+ services.AddSingleton();
+ }
+
+ // ---- geocoding (5.4) -------------------------------------------------------------------------------------
+
+ private static void RegisterGeocoder(IServiceCollection services, SeamOptions seams)
+ {
+ if (Is(seams.Geocoding.Provider, SeamProviders.Neshan))
+ {
+ services.AddHttpClient(HttpClients.Geocoding);
+ services.AddSingleton(sp => new NeshanGeocoder(
+ Client(sp, HttpClients.Geocoding),
+ sp.GetRequiredService>(),
+ sp.GetRequiredService>()));
+ }
+ else
+ {
+ services.AddSingleton();
+ }
+ }
+
+ // ---- card PSP + webhook signature + تسهیم split (6.1) ----------------------------------------------------
+
+ private static void RegisterPaymentRails(IServiceCollection services, SeamOptions seams)
+ {
+ var real = !Is(seams.Payments.Provider, SeamProviders.Mock) && !string.IsNullOrWhiteSpace(seams.Payments.Provider);
+
+ if (real)
+ {
+ services.AddHttpClient(HttpClients.Psp);
+ services.AddSingleton(sp => new ZarinPalPaymentProvider(
+ Client(sp, HttpClients.Psp),
+ sp.GetRequiredService>(),
+ sp.GetRequiredService>()));
+ services.AddSingleton(sp => new ProviderSettlementSplitProvider(
+ Client(sp, HttpClients.Psp),
+ sp.GetRequiredService>(),
+ sp.GetRequiredService>()));
+ // Per-provider HMAC over the raw callback body — never trust a callback alone (the confirm path still
+ // re-verifies server-side). Shared by the PSP + BNPL + payout-reconciliation callbacks.
+ services.AddSingleton();
+ }
+ else
+ {
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ }
+
+ // Money-path mutex — in-proc today (the DB uniques/state-machine are the authoritative backstop);
+ // Redis-backed for >1 instance. Unchanged by the vendor swap.
+ services.AddSingleton();
+ }
+
+ // ---- BNPL (6.2) ------------------------------------------------------------------------------------------
+
+ private static void RegisterBnpl(IServiceCollection services, SeamOptions seams)
+ {
+ // The in-house net-of-fee model is always available — it stands in for the `balinyaar` provider_code even
+ // in real mode (no external API), and is the mock for every code in mock mode.
+ services.AddSingleton();
+ services.AddSingleton(); // config-driven multiplier = the real impl
+
+ if (string.Equals(seams.Bnpl.Provider, "real", StringComparison.OrdinalIgnoreCase))
+ {
+ services.AddHttpClient(HttpClients.BnplSnappPay);
+ services.AddHttpClient(HttpClients.BnplDigipay);
+
+ services.AddSingleton(sp => new SnappPayBnplProvider(
+ Client(sp, HttpClients.BnplSnappPay),
+ sp.GetRequiredService(),
+ Connection(seams, Baya.Domain.Entities.Bnpl.BnplProviderCodes.SnappPay),
+ seams.Bnpl.WireCurrency,
+ sp.GetRequiredService>()));
+ services.AddSingleton(sp => new DigipayBnplProvider(
+ Client(sp, HttpClients.BnplDigipay),
+ sp.GetRequiredService(),
+ Connection(seams, Baya.Domain.Entities.Bnpl.BnplProviderCodes.Digipay),
+ seams.Bnpl.WireCurrency,
+ sp.GetRequiredService>()));
+
+ services.AddSingleton();
+ // The b11 refund `bnpl_revert` path injects IBnplProvider directly (not per-code); SnappPay is the
+ // default revert provider. Per-code revert resolution through the resolver is a documented follow-up.
+ services.AddSingleton(sp => sp.GetRequiredService());
+ }
+ else
+ {
+ services.AddSingleton(sp => sp.GetRequiredService());
+ services.AddSingleton();
+ }
+ }
+
+ // ---- PAYA/SATNA payout rail (6.3) ------------------------------------------------------------------------
+
+ private static void RegisterPayoutRail(IServiceCollection services, SeamOptions seams)
+ {
+ if (Is(seams.BankTransfer.Provider, SeamProviders.Jibit))
+ {
+ services.AddHttpClient(HttpClients.BankTransfer);
+ services.AddSingleton(sp => new JibitBankTransferProvider(
+ Client(sp, HttpClients.BankTransfer),
+ sp.GetRequiredService>(),
+ sp.GetRequiredService>()));
+ }
+ else
+ {
+ services.AddSingleton();
+ }
+ }
+
+ // ---- Moadian e-invoicing (6.5) ---------------------------------------------------------------------------
+
+ private static void RegisterMoadian(IServiceCollection services, SeamOptions seams)
+ {
+ if (Is(seams.Moadian.Provider, SeamProviders.Moadian))
+ {
+ services.AddHttpClient(HttpClients.Moadian);
+ services.AddSingleton(sp => new MoadianClient(
+ Client(sp, HttpClients.Moadian),
+ sp.GetRequiredService>(),
+ sp.GetRequiredService>()));
+ }
+ else
+ {
+ services.AddSingleton();
+ }
+ }
+
+ // ---- helpers ---------------------------------------------------------------------------------------------
+
+ private static bool Is(string? configured, string token)
+ => string.Equals(configured, token, StringComparison.OrdinalIgnoreCase);
+
+ private static System.Net.Http.HttpClient Client(IServiceProvider sp, string name)
+ => sp.GetRequiredService().CreateClient(name);
+
+ private static string BaseOrDefault(string configured, string fallback)
+ => string.IsNullOrWhiteSpace(configured) ? fallback : configured;
+
+ private static BnplProviderConnection Connection(SeamOptions seams, string code)
+ => seams.Bnpl.Providers.TryGetValue(code, out var connection) ? connection : new BnplProviderConnection();
+
+ private static class HttpClients
+ {
+ public const string ObjectStorage = "seam-object-storage";
+ public const string Sms = "seam-sms";
+ public const string Finnotech = "seam-finnotech";
+ public const string Geocoding = "seam-geocoding";
+ public const string Psp = "seam-psp";
+ public const string BnplSnappPay = "seam-bnpl-snapppay";
+ public const string BnplDigipay = "seam-bnpl-digipay";
+ public const string BankTransfer = "seam-bank-transfer";
+ public const string Moadian = "seam-moadian";
+ }
}
diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/InvoiceRepository.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/InvoiceRepository.cs
index c4ef500..3acac03 100644
--- a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/InvoiceRepository.cs
+++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/InvoiceRepository.cs
@@ -53,6 +53,13 @@ internal sealed class InvoiceRepository : BaseAsyncRepository, IInvoice
return new InvoiceProjection(row.CustomerUserId, row.Invoice.PdfStorageKey, dto);
}
+ public async Task> GetUnregisteredMoadianInvoicesAsync(int max, CancellationToken cancellationToken)
+ => await Table
+ .Where(i => i.MoadianStatus == MoadianStatus.Pending || i.MoadianStatus == MoadianStatus.Submitted)
+ .OrderBy(i => i.Id)
+ .Take(max)
+ .ToListAsync(cancellationToken);
+
public Task AddInvoiceAsync(Invoice invoice, CancellationToken cancellationToken)
=> base.AddAsync(invoice);
diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/ServiceConfiguration/ServiceCollectionExtensions.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/ServiceConfiguration/ServiceCollectionExtensions.cs
index dee1520..3c12315 100644
--- a/server/src/Infrastructure/Baya.Infrastructure.Persistence/ServiceConfiguration/ServiceCollectionExtensions.cs
+++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/ServiceConfiguration/ServiceCollectionExtensions.cs
@@ -70,6 +70,8 @@ public static class ServiceCollectionExtensions
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
+ // refinement-phase-8 (6.5): walk pending/submitted سامانه مودیان invoices toward their registered reference.
+ services.AddSingleton();
services.AddHostedService();
// Search (backend-phase-7). The index maintainer keeps nurse_search_index consistent inline inside
diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Scheduling/Jobs/MoadianReconciliationJob.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Scheduling/Jobs/MoadianReconciliationJob.cs
new file mode 100644
index 0000000..48ad69d
--- /dev/null
+++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Scheduling/Jobs/MoadianReconciliationJob.cs
@@ -0,0 +1,38 @@
+#nullable enable
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Baya.Application.Features.Invoices.Commands.ReconcileMoadianInvoices;
+using Mediator;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+
+namespace Baya.Infrastructure.Persistence.Services.Scheduling.Jobs;
+
+///
+/// Walks every pending/submitted سامانه مودیان invoice toward its registered 22-digit reference
+/// (refinement-phase-8, 6.5) — the async reconciliation the mock IMoadianClient collapses. Runs on a fixed
+/// cadence (no new seeded config key → no migration) and is idempotent: a re-submission of an already-registered
+/// invoice is a no-op, and مودیان dedups on the invoice number so a re-submit doubles as the status poll. Sends
+/// under the scheduler's per-tick lock.
+///
+internal sealed class MoadianReconciliationJob(ILogger logger) : IRecurringJob
+{
+ public string Name => "moadian_reconciliation";
+
+ // A fixed cadence keeps this off the seeded-config path (no migration); Moadian registration is not
+ // time-critical, so every few hours is ample.
+ public ValueTask GetIntervalAsync(IServiceProvider services, CancellationToken cancellationToken)
+ => ValueTask.FromResult(TimeSpan.FromHours(6));
+
+ public async ValueTask RunAsync(IServiceProvider services, CancellationToken cancellationToken)
+ {
+ var sender = services.GetRequiredService();
+ var result = await sender.Send(new ReconcileMoadianInvoicesCommand(), cancellationToken);
+
+ if (result.IsSuccess && result.Result is { } reconcile && reconcile.Scanned > 0)
+ logger.LogInformation(
+ "مودیان reconciliation scanned {Scanned} invoice(s); {Registered} reached registered",
+ reconcile.Scanned, reconcile.Registered);
+ }
+}