12 KiB
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'sverify/lookuptemplate API; free-form viasms/send. A non-200return.statusis surfaced as a delivery failure (the OTP command reports it, never a silent "success"). The OTP is never logged:Program.csnow runs the Development OTP-in-logs capture bridge only while the mock SMS sender is selected (Seams:Sms:Providerempty/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 sharedFinnotechClient(base URL, bearer auth, per-calltrackId) fronts both; creds inSeams: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 asexternal_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 todecimal(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-PAYLOADso a blob stream is never buffered to hash it);GetUrlreturns 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/ILicenseVerificationServicestay 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 onPayments: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 asha256=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 perprovider_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 sharedHttpBnplProviderBase.ToWire/FromWireoverICurrencyNormalizer(Seams:Bnpl:WireCurrency, Rial pass-through by default). The merchant commission is read from the settle response, never hardcoded. REQ-022 / balinyaar decision:balinyaaris 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/torobpayresolve tonull(unbuilt) so the handler rejects them cleanly. The b11bnpl_revertrefund path injectsIBnplProviderdirectly (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 backsubmitted(a track id, money not yet confirmed). The existingExecutePayoutBatchhandler alreadyMarkSubmitteds first and posts no ledger until paid, so it needed no change. New:ReconcilePayoutBatchCommand+WebhooksPayoutsController(POST webhooks/payouts/{provider}, anonymous,webhookrate policy) — HMAC-verified (an invalid signature mutates nothing), parses the per-transfer outcomes, matchessubmittedpayouts bytransfer_reference, and flipspaid(posts the payout ledger + nets clawbacks viaPayoutSettlement) /failed. Idempotent by the forward-only status machine + the ledger-exists guard — a replayed callback is a no-op. - 6.4
IPaymentCaptureSimulatorout of production. Prod registers the fail-closedDisabledPaymentCaptureSimulator(never fabricates a capture); Dev/Testing re-register the succeedingMockPaymentCaptureSimulatorviaAddDevelopmentPaymentCapture(last-wins). Thebookings/convertpath is a Dev/Testing affordance — production converts via the b10 webhook confirm callingConvertRequestToBookingdirectly. (Testing must keep the mock: Mediator constructs the handler before validation runs, so the API tests that expect400/401onbookings/convertwould otherwise500.) - 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 stayssubmittedso the next tick retries — never permanently failed on a transient fault). The reconciliation poll is a newIRecurringJob(fixed 6 h cadence — no seeded config key, so no migration) runningReconcileMoadianInvoicesCommand, which re-submits everypending/submittedinvoice 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_ibanas 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,ObjectStorageS3,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
IBnplProviderdirectly; SnappPay is the default. Route the revert throughIBnplProviderResolverby the transaction'sprovider_code. - SMS.ir / Ghasedak adapters — only Kavenegar is implemented; selecting the others throws a clear
NotSupportedExceptionat 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 6ConfirmRefundSettlement), 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).