Files
baya-monorepo/dev/shared-working-context/reports/refinement-phase-8-report.md
T
2026-07-13 21:49:50 +03:30

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'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 MarkSubmitteds 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 → submittedPOST webhooks/payouts/jibitpaid; invoice → MoadianReconciliationJobregistered).

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