refinement phase 8
This commit is contained in:
@@ -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 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
|
||||
@@ -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`).
|
||||
Reference in New Issue
Block a user