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