cleanup phases 6
This commit is contained in:
@@ -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