refinement phase 8

This commit is contained in:
hamid
2026-07-13 21:49:50 +03:30
parent 7edadadea1
commit ef3024ef2f
35 changed files with 2505 additions and 71 deletions
@@ -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 (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. + 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 **814** 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 inventory
| # | Service | Purpose | Depends via (seam / config) | MVP? | Registry row | | # | Service | Purpose | Depends via (seam / config) | MVP? | Registry row |
@@ -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.
@@ -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 seam updates its row. This is the checklist the team works through to go from "MVP with mocks" to
"production with real providers". "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 | | 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`).
+24
View File
@@ -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 real provider is a registration change — handlers depend only on the contract. Audit fields are
stamped by `AuditFieldInterceptor` (Persistence), not in handlers. 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 **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`, **`ops` schema** (mirroring how Identity uses `usr`): `PlatformConfigs`, `AuditLogs`, `SystemEvents`,
`IranianHolidays`, `Notifications`, `SupportAlerts`. Because they are DB-backed, their Application `IranianHolidays`, `Notifications`, `SupportAlerts`. Because they are DB-backed, their Application
@@ -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;
/// <summary>
/// The async PAYA/SATNA payout <b>reconciliation callback</b> (refinement-phase-8, 6.3). The real bank rail
/// accepts a payout as <c>submitted</c> and calls back later with the settled outcome, flipping each payout
/// <c>submitted → paid/failed</c> (the mock rail collapsed this into the submit). Authenticated by
/// <b>signature</b>, 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 <c>webhook</c> rate policy with the PSP/BNPL callbacks.
/// </summary>
[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<bool>]
public async Task<IActionResult> 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));
}
}
+11 -2
View File
@@ -86,10 +86,19 @@ builder.Services.AddApplicationServices()
.AddRateLimitingPolicies(); .AddRateLimitingPolicies();
// Development-only: capture each OTP in-memory so GET /api/v1/dev/last_otp/{phone} can complete a login // 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. // without an SMS gateway. refinement-phase-8: the capture bridge runs ONLY while the log-only mock SMS sender is
if (builder.Environment.IsDevelopment()) // 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(); 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.RegisterValidatorsAsServices();
builder.Services.AddExceptionHandler<ExceptionHandler>(); builder.Services.AddExceptionHandler<ExceptionHandler>();
@@ -22,6 +22,11 @@ public interface IInvoiceRepository
/// Null when none is issued yet.</summary> /// Null when none is issued yet.</summary>
Task<InvoiceProjection?> GetByBookingIdAsync(long bookingId, CancellationToken cancellationToken); Task<InvoiceProjection?> GetByBookingIdAsync(long bookingId, CancellationToken cancellationToken);
/// <summary>Tracked invoices still walking the مودیان state (<c>pending</c>/<c>submitted</c>), oldest first,
/// capped at <paramref name="max"/> — the refinement-phase-8 reconciliation poll re-submits each until مودیان
/// returns the 22-digit reference (or rejects it).</summary>
Task<IReadOnlyList<Invoice>> GetUnregisteredMoadianInvoicesAsync(int max, CancellationToken cancellationToken);
Task AddInvoiceAsync(Invoice invoice, CancellationToken cancellationToken); Task AddInvoiceAsync(Invoice invoice, CancellationToken cancellationToken);
/// <summary>Reads and increments the tracked counter row and returns the reserved value. The increment is /// <summary>Reads and increments the tracked counter row and returns the reserved value. The increment is
@@ -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;
/// <summary>
/// Loads the unregistered invoices (tracked), (re)submits each through <see cref="IMoadianClient"/>, and applies
/// the returned status/reference via the entity's guarded <c>ApplyMoadianResult</c>. One commit at the end. A
/// transient مودیان error leaves the invoice <c>submitted</c> so the next tick retries — it is never marked failed
/// on a transient fault.
/// </summary>
internal sealed class ReconcileMoadianInvoicesCommandHandler(
IUnitOfWork unitOfWork,
IMoadianClient moadianClient)
: IRequestHandler<ReconcileMoadianInvoicesCommand, OperationResult<ReconcileMoadianResult>>
{
private const int BatchSize = 100;
public async ValueTask<OperationResult<ReconcileMoadianResult>> 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<ReconcileMoadianResult>.SuccessResult(new ReconcileMoadianResult(invoices.Count, registered));
}
}
@@ -0,0 +1,18 @@
#nullable enable
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Invoices.Commands.ReconcileMoadianInvoices;
/// <summary>
/// Walks every invoice still <c>pending</c>/<c>submitted</c> with سامانه مودیان toward its registered 22-digit
/// reference (refinement-phase-8, 6.5). Run by the Moadian reconciliation <c>IRecurringJob</c> (and available as an
/// admin override); idempotent — a (re)submission of an already-registered invoice is a no-op, and the real
/// <c>IMoadianClient</c> dedups on the invoice number so re-submitting a still-<c>submitted</c> one doubles as the
/// status poll.
/// </summary>
public sealed record ReconcileMoadianInvoicesCommand : IRequest<OperationResult<ReconcileMoadianResult>>;
/// <param name="Scanned">How many unregistered invoices were examined this run.</param>
/// <param name="Registered">How many reached <c>registered</c> this run.</param>
public sealed record ReconcileMoadianResult(int Scanned, int Registered);
@@ -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;
/// <summary>
/// Verifies the callback signature (an invalid signature mutates <b>nothing</b> — an irreversible-money callback
/// fails closed), parses the per-transfer outcomes, and under <c>lock(payout:batch)</c> flips each matched
/// <c>submitted</c> payout: <c>paid</c> posts the payout ledger + nets clawbacks (reusing <see cref="PayoutSettlement"/>),
/// <c>failed</c> records the reason. Matched by <c>transfer_reference</c> (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.
/// </summary>
internal sealed class ReconcilePayoutBatchCommandHandler(
IUnitOfWork unitOfWork,
IDistributedLock distributedLock,
IWebhookVerifier webhookVerifier,
IDateTimeProvider dateTimeProvider)
: IRequestHandler<ReconcilePayoutBatchCommand, OperationResult<bool>>
{
public async ValueTask<OperationResult<bool>> Handle(ReconcilePayoutBatchCommand request, CancellationToken cancellationToken)
{
var verification = webhookVerifier.Verify(request.Provider, request.Headers, request.RawBody);
if (!verification.SignatureValid)
return OperationResult<bool>.FailureResult("signature", "Invalid payout callback signature; nothing was reconciled.");
if (!TryParse(request.RawBody, out var batchId, out var outcomes))
return OperationResult<bool>.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<bool>.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<bool>.SuccessResult(true);
}
private static bool TryParse(string rawBody, out long batchId, out List<TransferOutcome> 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);
}
@@ -0,0 +1,21 @@
#nullable enable
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Payouts.Commands.ReconcilePayoutBatch;
/// <summary>
/// The async PAYA/SATNA <b>reconciliation callback</b> (refinement-phase-8, 6.3). A real payout rail accepts a
/// transfer as <c>submitted</c> (a track id, but the money hasn't confirmed) and later calls back with the settled
/// outcome; this command flips each <c>submitted</c> payout <c>paid</c>/<c>failed</c>, 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 <b>signature</b> (not a user session) and idempotent — a replayed callback
/// re-driving an already-<c>paid</c>/<c>failed</c> payout is a no-op.
/// </summary>
/// <param name="Provider">The rail's <c>provider_code</c> (e.g. <c>jibit</c>) — selects the signing secret.</param>
/// <param name="Headers">The raw callback headers (carry the signature).</param>
/// <param name="RawBody">The verbatim callback body (HMAC-verified, then parsed for the per-transfer outcomes).</param>
public sealed record ReconcilePayoutBatchCommand(
string Provider,
IReadOnlyDictionary<string, string> Headers,
string RawBody) : IRequest<OperationResult<bool>>;
@@ -0,0 +1,20 @@
#nullable enable
using Baya.Application.Contracts.Common;
namespace Baya.Infrastructure.CrossCutting.Seams;
/// <summary>
/// The production registration for <see cref="IPaymentCaptureSimulator"/> (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 <c>ConvertRequestToBooking</c> directly on a real <c>payment_transactions.succeeded</c> —
/// <b>never</b> by fabricating a capture. So the simulator itself is a Development/Testing affordance
/// (the <c>bookings/convert</c> endpoint). This production stand-in <b>fails closed</b> — it never fabricates a
/// capture, so if the dev-only convert endpoint is somehow reached in production it cleanly creates no booking.
/// The real <see cref="MockPaymentCaptureSimulator"/> is re-registered only under Development/Testing via
/// <c>AddDevelopmentPaymentCapture</c>.
/// </summary>
public sealed class DisabledPaymentCaptureSimulator : IPaymentCaptureSimulator
{
public ValueTask<PaymentCaptureResult> ConfirmCaptureAsync(long bookingRequestId, CancellationToken cancellationToken = default)
=> ValueTask.FromResult(new PaymentCaptureResult(false, string.Empty, null));
}
@@ -0,0 +1,36 @@
#nullable enable
using Baya.Application.Contracts.Payments;
using Baya.Domain.Entities.Bnpl;
namespace Baya.Infrastructure.CrossCutting.Seams.Real;
/// <summary>
/// Real <see cref="IBnplProviderResolver"/> (refinement-phase-8, 6.2) — maps each <c>provider_code</c> to its
/// concrete adapter, config-selected, <b>never an <c>if (mock)</c> in a handler</b>. Selected by
/// <c>Seams:Bnpl:Provider = real</c>.
///
/// <list type="bullet">
/// <item><c>snapppay</c> → <see cref="SnappPayBnplProvider"/>.</item>
/// <item><c>digipay</c> → <see cref="DigipayBnplProvider"/>.</item>
/// <item><c>balinyaar</c> → the <b>in-house</b> plan (<see cref="Seams.MockBnplProvider"/>). <b>REQ-022 decision:</b>
/// 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.</item>
/// <item><c>tara</c> / <c>torobpay</c> → <c>null</c> (no adapter built yet) so the handler rejects them cleanly
/// rather than silently financing through the wrong provider.</item>
/// </list>
/// </summary>
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,
};
}
@@ -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;
/// <summary>
/// Real <see cref="IBnplProvider"/> for <b>Digipay</b> (refinement-phase-8, 6.2) over the UPG installment flow
/// (<c>type=13</c>): OAuth → <c>tickets/business</c> (create) → <c>purchases/verify</c> → <c>purchases/deliver</c>
/// (settle) → <c>refunds</c> (revert). Resolved for <c>provider_code = digipay</c>. Same currency boundary as the
/// SnappPay adapter (conversion only via the base's <c>ToWire</c>/<c>FromWire</c>); the merchant commission on
/// settle is read from the deliver response, never hardcoded. Credentials come from the encrypted
/// <c>payment_gateways.config_json</c> in a full deployment; base URL + non-secret facts from
/// <c>Seams:Bnpl:Providers[digipay]</c>.
/// </summary>
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<BnplEligibilityResult> 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<BnplTokenResult> 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<BnplVerifyResult> 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<BnplSettleResult> 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<BnplStatusResult> 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<BnplRevertResult> CancelAsync(string externalPaymentToken, string idempotencyKey, CancellationToken cancellationToken = default)
=> RefundAsync(externalPaymentToken, null, cancellationToken);
public ValueTask<BnplRevertResult> RevertAsync(string providerOrderReference, long amountIrr, string idempotencyKey, CancellationToken cancellationToken = default)
=> RefundAsync(providerOrderReference, ToWire(amountIrr), cancellationToken);
public ValueTask<BnplRevertResult> UpdateAsync(string providerOrderReference, long newAmountIrr, string idempotencyKey, CancellationToken cancellationToken = default)
=> RefundAsync(providerOrderReference, ToWire(newAmountIrr), cancellationToken);
private async ValueTask<BnplRevertResult> 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<JsonDocument?> 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<string> 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<string, string> { ["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();
}
}
}
@@ -0,0 +1,48 @@
#nullable enable
using System.Text.Json;
using Baya.Application.Contracts.Common;
namespace Baya.Infrastructure.CrossCutting.Seams.Real;
/// <summary>
/// Real <see cref="IBankAccountOwnershipVerifier"/> over the Finnotech استعلام شبا owner ↔ national-id inquiry
/// (refinement-phase-8, 5.3 — the b13 first-payout money-mule gate). Selected by
/// <c>Seams:BankOwnership:Provider = finnotech</c>. Resolves the IBAN's registered owner and matches it against
/// the nurse's national id; persists the vendor track id.
/// </summary>
public sealed class FinnotechBankAccountOwnershipVerifier(FinnotechClient client) : IBankAccountOwnershipVerifier
{
public async Task<OwnershipInquiryResult> 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();
}
@@ -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;
/// <summary>
/// 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 <c>trackId</c> — the
/// three seam adapters differ only in the resource path + response mapping. Credentials come from
/// <c>Seams:Finnotech</c>; a deployment supplies a current access token (the client-credential token exchange is
/// a deploy-time concern, out of the MVP adapter's scope).
/// </summary>
public sealed class FinnotechClient(HttpClient httpClient, IOptions<SeamOptions> options)
{
private const string DefaultBaseUrl = "https://apibeta.finnotech.ir";
private readonly FinnotechOptions _options = options.Value.Finnotech;
public string ClientId => _options.ClientId;
/// <summary>A fresh, unique inquiry track id (Finnotech requires one per call; kept for audit).</summary>
public static string NewTrackId() => Guid.NewGuid().ToString("N");
/// <summary>Issues an authenticated GET and returns the parsed body + the raw JSON (persisted as
/// <c>external_response_json</c>). The base URL defaults to Finnotech's host when unset.</summary>
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);
}
}
@@ -0,0 +1,45 @@
#nullable enable
using System.Text.Json;
using Baya.Application.Contracts.Common;
namespace Baya.Infrastructure.CrossCutting.Seams.Real;
/// <summary>
/// Real <see cref="IIdentityKycProvider"/> over the Finnotech civil-registry (ثبت احوال) inquiry
/// (refinement-phase-8, 5.2). Selected by <c>Seams:IdentityKyc:Provider = finnotech</c>. Verifies the national
/// id is valid and returns the civil-registry full name used for the later credential cross-check.
///
/// <para><b>Liveness scope.</b> 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 <paramref name="livenessPayload"/> to the liveness endpoint and folding its verdict into
/// <see cref="IdentityKycResult.Passed"/> — the seam contract does not change.</para>
/// </summary>
public sealed class FinnotechIdentityKycProvider(FinnotechClient client) : IIdentityKycProvider
{
public async Task<IdentityKycResult> 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.");
}
}
@@ -0,0 +1,42 @@
#nullable enable
using Baya.Application.Contracts.Common;
namespace Baya.Infrastructure.CrossCutting.Seams.Real;
/// <summary>
/// Real <see cref="IShahkarVerifier"/> over the Finnotech شاهکار phone↔national-id inquiry (refinement-phase-8,
/// 5.2). Selected by <c>Seams:Shahkar:Provider = finnotech</c>. Maps the vendor's boolean match to
/// <see cref="ShahkarMatchResult"/> and persists the raw response for audit.
///
/// <para><b>Shared-SIM is a handled state, not derivable from Shahkar alone.</b> 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 (<c>IsSharedSim = false</c>) 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.</para>
/// </summary>
public sealed class FinnotechShahkarVerifier(FinnotechClient client) : IShahkarVerifier
{
public async Task<ShahkarMatchResult> 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.");
}
}
@@ -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;
/// <summary>
/// Real <see cref="IWebhookVerifier"/> (refinement-phase-8, 6.1). Verifies an inbound PSP/BNPL callback's
/// authenticity by an <b>HMAC-SHA256 over the raw body</b> against the per-provider signing secret
/// (<c>Seams:Payments:WebhookSigningSecrets[{provider}]</c>), read from the <c>Seams:Payments:SignatureHeader</c>
/// header. Selected by <c>Seams:Payments:Provider</c> together with the PSP + settlement adapters. An invalid
/// signature yields <c>SignatureValid = false</c> so the handler stores the event ignored and <b>no money moves</b>.
///
/// <para><b>Signatureless fallback.</b> 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 <c>verify</c> re-check on every success
/// event (<c>IPaymentProvider.VerifyAsync</c>), exactly as the contract prescribes. The event-id/type/reference
/// extraction is unchanged from the mock, so <c>HandlePaymentWebhook</c>'s upsert-first/no-op-on-duplicate
/// ordering is untouched.</para>
/// </summary>
public sealed class HmacWebhookVerifier(IOptions<SeamOptions> options) : IWebhookVerifier
{
private readonly PaymentsOptions _options = options.Value.Payments;
public WebhookVerification Verify(string provider, IReadOnlyDictionary<string, string> 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<string, string> 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()));
}
}
@@ -0,0 +1,27 @@
#nullable enable
using Baya.Application.Contracts.Payments;
namespace Baya.Infrastructure.CrossCutting.Seams.Real;
/// <summary>
/// Shared base for the real HTTP BNPL adapters (refinement-phase-8, 6.2). Owns the <b>currency boundary</b> — the
/// one place Toman↔IRR conversion is allowed — via <see cref="ICurrencyNormalizer"/>: every amount leaving the
/// domain is turned into the provider's wire currency by <see cref="ToWire"/>, and every amount coming back is
/// normalized to IRR by <see cref="FromWire"/>. Conversion happens <b>only</b> here, never internally. Concrete
/// adapters (<see cref="SnappPayBnplProvider"/>, <see cref="DigipayBnplProvider"/>) implement the provider's verb
/// set; a real deployment reads the provider's wire currency from <c>Seams:Bnpl:WireCurrency</c> (SnappPay/Digipay
/// settle in Rial, so the default is a pass-through).
/// </summary>
public abstract class HttpBnplProviderBase(HttpClient httpClient, ICurrencyNormalizer currency, string wireCurrency)
{
protected HttpClient Http { get; } = httpClient;
/// <summary>Domain IRR → the provider's wire amount (Rial pass-through, or Toman when the provider speaks Toman).</summary>
protected long ToWire(long amountIrr)
=> string.Equals(wireCurrency, "TOMAN", StringComparison.OrdinalIgnoreCase)
? currency.ToDisplayToman(amountIrr)
: amountIrr;
/// <summary>A provider wire amount → domain IRR (the only inbound conversion point).</summary>
protected long FromWire(long wireAmount) => currency.ToIrr(wireAmount, wireCurrency);
}
@@ -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;
/// <summary>
/// Real <see cref="IBankTransferProvider"/> over the <b>Jibit</b> PAYA/SATNA payout rail (refinement-phase-8, 6.3).
/// Selected by <c>Seams:BankTransfer:Provider = jibit</c>. Registers each payout as a transfer from the platform's
/// registered <b>source settlement account</b> to the nurse's verified Sheba, honouring the PAYA/SATNA
/// <see cref="PayoutInstruction.Method"/> the handler chose by the config threshold. <b>The real rail is async</b>:
/// an accepted transfer comes back <see cref="BankTransferStatus.Submitted"/> (a track id is issued but the money
/// hasn't confirmed) — the execute handler already leaves such a payout <c>submitted</c> and posts no ledger until
/// the async reconciliation callback (<c>POST webhooks/payouts/jibit</c>) flips it <c>paid</c>/<c>failed</c>.
///
/// <para>The <c>idempotencyKey</c> (<c>payout-batch:{id}</c>) is passed as the client reference so a retried submit
/// never re-sends an already-accepted transfer. Every amount is IRR <c>long</c>. Credentials from
/// <c>Seams:BankTransfer</c>; the exact transfer payload is confirmed against the transferor at integration.</para>
/// </summary>
public sealed class JibitBankTransferProvider(
HttpClient httpClient,
IOptions<SeamOptions> options,
ILogger<JibitBankTransferProvider> 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<PayoutBatchSubmitResult> SubmitPayoutBatchAsync(
long payoutBatchId,
IReadOnlyList<PayoutInstruction> 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<BankTransferStatus> 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<string, (bool Accepted, string? Track)> ReadTransferStates(JsonDocument doc)
{
var map = new Dictionary<string, (bool, string?)>(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";
}
@@ -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;
/// <summary>
/// Real <see cref="ISmsSender"/> over the <b>Kavenegar</b> Iranian SMS gateway (refinement-phase-8, 5.1 —
/// launch-critical). OTP delivery uses Kavenegar's <c>verify/lookup</c> pattern API (a pre-approved template,
/// no marketing pre-clearance needed for transactional OTPs); free-form transactional messages use
/// <c>sms/send</c> from the registered sender line. Selected by <c>Seams:Sms:Provider = kavenegar</c>; the seam
/// is swapped by a registration change only, so no handler is touched.
///
/// <para><b>The OTP is never logged.</b> 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.</para>
/// </summary>
public sealed class KavenegarSmsSender(
HttpClient httpClient,
IOptions<SeamOptions> options,
ILogger<KavenegarSmsSender> 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<string, string?>
{
["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<string, string?>
{
["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<string, string?> 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<string, string?> 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)..];
}
@@ -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;
/// <summary>
/// Real <see cref="IMoadianClient"/> over سامانه مودیان (refinement-phase-8, 6.5). Selected by
/// <c>Seams:Moadian:Provider = moadian</c>. Submits the commission invoice (صورتحساب) to the tax-authority API and
/// maps the outcome: a returned 22-digit reference ⇒ <see cref="MoadianStatus.Registered"/>; an accepted-but-not-
/// yet-registered submission ⇒ <see cref="MoadianStatus.Submitted"/> (the reconciliation poll walks it to
/// <c>registered</c>); a rejection ⇒ <see cref="MoadianStatus.Failed"/>.
///
/// <para><b>Idempotent submit.</b> 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-<c>submitted</c>
/// invoice (the seam has one verb; a re-submit doubles as the status poll). <b>Enrollment + the signing
/// certificate</b> (memory/economic id, تعهدنامه) are a deploy-time concern — a deployment supplies the memory id
/// + a current token via <c>Seams:Moadian</c>; signing the payload with the platform's private key is added inside
/// this adapter at integration without changing the seam.</para>
/// </summary>
public sealed class MoadianClient(
HttpClient httpClient,
IOptions<SeamOptions> options,
ILogger<MoadianClient> 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<MoadianSubmissionResult> 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);
}
}
}
@@ -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;
/// <summary>
/// Real <see cref="IGeocoder"/> over the <b>Neshan</b> geocoding API (refinement-phase-8, 5.4) — turns a typed
/// address into coordinates for the b9 EVV distance check. Selected by <c>Seams:Geocoding:Provider = neshan</c>.
/// Coordinates are parsed to <see cref="decimal"/> (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.
/// </summary>
public sealed class NeshanGeocoder(
HttpClient httpClient,
IOptions<SeamOptions> options,
ILogger<NeshanGeocoder> logger) : IGeocoder
{
private const string DefaultBaseUrl = "https://api.neshan.org";
private readonly GeocodingOptions _options = options.Value.Geocoding;
public async ValueTask<GeocodeResult> 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)));
}
@@ -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;
/// <summary>
/// Real <see cref="ISettlementSplitProvider"/> — the تسهیم (settlement-sharing) rail (refinement-phase-8, 6.1).
/// Selected by <c>Seams:Payments:Provider</c> together with the PSP + webhook adapters. Registers a split-by-ratio
/// to the beneficiaries' <b>registered IBANs</b> (nurse payout + platform commission); the acquirer credits each
/// IBAN directly — <b>the platform never custodies funds</b>, the ledger only mirrors what legally sits at the
/// provider/bank. Amounts are IRR <c>long</c>.
///
/// <para>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 <c>Seams:Payments:PlatformSheba</c>, each nurse
/// SHEBA from the b3 <c>matched_national_id</c>-gated bank account) at integration.</para>
/// </summary>
public sealed class ProviderSettlementSplitProvider(
HttpClient httpClient,
IOptions<SeamOptions> options,
ILogger<ProviderSettlementSplitProvider> logger) : ISettlementSplitProvider
{
private readonly PaymentsOptions _options = options.Value.Payments;
private string BaseUrl => _options.BaseUrl.TrimEnd('/');
public async ValueTask<SettlementResult> RegisterSplitAsync(long bookingId, IReadOnlyList<SettlementLeg> 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<SettlementResult> 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);
}
}
}
@@ -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;
/// <summary>
/// Real <see cref="IObjectStorage"/> over an S3-compatible store — MinIO / AWS S3 / ArvanCloud
/// (refinement-phase-8, 5.5). Selected by <c>Seams:ObjectStorage:Provider = s3</c>. Makes the b6 signed-URL
/// document contract and the REQ-006 avatar path real (local disk + <c>file://</c> today). Server-side
/// put/get/delete are AWS-SigV4-authenticated requests; <see cref="GetUrl"/> returns a time-limited
/// <b>presigned GET</b> so a document is fetched directly from storage without proxying bytes through the API.
///
/// <para>Signing is implemented directly against SigV4 (HMAC-SHA256, all in the BCL) so no AWS SDK dependency is
/// pulled in. Uploads use <c>UNSIGNED-PAYLOAD</c> so a blob stream is never buffered to hash it.</para>
/// </summary>
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<SeamOptions> 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<Stream?> 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();
}
/// <summary>A presigned GET URL valid for <c>PresignExpirySeconds</c> — the real form of the b6 signed-URL contract.</summary>
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<string, string>(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();
}
}
@@ -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;
/// <summary>
/// Real <see cref="IBnplProvider"/> for <b>SnappPay</b> (refinement-phase-8, 6.2) — the canonical superset the
/// seam was designed around: OAuth token → <c>offer/eligible</c> → <c>payment/token|verify|settle|revert|
/// cancel|update|status</c>. Resolved for <c>provider_code = snapppay</c> by <see cref="Real.ConfiguredBnplProviderResolver"/>.
/// Amounts cross the wire in the provider's currency (converted only at this boundary via the base's
/// <c>ToWire</c>/<c>FromWire</c>); the settle reads the <b>merchant commission from the actual response</b>, never
/// hardcoded, and the <c>settled_at</c> is whatever the provider reports (nullable — never assumed instant).
/// Money always flows <c>customer ↔ provider ↔ Balinyaar</c>.
///
/// <para><b>Warn:</b> this is the Iranian SnappPay provider-financed BNPL, <i>not</i> the unrelated Canadian
/// <c>SnapPayInc/open-api-java-sdk</c>. Client id/secret come from the encrypted <c>payment_gateways.config_json</c>
/// in a full deployment; the base URL + non-secret facts are in <c>Seams:Bnpl:Providers[snapppay]</c>.</para>
/// </summary>
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<BnplEligibilityResult> 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<BnplTokenResult> 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<BnplVerifyResult> 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<BnplSettleResult> 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<BnplStatusResult> 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<BnplRevertResult> CancelAsync(string externalPaymentToken, string idempotencyKey, CancellationToken cancellationToken = default)
=> ReverseAsync("api/online/payment/v1/cancel", new { paymentToken = externalPaymentToken }, cancellationToken);
public ValueTask<BnplRevertResult> RevertAsync(string providerOrderReference, long amountIrr, string idempotencyKey, CancellationToken cancellationToken = default)
=> ReverseAsync("api/online/payment/v1/revert", new { paymentToken = providerOrderReference }, cancellationToken);
public ValueTask<BnplRevertResult> 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<BnplRevertResult> 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<JsonDocument?> 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<string> 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();
}
}
}
@@ -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;
/// <summary>
/// Real <see cref="IPaymentProvider"/> over the <b>ZarinPal</b> IPG (refinement-phase-8, 6.1). Selected by
/// <c>Seams:Payments:Provider = zarinpal</c>. Opens a v4 payment session (returns the Shaparak-routed redirect +
/// the <c>authority</c> as the gateway reference), performs the <b>mandatory server-side verify</b> (never trusts
/// a callback alone — the amount is re-checked against the gateway), and reverses a captured payment.
///
/// <para>Every amount crossing this seam is IRR <c>long</c>; ZarinPal v4 amounts are Rial, so no conversion
/// happens here. The merchant id defaults from <c>Seams:Payments:MerchantId</c>; a full multi-gateway deployment
/// resolves it per gateway row from the encrypted <c>payment_gateways.config_json</c> via a provider factory —
/// the seam contract is identical either way.</para>
/// </summary>
public sealed class ZarinPalPaymentProvider(
HttpClient httpClient,
IOptions<SeamOptions> options,
ILogger<ZarinPalPaymentProvider> 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<PaymentInitResult> 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<PaymentVerifyResult> 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<PaymentRefundResult> 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);
}
}
@@ -3,12 +3,19 @@ namespace Baya.Infrastructure.CrossCutting.Seams;
/// <summary> /// <summary>
/// Options bound from the <c>Seams</c> configuration section. The mock seams read non-secret defaults /// Options bound from the <c>Seams</c> configuration section. The mock seams read non-secret defaults
/// from here; production keys/paths come from environment variables or user-secrets, never committed. /// from here; production keys/paths come from environment variables or user-secrets, never committed.
///
/// <para><b>Provider selection (refinement-phase-8).</b> Each vendor rail carries a <c>Provider</c> selector
/// (default = the mock, so an unconfigured environment behaves exactly as before). Setting it to a real
/// provider token (e.g. <c>Seams:Sms:Provider = kavenegar</c>) swaps in the real HTTP adapter behind the same
/// contract — handlers never change. This makes a <b>partial rollout</b> the normal case: real SMS + real
/// geocoder while payments stay mocked in a pre-launch environment is just three config keys.</para>
/// </summary> /// </summary>
public sealed class SeamOptions public sealed class SeamOptions
{ {
public const string SectionName = "Seams"; public const string SectionName = "Seams";
public FieldEncryptionOptions FieldEncryption { get; set; } = new(); public FieldEncryptionOptions FieldEncryption { get; set; } = new();
public SmsOptions Sms { get; set; } = new();
public ObjectStorageOptions ObjectStorage { get; set; } = new(); public ObjectStorageOptions ObjectStorage { get; set; } = new();
public BankOwnershipOptions BankOwnership { get; set; } = new(); public BankOwnershipOptions BankOwnership { get; set; } = new();
public GeocodingOptions Geocoding { get; set; } = new(); public GeocodingOptions Geocoding { get; set; } = new();
@@ -22,6 +29,85 @@ public sealed class SeamOptions
public BankTransferOptions BankTransfer { get; set; } = new(); public BankTransferOptions BankTransfer { get; set; } = new();
public ReviewModerationOptions ReviewModeration { get; set; } = new(); public ReviewModerationOptions ReviewModeration { get; set; } = new();
public LicenseVerificationOptions LicenseVerification { get; set; } = new(); public LicenseVerificationOptions LicenseVerification { get; set; } = new();
public FinnotechOptions Finnotech { get; set; } = new();
}
/// <summary>
/// Shared credentials for the Finnotech-class KYC bridge that fronts three trust rails — شاهکار
/// (<c>IShahkarVerifier</c>), e-KYC (<c>IIdentityKycProvider</c>), and استعلام شبا
/// (<c>IBankAccountOwnershipVerifier</c>). Each seam opts in with its own <c>Provider = finnotech</c> selector,
/// but they authenticate against the same tenant, so the connection facts live here once. All values are
/// secrets — user-secrets / environment, never committed.
/// </summary>
public sealed class FinnotechOptions
{
/// <summary>API host (defaults to Finnotech's public sandbox/production host at the adapter).</summary>
public string BaseUrl { get; set; } = string.Empty;
/// <summary>The tenant's client id (<c>NID</c>) — the Finnotech app identifier.</summary>
public string ClientId { get; set; } = string.Empty;
/// <summary>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).</summary>
public string AccessToken { get; set; } = string.Empty;
}
/// <summary>Stable provider tokens for the <c>Provider</c> selectors, so a typo fails closed to the mock.</summary>
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";
}
/// <summary>
/// The outbound SMS rail (<c>ISmsSender</c>). <see cref="Provider"/> = <c>mock</c> logs the OTP (the b2
/// <c>LoggingSmsSender</c>); set it to <c>kavenegar</c> / <c>smsir</c> / <c>ghasedak</c> to deliver over a real
/// Iranian gateway. <b>refinement-phase-8:</b> 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.
/// </summary>
public sealed class SmsOptions
{
/// <summary><c>mock</c> (default) | <c>kavenegar</c> | <c>smsir</c> | <c>ghasedak</c>.</summary>
public string Provider { get; set; } = SeamProviders.Mock;
/// <summary>Gateway API key / token (secret — user-secrets or environment, never committed).</summary>
public string ApiKey { get; set; } = string.Empty;
/// <summary>The registered sender line (used by <c>SendAsync</c> free-form messages and non-template sends).</summary>
public string SenderLine { get; set; } = string.Empty;
/// <summary>Override the gateway base URL (defaults to the provider's public API host).</summary>
public string BaseUrl { get; set; } = string.Empty;
/// <summary>The approved OTP template/pattern name the gateway sends the code through (verify-lookup APIs).</summary>
public string OtpTemplate { get; set; } = string.Empty;
} }
/// <summary> /// <summary>
@@ -60,6 +146,19 @@ public sealed class ReviewModerationOptions
/// </summary> /// </summary>
public sealed class BankTransferOptions public sealed class BankTransferOptions
{ {
/// <summary><c>mock</c> (default) | <c>jibit</c> | <c>vandar</c> | <c>sadad</c> — the payout transferor.</summary>
public string Provider { get; set; } = SeamProviders.Mock;
/// <summary>Transferor API base URL.</summary>
public string BaseUrl { get; set; } = string.Empty;
/// <summary>Transferor API key / bearer token (secret).</summary>
public string ApiKey { get; set; } = string.Empty;
/// <summary>The platform's registered <b>source settlement account</b> the batch debits (IBAN/account id the
/// transferor recognises). Every PAYA/SATNA transfer originates here.</summary>
public string SourceSettlementAccount { get; set; } = string.Empty;
/// <summary>When true, every payout instruction is rejected so the whole-batch-failure path is testable.</summary> /// <summary>When true, every payout instruction is rejected so the whole-batch-failure path is testable.</summary>
public bool ForceFailure { get; set; } public bool ForceFailure { get; set; }
@@ -86,6 +185,19 @@ public sealed class CurrencyOptions
/// </summary> /// </summary>
public sealed class MoadianOptions public sealed class MoadianOptions
{ {
/// <summary><c>mock</c> (default) | <c>moadian</c> — the real سامانه مودیان submission adapter.</summary>
public string Provider { get; set; } = SeamProviders.Mock;
/// <summary>مودیان API base URL (the tax-authority endpoint).</summary>
public string BaseUrl { get; set; } = string.Empty;
/// <summary>The platform's مودیان memory/economic id (<c>memoryId</c> / شناسه یکتای حافظه مالیاتی).</summary>
public string MemoryId { get; set; } = string.Empty;
/// <summary>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.</summary>
public string AccessToken { get; set; } = string.Empty;
/// <summary>When true, a submission returns <c>registered</c> + a deterministic fake 22-digit reference.</summary> /// <summary>When true, a submission returns <c>registered</c> + a deterministic fake 22-digit reference.</summary>
public bool ForceRegistered { get; set; } public bool ForceRegistered { get; set; }
} }
@@ -97,6 +209,19 @@ public sealed class MoadianOptions
/// </summary> /// </summary>
public sealed class BnplOptions public sealed class BnplOptions
{ {
/// <summary><c>mock</c> (default) | <c>real</c> — when <c>real</c>, <c>IBnplProviderResolver</c> maps each
/// <c>provider_code</c> to its concrete adapter (SnappPay / Digipay) instead of the one mock.</summary>
public string Provider { get; set; } = SeamProviders.Mock;
/// <summary>Per-provider connection facts, keyed by <c>provider_code</c> (<c>snapppay</c>/<c>digipay</c>).
/// Credentials proper (client id/secret) come from the encrypted <c>payment_gateways.config_json</c> in a
/// full deployment; the base URL + non-secret facts can be defaulted here.</summary>
public Dictionary<string, BnplProviderConnection> Providers { get; set; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>The currency the BNPL providers speak on the wire (<c>TOMAN</c> or <c>IRR</c>); conversion to IRR
/// happens only at the adapter boundary via <c>ICurrencyNormalizer</c>. SnappPay/Digipay speak Rial.</summary>
public string WireCurrency { get; set; } = "IRR";
/// <summary>When true, token/revert/update/cancel all fail so the provider-declined paths are testable.</summary> /// <summary>When true, token/revert/update/cancel all fail so the provider-declined paths are testable.</summary>
public bool ForceFailure { get; set; } public bool ForceFailure { get; set; }
@@ -119,6 +244,17 @@ public sealed class BnplOptions
public string NotEligibleMobile { get; set; } = "09120000099"; public string NotEligibleMobile { get; set; } = "09120000099";
} }
/// <summary>Non-secret connection facts for one BNPL provider (base URL, sandbox flag, merchant handle). The
/// secret client id/secret live in the encrypted <c>payment_gateways.config_json</c>; a real adapter reads both.</summary>
public sealed class BnplProviderConnection
{
public string BaseUrl { get; set; } = string.Empty;
public bool Sandbox { get; set; }
/// <summary>Optional non-secret merchant/terminal identifier the provider expects on requests.</summary>
public string MerchantId { get; set; } = string.Empty;
}
/// <summary> /// <summary>
/// Tunes the b10 money-path mocks (PSP acquirer, تسهیم split, webhook verifier). The real adapters ignore /// Tunes the b10 money-path mocks (PSP acquirer, تسهیم split, webhook verifier). The real adapters ignore
/// these — production merchant ids / signing keys come from <c>payment_gateways.config_json</c> and secrets, /// these — production merchant ids / signing keys come from <c>payment_gateways.config_json</c> and secrets,
@@ -126,7 +262,30 @@ public sealed class BnplOptions
/// </summary> /// </summary>
public sealed class PaymentsOptions public sealed class PaymentsOptions
{ {
/// <summary>The platform's own registered IBAN (SHEBA) the mock split credits the commission leg to.</summary> /// <summary><c>mock</c> (default) | <c>zarinpal</c> | <c>sadad</c> | <c>vandar</c> | <c>jibit</c> — the card
/// acquirer <c>IPaymentProvider</c> + <c>ISettlementSplitProvider</c> + <c>IWebhookVerifier</c> swap together.</summary>
public string Provider { get; set; } = SeamProviders.Mock;
/// <summary>Acquirer IPG base URL (the payment-request / verify / refund host).</summary>
public string BaseUrl { get; set; } = string.Empty;
/// <summary>The acquirer merchant id / terminal (non-secret handle). Production reads it from the encrypted
/// <c>payment_gateways.config_json</c>; this default enables a single-merchant deployment without the DB row.</summary>
public string MerchantId { get; set; } = string.Empty;
/// <summary>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).</summary>
public string CallbackUrl { get; set; } = string.Empty;
/// <summary>Per-provider webhook signing secret (HMAC key), keyed by <c>provider_code</c>. The real
/// <c>IWebhookVerifier</c> verifies the raw callback body against this; a provider with no signature falls back
/// to the mandatory server-side <c>verify</c> re-check.</summary>
public Dictionary<string, string> WebhookSigningSecrets { get; set; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>The header the provider carries its signature in (default <c>X-Signature</c>).</summary>
public string SignatureHeader { get; set; } = "X-Signature";
/// <summary>The platform's own registered IBAN (SHEBA) the split credits the commission leg to.</summary>
public string PlatformSheba { get; set; } = "IR000000000000000000000001"; public string PlatformSheba { get; set; } = "IR000000000000000000000001";
/// <summary>A callback whose raw body contains this marker is treated as an <b>invalid signature</b> by the /// <summary>A callback whose raw body contains this marker is treated as an <b>invalid signature</b> by the
@@ -156,6 +315,9 @@ public sealed class PaymentCaptureOptions
/// </summary> /// </summary>
public sealed class ShahkarOptions public sealed class ShahkarOptions
{ {
/// <summary><c>mock</c> (default) | <c>finnotech</c> — the real شاهکار bridge (shares <c>Seams:Finnotech</c> creds).</summary>
public string Provider { get; set; } = SeamProviders.Mock;
/// <summary>The designated test phone that returns the shared-SIM failure state.</summary> /// <summary>The designated test phone that returns the shared-SIM failure state.</summary>
public string SharedSimPhone { get; set; } = "09120000000"; public string SharedSimPhone { get; set; } = "09120000000";
@@ -170,6 +332,9 @@ public sealed class ShahkarOptions
/// </summary> /// </summary>
public sealed class IdentityKycOptions public sealed class IdentityKycOptions
{ {
/// <summary><c>mock</c> (default) | <c>finnotech</c> — the real e-KYC bridge (shares <c>Seams:Finnotech</c> creds).</summary>
public string Provider { get; set; } = SeamProviders.Mock;
/// <summary>The designated test national id that fails identity KYC.</summary> /// <summary>The designated test national id that fails identity KYC.</summary>
public string FailNationalId { get; set; } = "0000000000"; public string FailNationalId { get; set; } = "0000000000";
@@ -186,6 +351,15 @@ public sealed class IdentityKycOptions
/// </summary> /// </summary>
public sealed class GeocodingOptions public sealed class GeocodingOptions
{ {
/// <summary><c>mock</c> (default) | <c>neshan</c> — the real Neshan geocoding adapter.</summary>
public string Provider { get; set; } = SeamProviders.Mock;
/// <summary>Neshan API key (secret). The real geocoder sends it as the <c>Api-Key</c> header.</summary>
public string ApiKey { get; set; } = string.Empty;
/// <summary>Neshan API base URL (defaults to the public host at the adapter).</summary>
public string BaseUrl { get; set; } = string.Empty;
/// <summary>When true, every geocode returns null coordinates with low confidence.</summary> /// <summary>When true, every geocode returns null coordinates with low confidence.</summary>
public bool ReturnNullCoordinates { get; set; } public bool ReturnNullCoordinates { get; set; }
@@ -203,6 +377,9 @@ public sealed class GeocodingOptions
/// </summary> /// </summary>
public sealed class BankOwnershipOptions public sealed class BankOwnershipOptions
{ {
/// <summary><c>mock</c> (default) | <c>finnotech</c> — the real استعلام شبا bridge (shares <c>Seams:Finnotech</c> creds).</summary>
public string Provider { get; set; } = SeamProviders.Mock;
/// <summary>The designated test IBAN that returns <c>matched_national_id = false</c>.</summary> /// <summary>The designated test IBAN that returns <c>matched_national_id = false</c>.</summary>
public string MismatchIban { get; set; } = "IR000000000000000000000000"; public string MismatchIban { get; set; } = "IR000000000000000000000000";
@@ -224,6 +401,31 @@ public sealed class FieldEncryptionOptions
public sealed class ObjectStorageOptions public sealed class ObjectStorageOptions
{ {
/// <summary><c>local</c> (default) | <c>s3</c> — S3/MinIO/ArvanCloud object storage with presigned PUT/GET.</summary>
public string Provider { get; set; } = SeamProviders.LocalDisk;
/// <summary>Filesystem root the local-disk mock writes blobs under.</summary> /// <summary>Filesystem root the local-disk mock writes blobs under.</summary>
public string RootPath { get; set; } = string.Empty; public string RootPath { get; set; } = string.Empty;
/// <summary>S3-compatible endpoint host, e.g. <c>https://s3.ir-thr-at1.arvanstorage.ir</c> or a MinIO URL.</summary>
public string ServiceUrl { get; set; } = string.Empty;
/// <summary>The bucket blobs are stored in.</summary>
public string Bucket { get; set; } = string.Empty;
/// <summary>The S3 region (SigV4 credential scope; MinIO/ArvanCloud commonly use <c>us-east-1</c> or their own).</summary>
public string Region { get; set; } = "us-east-1";
/// <summary>S3 access key id (secret).</summary>
public string AccessKey { get; set; } = string.Empty;
/// <summary>S3 secret access key (secret).</summary>
public string SecretKey { get; set; } = string.Empty;
/// <summary>Use path-style addressing (<c>{endpoint}/{bucket}/{key}</c>) — required by MinIO/ArvanCloud; AWS
/// proper uses virtual-host style. Default true (path-style) since Iranian S3 endpoints expect it.</summary>
public bool UsePathStyle { get; set; } = true;
/// <summary>How long a presigned GET/PUT URL stays valid (seconds).</summary>
public int PresignExpirySeconds { get; set; } = 900;
} }
@@ -28,4 +28,16 @@ public static class DevelopmentSeamExtensions
return services; return services;
} }
/// <summary>
/// Development/Testing-only re-registration of the real <see cref="MockPaymentCaptureSimulator"/> over the
/// production <see cref="DisabledPaymentCaptureSimulator"/> (refinement-phase-8, 6.4). The <c>bookings/convert</c>
/// 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.
/// </summary>
public static IServiceCollection AddDevelopmentPaymentCapture(this IServiceCollection services)
{
services.AddSingleton<IPaymentCaptureSimulator, MockPaymentCaptureSimulator>();
return services;
}
} }
@@ -3,96 +3,287 @@ using Baya.Application.Contracts.Invoices;
using Baya.Application.Contracts.Payments; using Baya.Application.Contracts.Payments;
using Baya.Application.Contracts.Reviews; using Baya.Application.Contracts.Reviews;
using Baya.Infrastructure.CrossCutting.Seams; using Baya.Infrastructure.CrossCutting.Seams;
using Baya.Infrastructure.CrossCutting.Seams.Real;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace Baya.Infrastructure.CrossCutting.ServiceConfiguration; namespace Baya.Infrastructure.CrossCutting.ServiceConfiguration;
public static class ServiceCollectionExtension public static class ServiceCollectionExtension
{ {
/// <summary> /// <summary>
/// Registers the cross-cutting seams (time, PII encryption, cache, object storage, SMS) with their /// Registers the cross-cutting + vendor-rail seams. Each rail is <b>config-selected</b> (refinement-phase-8):
/// in-memory/local mock implementations. Swapping in a real provider later is a registration change /// the deterministic mock is the default, and setting the rail's <c>Seams:*:Provider</c> to a real provider
/// here — callers depend only on the Application contracts. (The real in-app /// token swaps in the real HTTP adapter behind the same Application contract — <b>callers never change</b>. An
/// <c>INotificationDispatcher</c> needs the database, so it is registered in the Persistence layer.) /// 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
/// <c>Seams:*</c> (user-secrets/environment) and get an <see cref="System.Net.Http.HttpClient"/> from the
/// <c>IHttpClientFactory</c>. (The real in-app <c>INotificationDispatcher</c> needs the database, so it is
/// registered in the Persistence layer.)
/// </summary> /// </summary>
public static IServiceCollection AddCrossCuttingSeams(this IServiceCollection services, IConfiguration configuration) public static IServiceCollection AddCrossCuttingSeams(this IServiceCollection services, IConfiguration configuration)
{ {
services.Configure<SeamOptions>(configuration.GetSection(SeamOptions.SectionName)); services.Configure<SeamOptions>(configuration.GetSection(SeamOptions.SectionName));
var seams = configuration.GetSection(SeamOptions.SectionName).Get<SeamOptions>() ?? new SeamOptions();
services.AddHttpClient();
services.AddMemoryCache(); services.AddMemoryCache();
services.AddSingleton<IDateTimeProvider, SystemDateTimeProvider>(); services.AddSingleton<IDateTimeProvider, SystemDateTimeProvider>();
services.AddSingleton<IFieldEncryptor, SymmetricFieldEncryptor>(); services.AddSingleton<IFieldEncryptor, SymmetricFieldEncryptor>();
services.AddSingleton<ICacheService, MemoryCacheService>(); services.AddSingleton<ICacheService, MemoryCacheService>();
services.AddSingleton<IObjectStorage, LocalDiskObjectStorage>();
// OTP/SMS delivery rail (backend-phase-2). The mock logs the code; a real gateway client RegisterObjectStorage(services, seams);
// (Kavenegar/Ghasedak/SMS.ir) replaces this registration only. RegisterSms(services, seams);
services.AddSingleton<ISmsSender, LoggingSmsSender>(); 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 // Payment-capture trigger (backend-phase-9). refinement-phase-8 (6.4): the real card capture (b10) supersedes
// fake match; a real Finnotech/banking-bridge client replaces this registration only. // it, so production registers the fail-closed DisabledPaymentCaptureSimulator (the dev-only bookings/convert
services.AddSingleton<IBankAccountOwnershipVerifier, MockBankAccountOwnershipVerifier>(); // endpoint fabricates nothing in a deployed env); Development/Testing re-register the real MockPaymentCaptureSimulator
// over this via AddDevelopmentPaymentCapture (last registration wins).
services.AddSingleton<IPaymentCaptureSimulator, DisabledPaymentCaptureSimulator>();
// Address geocoding (backend-phase-4). The mock derives deterministic coordinates around the city // Non-vendor mocks that stay as-is (real behaviour is out of this phase's scope / manual is the intended MVP).
// centroid with no network call; a real Neshan/Google geocoding client replaces this registration. services.AddSingleton<ICredentialVerifier, MockCredentialVerifier>(); // 5.6 manual = intended MVP
services.AddSingleton<IGeocoder, MockGeocoder>();
// 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<IShahkarVerifier, MockShahkarVerifier>();
services.AddSingleton<IIdentityKycProvider, MockIdentityKycProvider>();
services.AddSingleton<ICredentialVerifier, MockCredentialVerifier>();
// 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<IPaymentCaptureSimulator, MockPaymentCaptureSimulator>();
// 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<IPaymentProvider, MockPaymentProvider>();
services.AddSingleton<ISettlementSplitProvider, MockSettlementSplitProvider>();
services.AddSingleton<IWebhookVerifier, MockWebhookVerifier>();
services.AddSingleton<IDistributedLock, InProcessDistributedLock>();
// Refunds/invoices seams (backend-phase-11). سامانه مودیان e-invoicing is mocked (pending/no-ref by
// default; config can force registered).
services.AddSingleton<IMoadianClient, MockMoadianClient>();
// 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<MockBnplProvider>();
services.AddSingleton<IBnplProvider>(sp => sp.GetRequiredService<MockBnplProvider>());
services.AddSingleton<IBnplProviderResolver, MockBnplProviderResolver>();
services.AddSingleton<ICurrencyNormalizer, MockCurrencyNormalizer>();
// 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<IBankTransferProvider, MockBankTransferProvider>();
// 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.
services.AddSingleton<IReviewModerationService, MockReviewModerationService>(); services.AddSingleton<IReviewModerationService, MockReviewModerationService>();
services.AddSingleton<ILicenseVerificationService, MockLicenseVerificationService>(); // 5.6 manual = intended MVP
// 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<ILicenseVerificationService, MockLicenseVerificationService>();
return services; 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<IObjectStorage>(sp => new S3ObjectStorage(
Client(sp, HttpClients.ObjectStorage),
sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<SeamOptions>>()));
}
else
{
services.AddSingleton<IObjectStorage, LocalDiskObjectStorage>();
}
}
// ---- 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<ISmsSender>(sp => new KavenegarSmsSender(
Client(sp, HttpClients.Sms),
sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<SeamOptions>>(),
sp.GetRequiredService<ILogger<KavenegarSmsSender>>()));
}
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<ISmsSender, LoggingSmsSender>();
}
}
// ---- 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<Microsoft.Extensions.Options.IOptions<SeamOptions>>()));
}
if (Is(seams.Shahkar.Provider, SeamProviders.Finnotech))
services.AddSingleton<IShahkarVerifier>(sp => new FinnotechShahkarVerifier(sp.GetRequiredService<FinnotechClient>()));
else
services.AddSingleton<IShahkarVerifier, MockShahkarVerifier>();
if (Is(seams.IdentityKyc.Provider, SeamProviders.Finnotech))
services.AddSingleton<IIdentityKycProvider>(sp => new FinnotechIdentityKycProvider(sp.GetRequiredService<FinnotechClient>()));
else
services.AddSingleton<IIdentityKycProvider, MockIdentityKycProvider>();
if (Is(seams.BankOwnership.Provider, SeamProviders.Finnotech))
services.AddSingleton<IBankAccountOwnershipVerifier>(sp => new FinnotechBankAccountOwnershipVerifier(sp.GetRequiredService<FinnotechClient>()));
else
services.AddSingleton<IBankAccountOwnershipVerifier, MockBankAccountOwnershipVerifier>();
}
// ---- geocoding (5.4) -------------------------------------------------------------------------------------
private static void RegisterGeocoder(IServiceCollection services, SeamOptions seams)
{
if (Is(seams.Geocoding.Provider, SeamProviders.Neshan))
{
services.AddHttpClient(HttpClients.Geocoding);
services.AddSingleton<IGeocoder>(sp => new NeshanGeocoder(
Client(sp, HttpClients.Geocoding),
sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<SeamOptions>>(),
sp.GetRequiredService<ILogger<NeshanGeocoder>>()));
}
else
{
services.AddSingleton<IGeocoder, MockGeocoder>();
}
}
// ---- 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<IPaymentProvider>(sp => new ZarinPalPaymentProvider(
Client(sp, HttpClients.Psp),
sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<SeamOptions>>(),
sp.GetRequiredService<ILogger<ZarinPalPaymentProvider>>()));
services.AddSingleton<ISettlementSplitProvider>(sp => new ProviderSettlementSplitProvider(
Client(sp, HttpClients.Psp),
sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<SeamOptions>>(),
sp.GetRequiredService<ILogger<ProviderSettlementSplitProvider>>()));
// 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<IWebhookVerifier, HmacWebhookVerifier>();
}
else
{
services.AddSingleton<IPaymentProvider, MockPaymentProvider>();
services.AddSingleton<ISettlementSplitProvider, MockSettlementSplitProvider>();
services.AddSingleton<IWebhookVerifier, MockWebhookVerifier>();
}
// 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<IDistributedLock, InProcessDistributedLock>();
}
// ---- 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<MockBnplProvider>();
services.AddSingleton<ICurrencyNormalizer, MockCurrencyNormalizer>(); // 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<ICurrencyNormalizer>(),
Connection(seams, Baya.Domain.Entities.Bnpl.BnplProviderCodes.SnappPay),
seams.Bnpl.WireCurrency,
sp.GetRequiredService<ILogger<SnappPayBnplProvider>>()));
services.AddSingleton(sp => new DigipayBnplProvider(
Client(sp, HttpClients.BnplDigipay),
sp.GetRequiredService<ICurrencyNormalizer>(),
Connection(seams, Baya.Domain.Entities.Bnpl.BnplProviderCodes.Digipay),
seams.Bnpl.WireCurrency,
sp.GetRequiredService<ILogger<DigipayBnplProvider>>()));
services.AddSingleton<IBnplProviderResolver, ConfiguredBnplProviderResolver>();
// 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<IBnplProvider>(sp => sp.GetRequiredService<SnappPayBnplProvider>());
}
else
{
services.AddSingleton<IBnplProvider>(sp => sp.GetRequiredService<MockBnplProvider>());
services.AddSingleton<IBnplProviderResolver, MockBnplProviderResolver>();
}
}
// ---- 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<IBankTransferProvider>(sp => new JibitBankTransferProvider(
Client(sp, HttpClients.BankTransfer),
sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<SeamOptions>>(),
sp.GetRequiredService<ILogger<JibitBankTransferProvider>>()));
}
else
{
services.AddSingleton<IBankTransferProvider, MockBankTransferProvider>();
}
}
// ---- 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<IMoadianClient>(sp => new MoadianClient(
Client(sp, HttpClients.Moadian),
sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<SeamOptions>>(),
sp.GetRequiredService<ILogger<MoadianClient>>()));
}
else
{
services.AddSingleton<IMoadianClient, MockMoadianClient>();
}
}
// ---- 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<System.Net.Http.IHttpClientFactory>().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";
}
} }
@@ -53,6 +53,13 @@ internal sealed class InvoiceRepository : BaseAsyncRepository<Invoice>, IInvoice
return new InvoiceProjection(row.CustomerUserId, row.Invoice.PdfStorageKey, dto); return new InvoiceProjection(row.CustomerUserId, row.Invoice.PdfStorageKey, dto);
} }
public async Task<IReadOnlyList<Invoice>> 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) public Task AddInvoiceAsync(Invoice invoice, CancellationToken cancellationToken)
=> base.AddAsync(invoice); => base.AddAsync(invoice);
@@ -70,6 +70,8 @@ public static class ServiceCollectionExtensions
services.AddSingleton<IRecurringJob, CredentialExpiryScanJob>(); services.AddSingleton<IRecurringJob, CredentialExpiryScanJob>();
services.AddSingleton<IRecurringJob, NoShowSweepJob>(); services.AddSingleton<IRecurringJob, NoShowSweepJob>();
services.AddSingleton<IRecurringJob, WeeklyPayoutGenerationJob>(); services.AddSingleton<IRecurringJob, WeeklyPayoutGenerationJob>();
// refinement-phase-8 (6.5): walk pending/submitted سامانه مودیان invoices toward their registered reference.
services.AddSingleton<IRecurringJob, MoadianReconciliationJob>();
services.AddHostedService<RecurringJobSchedulerHostedService>(); services.AddHostedService<RecurringJobSchedulerHostedService>();
// Search (backend-phase-7). The index maintainer keeps nurse_search_index consistent inline inside // Search (backend-phase-7). The index maintainer keeps nurse_search_index consistent inline inside
@@ -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;
/// <summary>
/// Walks every <c>pending</c>/<c>submitted</c> سامانه مودیان invoice toward its registered 22-digit reference
/// (refinement-phase-8, 6.5) — the async reconciliation the mock <c>IMoadianClient</c> 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
/// <see cref="ReconcileMoadianInvoicesCommand"/> under the scheduler's per-tick lock.
/// </summary>
internal sealed class MoadianReconciliationJob(ILogger<MoadianReconciliationJob> 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<TimeSpan> GetIntervalAsync(IServiceProvider services, CancellationToken cancellationToken)
=> ValueTask.FromResult(TimeSpan.FromHours(6));
public async ValueTask RunAsync(IServiceProvider services, CancellationToken cancellationToken)
{
var sender = services.GetRequiredService<ISender>();
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);
}
}