diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3602361 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +temp \ No newline at end of file diff --git a/dev/contracts/domains/payments.md b/dev/contracts/domains/payments.md new file mode 100644 index 0000000..e9fa507 --- /dev/null +++ b/dev/contracts/domains/payments.md @@ -0,0 +1,71 @@ +# Contract — Payments core: ledger, transactions, webhooks & card capture (backend phase b10) + +> One-line: the inbound money rail — start a card payment against an accepted request, a PSP webhook confirms +> it, the balanced card-capture ledger group posts, and the booking confirms. Assumes +> [`../conventions/api-conventions.md`](../conventions/api-conventions.md) + +> [`../conventions/money-and-types.md`](../conventions/money-and-types.md). Machine schema: +> [`../openapi/swagger.v1.json`](../openapi/README.md). + +**Status:** live as of backend-phase-b10 · **Frontend consumer:** frontend-phase-f9-b10 + +All money is **IRR Rials, integer, on the wire as a string of digits** (`"23300000"`). The card-capture ledger +group is always **balanced** (Σ debit = Σ credit). **Internal `account_type`s are never exposed to the +customer** — the checkout UI shows gross + the commission/VAT breakdown only. Timestamps are UTC ISO-8601. + +## Enums used +- `payment` status (`payment_transactions.status`): `pending` | `succeeded` | `failed`. +- `payment_gateways.type`: `standard` (card IPG) | `bnpl`. +- `payment_webhook_events.processing_status`: `received` | `processed` | `failed` | `ignored`. +- `account_type` (internal, never on the customer wire): `escrow_held` | `platform_revenue` | `nurse_payable` + | `refund_payable` | `bnpl_fee_expense` | `psp_fee_expense` | `nurse_clawback_receivable` | `bad_debt`. + b10 posts only the first three (card capture); the rest are reserved for b11/b12/b13. + +## Endpoints + +### `POST api/v1/bookings/{bookingRequestId}/payments` +- **Purpose:** start a card payment for an `accepted_awaiting_payment` booking request owned by the caller. + A `bookings` row exists only on capture (b9), so payment is initiated against the **request**; the amount + charged is the request's **frozen gross** (variant price × session count), never client-supplied. +- **Auth:** authenticated (the owning customer) · **Rate-limited:** yes (sensitive) · **Idempotency:** send an + **`Idempotency-Key`** header — a retried start reuses the same attempt/reference. +- **Request body:** none (the id is in the route; the key is a header). +- **Success `200` (`data`):** `{ "transactionId": 42, "redirectUrl": "https://…", "gatewayReferenceCode": "…" }`. + No ledger rows yet; the booking is not created yet. +- **Failure:** `400` bad id, `401` unauth, `404` request not found / not the caller's, `409` already paid / + not awaiting payment / payment window lapsed, `400` no active gateway configured. + +### `POST api/v1/webhooks/payments/{provider}` +- **Purpose:** the inbound PSP/BNPL callback — verify-then-dedup-then-mutate. +- **Auth:** **none (signature-authenticated)**, anonymous to the auth pipeline · **Rate-limited:** yes (global + per-IP) · **Idempotent:** yes, at-least-once tolerant. +- **Request:** the raw provider callback body (stored verbatim in `payload_json`); signature material in headers. +- **Behaviour:** upserts `payment_webhook_events` **first** on `(provider, external_event_id)` and **no-ops on + a duplicate**; an **invalid signature** is stored `ignored` and mutates nothing; on a **new success event** + it re-verifies server-side (never trusts the callback alone), then captures — posts the balanced card-capture + group and creates/confirms the booking — all under a `lock(booking:{id}:payment)` with the DB uniques as the + authoritative backstop. +- **Success `200` (`data`):** `{ "processingStatus": "processed" | "ignored" | "failed", "duplicate": false }` + (`duplicate: true` on a replayed event). + +### `GET api/v1/nurses/{nurseId}/payable_balance` +- **Purpose:** the IRR balance currently owed a nurse — the **signed sum** over `nurse_payable` ledger legs + (credit adds, debit subtracts), **derived, never a stored column**. This is what b13 payouts read. +- **Auth:** authenticated — the **nurse themself or an admin/finance role** (`403` otherwise). +- **Success `200` (`data`):** `{ "nurseId": 7, "balanceIrr": "19805000" }`. + +## The card-capture ledger group (posted on webhook confirm) +One `transaction_group_id`, `amount_irr` positive with `direction` carrying the sign, Σdebit = Σcredit: + +``` +DEBIT escrow_held gross_price_irr (e.g. 23300000) + CREDIT platform_revenue balinyaar_commission_irr (e.g. 3495000) + CREDIT nurse_payable nurse_payout_amount (e.g. 19805000, nurse_id set) +``` + +## Load-bearing rules the client must honour +- **Money is IRR integer, on the wire as a digit-string.** Never coerce to a JS number for math. +- **A booking is created & confirmed on capture** (the webhook), not on initiate — after `initiate` the + redirect is shown; the booking appears once the PSP callback confirms. +- **The checkout shows gross + commission/VAT breakdown only** — never the internal `account_type`s. +- **Payment is idempotent end-to-end**: a retried `initiate` (same `Idempotency-Key`) reuses the attempt; a + replayed webhook is a no-op; a repeat `initiate` after capture is a `409`. diff --git a/dev/contracts/openapi/swagger.v1.json b/dev/contracts/openapi/swagger.v1.json index 35c0fc7..3e8e571 100644 --- a/dev/contracts/openapi/swagger.v1.json +++ b/dev/contracts/openapi/swagger.v1.json @@ -7,7 +7,7 @@ }, "servers": [ { - "url": "http://localhost" + "url": "https://localhost:5002" } ], "paths": { @@ -5698,7 +5698,7 @@ "tags": [ "Me" ], - "description": "Role claims live inside the access token \u2014 after selecting a role the client should\n refresh its tokens to pick the new role up.", + "description": "Role claims live inside the access token — after selecting a role the client should\n refresh its tokens to pick the new role up.", "operationId": "Me_SelectRole", "requestBody": { "x-name": "command", @@ -6360,6 +6360,83 @@ ] } }, + "/api/v1/nurses/{nurseId}/payable_balance": { + "get": { + "tags": [ + "NursePayableBalance" + ], + "operationId": "NursePayableBalance_PayableBalance", + "parameters": [ + { + "name": "nurseId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + }, + "x-position": 1 + } + ], + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfNursePayableBalanceDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, "/api/v1/nurse_profiles/upsert": { "post": { "tags": [ @@ -8237,6 +8314,83 @@ ] } }, + "/api/v1/bookings/{bookingRequestId}/payments": { + "post": { + "tags": [ + "Payments" + ], + "operationId": "Payments_Initiate", + "parameters": [ + { + "name": "bookingRequestId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + }, + "x-position": 1 + } + ], + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfInitiatePaymentResult" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, "/api/v1/ping/get_status": { "get": { "tags": [ @@ -9037,6 +9191,77 @@ } ] } + }, + "/api/v1/webhooks/payments/{provider}": { + "post": { + "tags": [ + "Webhooks" + ], + "operationId": "Webhooks_Payments", + "parameters": [ + { + "name": "provider", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "x-position": 1 + } + ], + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfWebhookIngestResult" + } + } + } + } + } + } } }, "components": { @@ -12600,6 +12825,41 @@ } ] }, + "ApiResultOfNursePayableBalanceDto": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/NursePayableBalanceDto" + } + ] + } + } + } + ] + }, + "NursePayableBalanceDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "nurseId": { + "type": "integer", + "format": "int64" + }, + "balanceIrr": { + "type": "string", + "nullable": true + } + } + }, "ApiResultOfNurseProfileDto": { "allOf": [ { @@ -13519,6 +13779,45 @@ } } }, + "ApiResultOfInitiatePaymentResult": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/InitiatePaymentResult" + } + ] + } + } + } + ] + }, + "InitiatePaymentResult": { + "type": "object", + "additionalProperties": false, + "properties": { + "transactionId": { + "type": "integer", + "format": "int64" + }, + "redirectUrl": { + "type": "string", + "nullable": true + }, + "gatewayReferenceCode": { + "type": "string", + "nullable": true + } + } + }, "ApiResultOfPingQueryResult": { "allOf": [ { @@ -13927,6 +14226,40 @@ "nullable": true } } + }, + "ApiResultOfWebhookIngestResult": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/WebhookIngestResult" + } + ] + } + } + } + ] + }, + "WebhookIngestResult": { + "type": "object", + "additionalProperties": false, + "properties": { + "processingStatus": { + "type": "string", + "nullable": true + }, + "duplicate": { + "type": "boolean" + } + } } }, "securitySchemes": { diff --git a/dev/shared-working-context/backend/STATUS.md b/dev/shared-working-context/backend/STATUS.md index 7f33fed..6f15d5f 100644 --- a/dev/shared-working-context/backend/STATUS.md +++ b/dev/shared-working-context/backend/STATUS.md @@ -12,6 +12,24 @@ One block per completed backend phase. Newest at the top. Backend lane writes he - **Notes for frontend:** --> +## backend-phase-10 — Payments core: ledger, transactions, webhooks & card capture — 2026-07-06 +- **Shipped:** the money core via one migration in a new **`payments`** schema — **4 tables** `PaymentGateways` + (encrypted `config_json`) / `PaymentTransactions` (the **two filtered uniques** — `gateway_reference_code` WHERE + NOT NULL, `booking_id` WHERE status='succeeded') / `PaymentWebhookEvents` (**UNIQUE(provider_code, + external_event_id)**) / **append-only** `LedgerEntries`. Features `InitiatePayment`, `HandlePaymentWebhook`, + `ConfirmPaymentAndPostLedger` (internal), `GetNursePayableBalance`; controllers + `POST bookings/{id}/payments`, public `POST webhooks/payments/{provider}`, `GET nurses/{id}/payable_balance`. + Extracted **`BookingFactory`** so b10's real capture reuses b9's conversion (b9 unchanged). +- **Contracts:** `dev/contracts/domains/payments.md` written; **openapi snapshot refreshed** + (`dev/contracts/openapi/swagger.v1.json` — the three b10 paths + DTOs present). +- **Mocked:** `IPaymentProvider`, `ISettlementSplitProvider`, `IWebhookVerifier`, `IDistributedLock` → 🟡 + (see reports/mocks-registry.md). `IPaymentCaptureSimulator` (b9) retained for b9's Convert path/tests. +- **Gate:** build clean (0 new warnings) / tests green (Foundation 198, Identity 4, Api 83; +12 Foundation +4 Api new). +- **Handoff:** backend/handoff/after-backend-phase-10.md +- **Notes for frontend:** money is an **IRR digit-string**; a booking exists only **on capture** (pay against the + accepted **request** id; the booking appears `confirmed` after the webhook); **never expose internal + `account_type`s** (checkout = gross + commission/VAT); payment idempotent end-to-end (`Idempotency-Key` header). + ## backend-phase-9 — Bookings, sessions, care instructions & EVV — 2026-07-06 - **Shipped:** the post-payment engine via one additive migration in the **`booking`** schema — **5 tables** `Bookings` / `BookingSessions` / `BookingCareInstructions` / `VisitVerifications` / `CancellationPolicies` diff --git a/dev/shared-working-context/backend/handoff/after-backend-phase-10.md b/dev/shared-working-context/backend/handoff/after-backend-phase-10.md new file mode 100644 index 0000000..eb57951 --- /dev/null +++ b/dev/shared-working-context/backend/handoff/after-backend-phase-10.md @@ -0,0 +1,55 @@ +# Handoff — after backend phase 10 (Payments core: ledger, transactions, webhooks & card capture) + +**The money core is live.** A family can now pay the gross price by card: **initiate → PSP webhook confirms → +the balanced card-capture ledger group posts → the booking converts/confirms**. The PSP acquirer, تسهیم split, +webhook signature verify, and the distributed lock are **mocked behind seams**; the DB constraints are the +authoritative money-path backstops. + +## What f9-b10 can now build +- **Summary & pay (C6)** — after the nurse accepts, `POST api/v1/bookings/{bookingRequestId}/payments` (as the + customer, with an `Idempotency-Key` header) → `{ transactionId, redirectUrl, gatewayReferenceCode }`. Show + the checkout summary (gross + commission/VAT/escrow notice) and send the payer to `redirectUrl`. **Never show + the internal `account_type`s** — gross + the commission/VAT breakdown only. +- **Card payment redirect + confirmation** — the (mock) redirect completes; the PSP callback hits + `POST api/v1/webhooks/payments/{provider}` and confirms. Poll the booking (b9 `bookings/list` / + `bookings/get`) → it appears **`confirmed`** once the webhook captures. (A booking exists only on capture.) +- **Nurse payable balance** — `GET api/v1/nurses/{nurseId}/payable_balance` (the nurse themself or admin) → + `{ nurseId, balanceIrr }` as a digit-string, derived from the ledger. + +## Live endpoints / contracts +- Contract: [`dev/contracts/domains/payments.md`](../../contracts/domains/payments.md). +- Enums: `payment` status (`pending`/`succeeded`/`failed`), `payment_gateways.type` (`standard`/`bnpl`), + `processing_status` (`received`/`processed`/`failed`/`ignored`), the eight `account_type`s (internal). + +## Load-bearing rules the client must honour +- **Money is IRR integer, on the wire as a digit-string.** Never coerce to a JS number for math. +- **The booking is created & confirmed on capture (the webhook), not on initiate.** After `initiate` you have a + redirect only; the `bookings` row appears when the PSP callback confirms. +- **Payment is idempotent end-to-end** — a retried `initiate` (same `Idempotency-Key`) reuses the attempt; a + replayed webhook is a no-op; a repeat `initiate` after capture is a `409`. +- **Internal account types are never exposed to the customer** — checkout shows gross + commission/VAT only. + +## Design note (reconciling b10 with b9's as-built) +b9 creates the `bookings` row **on capture** (it never persists a standalone `pending_payment` booking). So b10 +initiates payment against the `accepted_awaiting_payment` **request**; `payment_transactions.booking_id` is +**nullable**, bound only at confirm. Confirm reuses b9's conversion/amount logic through the extracted +**`BookingFactory`** (no duplication) and posts the ledger + registers the split. The mock +`IPaymentCaptureSimulator` + the `POST bookings/convert` endpoint **stay** (b9's own tests use them); the real +capture path uses `BookingFactory` directly, not that seam. + +## Mocked here → make real later (see reports/mocks-registry.md) +- **`IPaymentProvider`** (🟡) — deterministic ref + fake redirect; `VerifyAsync` instant-succeeds. Real: + ZarinPal/Sadad/Vandar/Jibit acquirer-with-تسهیم, merchant id from encrypted `config_json`, server-side verify. +- **`ISettlementSplitProvider`** (🟡) — records split intent, returns `Settled`. Real: split-by-ratio to + registered SHEBAs; provider credits IBANs directly. +- **`IWebhookVerifier`** (🟡) — signature valid unless a marker; parses a test JSON body. Real: per-provider + HMAC / mandatory server-side verify. +- **`IDistributedLock`** (🟡) — in-process semaphore. Real: StackExchange.Redis lease, key `booking:{id}:payment`. + +## Consumed by later backend phases +- **b11** — refunds/clawbacks/invoices post against the ledger (`refund_payable`/`nurse_clawback_receivable` + account types are already defined); `IPaymentProvider.RefundAsync` is ready to call. +- **b12** — BNPL settle routes callbacks through the **same** `payment_webhook_events` idempotency store and + posts the BNPL-settle group (adds `bnpl_fee_expense`). +- **b13** — payouts read `GetNursePayableBalance` (the signed `nurse_payable` ledger sum) and post + DEBIT `nurse_payable` / CREDIT `escrow_held`, gated on b9's `dispute_window_ends_at`. diff --git a/dev/shared-working-context/reports/backend-phase-10-report.md b/dev/shared-working-context/reports/backend-phase-10-report.md new file mode 100644 index 0000000..6ef2a51 --- /dev/null +++ b/dev/shared-working-context/reports/backend-phase-10-report.md @@ -0,0 +1,69 @@ +# Backend Phase 10 report — Payments core: ledger, transactions, webhooks & card capture + +## What was built +- **`payments` schema, four tables, one migration (`PaymentsCoreLedger`):** + - `PaymentGateways` — PSP config; **encrypted `config_json`** (`IFieldEncryptor`); selection index on + `(type, is_active, priority)`. + - `PaymentTransactions` — every attempt; the **two filtered uniques**: + `UNIQUE(gateway_reference_code) WHERE NOT NULL` and `UNIQUE(booking_id) WHERE status='succeeded' AND + booking_id IS NOT NULL`. `booking_id` is **nullable** (bound at confirm — a b9 booking exists only on capture). + - `PaymentWebhookEvents` — the idempotency store; **`UNIQUE(provider_code, external_event_id)`**. + - `LedgerEntries` — the append-only double-entry source of truth. Implements `IEntity` **only** (no + `ITimeModification`/audit-modify, no soft-delete) so the audit interceptor never stamps it; `created_at` + set explicitly from `IDateTimeProvider`. +- **Domain:** `LedgerPosting.CardCapture` (the balanced-group builder — throws if `gross ≠ commission + payout`); + `LedgerAccountType` (all 8 codes), `LedgerDirection`/`LedgerSourceRefType`, `PaymentTransactionStatus`, + `WebhookProcessingStatus`, `PaymentGatewayType`. +- **Features (`Baya.Application/Features/Payments/`):** `InitiatePayment`, `HandlePaymentWebhook`, + `ConfirmPaymentAndPostLedger` (internal — dispatched only from the webhook + tests), `GetNursePayableBalance`. +- **Seams (`Application/Contracts/Payments/`) + mocks (`CrossCutting/Seams/`):** `IPaymentProvider`, + `ISettlementSplitProvider`, `IWebhookVerifier`, `IDistributedLock`; registered in `AddCrossCuttingSeams`. +- **Persistence:** `IPaymentRepository` + `PaymentRepository` on `IUnitOfWork`; `config_json` encryption wired + in `ApplicationDbContext`. +- **Controllers:** `PaymentsController` (`POST bookings/{bookingRequestId}/payments`), `WebhooksController` + (public `POST webhooks/payments/{provider}`), `NursePayableBalanceController` (`GET nurses/{id}/payable_balance`). +- **Shared conversion:** extracted **`BookingFactory`** from b9's `ConvertRequestToBooking` handler so b9 (mock + capture) and b10 (real webhook capture) share the conversion/amount logic — b9 behaviour unchanged. + +## What is now testable and exactly how (mirrors phase §7) +1. **Initiate** `POST api/v1/bookings/{requestId}/payments` (as the customer) → `200` with a redirect URL + a + `pending` transaction carrying the deterministic `gatewayReferenceCode`; no ledger rows, no booking yet. +2. **Webhook confirms** `POST api/v1/webhooks/payments/{provider}` with a `succeeded` event for that reference → + the transaction flips `succeeded`; **one balanced ledger group** posts (DEBIT `escrow_held` 23300000 = + CREDIT `platform_revenue` 3495000 + `nurse_payable` 19805000); the booking is created & **confirmed**. +3. **Replay** the same `external_event_id` → `200` with `duplicate: true`; no second confirm, no second ledger + group; still one webhook-event row. +4. **`GET api/v1/nurses/{nurseId}/payable_balance`** → `19805000` (signed ledger sum; a debit reduces it). +5. **Second `succeeded` transaction for a booking is blocked** by the filtered `UNIQUE(booking_id) WHERE + status='succeeded'` — an idempotent no-op success, never a second capture. +6. **Unverified callback** (mock verifier marks it invalid) → stored `ignored`, no transaction flip, no ledger. +7. **Encrypted gateway config** — `payment_gateways.config_json` is ciphertext at rest. + +Tests: **12 Foundation** (`Baya.Test.Foundation/Payments/`: `LedgerPostingTests`, `PaymentConfirmTests`, +`InitiatePaymentTests`, `NursePayableBalanceTests`, `PaymentWebhookTests`) + **4 Api integration** +(`PaymentsApiTests`: 401, validation 400, full initiate→webhook→ledger flow, duplicate-replay). Whole suite +green (Foundation 198, Identity 4, Api 83); `dotnet build Baya.sln` 0 new warnings. Filtered uniques + the +balanced ledger are exercised against the real EF model on SQLite (partial indexes work there). + +## What is mocked + how to make it real +The four money-path seams — `IPaymentProvider`, `ISettlementSplitProvider`, `IWebhookVerifier`, +`IDistributedLock` — see `reports/mocks-registry.md` (each row → 🟡 with step-by-step make-it-real). The mock +`IPaymentCaptureSimulator` (b9) is **retained** (b9's `bookings/convert` endpoint + tests use it); b10's real +capture uses `BookingFactory` directly. + +## Account types reserved for b11–b13 +`refund_payable`, `nurse_clawback_receivable` (b11); `bnpl_fee_expense` (b12); `psp_fee_expense`, `bad_debt` +(reserved). All defined now so later phases only post against them. + +## Contracts produced +- `dev/contracts/domains/payments.md` (human contract) + **`dev/contracts/openapi/swagger.v1.json` refreshed** + (the three b10 paths — `bookings/{bookingRequestId}/payments`, `webhooks/payments/{provider}`, + `nurses/{nurseId}/payable_balance` — and the `InitiatePaymentResult`/`WebhookIngestResult`/ + `NursePayableBalanceDto` schemas are present). + +## Follow-ups +- b11 removes the last coupling to `IPaymentCaptureSimulator` once the refund path exercises the real rail end + to end; consider retiring the `bookings/convert` mock endpoint then. +- The webhook confirm crosses two commits (booking creation, then txn+ledger) because the ledger legs need the + DB-generated `booking_id`; the webhook dedup + forward-only request guard + the succeeded-unique keep it + idempotent. A single explicit transaction is a future hardening if `IUnitOfWork` grows a transaction scope. diff --git a/dev/shared-working-context/reports/mocks-registry.md b/dev/shared-working-context/reports/mocks-registry.md index 4a3d88c..4fd624f 100644 --- a/dev/shared-working-context/reports/mocks-registry.md +++ b/dev/shared-working-context/reports/mocks-registry.md @@ -35,6 +35,10 @@ Status legend: 🔴 not built · 🟡 mocked (seam + fake impl in place) · 🟢 | `ILicenseVerificationService` | backend-phase-15 | eNamad / MoH establishment-permit — manual approve | _tbd_ | Real registry/API | 🔴 | | `IPaymentCaptureSimulator` | backend-phase-9 | The **temporary conversion trigger** standing in for b10's real card capture. `MockPaymentCaptureSimulator` (`Baya.Infrastructure.CrossCutting/Seams/`) returns a deterministic *succeeded* capture (a fake `gateway_reference` + a configurable `psp_fee_amount`) so `ConvertRequestToBookingCommand` is exercisable now; a config switch forces a *failed* capture (→ no booking is created). **This is the trigger, not a parallel money path** — registered singleton in `AddCrossCuttingSeams` | `Seams:PaymentCapture:ForceFailure` (default `false`), `Seams:PaymentCapture:PspFeeAmount` (default unset) | In b10: 1) build the real card capture (`payment_transactions`, PSP/IPG client, webhook verify); 2) on a real `payment_transactions.succeeded`, call `ConvertRequestToBooking` **directly** (the same conversion command that computes the three-amount split + generates sessions) instead of this seam; 3) remove the `IPaymentCaptureSimulator` registration + `MockPaymentCaptureSimulator`; the conversion/idempotency logic is unchanged | 🟡 | | `INurseSearch` | backend-phase-7 | The search-service seam (read side). **The MVP impl `SqlNurseSearch` (`Persistence/Services/Search/`) is REAL, not a mock** — it reads the maintained `nurse_search_index WHERE is_searchable=1`, applies the category/city/district (NULL=whole-city)/gender/price filters + rating sort + pagination, projected & `AsNoTracking`. Registered by `AddPersistenceServices`, config-selected. Only the DEFERRED Elasticsearch backend is unbuilt | `Search:Backend` (default `sql`; any other value throws until Elastic ships) | 1) add an Elasticsearch client package (`Elastic.Clients.Elasticsearch`) to `Directory.Packages.props`; 2) define the index mapping (the `NurseSearchResultDto` fields + `is_searchable`); 3) implement `ElasticNurseSearch : INurseSearch` (same filters/sort/paging) reading the ES index; 4) build the feeder that consumes the `ISearchIndexMaintainer` change events via an **outbox/CDC** stream into ES (see the next row); 5) point `Search:Backend=elastic` in config — **callers unchanged**; 6) keep the SQL index as the projection/fallback + the reconciliation source (`RebuildAsync`); 7) test filter/sort/paging parity vs `SqlNurseSearch` | 🟢 SQL real; Elastic 🟡 | +| `IPaymentProvider` | backend-phase-10 | Card PSP acquirer — `MockPaymentProvider` (`Baya.Infrastructure.CrossCutting/Seams/`), **no external call**: `InitPaymentAsync` → a deterministic `gatewayReferenceCode` (`mock-ref-{requestId}-{key}`) + a fake redirect URL; `VerifyAsync` → instant `Succeeded` echoing the expected amount (the server-side re-check); `RefundAsync` → always `Succeeded` (for b11 to call). Registered singleton in `AddCrossCuttingSeams` | none today; real client needs merchant id + terminal/IBAN registration + sandbox flag from `payment_gateways.config_json` (encrypted), not appsettings | 1) pick ZarinPal/Sadad/Vandar/Jibit as an acquirer-with-تسهیم, add its client package to `Directory.Packages.props`; 2) implement `InitPaymentAsync` (open the IPG session, return the Shaparak-routed redirect + reference), `VerifyAsync` (the mandatory server-side `verify` re-check of amount + reference — **never trust the callback alone**), `RefundAsync`; 3) read merchant id/terminal from the encrypted `payment_gateways.config_json`; 4) a config-driven `IProviderRegistry`/factory selects the concrete provider per gateway so a cut-off provider swaps without code change; 5) persist the full gateway response into `gateway_response_json`; 6) swap the registration (config-selected) — handlers unchanged | 🟡 | +| `ISettlementSplitProvider` | backend-phase-10 | تسهیم settlement-sharing — `MockSettlementSplitProvider` (`.../Seams/`) records the split intent and returns `Settled` for any legs whose sum is positive; the platform never moves money. Registered singleton in `AddCrossCuttingSeams` | none today; real client needs each beneficiary's registered SHEBA + split-by-ratio config | 1) pick the acquirer's تسهیم API, implement `RegisterSplitAsync(bookingId, legs)` to register the split-by-ratio to each beneficiary's **registered IBAN** (nurse payout + platform commission), honouring the ~100,000 IRR min-amount caveat; 2) resolve each nurse's SHEBA from `nurse_bank_accounts` (the b3 `matched_national_id` gate) and the platform SHEBA from config; 3) `GetSplitStatusAsync` polls the provider; the provider credits IBANs directly — the ledger only mirrors it; 4) swap the registration (config-selected) | 🟡 | +| `IWebhookVerifier` | backend-phase-10 | PSP callback signature verify — `MockWebhookVerifier` (`.../Seams/`) treats the signature as valid unless the body carries `Seams:Payments:InvalidSignatureMarker`, and extracts `external_event_id`/`event_type`/`gateway_reference_code` from a small JSON body (so tests can replay duplicates + exercise the invalid-signature path). Registered singleton in `AddCrossCuttingSeams` | `Seams:Payments:InvalidSignatureMarker` (default `INVALID_SIGNATURE`) | 1) implement the per-provider HMAC/signature scheme (verify the raw body against the provider's signing key from the gateway config); 2) where a provider offers no signature, fall back to the mandatory server-side `verify` re-check (amount + reference) via `IPaymentProvider.VerifyAsync`; 3) parse the real provider event shape into `WebhookVerification`; 4) swap the registration (config-selected) — the `HandlePaymentWebhook` upsert-first/no-op-on-duplicate ordering is unchanged | 🟡 | +| `IDistributedLock` | backend-phase-10 | Money-path mutex — `InProcessDistributedLock` (`.../Seams/`): a per-key `SemaphoreSlim` so the capture path runs the same acquire/release shape it will with real Redis, **within one process only**. **Not** a cross-instance correctness guarantee — the DB uniques/state-machine are the authoritative backstop. Registered singleton in `AddCrossCuttingSeams` | none today; real client needs a Redis connection string | 1) add `StackExchange.Redis` to `Directory.Packages.props`; 2) implement `AcquireAsync(key)` with a lease/expiry (RedLock-style SET NX PX + a token-checked release), key convention `booking:{id}:payment`; 3) bind `Seams:Payments:Redis` (or reuse the `ICacheService` Redis swap); 4) swap the registration (config-selected) — handlers unchanged, and correctness still rests on the DB uniques if Redis is down/expired | 🟡 | | `ISearchIndexMaintainer` (the "`ISearchIndexWriter`" event shape) | backend-phase-7 | The index-maintenance seam (write side). **The inline SQL path is REAL** — `SearchIndexMaintainer` (`Persistence/Services/Search/`) re-derives `nurse_search_index` from source and **stages** it inside the owning source write's unit of work (single `CommitAsync`), invoked from the b3/b4/b5/b6 handlers (`ReindexVariantAsync`/`ReindexNurseAsync`/`FanOutServiceAreaAsync`/`RemoveServiceAreaRowsAsync`/`RebuildAsync`). Only the **outbox/queue routing** for an async Elastic feeder is deferred — the seam is shaped so the same change events can later be emitted to an outbox instead of an inline upsert | _none_ | 1) introduce an `outbox` table + a SaveChanges interceptor that captures each maintainer change as an event row in the same transaction; 2) a background feeder (Hangfire/Quartz or a hosted service) reads the outbox and applies to `ElasticNurseSearch`; 3) keep the inline SQL upsert as the projection/fallback so `RebuildAsync` stays the reconciliation path; 4) test that an outbox replay converges to the same rows as the inline path | 🟡 outbox deferred (inline real) | > Exact config keys and file paths get filled in by the phase that builds each seam. Keep the diff --git a/product/payments/escrow-ledger.html b/product/payments/escrow-ledger.html index 0e5e5ae..530326e 100644 --- a/product/payments/escrow-ledger.html +++ b/product/payments/escrow-ledger.html @@ -52,6 +52,20 @@ DEBIT nurse_payable nurse_payout_refunded_irr
DEBIT  nurse_clawback_receivable   amount_irr   (nurse_id set; nurse now owes the platform)
   CREDIT refund_payable               amount_irr

Recovered by netting against the nurse's next nurse_payable at batch time, or marked written_off if uncollectable. A nurse_clawbacks row carries the lifecycle (pending / recovered / written_off). This is unavoidable because Iranian payouts are real bank transfers — hard/impossible to reverse — so the right defense is gating payout on the dispute window, with clawback as the fallback.

+

3.2a Implementation rules confirmed in build (backend b10)

+

These are the exact orderings the card-capture rail was built to (see dev/contracts/domains/payments.md):

+ +

payment_webhook_events keyed on UNIQUE(provider_code, external_event_id) before any money state changes; a duplicate replay is a no-op. Only a new, signature-valid success event proceeds — and it is re-verified against the gateway (amount + reference) before the capture posts. A callback with an invalid signature is stored ignored and mutates nothing (never trust a callback alone).

+ +

UNIQUE(booking_id) WHERE status='succeeded' is the authoritative anti-double-capture backstop: if a concurrent confirm races past the Redis lock, the second one fails the constraint and is treated as "already captured → success".

+ +

on capture, payment_transactions.booking_id is nullable and bound at confirm (when the booking is created/loaded). The captured gross is the request's frozen amount (variant price × session count).

3.3 Why the ledger, not more columns

A marketplace that holds escrow, pays out weekly minus commission, and handles refunds + clawbacks has exactly the shape double-entry was invented for. The MVP cost is one table + posting discipline. The alternative (more money columns on bookings/payouts) cannot answer "how much is held but unreleased" without fragile joins and makes bank/Shaparak reconciliation nearly impossible. Keep the per-booking fee snapshot as the pricing record; the ledger is the financial-truth / reconciliation layer posted alongside.

↑ Back to top diff --git a/product/payments/escrow-ledger.md b/product/payments/escrow-ledger.md index 8f99134..6716934 100644 --- a/product/payments/escrow-ledger.md +++ b/product/payments/escrow-ledger.md @@ -58,6 +58,23 @@ DEBIT nurse_clawback_receivable amount_irr (nurse_id set; nurse now owes th ``` Recovered by **netting against the nurse's next `nurse_payable`** at batch time, or marked `written_off` if uncollectable. A `nurse_clawbacks` row carries the lifecycle (`pending` / `recovered` / `written_off`). This is unavoidable because **Iranian payouts are real bank transfers — hard/impossible to reverse** — so the right defense is *gating payout on the dispute window*, with clawback as the fallback. +## 3.2a Implementation rules confirmed in build (backend b10) + +These are the exact orderings the card-capture rail was built to (see `dev/contracts/domains/payments.md`): + +- **Upsert the webhook event first, then re-verify server-side, then confirm.** Every PSP callback lands in + `payment_webhook_events` keyed on `UNIQUE(provider_code, external_event_id)` **before** any money state + changes; a duplicate replay is a **no-op**. Only a new, signature-valid *success* event proceeds — and it is + **re-verified against the gateway** (amount + reference) before the capture posts. A callback with an invalid + signature is stored `ignored` and mutates nothing (**never trust a callback alone**). +- **A unique-violation on confirm is an idempotent no-op success, not an error.** The filtered + `UNIQUE(booking_id) WHERE status='succeeded'` is the authoritative anti-double-capture backstop: if a + concurrent confirm races past the Redis lock, the second one fails the constraint and is treated as + "already captured → success". +- **Payment is initiated against the accepted *request*, not a booking.** Because a `bookings` row exists only + on capture, `payment_transactions.booking_id` is **nullable** and bound at confirm (when the booking is + created/loaded). The captured gross is the request's frozen amount (variant price × session count). + ## 3.3 Why the ledger, not more columns A marketplace that holds escrow, pays out weekly minus commission, and handles refunds + clawbacks has exactly the shape double-entry was invented for. The MVP cost is **one table + posting discipline**. The alternative (more money columns on bookings/payouts) cannot answer "how much is held but unreleased" without fragile joins and makes bank/Shaparak reconciliation nearly impossible. Keep the per-booking fee snapshot as the *pricing* record; the ledger is the *financial-truth / reconciliation* layer posted alongside. diff --git a/server/CLAUDE.md b/server/CLAUDE.md index 52b1729..76a6dcc 100644 --- a/server/CLAUDE.md +++ b/server/CLAUDE.md @@ -287,6 +287,42 @@ Load-bearing rules: real card capture replaces it by calling `ConvertRequestToBooking` directly on a `succeeded` transaction. The no-show sweep (`DetectNoShowSessions`) is admin/test-triggered; its recurring cron is DEFERRED (like b8's expiry sweep). +**Payments core — ledger, transactions, webhooks & card capture (backend-phase-10).** A new **`payments` +schema** holds the money core: `PaymentGateways` (config per PSP; **encrypted `config_json`**; +selection by `type`+`priority`), `PaymentTransactions` (every attempt; the **two filtered uniques** — +`UNIQUE(gateway_reference_code) WHERE NOT NULL` and `UNIQUE(booking_id) WHERE status='succeeded'` — are the +anti-double-capture backstop), `PaymentWebhookEvents` (the idempotency store; **`UNIQUE(provider_code, +external_event_id)`**), and the **append-only** `LedgerEntries` (double-entry source of truth). Entities in +`Domain/Entities/Payments/` (+ `LedgerPosting` balanced-group builder, `LedgerAccountType`/`PaymentTransactionStatus`/ +`WebhookProcessingStatus`/`PaymentGatewayType` code sets); configs in `Persistence/Configuration/PaymentsConfig/`; +one migration (`PaymentsCoreLedger`). Features under `Baya.Application/Features/Payments/{Commands|Queries}/` +(`InitiatePayment`, `HandlePaymentWebhook`, `ConfirmPaymentAndPostLedger`, `GetNursePayableBalance`); +`IPaymentRepository` on `IUnitOfWork`; controllers `PaymentsController` (`POST bookings/{id}/payments`), +`WebhooksController` (public `POST webhooks/payments/{provider}`), `NursePayableBalanceController` +(`GET nurses/{id}/payable_balance`). Load-bearing rules: +- **A `bookings` row exists only on capture (b9).** So a payment is initiated against the + `accepted_awaiting_payment` **request**; `payment_transactions.booking_id` is **nullable**, bound only when + the confirm creates/loads the booking. Confirm reuses b9 via the extracted **`BookingFactory`** (shared + conversion/amount logic) rather than re-implementing it — the mock `IPaymentCaptureSimulator` Convert path + stays for b9's own tests. +- **Idempotency ordering:** `HandlePaymentWebhook` **upserts the webhook event first** on `(provider, + external_event_id)` and **no-ops on a duplicate**; on a new success event it **re-verifies server-side** + (`IPaymentProvider.VerifyAsync`) then dispatches `ConfirmPaymentAndPostLedger`, all under + `IDistributedLock(booking-request:{id}:payment)`. A unique-violation on confirm is treated as an + **idempotent no-op success**, not an error. +- **The card-capture group is balanced:** `LedgerPosting.CardCapture` posts DEBIT `escrow_held` gross = + CREDIT `platform_revenue` commission + `nurse_payable` payout under one `transaction_group_id` + (Σdebit = Σcredit; throws if the three frozen amounts don't reconcile). `ledger_entries` is **append-only** + (implements `IEntity` only — no `ITimeModification`, so the audit interceptor never stamps it; no soft-delete). +- **Escrow IS the ledger.** `GetNursePayableBalance` is the **signed sum** over `nurse_payable` legs — never a + stored column. The lawful split is **تسهیم via `ISettlementSplitProvider`** to registered IBANs (the platform + never moves money). +- **Four money-path seams** in `Application/Contracts/Payments/` — `IPaymentProvider`, + `ISettlementSplitProvider`, `IWebhookVerifier`, `IDistributedLock` — with faithful mocks in + `CrossCutting/Seams/` (`MockPaymentProvider`, `MockSettlementSplitProvider`, `MockWebhookVerifier`, + `InProcessDistributedLock`), registered by `AddCrossCuttingSeams`. `payment_gateways.config_json` is + encrypted through the b0 `IFieldEncryptor` (converter wired in `ApplicationDbContext`). + **Keeping the Project map current.** When a change touches the architecture — adds, removes, or renames a project/assembly, a Clean-Architecture layer, or a major folder, or changes a cross-layer dependency — you **must** update this Project map (and the dependency rule above, if affected) in the diff --git a/server/src/API/Baya.Web.Api/Controllers/V1/NursePayableBalanceController.cs b/server/src/API/Baya.Web.Api/Controllers/V1/NursePayableBalanceController.cs new file mode 100644 index 0000000..57e66c2 --- /dev/null +++ b/server/src/API/Baya.Web.Api/Controllers/V1/NursePayableBalanceController.cs @@ -0,0 +1,28 @@ +using System.ComponentModel.DataAnnotations; +using Asp.Versioning; +using Baya.Application.Features.Payments.Queries.GetNursePayableBalance; +using Baya.Application.Models.Payments; +using Baya.WebFramework.Attributes; +using Baya.WebFramework.BaseController; +using Mediator; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Baya.Web.Api.Controllers.V1; + +/// +/// The derived per-nurse payable balance (IRR digit-string), summed from the append-only ledger — never a +/// stored column. Authorized to the nurse themself or an admin/finance role. This is what b13 payouts read. +/// +[ApiVersion("1")] +[ApiController] +[Route("api/v{version:apiVersion}/nurses")] +[Authorize] +[Display(Description = "Derived nurse payable balance (ledger projection)")] +public sealed class NursePayableBalanceController(ISender sender) : BaseController +{ + [HttpGet("{nurseId}/payable_balance")] + [ProducesOkApiResponseType] + public async Task PayableBalance(long nurseId, CancellationToken cancellationToken) + => OperationResult(await sender.Send(new GetNursePayableBalanceQuery(nurseId), cancellationToken)); +} diff --git a/server/src/API/Baya.Web.Api/Controllers/V1/PaymentsController.cs b/server/src/API/Baya.Web.Api/Controllers/V1/PaymentsController.cs new file mode 100644 index 0000000..baa75d5 --- /dev/null +++ b/server/src/API/Baya.Web.Api/Controllers/V1/PaymentsController.cs @@ -0,0 +1,37 @@ +using System.ComponentModel.DataAnnotations; +using System.Linq; +using Asp.Versioning; +using Baya.Application.Features.Payments.Commands.InitiatePayment; +using Baya.Application.Models.Payments; +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 customer-facing card-payment start. A booking exists only on capture, so a payment is initiated against +/// the accepted booking_requests id; the money charged is the request's frozen gross. Rate-limited as a +/// money endpoint and idempotency-keyed (the Idempotency-Key header) so a retried start reuses the same +/// attempt. Internal account types are never exposed here — the response is just the redirect + the attempt id. +/// +[ApiVersion("1")] +[ApiController] +[Route("api/v{version:apiVersion}/bookings")] +[Authorize] +[Display(Description = "Card payment initiation against an accepted booking request")] +public sealed class PaymentsController(ISender sender) : BaseController +{ + [HttpPost("{bookingRequestId}/payments")] + [EnableRateLimiting(RateLimitingServiceExtension.SensitivePolicy)] + [ProducesOkApiResponseType] + public async Task Initiate(long bookingRequestId, CancellationToken cancellationToken) + { + var idempotencyKey = Request.Headers["Idempotency-Key"].FirstOrDefault(); + return OperationResult(await sender.Send(new InitiatePaymentCommand(bookingRequestId, idempotencyKey), cancellationToken)); + } +} diff --git a/server/src/API/Baya.Web.Api/Controllers/V1/WebhooksController.cs b/server/src/API/Baya.Web.Api/Controllers/V1/WebhooksController.cs new file mode 100644 index 0000000..824d703 --- /dev/null +++ b/server/src/API/Baya.Web.Api/Controllers/V1/WebhooksController.cs @@ -0,0 +1,40 @@ +using System.ComponentModel.DataAnnotations; +using System.IO; +using System.Linq; +using System.Text; +using Asp.Versioning; +using Baya.Application.Features.Payments.Commands.HandlePaymentWebhook; +using Baya.Application.Models.Payments; +using Baya.WebFramework.Attributes; +using Baya.WebFramework.BaseController; +using Mediator; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Baya.Web.Api.Controllers.V1; + +/// +/// The inbound PSP/BNPL callback surface. Authenticated by signature, not a user session, so it is +/// anonymous to the auth pipeline; at-least-once tolerant and idempotency-deduplicated on +/// (provider, external_event_id) before any money moves. The raw body is read verbatim and stored in +/// payload_json. +/// +[ApiVersion("1")] +[ApiController] +[Route("api/v{version:apiVersion}/webhooks")] +[AllowAnonymous] +[Display(Description = "PSP/BNPL payment callbacks (signature-authenticated, idempotent)")] +public sealed class WebhooksController(ISender sender) : BaseController +{ + [HttpPost("payments/{provider}")] + [ProducesOkApiResponseType] + public async Task Payments(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 HandlePaymentWebhookCommand(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 cdd2422..8959033 100644 --- a/server/src/API/Baya.Web.Api/Program.cs +++ b/server/src/API/Baya.Web.Api/Program.cs @@ -100,6 +100,7 @@ if (!app.Environment.IsEnvironment("Testing")) { await app.ApplyMigrationsAsync(); await app.SeedDefaultUsersAsync(); + await app.SeedPaymentGatewaysAsync(); } if (app.Environment.IsDevelopment()) diff --git a/server/src/Core/Baya.Application/Contracts/Payments/IDistributedLock.cs b/server/src/Core/Baya.Application/Contracts/Payments/IDistributedLock.cs new file mode 100644 index 0000000..cffe21b --- /dev/null +++ b/server/src/Core/Baya.Application/Contracts/Payments/IDistributedLock.cs @@ -0,0 +1,15 @@ +#nullable enable +namespace Baya.Application.Contracts.Payments; + +/// +/// A distributed mutex around a money-path critical section (real impl: StackExchange.Redis with a lease/ +/// expiry, key convention booking:{id}:payment). It is the fast first line so a double callback +/// and a user retry don't both start a money mutation — but never the sole correctness guarantee: if +/// Redis is down or the lease expires, the DB uniques/state-machine remain the authoritative backstop. +/// +public interface IDistributedLock +{ + /// Acquires the lock for ; dispose the handle to release. The mock is an + /// in-process semaphore per key, so the money-path code runs the same shape it will with real Redis. + ValueTask AcquireAsync(string key, CancellationToken cancellationToken = default); +} diff --git a/server/src/Core/Baya.Application/Contracts/Payments/IPaymentProvider.cs b/server/src/Core/Baya.Application/Contracts/Payments/IPaymentProvider.cs new file mode 100644 index 0000000..24802c5 --- /dev/null +++ b/server/src/Core/Baya.Application/Contracts/Payments/IPaymentProvider.cs @@ -0,0 +1,44 @@ +#nullable enable +namespace Baya.Application.Contracts.Payments; + +/// +/// The swappable card-PSP acquirer seam (ZarinPal / Sadad / Vandar / Jibit …). Handlers depend only on this +/// contract; the concrete provider is selected from payment_gateways config so a cut-off provider is +/// swapped without a code change. Every amount crossing this seam is IRR long — Toman conversion +/// happens only inside a real adapter at its boundary, never here. +/// +public interface IPaymentProvider +{ + /// Starts an IPG session and returns the redirect URL plus a deterministic gateway reference to + /// persist on the pending transaction (honouring its filtered unique). + /// makes a retried initiate return the same reference rather than opening a second session. + ValueTask InitPaymentAsync(long bookingRequestId, long amountIrr, string idempotencyKey, CancellationToken cancellationToken = default); + + /// The mandatory server-side re-check (never trust a callback alone): re-verifies the + /// amount + reference against the gateway before a success callback is allowed to confirm. + ValueTask VerifyAsync(string gatewayReferenceCode, long expectedAmountIrr, CancellationToken cancellationToken = default); + + /// Reverses a captured payment (partial or full). Exposed here so b11 refunds can call it; this + /// phase builds no refund flow. + ValueTask RefundAsync(string gatewayReferenceCode, long amountIrr, CancellationToken cancellationToken = default); +} + +/// The outcome verb of a provider verify/refund — mirrors the wire payment status codes. +public enum PaymentProviderStatus +{ + Pending, + Succeeded, + Failed +} + +/// Where the client is sent to complete the card payment. +/// The deterministic reference persisted on the pending transaction. +public sealed record PaymentInitResult(string RedirectUrl, string GatewayReferenceCode); + +/// The re-verified outcome — only confirms. +/// The amount the gateway reports, re-checked against the stored transaction. +public sealed record PaymentVerifyResult(PaymentProviderStatus Status, long AmountIrr); + +/// Whether the reversal was accepted by the gateway. +/// The provider's refund reference, when issued. +public sealed record PaymentRefundResult(PaymentProviderStatus Status, string? GatewayRefundReference); diff --git a/server/src/Core/Baya.Application/Contracts/Payments/ISettlementSplitProvider.cs b/server/src/Core/Baya.Application/Contracts/Payments/ISettlementSplitProvider.cs new file mode 100644 index 0000000..06e9e54 --- /dev/null +++ b/server/src/Core/Baya.Application/Contracts/Payments/ISettlementSplitProvider.cs @@ -0,0 +1,31 @@ +#nullable enable +namespace Baya.Application.Contracts.Payments; + +/// +/// The تسهیم (settlement-sharing) seam — the lawful split primitive. A پرداخت‌یار may not custody funds +/// or run a wallet, so the platform never moves money between merchants: it registers a split-by-ratio to the +/// beneficiaries' registered IBANs and the provider credits each IBAN directly. The ledger only mirrors +/// money that legally sits at the provider/bank. Amounts are IRR long. +/// +public interface ISettlementSplitProvider +{ + /// Registers the split for a captured booking. credit the nurse's payout + /// and the platform's commission to their registered IBANs; their sum equals the captured gross. + ValueTask RegisterSplitAsync(long bookingId, IReadOnlyList legs, CancellationToken cancellationToken = default); + + ValueTask GetSplitStatusAsync(long bookingId, CancellationToken cancellationToken = default); +} + +public enum SettlementStatus +{ + Registered, + Settled, + Failed +} + +/// The beneficiary's registered IBAN (SHEBA) the provider credits directly. +/// The IRR amount for this beneficiary. +/// A label — nurse / platform. +public sealed record SettlementLeg(string Sheba, long AmountIrr, string Beneficiary); + +public sealed record SettlementResult(SettlementStatus Status); diff --git a/server/src/Core/Baya.Application/Contracts/Payments/IWebhookVerifier.cs b/server/src/Core/Baya.Application/Contracts/Payments/IWebhookVerifier.cs new file mode 100644 index 0000000..1a074ef --- /dev/null +++ b/server/src/Core/Baya.Application/Contracts/Payments/IWebhookVerifier.cs @@ -0,0 +1,25 @@ +#nullable enable +namespace Baya.Application.Contracts.Payments; + +/// +/// Verifies an inbound PSP/BNPL callback's authenticity and extracts its idempotency key + payload. A callback +/// is authenticated by signature, not a user session; an invalid signature must mutate nothing +/// (stored with signature_valid = 0, processing_status = ignored). Where a provider offers no +/// signature, the real adapter falls back to the mandatory server-side verify re-check. +/// +public interface IWebhookVerifier +{ + WebhookVerification Verify(string provider, IReadOnlyDictionary headers, string rawBody); +} + +/// False ⇒ the callback is stored ignored and no money moves. +/// The provider's event id — half of the (provider, external_event_id) key. +/// The provider's event type; a success type triggers the confirm path. +/// The reference tying the callback to a pending transaction. +/// Whether this event asserts a successful capture (gates the confirm path). +public sealed record WebhookVerification( + bool SignatureValid, + string ExternalEventId, + string EventType, + string? GatewayReferenceCode, + bool IsSuccessEvent); diff --git a/server/src/Core/Baya.Application/Contracts/Persistence/IBookingRepository.cs b/server/src/Core/Baya.Application/Contracts/Persistence/IBookingRepository.cs index 5e0c1f6..a475b7c 100644 --- a/server/src/Core/Baya.Application/Contracts/Persistence/IBookingRepository.cs +++ b/server/src/Core/Baya.Application/Contracts/Persistence/IBookingRepository.cs @@ -1,6 +1,7 @@ #nullable enable using Baya.Application.Models.Booking; using Baya.Application.Models.Common; +using Baya.Application.Models.Payments; using Baya.Domain.Entities.Booking; namespace Baya.Application.Contracts.Persistence; @@ -20,6 +21,10 @@ public interface IBookingRepository /// a replayed conversion return the existing booking instead of creating a second one. Task GetBookingIdByRequestIdAsync(long bookingRequestId, CancellationToken cancellationToken); + /// The three frozen amounts + nurse for a booking — what b10's card-capture ledger group posts + /// against when the booking already exists (idempotent confirm). NULL when absent. + Task GetLedgerAmountsAsync(long id, CancellationToken cancellationToken); + // ---- detail + lists ---- Task GetDetailAsync(long id, CancellationToken cancellationToken); Task> ListForCustomerAsync(long customerId, string? status, int page, int pageSize, CancellationToken cancellationToken); diff --git a/server/src/Core/Baya.Application/Contracts/Persistence/IBookingRequestRepository.cs b/server/src/Core/Baya.Application/Contracts/Persistence/IBookingRequestRepository.cs index 1a54af9..49af701 100644 --- a/server/src/Core/Baya.Application/Contracts/Persistence/IBookingRequestRepository.cs +++ b/server/src/Core/Baya.Application/Contracts/Persistence/IBookingRequestRepository.cs @@ -1,6 +1,7 @@ #nullable enable using Baya.Application.Models.Booking; using Baya.Application.Models.Common; +using Baya.Application.Models.Payments; using Baya.Domain.Entities.Booking; namespace Baya.Application.Contracts.Persistence; @@ -49,4 +50,8 @@ public interface IBookingRequestRepository /// one projected read: ids + participant user ids, the engagement schedule, and the source data for the /// two frozen snapshots (variant + decrypted address). NULL when absent. Task GetConversionSourceAsync(long id, CancellationToken cancellationToken); + + /// The facts b10's InitiatePayment needs to validate a card attempt (owning customer, + /// status, frozen payment window, gross to charge). NULL when absent. + Task GetPaymentContextAsync(long id, CancellationToken cancellationToken); } diff --git a/server/src/Core/Baya.Application/Contracts/Persistence/IPaymentRepository.cs b/server/src/Core/Baya.Application/Contracts/Persistence/IPaymentRepository.cs new file mode 100644 index 0000000..320a27f --- /dev/null +++ b/server/src/Core/Baya.Application/Contracts/Persistence/IPaymentRepository.cs @@ -0,0 +1,51 @@ +#nullable enable +using Baya.Domain.Entities.Payments; + +namespace Baya.Application.Contracts.Persistence; + +/// +/// The payments-core aggregate (gateways, transactions, webhook events, ledger). Writes load tracked rows; +/// balance reads project a signed aggregate over the append-only ledger. Money is IRR long throughout. +/// The DB uniques (Shaparak ref, succeeded-per-booking, the webhook idempotency key) are the authoritative +/// money-path backstops — the handlers rely on them, not just on a pre-check. +/// +public interface IPaymentRepository +{ + // ---- gateway selection ---- + /// The active gateway of with the lowest priority — config-driven + /// selection so a cut-off provider is swapped without a code change. Null when none is configured. + Task GetActiveGatewayIdAsync(string type, CancellationToken cancellationToken); + + Task AddGatewayAsync(PaymentGateway gateway, CancellationToken cancellationToken); + + // ---- transactions ---- + Task AddTransactionAsync(PaymentTransaction transaction, CancellationToken cancellationToken); + + /// Whether a booking created from this request already has a captured (succeeded) transaction — + /// the initiate idempotency pre-check (the filtered succeeded-unique is the backstop). + Task HasSucceededTransactionForRequestAsync(long bookingRequestId, CancellationToken cancellationToken); + + /// The tracked pending transaction carrying — the webhook + /// re-verify + confirm loads it to bind the capture. Null when absent. + Task GetTrackedTransactionByReferenceAsync(string gatewayReferenceCode, CancellationToken cancellationToken); + + Task GetTrackedTransactionByIdAsync(long id, CancellationToken cancellationToken); + + // ---- webhook idempotency store ---- + /// The existing webhook event for this idempotency key, if any — a non-null result means a + /// duplicate replay the handler no-ops on. Null on a brand-new event. + Task GetWebhookEventByKeyAsync(string providerCode, string externalEventId, CancellationToken cancellationToken); + + Task AddWebhookEventAsync(PaymentWebhookEvent webhookEvent, CancellationToken cancellationToken); + + // ---- ledger ---- + /// Whether a posting group already exists for this payment transaction — makes the ledger post + /// idempotent so a re-confirm never writes a second capture group. + Task LedgerGroupExistsForTransactionAsync(long paymentTransactionId, CancellationToken cancellationToken); + + Task AddLedgerEntriesAsync(IEnumerable entries, CancellationToken cancellationToken); + + /// The IRR balance currently owed a nurse — the signed sum over nurse_payable legs + /// (credit adds, debit subtracts). Pure ledger projection, never a stored column. This is what b13 reads. + Task GetNursePayableBalanceAsync(long nurseId, CancellationToken cancellationToken); +} diff --git a/server/src/Core/Baya.Application/Contracts/Persistence/IUnitOfWork.cs b/server/src/Core/Baya.Application/Contracts/Persistence/IUnitOfWork.cs index 6e3ff0d..2af6d1b 100644 --- a/server/src/Core/Baya.Application/Contracts/Persistence/IUnitOfWork.cs +++ b/server/src/Core/Baya.Application/Contracts/Persistence/IUnitOfWork.cs @@ -18,6 +18,7 @@ public interface IUnitOfWork public IBookingRequestRepository BookingRequestRepository { get; } public IBookingRepository BookingRepository { get; } public ICancellationPolicyRepository CancellationPolicyRepository { get; } + public IPaymentRepository PaymentRepository { get; } Task CommitAsync(); ValueTask RollBackAsync(); } diff --git a/server/src/Core/Baya.Application/Features/Bookings/BookingFactory.cs b/server/src/Core/Baya.Application/Features/Bookings/BookingFactory.cs new file mode 100644 index 0000000..88f1ec9 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Bookings/BookingFactory.cs @@ -0,0 +1,108 @@ +#nullable enable +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Unicode; +using Baya.Application.Contracts.Common; +using Baya.Application.Models.Booking; +using Baya.Domain.Entities.Booking; +using BookingEntity = Baya.Domain.Entities.Booking.Booking; + +namespace Baya.Application.Features.Bookings; + +/// +/// The single place the confirmed bookings row (+ its reconciling sessions + the two frozen snapshots) +/// is built from an accepted_awaiting_payment request. It holds the conversion/amount logic so +/// both the b9 ConvertRequestToBooking path (mock-capture trigger) and the b10 real card-capture +/// confirm path share it rather than duplicate it. Pure construction only — no DB, no commit, no capture, no +/// current-user gate; the caller owns tenancy, capture, idempotency and persistence. +/// +internal static class BookingFactory +{ + /// session_count comes from the variant (a single visit is 1). + public static int SessionCount(BookingConversionSource source) + => source.VariantSnapshot.SessionCount is { } sc && sc > 0 ? sc : 1; + + /// The charged gross (IRR) = variant price × session count. Frozen onto the booking at build time. + public static long Gross(BookingConversionSource source) + => source.VariantSnapshot.Price * SessionCount(source); + + /// + /// Builds the booking (status confirmed) with its ≥ 1 reconciling sessions and the two frozen + /// snapshots. The three amounts derive from the snapshotted via + /// so gross = commission + payout always holds. + /// + public static BookingEntity Create( + BookingConversionSource source, + decimal rate, + DateTime now, + long? pspFeeAmount, + IVariantSnapshotSerializer variantSnapshotSerializer) + { + var sessionCount = SessionCount(source); + var gross = source.VariantSnapshot.Price * sessionCount; + var (commission, payout) = BookingAmounts.Split(gross, rate); + + var booking = new BookingEntity + { + BookingRequestId = source.RequestId, + CustomerId = source.CustomerId, + NurseId = source.NurseId, + PatientId = source.PatientId, + VariantId = source.VariantId, + CustomerAddressId = source.CustomerAddressId, + VariantSnapshotJson = variantSnapshotSerializer.Serialize(source.VariantSnapshot), + AddressSnapshotJson = SerializeAddress(source.AddressSnapshot), + GrossPriceIrr = gross, + BalinyaarCommissionIrr = commission, + PlatformFeeRate = rate, + NursePayoutAmount = payout, + PspFeeAmount = pspFeeAmount, + SessionCount = (short)sessionCount, + ScheduledDate = source.RequestedDate, + ScheduledTimeStart = source.RequestedTimeStart, + ScheduledTimeEnd = source.RequestedTimeEnd + }; + + // Always ≥ 1 session; Σ(visit_payout_amount) reconciles exactly to nurse_payout_amount. + var visitPayouts = BookingAmounts.SplitPayout(payout, sessionCount); + for (var i = 0; i < sessionCount; i++) + { + booking.Sessions.Add(new BookingSession + { + SessionIndex = i + 1, + ScheduledDate = source.RequestedDate, + ScheduledTimeStart = source.RequestedTimeStart, + ScheduledTimeEnd = source.RequestedTimeEnd, + VisitPayoutAmount = visitPayouts[i] + }); + } + + booking.TransitionTo(BookingStatus.Confirmed, now); + return booking; + } + + // Persian labels land in the snapshot as readable text (not \uXXXX escapes), mirroring the variant + // snapshot serializer; the encoder still escapes HTML-sensitive ASCII so the stored JSON stays safe. + private static readonly JsonSerializerOptions AddressJson = new() + { + Encoder = JavaScriptEncoder.Create(UnicodeRanges.All) + }; + + public static string SerializeAddress(AddressSnapshot a) => JsonSerializer.Serialize(new + { + addressId = a.AddressId, + title = a.Title, + cityId = a.CityId, + cityNameFa = a.CityNameFa, + cityNameEn = a.CityNameEn, + districtId = a.DistrictId, + districtNameFa = a.DistrictNameFa, + districtNameEn = a.DistrictNameEn, + addressLine = a.AddressLine, + postalCode = a.PostalCode, + recipientName = a.RecipientName, + recipientPhone = a.RecipientPhone, + latitude = a.Latitude, + longitude = a.Longitude + }, AddressJson); +} diff --git a/server/src/Core/Baya.Application/Features/Bookings/Commands/ConvertRequestToBooking/ConvertRequestToBookingCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Bookings/Commands/ConvertRequestToBooking/ConvertRequestToBookingCommand.Handler.cs index 9216664..71d0fde 100644 --- a/server/src/Core/Baya.Application/Features/Bookings/Commands/ConvertRequestToBooking/ConvertRequestToBookingCommand.Handler.cs +++ b/server/src/Core/Baya.Application/Features/Bookings/Commands/ConvertRequestToBooking/ConvertRequestToBookingCommand.Handler.cs @@ -1,7 +1,5 @@ #nullable enable -using System.Text.Encodings.Web; using System.Text.Json; -using System.Text.Unicode; using Baya.Application.Contracts.Common; using Baya.Application.Contracts.Configuration; using Baya.Application.Contracts.Persistence; @@ -9,9 +7,6 @@ using Baya.Application.Models.Booking; using Baya.Application.Models.Common; using Baya.Domain.Entities.Booking; using Mediator; -// The singular b8 namespace Baya.Application.Features.Booking shadows the entity type name `Booking` when -// referenced unqualified from this (plural) Features.Bookings area — alias it to disambiguate. -using BookingEntity = Baya.Domain.Entities.Booking.Booking; namespace Baya.Application.Features.Bookings.Commands.ConvertRequestToBooking; @@ -60,49 +55,10 @@ internal sealed class ConvertRequestToBookingCommandHandler( var now = dateTimeProvider.UtcNow.UtcDateTime; - // session_count comes from the variant (a single visit is 1); gross = price × sessions. - var sessionCount = source.VariantSnapshot.SessionCount is { } sc && sc > 0 ? sc : 1; - var gross = source.VariantSnapshot.Price * sessionCount; - var rate = await platformConfig.GetConfig("platform_fee_rate", cancellationToken); - var (commission, payout) = BookingAmounts.Split(gross, rate); - var booking = new BookingEntity - { - BookingRequestId = source.RequestId, - CustomerId = source.CustomerId, - NurseId = source.NurseId, - PatientId = source.PatientId, - VariantId = source.VariantId, - CustomerAddressId = source.CustomerAddressId, - VariantSnapshotJson = variantSnapshotSerializer.Serialize(source.VariantSnapshot), - AddressSnapshotJson = SerializeAddress(source.AddressSnapshot), - GrossPriceIrr = gross, - BalinyaarCommissionIrr = commission, - PlatformFeeRate = rate, - NursePayoutAmount = payout, - PspFeeAmount = capture.PspFeeAmount, - SessionCount = (short)sessionCount, - ScheduledDate = source.RequestedDate, - ScheduledTimeStart = source.RequestedTimeStart, - ScheduledTimeEnd = source.RequestedTimeEnd - }; - - // Always ≥ 1 session; Σ(visit_payout_amount) reconciles exactly to nurse_payout_amount. - var visitPayouts = BookingAmounts.SplitPayout(payout, sessionCount); - for (var i = 0; i < sessionCount; i++) - { - booking.Sessions.Add(new BookingSession - { - SessionIndex = i + 1, - ScheduledDate = source.RequestedDate, - ScheduledTimeStart = source.RequestedTimeStart, - ScheduledTimeEnd = source.RequestedTimeEnd, - VisitPayoutAmount = visitPayouts[i] - }); - } - - booking.TransitionTo(BookingStatus.Confirmed, now); + // The conversion/amount logic is shared with b10's real card capture — build through BookingFactory. + var booking = BookingFactory.Create(source, rate, now, capture.PspFeeAmount, variantSnapshotSerializer); // Flip the request → converted in the same unit of work. Re-check the tracked state so a racing // cancel/expiry that already moved it is a clean conflict, not a double conversion. @@ -136,29 +92,4 @@ internal sealed class ConvertRequestToBookingCommandHandler( var detail = await unitOfWork.BookingRepository.GetDetailAsync(booking.Id, cancellationToken); return OperationResult.SuccessResult(BookingMapper.ToDetailDto(detail!, includeAddress: true)); } - - // Persian labels land in the snapshot as readable text (not \uXXXX escapes), mirroring the variant - // snapshot serializer; the encoder still escapes HTML-sensitive ASCII so the stored JSON stays safe. - private static readonly JsonSerializerOptions AddressJson = new() - { - Encoder = JavaScriptEncoder.Create(UnicodeRanges.All) - }; - - private static string SerializeAddress(AddressSnapshot a) => JsonSerializer.Serialize(new - { - addressId = a.AddressId, - title = a.Title, - cityId = a.CityId, - cityNameFa = a.CityNameFa, - cityNameEn = a.CityNameEn, - districtId = a.DistrictId, - districtNameFa = a.DistrictNameFa, - districtNameEn = a.DistrictNameEn, - addressLine = a.AddressLine, - postalCode = a.PostalCode, - recipientName = a.RecipientName, - recipientPhone = a.RecipientPhone, - latitude = a.Latitude, - longitude = a.Longitude - }, AddressJson); } diff --git a/server/src/Core/Baya.Application/Features/Payments/Commands/ConfirmPaymentAndPostLedger/ConfirmPaymentAndPostLedgerCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Payments/Commands/ConfirmPaymentAndPostLedger/ConfirmPaymentAndPostLedgerCommand.Handler.cs new file mode 100644 index 0000000..320a6d0 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Payments/Commands/ConfirmPaymentAndPostLedger/ConfirmPaymentAndPostLedgerCommand.Handler.cs @@ -0,0 +1,144 @@ +#nullable enable +using System; +using System.Text.Json; +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Configuration; +using Baya.Application.Contracts.Payments; +using Baya.Application.Contracts.Persistence; +using Baya.Application.Features.Bookings; +using Baya.Application.Models.Common; +using Baya.Domain.Entities.Booking; +using Baya.Domain.Entities.Payments; +using Mediator; +using Microsoft.EntityFrameworkCore; + +namespace Baya.Application.Features.Payments.Commands.ConfirmPaymentAndPostLedger; + +internal sealed class ConfirmPaymentAndPostLedgerCommandHandler( + IUnitOfWork unitOfWork, + IPlatformConfig platformConfig, + IDateTimeProvider dateTimeProvider, + IPaymentProvider paymentProvider, + ISettlementSplitProvider settlementSplitProvider, + IVariantSnapshotSerializer variantSnapshotSerializer, + INotificationDispatcher notifications) + : IRequestHandler> +{ + public async ValueTask> Handle(ConfirmPaymentAndPostLedgerCommand request, CancellationToken cancellationToken) + { + var transaction = await unitOfWork.PaymentRepository.GetTrackedTransactionByIdAsync(request.PaymentTransactionId, cancellationToken); + if (transaction is null) + return OperationResult.NotFoundResult("Payment transaction not found."); + + // Already captured — idempotent no-op success (a replayed confirm must not re-post). + if (transaction.Status == PaymentTransactionStatus.Succeeded) + return OperationResult.SuccessResult(true); + + var now = dateTimeProvider.UtcNow.UtcDateTime; + + // Never trust a callback alone — re-verify amount + reference with the gateway before confirming. + var verify = await paymentProvider.VerifyAsync(transaction.GatewayReferenceCode ?? string.Empty, transaction.Amount, cancellationToken); + if (verify.Status != PaymentProviderStatus.Succeeded || verify.AmountIrr != transaction.Amount) + { + transaction.MarkFailed(verify.Status.ToString(), null); + await unitOfWork.CommitAsync(); + return OperationResult.FailureResult("The payment could not be verified with the gateway."); + } + + // Create/confirm the booking through the shared b9 conversion (idempotent on UNIQUE booking_request_id). + var (bookingId, amounts, created, participants) = await EnsureBookingAsync(transaction.BookingRequestId, now, cancellationToken); + if (bookingId is not { } booking) + return OperationResult.ConflictResult("This request can no longer be converted to a booking."); + + transaction.MarkSucceeded(booking, verify.Status.ToString(), transaction.GatewayResponseJson); + + // Idempotent ledger: don't post a second capture group if one already exists for this transaction. + if (!await unitOfWork.PaymentRepository.LedgerGroupExistsForTransactionAsync(transaction.Id, cancellationToken)) + { + var legs = LedgerPosting.CardCapture( + booking, amounts!.NurseId, amounts.GrossIrr, amounts.CommissionIrr, amounts.PayoutIrr, transaction.Id, now); + await unitOfWork.PaymentRepository.AddLedgerEntriesAsync(legs, cancellationToken); + } + + try + { + await unitOfWork.CommitAsync(); + } + catch (DbUpdateException) + { + // A concurrent confirm for a different transaction of the same booking hit the filtered + // UNIQUE(booking_id) WHERE status='succeeded' — the DB backstop. Treat as already-captured. + await unitOfWork.RollBackAsync(); + return OperationResult.SuccessResult(true); + } + + // The lawful تسهیم split to the registered IBANs (the provider credits each directly; the platform + // never moves money). The mock records the intent; a real adapter resolves each beneficiary's SHEBA. + await settlementSplitProvider.RegisterSplitAsync( + booking, + [ + new SettlementLeg("nurse-registered-sheba", amounts.PayoutIrr, "nurse"), + new SettlementLeg("platform-registered-sheba", amounts.CommissionIrr, "platform") + ], + cancellationToken); + + if (created && participants is { } p) + await NotifyConfirmedAsync(p, booking, cancellationToken); + + return OperationResult.SuccessResult(true); + } + + private async Task<(long? BookingId, BookingLedgerAmountsLocal? Amounts, bool Created, BookingParticipantsLocal? Participants)> + EnsureBookingAsync(long bookingRequestId, DateTime now, CancellationToken cancellationToken) + { + var existingId = await unitOfWork.BookingRepository.GetBookingIdByRequestIdAsync(bookingRequestId, cancellationToken); + if (existingId is { } eb) + { + var amounts = await unitOfWork.BookingRepository.GetLedgerAmountsAsync(eb, cancellationToken); + return amounts is null + ? (null, null, false, null) + : (eb, new BookingLedgerAmountsLocal(amounts.NurseId, amounts.GrossIrr, amounts.CommissionIrr, amounts.PayoutIrr), false, null); + } + + var source = await unitOfWork.BookingRequestRepository.GetConversionSourceAsync(bookingRequestId, cancellationToken); + if (source is null || source.Status != BookingRequestStatus.AcceptedAwaitingPayment) + return (null, null, false, null); + + var rate = await platformConfig.GetConfig("platform_fee_rate", cancellationToken); + var booking = BookingFactory.Create(source, rate, now, pspFeeAmount: null, variantSnapshotSerializer); + + // Re-check the tracked request so a racing cancel/expiry is a clean conflict, not a double conversion. + var trackedRequest = await unitOfWork.BookingRequestRepository.GetTrackedByIdAsync(source.RequestId, cancellationToken); + if (trackedRequest is null || !trackedRequest.CanTransitionTo(BookingRequestStatus.Converted)) + return (null, null, false, null); + + trackedRequest.MarkConverted(); + await unitOfWork.BookingRepository.AddAsync(booking, cancellationToken); + await unitOfWork.CommitAsync(); + + return ( + booking.Id, + new BookingLedgerAmountsLocal(booking.NurseId, booking.GrossPriceIrr, booking.BalinyaarCommissionIrr, booking.NursePayoutAmount), + true, + new BookingParticipantsLocal(source.CustomerUserId, source.NurseUserId)); + } + + private async Task NotifyConfirmedAsync(BookingParticipantsLocal p, long bookingId, CancellationToken cancellationToken) + { + var payload = JsonSerializer.Serialize(new { booking_id = bookingId }); + + await notifications.DispatchAsync( + new Notification(p.CustomerUserId, "booking_confirmed", "Booking confirmed", + "Your payment was captured and your booking is confirmed.", payload), + cancellationToken); + + await notifications.DispatchAsync( + new Notification(p.NurseUserId, "booking_confirmed_nurse", "New confirmed booking", + "A booking has been confirmed and paid. The care instructions and schedule are now available.", payload), + cancellationToken); + } + + private sealed record BookingLedgerAmountsLocal(long NurseId, long GrossIrr, long CommissionIrr, long PayoutIrr); + + private sealed record BookingParticipantsLocal(int CustomerUserId, int NurseUserId); +} diff --git a/server/src/Core/Baya.Application/Features/Payments/Commands/ConfirmPaymentAndPostLedger/ConfirmPaymentAndPostLedgerCommand.cs b/server/src/Core/Baya.Application/Features/Payments/Commands/ConfirmPaymentAndPostLedger/ConfirmPaymentAndPostLedgerCommand.cs new file mode 100644 index 0000000..05ae4a2 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Payments/Commands/ConfirmPaymentAndPostLedger/ConfirmPaymentAndPostLedgerCommand.cs @@ -0,0 +1,14 @@ +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Payments.Commands.ConfirmPaymentAndPostLedger; + +/// +/// Captures a verified payment: server-side re-verifies the attempt, creates/confirms the booking (through the +/// shared b9 conversion), posts the balanced card-capture ledger group (DEBIT escrow_held gross = +/// CREDIT platform_revenue commission + nurse_payable payout), and registers the تسهیم split. +/// It is never a public endpoint — dispatched only by HandlePaymentWebhook (and directly in +/// tests). Idempotent: an already-succeeded transaction, or a concurrent double-confirm caught by the filtered +/// UNIQUE(booking_id) WHERE status='succeeded', is a no-op success. +/// +public record ConfirmPaymentAndPostLedgerCommand(long PaymentTransactionId) : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Payments/Commands/HandlePaymentWebhook/HandlePaymentWebhookCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Payments/Commands/HandlePaymentWebhook/HandlePaymentWebhookCommand.Handler.cs new file mode 100644 index 0000000..c4acdc7 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Payments/Commands/HandlePaymentWebhook/HandlePaymentWebhookCommand.Handler.cs @@ -0,0 +1,118 @@ +#nullable enable +using System.Collections.Generic; +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Payments; +using Baya.Application.Contracts.Persistence; +using Baya.Application.Features.Payments.Commands.ConfirmPaymentAndPostLedger; +using Baya.Application.Models.Common; +using Baya.Application.Models.Payments; +using Baya.Domain.Entities.Payments; +using Mediator; +using Microsoft.EntityFrameworkCore; + +namespace Baya.Application.Features.Payments.Commands.HandlePaymentWebhook; + +internal sealed class HandlePaymentWebhookCommandHandler( + ISender sender, + IUnitOfWork unitOfWork, + IWebhookVerifier webhookVerifier, + IDistributedLock distributedLock, + IDateTimeProvider dateTimeProvider) + : IRequestHandler> +{ + public async ValueTask> Handle(HandlePaymentWebhookCommand request, CancellationToken cancellationToken) + { + var headers = request.Headers ?? new Dictionary(); + var rawBody = request.RawBody ?? string.Empty; + var verification = webhookVerifier.Verify(request.Provider, headers, rawBody); + var now = dateTimeProvider.UtcNow.UtcDateTime; + + // Dedup FIRST on the idempotency key: a duplicate replay never mutates money state again. + if (!string.IsNullOrEmpty(verification.ExternalEventId)) + { + var duplicate = await unitOfWork.PaymentRepository.GetWebhookEventByKeyAsync(request.Provider, verification.ExternalEventId, cancellationToken); + if (duplicate is not null) + return Success(duplicate.ProcessingStatus, isDuplicate: true); + } + + var webhookEvent = new PaymentWebhookEvent + { + ProviderCode = request.Provider, + ExternalEventId = verification.ExternalEventId, + EventType = verification.EventType, + SignatureValid = verification.SignatureValid, + PayloadJson = rawBody, + ReceivedAt = now + }; + + // An unverified-signature callback mutates nothing — stored ignored and stopped. + if (!verification.SignatureValid) + { + webhookEvent.MarkIgnored(now); + return await PersistNewEventAsync(webhookEvent, cancellationToken); + } + + // A non-success event (or one with no reference) has nothing to confirm — acknowledged, no money moves. + if (!verification.IsSuccessEvent || string.IsNullOrEmpty(verification.GatewayReferenceCode)) + { + webhookEvent.MarkProcessed(null, now); + return await PersistNewEventAsync(webhookEvent, cancellationToken); + } + + var transaction = await unitOfWork.PaymentRepository.GetTrackedTransactionByReferenceAsync(verification.GatewayReferenceCode!, cancellationToken); + if (transaction is null) + { + // No matching pending attempt — retryable (failed), never a silent success. + webhookEvent.MarkFailed(now); + return await PersistNewEventAsync(webhookEvent, cancellationToken); + } + + // The whole money mutation runs under the lock; the DB uniques remain the authoritative backstop if + // the lock is lost/expired. Keyed on the request (a b9 booking exists only after this confirm). + await using var _ = await distributedLock.AcquireAsync($"booking-request:{transaction.BookingRequestId}:payment", cancellationToken); + + // Claim the idempotency key first (inside the same context that mutates payment state). A racing + // duplicate insert loses on the unique index and is treated as a no-op replay. + await unitOfWork.PaymentRepository.AddWebhookEventAsync(webhookEvent, cancellationToken); + try + { + await unitOfWork.CommitAsync(); + } + catch (DbUpdateException) + { + await unitOfWork.RollBackAsync(); + return Success(WebhookProcessingStatus.Processed, isDuplicate: true); + } + + // Re-verify server-side + capture + post the balanced ledger group + confirm the booking. + var confirm = await sender.Send(new ConfirmPaymentAndPostLedgerCommand(transaction.Id), cancellationToken); + + if (confirm.IsSuccess) + webhookEvent.MarkProcessed(transaction.Id, now); + else + webhookEvent.MarkFailed(now); + + await unitOfWork.CommitAsync(); + return Success(webhookEvent.ProcessingStatus, isDuplicate: false); + } + + private async Task> PersistNewEventAsync(PaymentWebhookEvent webhookEvent, CancellationToken cancellationToken) + { + await unitOfWork.PaymentRepository.AddWebhookEventAsync(webhookEvent, cancellationToken); + try + { + await unitOfWork.CommitAsync(); + } + catch (DbUpdateException) + { + // A concurrent insert of the same (provider, external_event_id) — the idempotency backstop. + await unitOfWork.RollBackAsync(); + return Success(webhookEvent.ProcessingStatus, isDuplicate: true); + } + + return Success(webhookEvent.ProcessingStatus, isDuplicate: false); + } + + private static OperationResult Success(string processingStatus, bool isDuplicate) + => OperationResult.SuccessResult(new WebhookIngestResult(processingStatus, isDuplicate)); +} diff --git a/server/src/Core/Baya.Application/Features/Payments/Commands/HandlePaymentWebhook/HandlePaymentWebhookCommand.cs b/server/src/Core/Baya.Application/Features/Payments/Commands/HandlePaymentWebhook/HandlePaymentWebhookCommand.cs new file mode 100644 index 0000000..14132e9 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Payments/Commands/HandlePaymentWebhook/HandlePaymentWebhookCommand.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; +using Baya.Application.Models.Common; +using Baya.Application.Models.Payments; +using Mediator; + +namespace Baya.Application.Features.Payments.Commands.HandlePaymentWebhook; + +/// +/// The verify-then-dedup-then-mutate ingest for every inbound PSP callback. It verifies the signature, upserts +/// payment_webhook_events first on (provider_code, external_event_id) — no-op on a +/// duplicate replay — and, on a new success event, re-verifies server-side and dispatches +/// ConfirmPaymentAndPostLedger, all under a Redis lock(booking:{id}:payment) with the DB uniques +/// as the authoritative backstop. Authenticated by signature, not a user session; at-least-once tolerant. +/// +/// The provider code from the route (zarinpal/sadad/…). +/// The raw callback headers (signature material for the verifier). +/// The raw callback body — stored verbatim in payload_json. +public record HandlePaymentWebhookCommand(string Provider, IReadOnlyDictionary Headers, string RawBody) + : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Payments/Commands/InitiatePayment/InitiatePaymentCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Payments/Commands/InitiatePayment/InitiatePaymentCommand.Handler.cs new file mode 100644 index 0000000..e934805 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Payments/Commands/InitiatePayment/InitiatePaymentCommand.Handler.cs @@ -0,0 +1,82 @@ +#nullable enable +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Payments; +using Baya.Application.Contracts.Persistence; +using Baya.Application.Models.Common; +using Baya.Application.Models.Payments; +using Baya.Domain.Entities.Booking; +using Baya.Domain.Entities.Payments; +using Mediator; + +namespace Baya.Application.Features.Payments.Commands.InitiatePayment; + +internal sealed class InitiatePaymentCommandHandler( + ICurrentUser currentUser, + IUnitOfWork unitOfWork, + IPaymentProvider paymentProvider, + IDateTimeProvider dateTimeProvider) + : IRequestHandler> +{ + public async ValueTask> Handle(InitiatePaymentCommand request, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return OperationResult.UnauthorizedResult("Not authenticated."); + + var customerId = await unitOfWork.CustomerProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken); + if (customerId is null) + return OperationResult.NotFoundResult("Booking request not found."); + + // Tenancy: only the owning customer may pay; any other caller must not learn the request exists. + var ctx = await unitOfWork.BookingRequestRepository.GetPaymentContextAsync(request.BookingRequestId, cancellationToken); + if (ctx is null || ctx.CustomerId != customerId) + return OperationResult.NotFoundResult("Booking request not found."); + + // Already paid — do not open a second attempt. The filtered succeeded-unique is the structural backstop. + if (await unitOfWork.PaymentRepository.HasSucceededTransactionForRequestAsync(request.BookingRequestId, cancellationToken)) + return OperationResult.ConflictResult("This booking has already been paid."); + + if (ctx.Status != BookingRequestStatus.AcceptedAwaitingPayment) + return OperationResult.ConflictResult("This request is not awaiting payment."); + + var now = dateTimeProvider.UtcNow.UtcDateTime; + if (ctx.PaymentDeadlineAt is { } deadline && deadline < now) + return OperationResult.ConflictResult("The payment window for this request has lapsed."); + + // Config-driven selection: the active standard gateway with the lowest priority (swap a cut-off + // provider by config, not a code change). + var gatewayId = await unitOfWork.PaymentRepository.GetActiveGatewayIdAsync(PaymentGatewayType.Standard, cancellationToken); + if (gatewayId is null) + return OperationResult.FailureResult("No active payment gateway is configured."); + + var idempotencyKey = string.IsNullOrWhiteSpace(request.IdempotencyKey) + ? $"br-{request.BookingRequestId}" + : request.IdempotencyKey!; + + // amount is the request's frozen gross — never recomputed from client input. + var init = await paymentProvider.InitPaymentAsync(request.BookingRequestId, ctx.GrossIrr, idempotencyKey, cancellationToken); + + // A retried initiate (same idempotency key ⇒ same reference) reuses the existing pending attempt + // instead of colliding on the filtered UNIQUE(gateway_reference_code). + var existing = await unitOfWork.PaymentRepository.GetTrackedTransactionByReferenceAsync(init.GatewayReferenceCode, cancellationToken); + if (existing is not null) + return OperationResult.SuccessResult( + new InitiatePaymentResult(existing.Id, init.RedirectUrl, init.GatewayReferenceCode)); + + var transaction = new PaymentTransaction + { + BookingRequestId = request.BookingRequestId, + CustomerId = customerId.Value, + GatewayId = gatewayId.Value, + Amount = ctx.GrossIrr, + Currency = "IRR", + GatewayReferenceCode = init.GatewayReferenceCode, + IpAddress = currentUser.IpAddress + }; + + await unitOfWork.PaymentRepository.AddTransactionAsync(transaction, cancellationToken); + await unitOfWork.CommitAsync(); + + return OperationResult.SuccessResult( + new InitiatePaymentResult(transaction.Id, init.RedirectUrl, init.GatewayReferenceCode)); + } +} diff --git a/server/src/Core/Baya.Application/Features/Payments/Commands/InitiatePayment/InitiatePaymentCommand.Validator.cs b/server/src/Core/Baya.Application/Features/Payments/Commands/InitiatePayment/InitiatePaymentCommand.Validator.cs new file mode 100644 index 0000000..bb5feef --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Payments/Commands/InitiatePayment/InitiatePaymentCommand.Validator.cs @@ -0,0 +1,11 @@ +using FluentValidation; + +namespace Baya.Application.Features.Payments.Commands.InitiatePayment; + +public sealed class InitiatePaymentCommandValidator : AbstractValidator +{ + public InitiatePaymentCommandValidator() + { + RuleFor(x => x.BookingRequestId).GreaterThan(0); + } +} diff --git a/server/src/Core/Baya.Application/Features/Payments/Commands/InitiatePayment/InitiatePaymentCommand.cs b/server/src/Core/Baya.Application/Features/Payments/Commands/InitiatePayment/InitiatePaymentCommand.cs new file mode 100644 index 0000000..2906d53 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Payments/Commands/InitiatePayment/InitiatePaymentCommand.cs @@ -0,0 +1,18 @@ +using Baya.Application.Models.Common; +using Baya.Application.Models.Payments; +using Mediator; + +namespace Baya.Application.Features.Payments.Commands.InitiatePayment; + +/// +/// Starts a card payment for an accepted_awaiting_payment booking request owned by the caller: selects +/// the active standard gateway, asks the PSP to open an IPG session, and persists a pending +/// payment_transactions row (with the gateway reference, honouring its filtered unique). The charged +/// amount is the request's frozen gross (variant price × session count) — never client-supplied. A repeat for +/// a request already captured is a 409: the filtered UNIQUE(booking_id) WHERE status='succeeded' +/// is the structural backstop. +/// +/// The accepted request to pay for (a b9 bookings row exists only on capture). +/// The client's idempotency key, so a retried initiate reuses the same reference. +public record InitiatePaymentCommand(long BookingRequestId, string? IdempotencyKey) + : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Payments/Queries/GetNursePayableBalance/GetNursePayableBalanceQuery.Handler.cs b/server/src/Core/Baya.Application/Features/Payments/Queries/GetNursePayableBalance/GetNursePayableBalanceQuery.Handler.cs new file mode 100644 index 0000000..b971dc5 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Payments/Queries/GetNursePayableBalance/GetNursePayableBalanceQuery.Handler.cs @@ -0,0 +1,35 @@ +#nullable enable +using System.Globalization; +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Persistence; +using Baya.Application.Features.Bookings; +using Baya.Application.Models.Common; +using Baya.Application.Models.Payments; +using Mediator; + +namespace Baya.Application.Features.Payments.Queries.GetNursePayableBalance; + +internal sealed class GetNursePayableBalanceQueryHandler( + ICurrentUser currentUser, + IUnitOfWork unitOfWork) + : IRequestHandler> +{ + public async ValueTask> Handle(GetNursePayableBalanceQuery request, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return OperationResult.UnauthorizedResult("Not authenticated."); + + // The nurse themself, or an admin/finance role — no one else may read a nurse's payable balance. + var isAdmin = currentUser.Roles?.Any(BookingRoles.Admin.Contains) == true; + if (!isAdmin) + { + var callerNurseId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken); + if (callerNurseId is null || callerNurseId != request.NurseId) + return OperationResult.ForbiddenResult("You may only read your own payable balance."); + } + + var balance = await unitOfWork.PaymentRepository.GetNursePayableBalanceAsync(request.NurseId, cancellationToken); + return OperationResult.SuccessResult( + new NursePayableBalanceDto(request.NurseId, balance.ToString(CultureInfo.InvariantCulture))); + } +} diff --git a/server/src/Core/Baya.Application/Features/Payments/Queries/GetNursePayableBalance/GetNursePayableBalanceQuery.cs b/server/src/Core/Baya.Application/Features/Payments/Queries/GetNursePayableBalance/GetNursePayableBalanceQuery.cs new file mode 100644 index 0000000..1565baa --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Payments/Queries/GetNursePayableBalance/GetNursePayableBalanceQuery.cs @@ -0,0 +1,12 @@ +using Baya.Application.Models.Common; +using Baya.Application.Models.Payments; +using Mediator; + +namespace Baya.Application.Features.Payments.Queries.GetNursePayableBalance; + +/// +/// The IRR balance currently owed a nurse — the signed sum of nurse_payable ledger legs (credit adds, +/// debit subtracts). A pure projection over the append-only ledger (no cached wallet column ever); this +/// is what b13 payouts read to know what to pay. Authorized to the nurse themself or an admin/finance role. +/// +public record GetNursePayableBalanceQuery(long NurseId) : IRequest>; diff --git a/server/src/Core/Baya.Application/Models/Payments/PaymentDtos.cs b/server/src/Core/Baya.Application/Models/Payments/PaymentDtos.cs new file mode 100644 index 0000000..733859e --- /dev/null +++ b/server/src/Core/Baya.Application/Models/Payments/PaymentDtos.cs @@ -0,0 +1,39 @@ +namespace Baya.Application.Models.Payments; + +/// +/// The minimal facts InitiatePayment needs to validate a card attempt against an +/// accepted_awaiting_payment request: the owning customer (tenancy), the request status, the frozen +/// payment window, and the gross to charge (variant price × session count — the same figure b9 freezes onto +/// the booking on capture). +/// +public record BookingPaymentContext(long RequestId, long CustomerId, string Status, System.DateTime? PaymentDeadlineAt, long GrossIrr); + +/// The three frozen amounts + nurse a card-capture ledger group posts against, read from an existing +/// booking (the confirm path is idempotent — a booking created by a prior confirm reuses this). +public record BookingLedgerAmounts(long NurseId, long GrossIrr, long CommissionIrr, long PayoutIrr); + +/// +/// The result of starting a card payment: the redirect the client sends the payer to, plus the ids that +/// identify the attempt. Money never appears here — it is the booking's frozen gross, echoed nowhere the +/// customer could tamper with it. +/// +/// The payment_transactions row id for this attempt. +/// Where to send the payer to complete the card payment. +/// The deterministic gateway reference persisted on the attempt. +public record InitiatePaymentResult(long TransactionId, string RedirectUrl, string GatewayReferenceCode); + +/// +/// The outcome of a webhook ingest — the terminal processing_status and whether this call was a +/// duplicate replay that was short-circuited. The endpoint always returns success (at-least-once tolerant). +/// +/// A payment_webhook_events.processing_status code. +/// True when the idempotency key was already stored — no money state was touched. +public record WebhookIngestResult(string ProcessingStatus, bool Duplicate); + +/// +/// The IRR balance currently owed a nurse, derived from the ledger (signed by direction), never a +/// stored column. Serialized as a string of digits per the money convention. +/// +/// The nurse profile id. +/// The signed nurse_payable sum, as a digit string. +public record NursePayableBalanceDto(long NurseId, string BalanceIrr); diff --git a/server/src/Core/Baya.Domain/Entities/Payments/LedgerAccountType.cs b/server/src/Core/Baya.Domain/Entities/Payments/LedgerAccountType.cs new file mode 100644 index 0000000..7c17cf3 --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/Payments/LedgerAccountType.cs @@ -0,0 +1,35 @@ +namespace Baya.Domain.Entities.Payments; + +/// +/// The closed set of double-entry ledger_entries.account_type codes — the accounts every money event +/// posts against. This phase only posts the first three (the card-capture group), but all are defined +/// now because b11 (refunds/clawbacks) and b12 (BNPL) post against the rest. Balances are derived by +/// filtering on these, never stored in a drifting column. +/// +public static class LedgerAccountType +{ + /// Funds held in escrow state (legally at the PSP/bank, never platform cash). Debited on capture. + public const string EscrowHeld = "escrow_held"; + + /// Balinyaar's own commission revenue. Credited on capture. + public const string PlatformRevenue = "platform_revenue"; + + /// Amount owed to a nurse (carries nurse_id). Credited on capture; b13 pays it down. + public const string NursePayable = "nurse_payable"; + + /// Amount owed back to a customer for a refund. Posted by b11. + public const string RefundPayable = "refund_payable"; + + /// The BNPL provider's commission expense. Posted by b12's settle group. + public const string BnplFeeExpense = "bnpl_fee_expense"; + + /// The PSP/gateway fee expense on a capture (true margin). Reserved. + public const string PspFeeExpense = "psp_fee_expense"; + + /// A receivable from a nurse already paid when a booking is later refunded (carries nurse_id). + /// Posted by b11. + public const string NurseClawbackReceivable = "nurse_clawback_receivable"; + + /// Written-off uncollectable amount. Reserved. + public const string BadDebt = "bad_debt"; +} diff --git a/server/src/Core/Baya.Domain/Entities/Payments/LedgerDirection.cs b/server/src/Core/Baya.Domain/Entities/Payments/LedgerDirection.cs new file mode 100644 index 0000000..a745e70 --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/Payments/LedgerDirection.cs @@ -0,0 +1,23 @@ +namespace Baya.Domain.Entities.Payments; + +/// +/// The two ledger_entries.direction codes. amount_irr is always positive; the direction +/// carries the sign. A balanced posting group has Σ(debit) = Σ(credit). +/// +public static class LedgerDirection +{ + public const string Debit = "debit"; + public const string Credit = "credit"; +} + +/// +/// The ledger_entries.source_ref_type codes — what a posting group's source_ref_id points at. +/// +public static class LedgerSourceRefType +{ + public const string PaymentTransaction = "payment_transaction"; + public const string Refund = "refund"; + public const string NursePayout = "nurse_payout"; + public const string BnplTransaction = "bnpl_transaction"; + public const string Clawback = "clawback"; +} diff --git a/server/src/Core/Baya.Domain/Entities/Payments/LedgerEntry.cs b/server/src/Core/Baya.Domain/Entities/Payments/LedgerEntry.cs new file mode 100644 index 0000000..c9e1a3c --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/Payments/LedgerEntry.cs @@ -0,0 +1,48 @@ +#nullable enable +using Baya.Domain.Common; + +namespace Baya.Domain.Entities.Payments; + +/// +/// One leg of the append-only, double-entry financial source of truth. Every money event posts +/// balanced legs sharing a (Σ debit = Σ credit per group); +/// is always positive and carries the sign. Per-nurse balances +/// (b13) derive by filtering account_type = 'nurse_payable' AND nurse_id = … — never a stored column. +/// +/// This entity is insert-only: it implements but not +/// /, so the audit interceptor never stamps a +/// modify on it and there is no soft-delete path. Corrections are new balancing rows, never edits. +/// is set explicitly from IDateTimeProvider at post time. +/// +/// +public class LedgerEntry : IEntity +{ + public long Id { get; set; } + + /// Groups the balanced legs of one money event. + public Guid TransactionGroupId { get; set; } + + /// A code. + public string AccountType { get; set; } = null!; + + /// Set for nurse_payable/nurse_clawback_receivable legs; null otherwise. + public long? NurseId { get; set; } + + /// A code — carries the sign of . + public string Direction { get; set; } = null!; + + /// Always positive IRR; the sign lives in . No floats. + public long AmountIrr { get; set; } + + public long? BookingId { get; set; } + + /// A code. + public string SourceRefType { get; set; } = null!; + + public long SourceRefId { get; set; } + + public string? Memo { get; set; } + + /// Append-only; never updated. Set from IDateTimeProvider at post time. + public DateTime CreatedAt { get; set; } +} diff --git a/server/src/Core/Baya.Domain/Entities/Payments/LedgerPosting.cs b/server/src/Core/Baya.Domain/Entities/Payments/LedgerPosting.cs new file mode 100644 index 0000000..f1a2806 --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/Payments/LedgerPosting.cs @@ -0,0 +1,52 @@ +#nullable enable +namespace Baya.Domain.Entities.Payments; + +/// +/// Builds the canonical, balanced ledger posting groups so the posting discipline lives in one +/// unit-testable place instead of a handler. Every group returned satisfies Σ(debit) = Σ(credit). +/// +public static class LedgerPosting +{ + /// + /// The card-capture group: DEBIT escrow_held gross = CREDIT platform_revenue commission + + /// nurse_payable payout, all under one fresh . The three + /// amounts come frozen from the booking (b9) — never recomputed here — and must reconcile + /// (gross = commission + payout); this method throws if they do not, so an unbalanced group can + /// never be persisted. + /// + public static IReadOnlyList CardCapture( + long bookingId, + long nurseId, + long grossIrr, + long commissionIrr, + long payoutIrr, + long paymentTransactionId, + DateTime createdAt) + { + if (grossIrr != commissionIrr + payoutIrr) + throw new InvalidOperationException( + $"Card-capture group would not balance: gross {grossIrr} != commission {commissionIrr} + payout {payoutIrr}."); + + var group = Guid.NewGuid(); + + LedgerEntry Leg(string account, string direction, long amount, long? nurse) => new() + { + TransactionGroupId = group, + AccountType = account, + Direction = direction, + AmountIrr = amount, + NurseId = nurse, + BookingId = bookingId, + SourceRefType = LedgerSourceRefType.PaymentTransaction, + SourceRefId = paymentTransactionId, + CreatedAt = createdAt + }; + + return + [ + Leg(LedgerAccountType.EscrowHeld, LedgerDirection.Debit, grossIrr, null), + Leg(LedgerAccountType.PlatformRevenue, LedgerDirection.Credit, commissionIrr, null), + Leg(LedgerAccountType.NursePayable, LedgerDirection.Credit, payoutIrr, nurseId) + ]; + } +} diff --git a/server/src/Core/Baya.Domain/Entities/Payments/PaymentGateway.cs b/server/src/Core/Baya.Domain/Entities/Payments/PaymentGateway.cs new file mode 100644 index 0000000..0e4bdb9 --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/Payments/PaymentGateway.cs @@ -0,0 +1,34 @@ +#nullable enable +using Baya.Domain.Common; + +namespace Baya.Domain.Entities.Payments; + +/// +/// Config per connected PSP/BNPL provider — the unit of selection and failover. The active +/// standard gateway with the lowest is chosen for a card capture, so a +/// cut-off provider (Toman/Jibit were abruptly suspended Nov 2024) is swapped by config, not a code +/// change. holds provider-selection/failover config (merchant id, terminal/IBAN +/// registration for the تسهیم split, base url, sandbox flag) — never per-transaction credentials — and +/// is encrypted at rest through the field encryptor and never logged in plaintext. +/// +public class PaymentGateway : BaseEntity +{ + /// Provider code — zarinpal / sadad / vandar / jibit + public string ProviderCode { get; set; } = null!; + + /// A code — selects the card (standard) vs BNPL flow. + public string Type { get; set; } = PaymentGatewayType.Standard; + + public string DisplayName { get; set; } = null!; + + /// Encrypted provider-selection/failover config (merchant id, terminal/IBAN registration, base + /// url, sandbox flag). Encrypted at rest via IFieldEncryptor; never per-transaction credentials. + public string ConfigJson { get; set; } = null!; + + public bool IsActive { get; set; } = true; + + /// Failover order — the active gateway of a type with the lowest priority wins selection. + public int Priority { get; set; } + + public DateTimeOffset? DeletedAt { get; set; } +} diff --git a/server/src/Core/Baya.Domain/Entities/Payments/PaymentGatewayType.cs b/server/src/Core/Baya.Domain/Entities/Payments/PaymentGatewayType.cs new file mode 100644 index 0000000..2cce6a6 --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/Payments/PaymentGatewayType.cs @@ -0,0 +1,15 @@ +namespace Baya.Domain.Entities.Payments; + +/// +/// The closed payment_gateways.type code set — it selects the flow. A standard gateway +/// is a card IPG (Shaparak-routed); a bnpl gateway is a Buy-Now-Pay-Later provider (b12). Persisted +/// as these stable snake_case codes, never a C# enum member name. +/// +public static class PaymentGatewayType +{ + /// Card IPG — the rail this phase drives end-to-end. + public const string Standard = "standard"; + + /// Buy-Now-Pay-Later provider — settle flow is DEFERRED to b12. + public const string Bnpl = "bnpl"; +} diff --git a/server/src/Core/Baya.Domain/Entities/Payments/PaymentTransaction.cs b/server/src/Core/Baya.Domain/Entities/Payments/PaymentTransaction.cs new file mode 100644 index 0000000..74a2107 --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/Payments/PaymentTransaction.cs @@ -0,0 +1,74 @@ +#nullable enable +using Baya.Domain.Common; + +namespace Baya.Domain.Entities.Payments; + +/// +/// Every payment attempt against a booking request; the row +/// is what triggers confirmation. It stores the full and the Shaparak +/// — the definitive proof for reconciliation and chargebacks. +/// +/// Two structural idempotency guards protect the money path (configured on the table, not just in a handler): +/// a filtered UNIQUE(gateway_reference_code) WHERE NOT NULL (Shaparak ref dedupe) and a filtered +/// UNIQUE(booking_id) WHERE status='succeeded' (at most one capturing transaction per booking). +/// The latter is the authoritative anti-double-capture backstop even if the Redis lock is lost. +/// +/// +/// Because a b9 bookings row is created only on capture, the attempt is opened against the +/// originating and stays null until confirmation binds +/// the two together (which is also when the succeeded-unique starts guarding the booking). +/// +/// +public class PaymentTransaction : BaseEntity +{ + /// The originating accepted_awaiting_payment request this attempt pays for. + public long BookingRequestId { get; set; } + + /// The confirmed booking, set only when this attempt succeeds and conversion creates it. Null + /// while pending — so the filtered succeeded-unique only ever guards a captured booking. + public long? BookingId { get; private set; } + + public long CustomerId { get; set; } + public long GatewayId { get; set; } + + /// The charged amount (IRR) — the booking's frozen gross. No floats, ever. + public long Amount { get; set; } + + /// Always IRR internally; Toman is a display concern at the provider boundary only. + public string Currency { get; set; } = "IRR"; + + /// Guarded — mutated only through /. + public string Status { get; private set; } = PaymentTransactionStatus.Pending; + + public string? GatewayTransactionId { get; set; } + + /// The Shaparak reference code — definitive reconciliation/chargeback proof. Filtered-unique. + public string? GatewayReferenceCode { get; set; } + + public string? GatewayResponseCode { get; private set; } + public string? GatewayResponseJson { get; private set; } + + public bool IsInstallment { get; set; } + public string? IpAddress { get; set; } + public string? UserAgent { get; set; } + + public DateTimeOffset? DeletedAt { get; set; } + + /// Binds the succeeded capture to its booking and records the gateway response. The filtered + /// UNIQUE(booking_id) WHERE status='succeeded' makes a second succeeded row for the same booking + /// impossible — a concurrent double-confirm fails on the constraint (treated as an idempotent no-op). + public void MarkSucceeded(long bookingId, string? responseCode, string? responseJson) + { + BookingId = bookingId; + Status = PaymentTransactionStatus.Succeeded; + GatewayResponseCode = responseCode; + GatewayResponseJson = responseJson; + } + + public void MarkFailed(string? responseCode, string? responseJson) + { + Status = PaymentTransactionStatus.Failed; + GatewayResponseCode = responseCode; + GatewayResponseJson = responseJson; + } +} diff --git a/server/src/Core/Baya.Domain/Entities/Payments/PaymentTransactionStatus.cs b/server/src/Core/Baya.Domain/Entities/Payments/PaymentTransactionStatus.cs new file mode 100644 index 0000000..9aea755 --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/Payments/PaymentTransactionStatus.cs @@ -0,0 +1,18 @@ +namespace Baya.Domain.Entities.Payments; + +/// +/// The closed payment_transactions.status code set. Exactly one row may exist +/// per booking — enforced structurally by the filtered UNIQUE(booking_id) WHERE status='succeeded', +/// the authoritative anti-double-capture backstop. Persisted as these stable snake_case codes. +/// +public static class PaymentTransactionStatus +{ + /// An IPG session was started; awaiting the PSP callback. + public const string Pending = "pending"; + + /// Capture confirmed (server-side re-verified) — triggers the ledger posting + booking confirm. + public const string Succeeded = "succeeded"; + + /// The attempt failed at the gateway; a fresh attempt is a new row. + public const string Failed = "failed"; +} diff --git a/server/src/Core/Baya.Domain/Entities/Payments/PaymentWebhookEvent.cs b/server/src/Core/Baya.Domain/Entities/Payments/PaymentWebhookEvent.cs new file mode 100644 index 0000000..da345df --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/Payments/PaymentWebhookEvent.cs @@ -0,0 +1,54 @@ +#nullable enable +using Baya.Domain.Common; + +namespace Baya.Domain.Entities.Payments; + +/// +/// The raw, deduplicated store of every inbound PSP/BNPL callback — the idempotency chokepoint of the +/// whole money path. PSP callbacks are at-least-once and retried, so the handler upserts here first, +/// keyed on UNIQUE(provider_code, external_event_id), and no-ops on a duplicate inside the same +/// transaction that would mutate payment state: a replayed succeeded can never double-confirm and a +/// replayed settled can never double-count. +/// +public class PaymentWebhookEvent : BaseEntity +{ + public string ProviderCode { get; set; } = null!; + + /// The provider's own event id — the second half of the idempotency key. + public string ExternalEventId { get; set; } = null!; + + public string EventType { get; set; } = null!; + + /// Whether the signature verified. A false here forces + /// and stops the path before any money moves. + public bool SignatureValid { get; set; } + + public string PayloadJson { get; set; } = null!; + + /// Guarded — mutated only through the mark-* methods. + public string ProcessingStatus { get; private set; } = WebhookProcessingStatus.Received; + + public long? RelatedPaymentTransactionId { get; private set; } + + public DateTime ReceivedAt { get; set; } + public DateTime? ProcessedAt { get; private set; } + + public void MarkProcessed(long? relatedPaymentTransactionId, DateTime processedAt) + { + ProcessingStatus = WebhookProcessingStatus.Processed; + RelatedPaymentTransactionId = relatedPaymentTransactionId; + ProcessedAt = processedAt; + } + + public void MarkIgnored(DateTime processedAt) + { + ProcessingStatus = WebhookProcessingStatus.Ignored; + ProcessedAt = processedAt; + } + + public void MarkFailed(DateTime processedAt) + { + ProcessingStatus = WebhookProcessingStatus.Failed; + ProcessedAt = processedAt; + } +} diff --git a/server/src/Core/Baya.Domain/Entities/Payments/WebhookProcessingStatus.cs b/server/src/Core/Baya.Domain/Entities/Payments/WebhookProcessingStatus.cs new file mode 100644 index 0000000..25ce0b5 --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/Payments/WebhookProcessingStatus.cs @@ -0,0 +1,20 @@ +namespace Baya.Domain.Entities.Payments; + +/// +/// The closed payment_webhook_events.processing_status code set. Persisted as these stable snake_case +/// codes. +/// +public static class WebhookProcessingStatus +{ + /// Stored, not yet acted on (the transient state a brand-new event is inserted in). + public const string Received = "received"; + + /// Verified, deduplicated and applied — money state was mutated by this event. + public const string Processed = "processed"; + + /// Verified but the downstream mutation failed; safe to retry. + public const string Failed = "failed"; + + /// Rejected before any money moved — an invalid signature or a nothing-to-do event. + public const string Ignored = "ignored"; +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/InProcessDistributedLock.cs b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/InProcessDistributedLock.cs new file mode 100644 index 0000000..43db9af --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/InProcessDistributedLock.cs @@ -0,0 +1,33 @@ +#nullable enable +using System.Collections.Concurrent; +using Baya.Application.Contracts.Payments; + +namespace Baya.Infrastructure.CrossCutting.Seams; + +/// +/// In-process mock — a per-key so the money-path +/// code runs the same acquire/release shape it will with real Redis, within a single process. It is +/// deliberately not a correctness guarantee across instances: the DB uniques/state-machine are the +/// authoritative backstop. A real StackExchange.Redis lock (lease/expiry, key booking:{id}:payment) +/// replaces this registration only. +/// +public sealed class InProcessDistributedLock : IDistributedLock +{ + private static readonly ConcurrentDictionary Gates = new(); + + public async ValueTask AcquireAsync(string key, CancellationToken cancellationToken = default) + { + var gate = Gates.GetOrAdd(key, _ => new SemaphoreSlim(1, 1)); + await gate.WaitAsync(cancellationToken); + return new Release(gate); + } + + private sealed class Release(SemaphoreSlim gate) : IAsyncDisposable + { + public ValueTask DisposeAsync() + { + gate.Release(); + return ValueTask.CompletedTask; + } + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/MockPaymentProvider.cs b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/MockPaymentProvider.cs new file mode 100644 index 0000000..5ceb96c --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/MockPaymentProvider.cs @@ -0,0 +1,29 @@ +#nullable enable +using Baya.Application.Contracts.Payments; + +namespace Baya.Infrastructure.CrossCutting.Seams; + +/// +/// Deterministic mock — no external call. returns +/// a stable fake reference derived from the request + idempotency key and a fake redirect URL; +/// instantly succeeds and echoes the expected amount (the server-side re-check always +/// passes in the mock); always succeeds (so b11 can call it). A real +/// ZarinPal/Sadad/Vandar/Jibit adapter (acquirer-with-تسهیم, Shaparak reference, sandbox flag from the +/// gateway's encrypted config) replaces this registration only — no mock behaviour is baked into a handler. +/// +public sealed class MockPaymentProvider : IPaymentProvider +{ + public ValueTask InitPaymentAsync(long bookingRequestId, long amountIrr, string idempotencyKey, CancellationToken cancellationToken = default) + { + var reference = $"mock-ref-{bookingRequestId}-{idempotencyKey}"; + return ValueTask.FromResult(new PaymentInitResult( + RedirectUrl: $"https://mock-psp.local/pay/{reference}", + GatewayReferenceCode: reference)); + } + + public ValueTask VerifyAsync(string gatewayReferenceCode, long expectedAmountIrr, CancellationToken cancellationToken = default) + => ValueTask.FromResult(new PaymentVerifyResult(PaymentProviderStatus.Succeeded, expectedAmountIrr)); + + public ValueTask RefundAsync(string gatewayReferenceCode, long amountIrr, CancellationToken cancellationToken = default) + => ValueTask.FromResult(new PaymentRefundResult(PaymentProviderStatus.Succeeded, $"mock-refund-{gatewayReferenceCode}")); +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/MockSettlementSplitProvider.cs b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/MockSettlementSplitProvider.cs new file mode 100644 index 0000000..d45dc34 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/MockSettlementSplitProvider.cs @@ -0,0 +1,25 @@ +#nullable enable +using Baya.Application.Contracts.Payments; + +namespace Baya.Infrastructure.CrossCutting.Seams; + +/// +/// Deterministic mock — records the split intent and reports it +/// settled without moving a Rial (the platform never custodies funds). It accepts any legs whose sum is +/// positive and returns . A real تسهیم adapter (each beneficiary's +/// registered SHEBA, split-by-ratio config, the ~100,000 IRR min-amount caveat; the provider credits IBANs +/// directly) replaces this registration only. +/// +public sealed class MockSettlementSplitProvider : ISettlementSplitProvider +{ + public ValueTask RegisterSplitAsync(long bookingId, IReadOnlyList legs, CancellationToken cancellationToken = default) + { + var status = legs.Count > 0 && legs.Sum(l => l.AmountIrr) > 0 + ? SettlementStatus.Settled + : SettlementStatus.Failed; + return ValueTask.FromResult(new SettlementResult(status)); + } + + public ValueTask GetSplitStatusAsync(long bookingId, CancellationToken cancellationToken = default) + => ValueTask.FromResult(new SettlementResult(SettlementStatus.Settled)); +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/MockWebhookVerifier.cs b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/MockWebhookVerifier.cs new file mode 100644 index 0000000..ea19000 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/MockWebhookVerifier.cs @@ -0,0 +1,47 @@ +#nullable enable +using System.Text.Json; +using Baya.Application.Contracts.Payments; +using Microsoft.Extensions.Options; + +namespace Baya.Infrastructure.CrossCutting.Seams; + +/// +/// Deterministic mock . It treats the signature as valid unless the raw body +/// carries the configured invalid-signature marker (so the "unverified callback mutates nothing" path is +/// testable), and extracts a test external_event_id / event_type / gateway_reference_code +/// from a small JSON body — which lets tests replay a duplicate callback to prove idempotency. A real adapter +/// implements the per-provider HMAC/signature scheme (or the mandatory server-side verify re-check). +/// +public sealed class MockWebhookVerifier(IOptions options) : IWebhookVerifier +{ + private readonly PaymentsOptions _options = options.Value.Payments; + + public WebhookVerification Verify(string provider, IReadOnlyDictionary headers, string rawBody) + { + var signatureValid = !rawBody.Contains(_options.InvalidSignatureMarker, StringComparison.Ordinal); + + 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) + { + // A body we can't parse yields an empty id/type; the handler stores it and no-ops (nothing to do). + } + + var isSuccessEvent = eventType.Contains("succeed", StringComparison.OrdinalIgnoreCase); + + return new WebhookVerification(signatureValid, externalEventId, eventType, gatewayReferenceCode, isSuccessEvent); + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/SeamOptions.cs b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/SeamOptions.cs index c4e2452..e850c54 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/SeamOptions.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/SeamOptions.cs @@ -15,6 +15,22 @@ public sealed class SeamOptions public ShahkarOptions Shahkar { get; set; } = new(); public IdentityKycOptions IdentityKyc { get; set; } = new(); public PaymentCaptureOptions PaymentCapture { get; set; } = new(); + public PaymentsOptions Payments { get; set; } = new(); +} + +/// +/// 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, +/// never from here. +/// +public sealed class PaymentsOptions +{ + /// The platform's own registered IBAN (SHEBA) the mock 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 + /// mock verifier, so the "unverified callback mutates nothing" path is testable. + public string InvalidSignatureMarker { get; set; } = "INVALID_SIGNATURE"; } /// diff --git a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/ServiceConfiguration/ServiceCollectionExtension.cs b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/ServiceConfiguration/ServiceCollectionExtension.cs index 5c05d4c..d073a84 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/ServiceConfiguration/ServiceCollectionExtension.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/ServiceConfiguration/ServiceCollectionExtension.cs @@ -1,4 +1,5 @@ using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Payments; using Baya.Infrastructure.CrossCutting.Seams; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -48,6 +49,15 @@ public static class ServiceCollectionExtension // 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(); + return services; } } diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/ApplicationDbContext.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/ApplicationDbContext.cs index 59da028..69110fb 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Persistence/ApplicationDbContext.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/ApplicationDbContext.cs @@ -3,6 +3,7 @@ using Baya.Application.Contracts.Common; using Baya.Domain.Common; using Baya.Domain.Entities.Booking; using Baya.Domain.Entities.Identity; +using Baya.Domain.Entities.Payments; using Baya.Domain.Entities.User; using Baya.Domain.Entities.Verification; using Baya.Infrastructure.Persistence.ValueConversion; @@ -157,5 +158,12 @@ public class ApplicationDbContext: IdentityDbContext c.EmergencyContactName).HasConversion(encrypted); builder.Property(c => c.EmergencyContactPhone).HasConversion(encrypted); }); + + // b10 gateway config: provider-selection/failover config (merchant id, terminal/IBAN registration, + // base url, sandbox flag) is encrypted at rest through the same seam and never logged in plaintext. + modelBuilder.Entity(builder => + { + builder.Property(g => g.ConfigJson).HasConversion(encrypted); + }); } } \ No newline at end of file diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/PaymentsConfig/LedgerEntryConfig.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/PaymentsConfig/LedgerEntryConfig.cs new file mode 100644 index 0000000..a7809da --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/PaymentsConfig/LedgerEntryConfig.cs @@ -0,0 +1,36 @@ +using Baya.Domain.Entities.Booking; +using Baya.Domain.Entities.Identity; +using Baya.Domain.Entities.Payments; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Baya.Infrastructure.Persistence.Configuration.PaymentsConfig; + +/// +/// ledger_entries is append-only: no soft-delete, no audit-modified columns, no query filter, +/// and (because the entity implements IEntity only, not ITimeModification) the audit interceptor +/// never stamps a modify on it. Corrections are new balancing rows, never edits. +/// +internal sealed class LedgerEntryConfig : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("LedgerEntries", "payments"); + + builder.HasKey(e => e.Id); + + builder.Property(e => e.AccountType).HasMaxLength(40).IsRequired(); + builder.Property(e => e.Direction).HasMaxLength(6).IsRequired(); + builder.Property(e => e.SourceRefType).HasMaxLength(40).IsRequired(); + builder.Property(e => e.Memo).HasMaxLength(300); + + // Balance reads (b13 payouts): the nurse_payable balance for a nurse; a posting group; a source's legs. + builder.HasIndex(e => new { e.AccountType, e.NurseId }); + builder.HasIndex(e => e.TransactionGroupId); + builder.HasIndex(e => new { e.SourceRefType, e.SourceRefId }); + builder.HasIndex(e => e.BookingId); + + builder.HasOne().WithMany().HasForeignKey(e => e.NurseId).IsRequired(false); + builder.HasOne().WithMany().HasForeignKey(e => e.BookingId).IsRequired(false); + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/PaymentsConfig/PaymentGatewayConfig.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/PaymentsConfig/PaymentGatewayConfig.cs new file mode 100644 index 0000000..7ee990c --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/PaymentsConfig/PaymentGatewayConfig.cs @@ -0,0 +1,24 @@ +using Baya.Domain.Entities.Payments; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Baya.Infrastructure.Persistence.Configuration.PaymentsConfig; + +internal sealed class PaymentGatewayConfig : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("PaymentGateways", "payments"); + + builder.Property(g => g.ProviderCode).HasMaxLength(50).IsRequired(); + builder.Property(g => g.Type).HasMaxLength(20).IsRequired(); + builder.Property(g => g.DisplayName).HasMaxLength(100).IsRequired(); + // config_json is encrypted at rest (converter wired in ApplicationDbContext); nvarchar(max), no cap. + builder.Property(g => g.ConfigJson).IsRequired(); + + // Gateway selection reads the active gateway of a type by lowest priority — a covering index. + builder.HasIndex(g => new { g.Type, g.IsActive, g.Priority }); + + builder.HasQueryFilter(g => g.DeletedAt == null); + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/PaymentsConfig/PaymentTransactionConfig.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/PaymentsConfig/PaymentTransactionConfig.cs new file mode 100644 index 0000000..a97fc32 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/PaymentsConfig/PaymentTransactionConfig.cs @@ -0,0 +1,45 @@ +using Baya.Domain.Entities.Booking; +using Baya.Domain.Entities.Identity; +using Baya.Domain.Entities.Payments; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Baya.Infrastructure.Persistence.Configuration.PaymentsConfig; + +internal sealed class PaymentTransactionConfig : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("PaymentTransactions", "payments"); + + builder.Property(t => t.Currency).HasMaxLength(3).IsRequired(); + builder.Property(t => t.Status).HasMaxLength(20).IsRequired(); + builder.Property(t => t.GatewayTransactionId).HasMaxLength(200); + builder.Property(t => t.GatewayReferenceCode).HasMaxLength(200); + builder.Property(t => t.GatewayResponseCode).HasMaxLength(50); + builder.Property(t => t.IpAddress).HasMaxLength(64); + builder.Property(t => t.UserAgent).HasMaxLength(400); + + // The two structural idempotency guards (the whole point of the phase). SQL Server/SQLite both honour + // filtered (partial) unique indexes; NULLs sit outside the filter so pending rows don't collide. + builder.HasIndex(t => t.GatewayReferenceCode) + .IsUnique() + .HasFilter("[GatewayReferenceCode] IS NOT NULL"); + + // At most one capturing transaction per booking — the authoritative anti-double-capture backstop. + builder.HasIndex(t => t.BookingId) + .IsUnique() + .HasFilter("[Status] = 'succeeded' AND [BookingId] IS NOT NULL"); + + // The lookup used by initiate/confirm (attempts for a request/booking by status). + builder.HasIndex(t => new { t.BookingId, t.Status }); + builder.HasIndex(t => new { t.BookingRequestId, t.Status }); + + builder.HasOne().WithMany().HasForeignKey(t => t.BookingRequestId).IsRequired(); + builder.HasOne().WithMany().HasForeignKey(t => t.BookingId).IsRequired(false); + builder.HasOne().WithMany().HasForeignKey(t => t.CustomerId).IsRequired(); + builder.HasOne().WithMany().HasForeignKey(t => t.GatewayId).IsRequired(); + + builder.HasQueryFilter(t => t.DeletedAt == null); + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/PaymentsConfig/PaymentWebhookEventConfig.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/PaymentsConfig/PaymentWebhookEventConfig.cs new file mode 100644 index 0000000..f91688e --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/PaymentsConfig/PaymentWebhookEventConfig.cs @@ -0,0 +1,24 @@ +using Baya.Domain.Entities.Payments; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Baya.Infrastructure.Persistence.Configuration.PaymentsConfig; + +internal sealed class PaymentWebhookEventConfig : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("PaymentWebhookEvents", "payments"); + + builder.Property(e => e.ProviderCode).HasMaxLength(50).IsRequired(); + builder.Property(e => e.ExternalEventId).HasMaxLength(200).IsRequired(); + builder.Property(e => e.EventType).HasMaxLength(80).IsRequired(); + builder.Property(e => e.ProcessingStatus).HasMaxLength(20).IsRequired(); + // payload_json is nvarchar(max) (raw callback); no length cap. + builder.Property(e => e.PayloadJson).IsRequired(); + + // THE idempotency key — a duplicate provider event can never insert twice, so a replay can never + // double-confirm/double-count. No soft-delete: this store is append/upsert-only. + builder.HasIndex(e => new { e.ProviderCode, e.ExternalEventId }).IsUnique(); + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260706163524_PaymentsCoreLedger.Designer.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260706163524_PaymentsCoreLedger.Designer.cs new file mode 100644 index 0000000..1b25e6d --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260706163524_PaymentsCoreLedger.Designer.cs @@ -0,0 +1,4502 @@ +// +using System; +using Baya.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Baya.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260706163524_PaymentsCoreLedger")] + partial class PaymentsCoreLedger + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Baya.Domain.Entities.Analytics.SystemEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("OccurredAt") + .HasColumnType("datetimeoffset"); + + b.Property("PropsJson") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.HasIndex("OccurredAt"); + + b.HasIndex("UserId"); + + b.ToTable("SystemEvents", "ops"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Audit.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("ActorUserId") + .HasColumnType("int"); + + b.Property("ChangedFieldsJson") + .HasColumnType("nvarchar(max)"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("OccurredAt") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("ActorUserId"); + + b.HasIndex("OccurredAt"); + + b.HasIndex("EntityType", "EntityId"); + + b.ToTable("AuditLogs", "ops"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Booking.Booking", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AddressSnapshotJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("BalinyaarCommissionIrr") + .HasColumnType("bigint"); + + b.Property("BookingRequestId") + .HasColumnType("bigint"); + + b.Property("CancellationPolicyCode") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("CancellationReason") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("CancellationRefundPercentage") + .HasPrecision(5, 2) + .HasColumnType("decimal(5,2)"); + + b.Property("CancelledAt") + .HasColumnType("datetime2"); + + b.Property("CancelledBy") + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("CompletedAt") + .HasColumnType("datetime2"); + + b.Property("ConfirmedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("CustomerAddressId") + .HasColumnType("bigint"); + + b.Property("CustomerId") + .HasColumnType("bigint"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DisputeWindowEndsAt") + .HasColumnType("datetime2"); + + b.Property("GrossPriceIrr") + .HasColumnType("bigint"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseId") + .HasColumnType("bigint"); + + b.Property("NursePayoutAmount") + .HasColumnType("bigint"); + + b.Property("PartnerCenterId") + .HasColumnType("bigint"); + + b.Property("PatientId") + .HasColumnType("bigint"); + + b.Property("PlatformFeeRate") + .HasPrecision(5, 4) + .HasColumnType("decimal(5,4)"); + + b.Property("PspFeeAmount") + .HasColumnType("bigint"); + + b.Property("RefundableAmountIrr") + .HasColumnType("bigint"); + + b.Property("ScheduledDate") + .HasColumnType("date"); + + b.Property("ScheduledTimeEnd") + .HasColumnType("time"); + + b.Property("ScheduledTimeStart") + .HasColumnType("time"); + + b.Property("SessionCount") + .HasColumnType("smallint"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("nvarchar(30)"); + + b.Property("VariantId") + .HasColumnType("bigint"); + + b.Property("VariantSnapshotJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("BookingRequestId") + .IsUnique(); + + b.HasIndex("CustomerAddressId"); + + b.HasIndex("DisputeWindowEndsAt"); + + b.HasIndex("PatientId"); + + b.HasIndex("VariantId"); + + b.HasIndex("CustomerId", "Status"); + + b.HasIndex("NurseId", "Status"); + + b.ToTable("Bookings", "booking", t => + { + t.HasCheckConstraint("CK_Bookings_AmountSplit", "[GrossPriceIrr] = [BalinyaarCommissionIrr] + [NursePayoutAmount] AND [GrossPriceIrr] >= 0 AND [BalinyaarCommissionIrr] >= 0 AND [NursePayoutAmount] >= 0"); + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Booking.BookingCareInstruction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Allergies") + .HasColumnType("nvarchar(max)"); + + b.Property("BookingId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("CurrentConditions") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("EmergencyContactName") + .HasColumnType("nvarchar(max)"); + + b.Property("EmergencyContactPhone") + .HasColumnType("nvarchar(max)"); + + b.Property("Medications") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("SpecialInstructions") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("BookingId") + .IsUnique(); + + b.ToTable("BookingCareInstructions", "booking"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Booking.BookingRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("CustomerAddressId") + .HasColumnType("bigint"); + + b.Property("CustomerId") + .HasColumnType("bigint"); + + b.Property("CustomerNotes") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseId") + .HasColumnType("bigint"); + + b.Property("NurseRejectionReason") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("NurseResponseDeadlineAt") + .HasColumnType("datetime2"); + + b.Property("PatientId") + .HasColumnType("bigint"); + + b.Property("PaymentDeadlineAt") + .HasColumnType("datetime2"); + + b.Property("RequestedDate") + .HasColumnType("date"); + + b.Property("RequestedTimeEnd") + .HasColumnType("time"); + + b.Property("RequestedTimeStart") + .HasColumnType("time"); + + b.Property("RequiredCaregiverGender") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("VariantId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("CustomerAddressId"); + + b.HasIndex("PatientId"); + + b.HasIndex("VariantId"); + + b.HasIndex("CustomerId", "Status"); + + b.HasIndex("NurseId", "Status"); + + b.HasIndex("Status", "NurseResponseDeadlineAt"); + + b.HasIndex("Status", "PaymentDeadlineAt"); + + b.ToTable("BookingRequests", "booking"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Booking.BookingSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BookingId") + .HasColumnType("bigint"); + + b.Property("CancellationEventId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("PayoutEligibleAt") + .HasColumnType("datetime2"); + + b.Property("ScheduledDate") + .HasColumnType("date"); + + b.Property("ScheduledTimeEnd") + .HasColumnType("time"); + + b.Property("ScheduledTimeStart") + .HasColumnType("time"); + + b.Property("SessionIndex") + .HasColumnType("int"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("VisitPayoutAmount") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("BookingId", "SessionIndex"); + + b.HasIndex("Status", "ScheduledDate"); + + b.ToTable("BookingSessions", "booking"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Booking.CancellationPolicy", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AppliesTo") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("FeeAmountIrr") + .HasColumnType("bigint"); + + b.Property("FeeRate") + .HasPrecision(5, 4) + .HasColumnType("decimal(5,4)"); + + b.Property("HoursBeforeStartMax") + .HasColumnType("int"); + + b.Property("HoursBeforeStartMin") + .HasColumnType("int"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("RefundPercentage") + .HasPrecision(5, 2) + .HasColumnType("decimal(5,2)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("AppliesTo", "IsActive"); + + b.ToTable("CancellationPolicies", "booking"); + + b.HasData( + new + { + Id = 1L, + AppliesTo = "customer", + Code = "standard_24h", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + FeeAmountIrr = 0L, + HoursBeforeStartMin = 24, + IsActive = true, + RefundPercentage = 100m + }, + new + { + Id = 2L, + AppliesTo = "customer", + Code = "standard_inside_24h", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + FeeAmountIrr = 0L, + HoursBeforeStartMax = 24, + IsActive = true, + RefundPercentage = 50m + }, + new + { + Id = 3L, + AppliesTo = "nurse", + Code = "nurse_no_show", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + FeeAmountIrr = 0L, + FeeRate = 0m, + IsActive = true, + RefundPercentage = 100m + }, + new + { + Id = 4L, + AppliesTo = "admin", + Code = "admin_cancellation", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + FeeAmountIrr = 0L, + IsActive = true, + RefundPercentage = 100m + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Booking.VisitVerification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BookingSessionId") + .HasColumnType("bigint"); + + b.Property("CheckInAddressMatch") + .HasColumnType("bit"); + + b.Property("CheckInAt") + .HasColumnType("datetime2"); + + b.Property("CheckInDistanceMeters") + .HasPrecision(10, 2) + .HasColumnType("decimal(10,2)"); + + b.Property("CheckInLat") + .HasPrecision(9, 6) + .HasColumnType("decimal(9,6)"); + + b.Property("CheckInLng") + .HasPrecision(9, 6) + .HasColumnType("decimal(9,6)"); + + b.Property("CheckOutAt") + .HasColumnType("datetime2"); + + b.Property("CheckOutLat") + .HasPrecision(9, 6) + .HasColumnType("decimal(9,6)"); + + b.Property("CheckOutLng") + .HasPrecision(9, 6) + .HasColumnType("decimal(9,6)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("BookingSessionId") + .IsUnique(); + + b.HasIndex("CheckInAddressMatch"); + + b.ToTable("VisitVerifications", "booking"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.NurseServiceVariant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseId") + .HasColumnType("bigint"); + + b.Property("OptionSetHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Price") + .HasColumnType("bigint"); + + b.Property("PriceUnit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("ServiceCategoryId") + .HasColumnType("bigint"); + + b.Property("SessionCount") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ServiceCategoryId"); + + b.HasIndex("NurseId", "IsActive"); + + b.HasIndex("NurseId", "ServiceCategoryId", "OptionSetHash") + .IsUnique() + .HasDatabaseName("UX_NurseServiceVariants_Nurse_Category_OptionSet") + .HasFilter("[DeletedAt] IS NULL"); + + b.ToTable("NurseServiceVariants", "catalog"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.NurseServiceVariantOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("OptionGroupId") + .HasColumnType("bigint"); + + b.Property("OptionValueId") + .HasColumnType("bigint"); + + b.Property("VariantId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("OptionGroupId"); + + b.HasIndex("OptionValueId"); + + b.HasIndex("VariantId", "OptionGroupId") + .IsUnique() + .HasDatabaseName("UX_NurseServiceVariantOptions_Variant_Group"); + + b.ToTable("NurseServiceVariantOptions", "catalog"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DescriptionEn") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("DescriptionFa") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("IconKey") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("NameFa") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("IsActive", "SortOrder"); + + b.ToTable("ServiceCategories", "catalog"); + + b.HasData( + new + { + Id = 1L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Elderly Care", + NameFa = "مراقبت از سالمند", + SortOrder = 1 + }, + new + { + Id = 2L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Post-Surgery Recovery", + NameFa = "مراقبت پس از جراحی", + SortOrder = 2 + }, + new + { + Id = 3L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Infant Care", + NameFa = "مراقبت از نوزاد", + SortOrder = 3 + }, + new + { + Id = 4L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Chronic Illness Management", + NameFa = "مدیریت بیماری مزمن", + SortOrder = 4 + }, + new + { + Id = 5L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Companionship", + NameFa = "همراهی و مراقبت روزمره", + SortOrder = 5 + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceOptionGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("IsRequired") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("NameFa") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("ServiceCategoryId") + .HasColumnType("bigint"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("ServiceCategoryId", "SortOrder"); + + b.ToTable("ServiceOptionGroups", "catalog"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceOptionValue", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("NameFa") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("OptionGroupId") + .HasColumnType("bigint"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("OptionGroupId", "SortOrder"); + + b.ToTable("ServiceOptionValues", "catalog"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Configuration.PlatformConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DataType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("Value") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("PlatformConfigs", "ops"); + + b.HasData( + new + { + Id = 1L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "decimal", + Description = "Balinyaar commission rate on the booking gross (fraction).", + Key = "platform_fee_rate", + Value = "0.15" + }, + new + { + Id = 2L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "decimal", + Description = "VAT rate applied to the commission line only (fraction).", + Key = "vat_rate", + Value = "0.10" + }, + new + { + Id = 3L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Hours after check-out a booking can be disputed.", + Key = "dispute_window_hours", + Value = "72" + }, + new + { + Id = 4L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Minutes a family has to pay before a pending booking expires.", + Key = "booking_payment_deadline_minutes", + Value = "30" + }, + new + { + Id = 5L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Hours a nurse has to accept/decline a booking request.", + Key = "nurse_response_deadline_hours", + Value = "24" + }, + new + { + Id = 6L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Weekly payout cadence in days.", + Key = "nurse_payout_interval_days", + Value = "7" + }, + new + { + Id = 7L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Allowed EVV check-in distance from the care address.", + Key = "evv_location_tolerance_meters", + Value = "200" + }, + new + { + Id = 8L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "decimal", + Description = "A review at or below this rating raises a support alert.", + Key = "min_rating_for_support_alert", + Value = "2" + }, + new + { + Id = 9L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "string", + Description = "Who is merchant of record for BNPL orders (platform|nurse).", + Key = "bnpl_merchant_of_record", + Value = "platform" + }, + new + { + Id = 10L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "decimal", + Description = "BNPL provider commission rate (fraction).", + Key = "bnpl_provider_commission_rate", + Value = "0.07" + }, + new + { + Id = 11L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "string", + Description = "When BNPL settles funds to the platform (immediate|deferred).", + Key = "bnpl_settlement_timing", + Value = "immediate" + }, + new + { + Id = 12L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "json", + Description = "Tiered cancellation refund policy: refund_percent by hours before the visit.", + Key = "cancellation_tiers", + Value = "[{\"min_hours_before\":48,\"refund_percent\":100},{\"min_hours_before\":24,\"refund_percent\":50},{\"min_hours_before\":0,\"refund_percent\":0}]" + }, + new + { + Id = 13L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Seconds a phone must wait before another OTP can be requested.", + Key = "auth_otp_resend_seconds", + Value = "120" + }, + new + { + Id = 14L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Wrong-code attempts allowed before OTP verification is refused until a fresh code.", + Key = "auth_otp_max_attempts", + Value = "5" + }, + new + { + Id = 15L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Refresh-token session lifetime in days.", + Key = "auth_session_ttl_days", + Value = "30" + }, + new + { + Id = 16L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Hours between credential-expiry scans (the scheduled cron is deferred; the scan is admin-triggered today).", + Key = "verification_expiry_scan_cadence_hours", + Value = "24" + }, + new + { + Id = 17L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Minutes after a session's scheduled start with no EVV check-in before it is flagged a no-show.", + Key = "no_show_threshold_minutes", + Value = "60" + }, + new + { + Id = 18L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Hours between no-show sweeps (the scheduled cron is deferred; the sweep is admin-triggered today).", + Key = "no_show_scan_cadence_hours", + Value = "1" + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.City", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("NameFa") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("ProvinceId") + .HasColumnType("bigint"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("ProvinceId", "SortOrder"); + + b.ToTable("Cities", "geo"); + + b.HasData( + new + { + Id = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Tehran", + NameFa = "تهران", + ProvinceId = 1L, + SortOrder = 1 + }, + new + { + Id = 102L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Karaj", + NameFa = "کرج", + ProvinceId = 2L, + SortOrder = 1 + }, + new + { + Id = 103L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Isfahan", + NameFa = "اصفهان", + ProvinceId = 3L, + SortOrder = 1 + }, + new + { + Id = 104L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Shiraz", + NameFa = "شیراز", + ProvinceId = 4L, + SortOrder = 1 + }, + new + { + Id = 105L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Mashhad", + NameFa = "مشهد", + ProvinceId = 5L, + SortOrder = 1 + }, + new + { + Id = 106L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Tabriz", + NameFa = "تبریز", + ProvinceId = 6L, + SortOrder = 1 + }, + new + { + Id = 107L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Urmia", + NameFa = "ارومیه", + ProvinceId = 7L, + SortOrder = 1 + }, + new + { + Id = 108L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Ahvaz", + NameFa = "اهواز", + ProvinceId = 8L, + SortOrder = 1 + }, + new + { + Id = 109L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Qom", + NameFa = "قم", + ProvinceId = 9L, + SortOrder = 1 + }, + new + { + Id = 110L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Kerman", + NameFa = "کرمان", + ProvinceId = 10L, + SortOrder = 1 + }, + new + { + Id = 111L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Rasht", + NameFa = "رشت", + ProvinceId = 11L, + SortOrder = 1 + }, + new + { + Id = 112L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Sari", + NameFa = "ساری", + ProvinceId = 12L, + SortOrder = 1 + }, + new + { + Id = 113L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Arak", + NameFa = "اراک", + ProvinceId = 13L, + SortOrder = 1 + }, + new + { + Id = 114L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Ardabil", + NameFa = "اردبیل", + ProvinceId = 14L, + SortOrder = 1 + }, + new + { + Id = 115L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Qazvin", + NameFa = "قزوین", + ProvinceId = 15L, + SortOrder = 1 + }, + new + { + Id = 116L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Kermanshah", + NameFa = "کرمانشاه", + ProvinceId = 16L, + SortOrder = 1 + }, + new + { + Id = 117L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Bojnord", + NameFa = "بجنورد", + ProvinceId = 17L, + SortOrder = 1 + }, + new + { + Id = 118L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Birjand", + NameFa = "بیرجند", + ProvinceId = 18L, + SortOrder = 1 + }, + new + { + Id = 119L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Hamadan", + NameFa = "همدان", + ProvinceId = 19L, + SortOrder = 1 + }, + new + { + Id = 120L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Sanandaj", + NameFa = "سنندج", + ProvinceId = 20L, + SortOrder = 1 + }, + new + { + Id = 121L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Khorramabad", + NameFa = "خرم‌آباد", + ProvinceId = 21L, + SortOrder = 1 + }, + new + { + Id = 122L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Gorgan", + NameFa = "گرگان", + ProvinceId = 22L, + SortOrder = 1 + }, + new + { + Id = 123L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Bandar Abbas", + NameFa = "بندرعباس", + ProvinceId = 23L, + SortOrder = 1 + }, + new + { + Id = 124L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Bushehr", + NameFa = "بوشهر", + ProvinceId = 24L, + SortOrder = 1 + }, + new + { + Id = 125L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Zanjan", + NameFa = "زنجان", + ProvinceId = 25L, + SortOrder = 1 + }, + new + { + Id = 126L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Semnan", + NameFa = "سمنان", + ProvinceId = 26L, + SortOrder = 1 + }, + new + { + Id = 127L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Yazd", + NameFa = "یزد", + ProvinceId = 27L, + SortOrder = 1 + }, + new + { + Id = 128L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Zahedan", + NameFa = "زاهدان", + ProvinceId = 28L, + SortOrder = 1 + }, + new + { + Id = 129L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Shahrekord", + NameFa = "شهرکرد", + ProvinceId = 29L, + SortOrder = 1 + }, + new + { + Id = 130L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Yasuj", + NameFa = "یاسوج", + ProvinceId = 30L, + SortOrder = 1 + }, + new + { + Id = 131L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Ilam", + NameFa = "ایلام", + ProvinceId = 31L, + SortOrder = 1 + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.District", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CityId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("NameFa") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("CityId", "SortOrder"); + + b.ToTable("Districts", "geo"); + + b.HasData( + new + { + Id = 1001L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 1", + NameFa = "منطقه ۱", + SortOrder = 1 + }, + new + { + Id = 1002L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 2", + NameFa = "منطقه ۲", + SortOrder = 2 + }, + new + { + Id = 1003L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 3", + NameFa = "منطقه ۳", + SortOrder = 3 + }, + new + { + Id = 1004L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 4", + NameFa = "منطقه ۴", + SortOrder = 4 + }, + new + { + Id = 1005L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 5", + NameFa = "منطقه ۵", + SortOrder = 5 + }, + new + { + Id = 1006L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 6", + NameFa = "منطقه ۶", + SortOrder = 6 + }, + new + { + Id = 1007L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 7", + NameFa = "منطقه ۷", + SortOrder = 7 + }, + new + { + Id = 1008L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 8", + NameFa = "منطقه ۸", + SortOrder = 8 + }, + new + { + Id = 1009L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 9", + NameFa = "منطقه ۹", + SortOrder = 9 + }, + new + { + Id = 1010L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 10", + NameFa = "منطقه ۱۰", + SortOrder = 10 + }, + new + { + Id = 1011L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 11", + NameFa = "منطقه ۱۱", + SortOrder = 11 + }, + new + { + Id = 1012L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 12", + NameFa = "منطقه ۱۲", + SortOrder = 12 + }, + new + { + Id = 1013L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 13", + NameFa = "منطقه ۱۳", + SortOrder = 13 + }, + new + { + Id = 1014L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 14", + NameFa = "منطقه ۱۴", + SortOrder = 14 + }, + new + { + Id = 1015L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 15", + NameFa = "منطقه ۱۵", + SortOrder = 15 + }, + new + { + Id = 1016L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 16", + NameFa = "منطقه ۱۶", + SortOrder = 16 + }, + new + { + Id = 1017L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 17", + NameFa = "منطقه ۱۷", + SortOrder = 17 + }, + new + { + Id = 1018L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 18", + NameFa = "منطقه ۱۸", + SortOrder = 18 + }, + new + { + Id = 1019L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 19", + NameFa = "منطقه ۱۹", + SortOrder = 19 + }, + new + { + Id = 1020L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 20", + NameFa = "منطقه ۲۰", + SortOrder = 20 + }, + new + { + Id = 1021L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 21", + NameFa = "منطقه ۲۱", + SortOrder = 21 + }, + new + { + Id = 1022L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 22", + NameFa = "منطقه ۲۲", + SortOrder = 22 + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.NurseServiceArea", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CityId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DistrictId") + .HasColumnType("bigint"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("CityId"); + + b.HasIndex("DistrictId"); + + b.HasIndex("NurseId", "CityId") + .IsUnique() + .HasDatabaseName("UX_NurseServiceAreas_Nurse_City_WholeCity") + .HasFilter("[DistrictId] IS NULL AND [DeletedAt] IS NULL"); + + b.HasIndex("NurseId", "CityId", "DistrictId") + .IsUnique() + .HasDatabaseName("UX_NurseServiceAreas_Nurse_City_District") + .HasFilter("[DistrictId] IS NOT NULL AND [DeletedAt] IS NULL"); + + b.ToTable("NurseServiceAreas", "geo"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.Province", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("NameFa") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("SortOrder"); + + b.ToTable("Provinces", "geo"); + + b.HasData( + new + { + Id = 1L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Tehran", + NameFa = "تهران", + SortOrder = 1 + }, + new + { + Id = 2L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Alborz", + NameFa = "البرز", + SortOrder = 2 + }, + new + { + Id = 3L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Isfahan", + NameFa = "اصفهان", + SortOrder = 3 + }, + new + { + Id = 4L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Fars", + NameFa = "فارس", + SortOrder = 4 + }, + new + { + Id = 5L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Razavi Khorasan", + NameFa = "خراسان رضوی", + SortOrder = 5 + }, + new + { + Id = 6L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "East Azerbaijan", + NameFa = "آذربایجان شرقی", + SortOrder = 6 + }, + new + { + Id = 7L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "West Azerbaijan", + NameFa = "آذربایجان غربی", + SortOrder = 7 + }, + new + { + Id = 8L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Khuzestan", + NameFa = "خوزستان", + SortOrder = 8 + }, + new + { + Id = 9L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Qom", + NameFa = "قم", + SortOrder = 9 + }, + new + { + Id = 10L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Kerman", + NameFa = "کرمان", + SortOrder = 10 + }, + new + { + Id = 11L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Gilan", + NameFa = "گیلان", + SortOrder = 11 + }, + new + { + Id = 12L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Mazandaran", + NameFa = "مازندران", + SortOrder = 12 + }, + new + { + Id = 13L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Markazi", + NameFa = "مرکزی", + SortOrder = 13 + }, + new + { + Id = 14L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Ardabil", + NameFa = "اردبیل", + SortOrder = 14 + }, + new + { + Id = 15L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Qazvin", + NameFa = "قزوین", + SortOrder = 15 + }, + new + { + Id = 16L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Kermanshah", + NameFa = "کرمانشاه", + SortOrder = 16 + }, + new + { + Id = 17L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "North Khorasan", + NameFa = "خراسان شمالی", + SortOrder = 17 + }, + new + { + Id = 18L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "South Khorasan", + NameFa = "خراسان جنوبی", + SortOrder = 18 + }, + new + { + Id = 19L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Hamadan", + NameFa = "همدان", + SortOrder = 19 + }, + new + { + Id = 20L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Kurdistan", + NameFa = "کردستان", + SortOrder = 20 + }, + new + { + Id = 21L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Lorestan", + NameFa = "لرستان", + SortOrder = 21 + }, + new + { + Id = 22L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Golestan", + NameFa = "گلستان", + SortOrder = 22 + }, + new + { + Id = 23L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Hormozgan", + NameFa = "هرمزگان", + SortOrder = 23 + }, + new + { + Id = 24L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Bushehr", + NameFa = "بوشهر", + SortOrder = 24 + }, + new + { + Id = 25L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Zanjan", + NameFa = "زنجان", + SortOrder = 25 + }, + new + { + Id = 26L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Semnan", + NameFa = "سمنان", + SortOrder = 26 + }, + new + { + Id = 27L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Yazd", + NameFa = "یزد", + SortOrder = 27 + }, + new + { + Id = 28L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Sistan and Baluchestan", + NameFa = "سیستان و بلوچستان", + SortOrder = 28 + }, + new + { + Id = 29L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Chaharmahal and Bakhtiari", + NameFa = "چهارمحال و بختیاری", + SortOrder = 29 + }, + new + { + Id = 30L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Kohgiluyeh and Boyer-Ahmad", + NameFa = "کهگیلویه و بویراحمد", + SortOrder = 30 + }, + new + { + Id = 31L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Ilam", + NameFa = "ایلام", + SortOrder = 31 + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Holidays.IranianHoliday", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("HolidayDate") + .HasColumnType("date"); + + b.Property("IsBankClosed") + .HasColumnType("bit"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NameFa") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("HolidayDate") + .IsUnique(); + + b.ToTable("IranianHolidays", "ops"); + + b.HasData( + new + { + Id = 1L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HolidayDate = new DateOnly(2026, 2, 11), + IsBankClosed = true, + NameFa = "پیروزی انقلاب اسلامی", + Type = "national" + }, + new + { + Id = 2L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HolidayDate = new DateOnly(2026, 3, 21), + IsBankClosed = true, + NameFa = "نوروز", + Type = "national" + }, + new + { + Id = 3L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HolidayDate = new DateOnly(2026, 3, 22), + IsBankClosed = true, + NameFa = "نوروز", + Type = "national" + }, + new + { + Id = 4L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HolidayDate = new DateOnly(2026, 3, 23), + IsBankClosed = true, + NameFa = "نوروز", + Type = "national" + }, + new + { + Id = 5L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HolidayDate = new DateOnly(2026, 3, 24), + IsBankClosed = true, + NameFa = "نوروز", + Type = "national" + }, + new + { + Id = 6L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HolidayDate = new DateOnly(2026, 4, 1), + IsBankClosed = true, + NameFa = "روز طبیعت (سیزده‌به‌در)", + Type = "official" + }, + new + { + Id = 7L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HolidayDate = new DateOnly(2026, 6, 26), + IsBankClosed = true, + NameFa = "عید سعید قربان", + Type = "religious" + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.CustomerAddress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AddressLine") + .HasColumnType("nvarchar(max)"); + + b.Property("CityId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("CustomerId") + .HasColumnType("bigint"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DistrictId") + .HasColumnType("bigint"); + + b.Property("IsPrimary") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("Latitude") + .HasPrecision(9, 6) + .HasColumnType("decimal(9,6)"); + + b.Property("Longitude") + .HasPrecision(9, 6) + .HasColumnType("decimal(9,6)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("PostalCode") + .HasColumnType("nvarchar(max)"); + + b.Property("RecipientName") + .HasColumnType("nvarchar(max)"); + + b.Property("RecipientPhone") + .HasColumnType("nvarchar(max)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("CityId"); + + b.HasIndex("CustomerId") + .IsUnique() + .HasDatabaseName("UX_CustomerAddresses_Customer_Primary") + .HasFilter("[IsPrimary] = 1 AND [DeletedAt] IS NULL"); + + b.HasIndex("DistrictId"); + + b.ToTable("CustomerAddresses", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.CustomerProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DefaultEmergencyContactName") + .HasColumnType("nvarchar(max)"); + + b.Property("DefaultEmergencyContactPhone") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("CustomerProfiles", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.NurseBankAccount", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountHolderFromBank") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("AccountHolderName") + .HasColumnType("nvarchar(max)"); + + b.Property("BankName") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("Iban") + .HasColumnType("nvarchar(max)"); + + b.Property("IbanHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("IsPrimary") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("IsVerified") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("MatchedNationalId") + .HasColumnType("bit"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseId") + .HasColumnType("bigint"); + + b.Property("OwnershipVendorRef") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("VerifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("VerifiedByAdminId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("IbanHash") + .IsUnique(); + + b.HasIndex("NurseId") + .IsUnique() + .HasDatabaseName("UX_NurseBankAccounts_NurseId_Primary") + .HasFilter("[IsPrimary] = 1"); + + b.HasIndex("VerifiedByAdminId"); + + b.ToTable("NurseBankAccounts", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.NurseProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AverageRating") + .ValueGeneratedOnAdd() + .HasPrecision(3, 2) + .HasColumnType("decimal(3,2)") + .HasDefaultValue(0m); + + b.Property("Bio") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("EducationField") + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("EducationLevel") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("IsAcceptingBookings") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("IsVerified") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("PartnerCenterId") + .HasColumnType("bigint"); + + b.Property("SpecializationsJson") + .HasColumnType("nvarchar(max)"); + + b.Property("TotalCompletedBookings") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.Property("TotalReviews") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.Property("UserId") + .HasColumnType("int"); + + b.Property("YearsOfExperience") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("NurseProfiles", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.Patient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BirthDate") + .HasColumnType("date"); + + b.Property("BloodType") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("CustomerId") + .HasColumnType("bigint"); + + b.Property("DisplayName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("FirstName") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Gender") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("InitialMedicalNotes") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("LastName") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId"); + + b.ToTable("Patients", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Notifications.Notification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Body") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DataJson") + .HasColumnType("nvarchar(max)"); + + b.Property("IsRead") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("ReadAt") + .HasColumnType("datetimeoffset"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "IsRead", "CreatedAt"); + + b.ToTable("Notifications", "ops"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Payments.LedgerEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountType") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("AmountIrr") + .HasColumnType("bigint"); + + b.Property("BookingId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Direction") + .IsRequired() + .HasMaxLength(6) + .HasColumnType("nvarchar(6)"); + + b.Property("Memo") + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("NurseId") + .HasColumnType("bigint"); + + b.Property("SourceRefId") + .HasColumnType("bigint"); + + b.Property("SourceRefType") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("TransactionGroupId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("BookingId"); + + b.HasIndex("NurseId"); + + b.HasIndex("TransactionGroupId"); + + b.HasIndex("AccountType", "NurseId"); + + b.HasIndex("SourceRefType", "SourceRefId"); + + b.ToTable("LedgerEntries", "payments"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Payments.PaymentGateway", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConfigJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("Priority") + .HasColumnType("int"); + + b.Property("ProviderCode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("Type", "IsActive", "Priority"); + + b.ToTable("PaymentGateways", "payments"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Payments.PaymentTransaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Amount") + .HasColumnType("bigint"); + + b.Property("BookingId") + .HasColumnType("bigint"); + + b.Property("BookingRequestId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("Currency") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("nvarchar(3)"); + + b.Property("CustomerId") + .HasColumnType("bigint"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("GatewayId") + .HasColumnType("bigint"); + + b.Property("GatewayReferenceCode") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("GatewayResponseCode") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("GatewayResponseJson") + .HasColumnType("nvarchar(max)"); + + b.Property("GatewayTransactionId") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("IpAddress") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("IsInstallment") + .HasColumnType("bit"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("UserAgent") + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.HasKey("Id"); + + b.HasIndex("BookingId") + .IsUnique() + .HasFilter("[Status] = 'succeeded' AND [BookingId] IS NOT NULL"); + + b.HasIndex("CustomerId"); + + b.HasIndex("GatewayId"); + + b.HasIndex("GatewayReferenceCode") + .IsUnique() + .HasFilter("[GatewayReferenceCode] IS NOT NULL"); + + b.HasIndex("BookingId", "Status"); + + b.HasIndex("BookingRequestId", "Status"); + + b.ToTable("PaymentTransactions", "payments"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Payments.PaymentWebhookEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("ExternalEventId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("PayloadJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ProcessedAt") + .HasColumnType("datetime2"); + + b.Property("ProcessingStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("ProviderCode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ReceivedAt") + .HasColumnType("datetime2"); + + b.Property("RelatedPaymentTransactionId") + .HasColumnType("bigint"); + + b.Property("SignatureValid") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("ProviderCode", "ExternalEventId") + .IsUnique(); + + b.ToTable("PaymentWebhookEvents", "payments"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Search.NurseSearchIndex", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AverageRating") + .HasPrecision(3, 2) + .HasColumnType("decimal(3,2)"); + + b.Property("CityId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DistrictId") + .HasColumnType("bigint"); + + b.Property("IsSearchable") + .HasColumnType("bit"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseGender") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("NurseId") + .HasColumnType("bigint"); + + b.Property("Price") + .HasColumnType("bigint"); + + b.Property("PriceUnit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("ServiceCategoryId") + .HasColumnType("bigint"); + + b.Property("TotalCompletedBookings") + .HasColumnType("int"); + + b.Property("TotalReviews") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("VariantId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("NurseId"); + + b.HasIndex("VariantId", "CityId") + .IsUnique() + .HasDatabaseName("UX_NurseSearchIndex_Variant_City_WholeCity") + .HasFilter("[DistrictId] IS NULL AND [DeletedAt] IS NULL"); + + b.HasIndex("VariantId", "CityId", "DistrictId") + .IsUnique() + .HasDatabaseName("UX_NurseSearchIndex_Variant_City_District") + .HasFilter("[DistrictId] IS NOT NULL AND [DeletedAt] IS NULL"); + + b.HasIndex("IsSearchable", "ServiceCategoryId", "CityId", "DistrictId") + .HasDatabaseName("IX_NurseSearchIndex_Search"); + + SqlServerIndexBuilderExtensions.IncludeProperties(b.HasIndex("IsSearchable", "ServiceCategoryId", "CityId", "DistrictId"), new[] { "Price", "NurseGender", "AverageRating", "TotalReviews", "NurseId", "VariantId" }); + + b.ToTable("NurseSearchIndices", "search"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.SupportAlerts.SupportAlert", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BookingId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("OwnerUserId") + .HasColumnType("int"); + + b.Property("ResolutionNote") + .HasColumnType("nvarchar(max)"); + + b.Property("ResolvedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ReviewId") + .HasColumnType("bigint"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.HasIndex("Status"); + + b.HasIndex("Type"); + + b.ToTable("SupportAlerts", "ops"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("Roles", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.RoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedClaim") + .HasColumnType("datetime2"); + + b.Property("RoleId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("RoleClaims", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("UserId"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("FamilyName") + .HasColumnType("nvarchar(max)"); + + b.Property("Gender") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("GeneratedCode") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("NationalId") + .HasColumnType("nvarchar(max)"); + + b.Property("NationalIdVerifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneHash") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("PhoneVerifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("ShahkarVerifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.HasIndex("PhoneHash") + .IsUnique() + .HasFilter("[PhoneHash] IS NOT NULL"); + + b.ToTable("Users", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserClaims", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("LoggedOn") + .HasColumnType("datetime2"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("UserLogins", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserRefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("IsValid") + .HasColumnType("bit"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserRefreshTokens", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserRole", b => + { + b.Property("UserId") + .HasColumnType("int"); + + b.Property("RoleId") + .HasColumnType("int"); + + b.Property("CreatedUserRoleDate") + .HasColumnType("datetime2"); + + b.Property("GrantedAt") + .HasColumnType("datetimeoffset"); + + b.Property("GrantedById") + .HasColumnType("int"); + + b.Property("RevokedAt") + .HasColumnType("datetimeoffset"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("GrantedById"); + + b.HasIndex("RoleId"); + + b.ToTable("UserRoles", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeviceInfo") + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("ExpiresAt") + .HasColumnType("datetimeoffset"); + + b.Property("IpAddress") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("IsRevoked") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("RefreshTokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("RevokedAt") + .HasColumnType("datetimeoffset"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("RefreshTokenHash") + .IsUnique(); + + b.HasIndex("UserId", "IsRevoked"); + + b.ToTable("UserSessions", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserToken", b => + { + b.Property("UserId") + .HasColumnType("int"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("GeneratedTime") + .HasColumnType("datetime2"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("UserTokens", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.NurseCredential", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("CredentialNumber") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("CredentialType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ExpiresAt") + .HasColumnType("date"); + + b.Property("HolderNameSnapshot") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("IssuedAt") + .HasColumnType("date"); + + b.Property("IssuingAuthority") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseId") + .HasColumnType("bigint"); + + b.Property("VerificationMethod") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("VerificationSource") + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("VerifiedByAdminId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("VerifiedByAdminId"); + + b.HasIndex("NurseId", "CredentialType"); + + b.ToTable("NurseCredentials", "verif"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.NurseVerification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ApprovedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("InternalNotes") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseId") + .HasColumnType("bigint"); + + b.Property("RejectedAt") + .HasColumnType("datetimeoffset"); + + b.Property("RejectionReason") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("ReviewedByAdminId") + .HasColumnType("int"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("SubmittedAt") + .HasColumnType("datetimeoffset"); + + b.Property("SuspendedAt") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("NurseId") + .IsUnique(); + + b.HasIndex("ReviewedByAdminId"); + + b.ToTable("NurseVerifications", "verif"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.VerificationDocument", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("FileSizeBytes") + .HasColumnType("bigint"); + + b.Property("IntegrityHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("ObjectStorageKey") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("OriginalFileName") + .HasMaxLength(260) + .HasColumnType("nvarchar(260)"); + + b.Property("StepId") + .HasColumnType("bigint"); + + b.Property("UploadedByUserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("StepId"); + + b.HasIndex("UploadedByUserId"); + + b.ToTable("VerificationDocuments", "verif"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.VerificationStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("ExpiresAt") + .HasColumnType("datetimeoffset"); + + b.Property("ExternalResponseJson") + .HasColumnType("nvarchar(max)"); + + b.Property("FailureReason") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("IsAutomated") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseVerificationId") + .HasColumnType("bigint"); + + b.Property("StartedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("StepTypeId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("StepTypeId"); + + b.HasIndex("NurseVerificationId", "StepTypeId") + .IsUnique() + .HasDatabaseName("UX_VerificationSteps_Verification_StepType"); + + b.ToTable("VerificationSteps", "verif"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.VerificationStepType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AutomationProvider") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("IsAutomated") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("IsRequired") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsActive", "SortOrder"); + + b.ToTable("VerificationStepTypes", "verif"); + + b.HasData( + new + { + Id = 1L, + AutomationProvider = "identity_kyc_vendor", + Code = "identity_kyc", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + Description = "National-ID validity, name match and photo/video liveness via an Iranian e-KYC vendor.", + DisplayName = "Identity Verification (KYC)", + IsActive = true, + IsAutomated = true, + IsRequired = true, + SortOrder = 1 + }, + new + { + Id = 2L, + AutomationProvider = "shahkar", + Code = "shahkar_match", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + Description = "Confirms the login SIM is registered to the nurse's own national ID (شاهکار).", + DisplayName = "Shahkar Phone Binding", + IsActive = true, + IsAutomated = true, + IsRequired = true, + SortOrder = 2 + }, + new + { + Id = 3L, + Code = "moh_competency_license", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + Description = "پروانه صلاحیت حرفه‌ای — the MoH-mandated in-home nursing licence (bundles the criminal-record screen). Manual today.", + DisplayName = "MoH Professional Competency License", + IsActive = true, + IsAutomated = false, + IsRequired = true, + SortOrder = 3 + }, + new + { + Id = 4L, + Code = "ino_membership", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + Description = "نظام پرستاری membership cross-check (ino.ir). Manual today.", + DisplayName = "Nursing Organization (INO) Membership", + IsActive = true, + IsAutomated = false, + IsRequired = true, + SortOrder = 4 + }, + new + { + Id = 5L, + Code = "criminal_record", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + Description = "عدم سوء پیشینه — consent-gated, nurse-uploaded, time-limited (reverts on expiry).", + DisplayName = "Criminal Record Certificate", + IsActive = true, + IsAutomated = false, + IsRequired = true, + SortOrder = 5 + }, + new + { + Id = 6L, + AutomationProvider = "sheba", + Code = "bank_account_verification", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + Description = "استعلام شبا — the payout IBAN owner's national ID must equal the verified nurse national ID.", + DisplayName = "Bank Account (IBAN) Ownership", + IsActive = true, + IsAutomated = true, + IsRequired = true, + SortOrder = 6 + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Analytics.SystemEvent", b => + { + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("UserId"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Audit.AuditLog", b => + { + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("ActorUserId"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Booking.Booking", b => + { + b.HasOne("Baya.Domain.Entities.Booking.BookingRequest", null) + .WithOne() + .HasForeignKey("Baya.Domain.Entities.Booking.Booking", "BookingRequestId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Identity.CustomerAddress", null) + .WithMany() + .HasForeignKey("CustomerAddressId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Identity.CustomerProfile", null) + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", null) + .WithMany() + .HasForeignKey("NurseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Identity.Patient", null) + .WithMany() + .HasForeignKey("PatientId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Catalog.NurseServiceVariant", null) + .WithMany() + .HasForeignKey("VariantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Booking.BookingCareInstruction", b => + { + b.HasOne("Baya.Domain.Entities.Booking.Booking", "Booking") + .WithOne("CareInstructions") + .HasForeignKey("Baya.Domain.Entities.Booking.BookingCareInstruction", "BookingId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Booking"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Booking.BookingRequest", b => + { + b.HasOne("Baya.Domain.Entities.Identity.CustomerAddress", "CustomerAddress") + .WithMany() + .HasForeignKey("CustomerAddressId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Identity.CustomerProfile", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", "Nurse") + .WithMany() + .HasForeignKey("NurseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Identity.Patient", "Patient") + .WithMany() + .HasForeignKey("PatientId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Catalog.NurseServiceVariant", "Variant") + .WithMany() + .HasForeignKey("VariantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Customer"); + + b.Navigation("CustomerAddress"); + + b.Navigation("Nurse"); + + b.Navigation("Patient"); + + b.Navigation("Variant"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Booking.BookingSession", b => + { + b.HasOne("Baya.Domain.Entities.Booking.Booking", "Booking") + .WithMany("Sessions") + .HasForeignKey("BookingId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Booking"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Booking.VisitVerification", b => + { + b.HasOne("Baya.Domain.Entities.Booking.BookingSession", "Session") + .WithOne("Verification") + .HasForeignKey("Baya.Domain.Entities.Booking.VisitVerification", "BookingSessionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Session"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.NurseServiceVariant", b => + { + b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", "Nurse") + .WithMany() + .HasForeignKey("NurseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Catalog.ServiceCategory", "ServiceCategory") + .WithMany("Variants") + .HasForeignKey("ServiceCategoryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Nurse"); + + b.Navigation("ServiceCategory"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.NurseServiceVariantOption", b => + { + b.HasOne("Baya.Domain.Entities.Catalog.ServiceOptionGroup", "OptionGroup") + .WithMany() + .HasForeignKey("OptionGroupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Catalog.ServiceOptionValue", "OptionValue") + .WithMany() + .HasForeignKey("OptionValueId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Catalog.NurseServiceVariant", "Variant") + .WithMany("Options") + .HasForeignKey("VariantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("OptionGroup"); + + b.Navigation("OptionValue"); + + b.Navigation("Variant"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceOptionGroup", b => + { + b.HasOne("Baya.Domain.Entities.Catalog.ServiceCategory", "ServiceCategory") + .WithMany("OptionGroups") + .HasForeignKey("ServiceCategoryId"); + + b.Navigation("ServiceCategory"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceOptionValue", b => + { + b.HasOne("Baya.Domain.Entities.Catalog.ServiceOptionGroup", "OptionGroup") + .WithMany("Values") + .HasForeignKey("OptionGroupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("OptionGroup"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.City", b => + { + b.HasOne("Baya.Domain.Entities.Geography.Province", "Province") + .WithMany("Cities") + .HasForeignKey("ProvinceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Province"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.District", b => + { + b.HasOne("Baya.Domain.Entities.Geography.City", "City") + .WithMany("Districts") + .HasForeignKey("CityId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("City"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.NurseServiceArea", b => + { + b.HasOne("Baya.Domain.Entities.Geography.City", "City") + .WithMany() + .HasForeignKey("CityId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Geography.District", "District") + .WithMany() + .HasForeignKey("DistrictId"); + + b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", null) + .WithMany() + .HasForeignKey("NurseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("City"); + + b.Navigation("District"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.CustomerAddress", b => + { + b.HasOne("Baya.Domain.Entities.Geography.City", "City") + .WithMany() + .HasForeignKey("CityId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Identity.CustomerProfile", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Geography.District", "District") + .WithMany() + .HasForeignKey("DistrictId"); + + b.Navigation("City"); + + b.Navigation("Customer"); + + b.Navigation("District"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.CustomerProfile", b => + { + b.HasOne("Baya.Domain.Entities.User.User", "User") + .WithOne() + .HasForeignKey("Baya.Domain.Entities.Identity.CustomerProfile", "UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.NurseBankAccount", b => + { + b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", "Nurse") + .WithMany("BankAccounts") + .HasForeignKey("NurseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("VerifiedByAdminId"); + + b.Navigation("Nurse"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.NurseProfile", b => + { + b.HasOne("Baya.Domain.Entities.User.User", "User") + .WithOne() + .HasForeignKey("Baya.Domain.Entities.Identity.NurseProfile", "UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.Patient", b => + { + b.HasOne("Baya.Domain.Entities.Identity.CustomerProfile", "Customer") + .WithMany("Patients") + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Customer"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Notifications.Notification", b => + { + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Payments.LedgerEntry", b => + { + b.HasOne("Baya.Domain.Entities.Booking.Booking", null) + .WithMany() + .HasForeignKey("BookingId"); + + b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", null) + .WithMany() + .HasForeignKey("NurseId"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Payments.PaymentTransaction", b => + { + b.HasOne("Baya.Domain.Entities.Booking.Booking", null) + .WithMany() + .HasForeignKey("BookingId"); + + b.HasOne("Baya.Domain.Entities.Booking.BookingRequest", null) + .WithMany() + .HasForeignKey("BookingRequestId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Identity.CustomerProfile", null) + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Payments.PaymentGateway", null) + .WithMany() + .HasForeignKey("GatewayId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Search.NurseSearchIndex", b => + { + b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", null) + .WithMany() + .HasForeignKey("NurseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Catalog.NurseServiceVariant", "Variant") + .WithMany() + .HasForeignKey("VariantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Variant"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.SupportAlerts.SupportAlert", b => + { + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("OwnerUserId"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.RoleClaim", b => + { + b.HasOne("Baya.Domain.Entities.User.Role", "Role") + .WithMany("Claims") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserClaim", b => + { + b.HasOne("Baya.Domain.Entities.User.User", "User") + .WithMany("Claims") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserLogin", b => + { + b.HasOne("Baya.Domain.Entities.User.User", "User") + .WithMany("Logins") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserRefreshToken", b => + { + b.HasOne("Baya.Domain.Entities.User.User", "User") + .WithMany("UserRefreshTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserRole", b => + { + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("GrantedById"); + + b.HasOne("Baya.Domain.Entities.User.Role", "Role") + .WithMany("Users") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.User.User", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserSession", b => + { + b.HasOne("Baya.Domain.Entities.User.User", "User") + .WithMany("Sessions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserToken", b => + { + b.HasOne("Baya.Domain.Entities.User.User", "User") + .WithMany("Tokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.NurseCredential", b => + { + b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", null) + .WithMany() + .HasForeignKey("NurseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("VerifiedByAdminId"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.NurseVerification", b => + { + b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", "Nurse") + .WithMany() + .HasForeignKey("NurseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("ReviewedByAdminId"); + + b.Navigation("Nurse"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.VerificationDocument", b => + { + b.HasOne("Baya.Domain.Entities.Verification.VerificationStep", "Step") + .WithMany("Documents") + .HasForeignKey("StepId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("UploadedByUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Step"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.VerificationStep", b => + { + b.HasOne("Baya.Domain.Entities.Verification.NurseVerification", "NurseVerification") + .WithMany("Steps") + .HasForeignKey("NurseVerificationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Verification.VerificationStepType", "StepType") + .WithMany("Steps") + .HasForeignKey("StepTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("NurseVerification"); + + b.Navigation("StepType"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Booking.Booking", b => + { + b.Navigation("CareInstructions"); + + b.Navigation("Sessions"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Booking.BookingSession", b => + { + b.Navigation("Verification"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.NurseServiceVariant", b => + { + b.Navigation("Options"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceCategory", b => + { + b.Navigation("OptionGroups"); + + b.Navigation("Variants"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceOptionGroup", b => + { + b.Navigation("Values"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.City", b => + { + b.Navigation("Districts"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.Province", b => + { + b.Navigation("Cities"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.CustomerProfile", b => + { + b.Navigation("Patients"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.NurseProfile", b => + { + b.Navigation("BankAccounts"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.Role", b => + { + b.Navigation("Claims"); + + b.Navigation("Users"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.User", b => + { + b.Navigation("Claims"); + + b.Navigation("Logins"); + + b.Navigation("Sessions"); + + b.Navigation("Tokens"); + + b.Navigation("UserRefreshTokens"); + + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.NurseVerification", b => + { + b.Navigation("Steps"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.VerificationStep", b => + { + b.Navigation("Documents"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.VerificationStepType", b => + { + b.Navigation("Steps"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260706163524_PaymentsCoreLedger.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260706163524_PaymentsCoreLedger.cs new file mode 100644 index 0000000..ba10d29 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260706163524_PaymentsCoreLedger.cs @@ -0,0 +1,265 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Baya.Infrastructure.Persistence.Migrations +{ + /// + public partial class PaymentsCoreLedger : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "payments"); + + migrationBuilder.CreateTable( + name: "LedgerEntries", + schema: "payments", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + TransactionGroupId = table.Column(type: "uniqueidentifier", nullable: false), + AccountType = table.Column(type: "nvarchar(40)", maxLength: 40, nullable: false), + NurseId = table.Column(type: "bigint", nullable: true), + Direction = table.Column(type: "nvarchar(6)", maxLength: 6, nullable: false), + AmountIrr = table.Column(type: "bigint", nullable: false), + BookingId = table.Column(type: "bigint", nullable: true), + SourceRefType = table.Column(type: "nvarchar(40)", maxLength: 40, nullable: false), + SourceRefId = table.Column(type: "bigint", nullable: false), + Memo = table.Column(type: "nvarchar(300)", maxLength: 300, nullable: true), + CreatedAt = table.Column(type: "datetime2", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_LedgerEntries", x => x.Id); + table.ForeignKey( + name: "FK_LedgerEntries_Bookings_BookingId", + column: x => x.BookingId, + principalSchema: "booking", + principalTable: "Bookings", + principalColumn: "Id"); + table.ForeignKey( + name: "FK_LedgerEntries_NurseProfiles_NurseId", + column: x => x.NurseId, + principalSchema: "usr", + principalTable: "NurseProfiles", + principalColumn: "Id"); + }); + + migrationBuilder.CreateTable( + name: "PaymentGateways", + schema: "payments", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + ProviderCode = table.Column(type: "nvarchar(50)", maxLength: 50, nullable: false), + Type = table.Column(type: "nvarchar(20)", maxLength: 20, nullable: false), + DisplayName = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), + ConfigJson = table.Column(type: "nvarchar(max)", nullable: false), + IsActive = table.Column(type: "bit", nullable: false), + Priority = table.Column(type: "int", nullable: false), + DeletedAt = table.Column(type: "datetimeoffset", nullable: true), + CreatedAt = table.Column(type: "datetimeoffset", nullable: false), + ModifiedAt = table.Column(type: "datetimeoffset", nullable: true), + CreatedById = table.Column(type: "int", nullable: true), + ModifiedById = table.Column(type: "int", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_PaymentGateways", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "PaymentWebhookEvents", + schema: "payments", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + ProviderCode = table.Column(type: "nvarchar(50)", maxLength: 50, nullable: false), + ExternalEventId = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: false), + EventType = table.Column(type: "nvarchar(80)", maxLength: 80, nullable: false), + SignatureValid = table.Column(type: "bit", nullable: false), + PayloadJson = table.Column(type: "nvarchar(max)", nullable: false), + ProcessingStatus = table.Column(type: "nvarchar(20)", maxLength: 20, nullable: false), + RelatedPaymentTransactionId = table.Column(type: "bigint", nullable: true), + ReceivedAt = table.Column(type: "datetime2", nullable: false), + ProcessedAt = table.Column(type: "datetime2", nullable: true), + CreatedAt = table.Column(type: "datetimeoffset", nullable: false), + ModifiedAt = table.Column(type: "datetimeoffset", nullable: true), + CreatedById = table.Column(type: "int", nullable: true), + ModifiedById = table.Column(type: "int", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_PaymentWebhookEvents", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "PaymentTransactions", + schema: "payments", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + BookingRequestId = table.Column(type: "bigint", nullable: false), + BookingId = table.Column(type: "bigint", nullable: true), + CustomerId = table.Column(type: "bigint", nullable: false), + GatewayId = table.Column(type: "bigint", nullable: false), + Amount = table.Column(type: "bigint", nullable: false), + Currency = table.Column(type: "nvarchar(3)", maxLength: 3, nullable: false), + Status = table.Column(type: "nvarchar(20)", maxLength: 20, nullable: false), + GatewayTransactionId = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: true), + GatewayReferenceCode = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: true), + GatewayResponseCode = table.Column(type: "nvarchar(50)", maxLength: 50, nullable: true), + GatewayResponseJson = table.Column(type: "nvarchar(max)", nullable: true), + IsInstallment = table.Column(type: "bit", nullable: false), + IpAddress = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: true), + UserAgent = table.Column(type: "nvarchar(400)", maxLength: 400, nullable: true), + DeletedAt = table.Column(type: "datetimeoffset", nullable: true), + CreatedAt = table.Column(type: "datetimeoffset", nullable: false), + ModifiedAt = table.Column(type: "datetimeoffset", nullable: true), + CreatedById = table.Column(type: "int", nullable: true), + ModifiedById = table.Column(type: "int", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_PaymentTransactions", x => x.Id); + table.ForeignKey( + name: "FK_PaymentTransactions_BookingRequests_BookingRequestId", + column: x => x.BookingRequestId, + principalSchema: "booking", + principalTable: "BookingRequests", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_PaymentTransactions_Bookings_BookingId", + column: x => x.BookingId, + principalSchema: "booking", + principalTable: "Bookings", + principalColumn: "Id"); + table.ForeignKey( + name: "FK_PaymentTransactions_CustomerProfiles_CustomerId", + column: x => x.CustomerId, + principalSchema: "usr", + principalTable: "CustomerProfiles", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_PaymentTransactions_PaymentGateways_GatewayId", + column: x => x.GatewayId, + principalSchema: "payments", + principalTable: "PaymentGateways", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_LedgerEntries_AccountType_NurseId", + schema: "payments", + table: "LedgerEntries", + columns: new[] { "AccountType", "NurseId" }); + + migrationBuilder.CreateIndex( + name: "IX_LedgerEntries_BookingId", + schema: "payments", + table: "LedgerEntries", + column: "BookingId"); + + migrationBuilder.CreateIndex( + name: "IX_LedgerEntries_NurseId", + schema: "payments", + table: "LedgerEntries", + column: "NurseId"); + + migrationBuilder.CreateIndex( + name: "IX_LedgerEntries_SourceRefType_SourceRefId", + schema: "payments", + table: "LedgerEntries", + columns: new[] { "SourceRefType", "SourceRefId" }); + + migrationBuilder.CreateIndex( + name: "IX_LedgerEntries_TransactionGroupId", + schema: "payments", + table: "LedgerEntries", + column: "TransactionGroupId"); + + migrationBuilder.CreateIndex( + name: "IX_PaymentGateways_Type_IsActive_Priority", + schema: "payments", + table: "PaymentGateways", + columns: new[] { "Type", "IsActive", "Priority" }); + + migrationBuilder.CreateIndex( + name: "IX_PaymentTransactions_BookingId", + schema: "payments", + table: "PaymentTransactions", + column: "BookingId", + unique: true, + filter: "[Status] = 'succeeded' AND [BookingId] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_PaymentTransactions_BookingId_Status", + schema: "payments", + table: "PaymentTransactions", + columns: new[] { "BookingId", "Status" }); + + migrationBuilder.CreateIndex( + name: "IX_PaymentTransactions_BookingRequestId_Status", + schema: "payments", + table: "PaymentTransactions", + columns: new[] { "BookingRequestId", "Status" }); + + migrationBuilder.CreateIndex( + name: "IX_PaymentTransactions_CustomerId", + schema: "payments", + table: "PaymentTransactions", + column: "CustomerId"); + + migrationBuilder.CreateIndex( + name: "IX_PaymentTransactions_GatewayId", + schema: "payments", + table: "PaymentTransactions", + column: "GatewayId"); + + migrationBuilder.CreateIndex( + name: "IX_PaymentTransactions_GatewayReferenceCode", + schema: "payments", + table: "PaymentTransactions", + column: "GatewayReferenceCode", + unique: true, + filter: "[GatewayReferenceCode] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_PaymentWebhookEvents_ProviderCode_ExternalEventId", + schema: "payments", + table: "PaymentWebhookEvents", + columns: new[] { "ProviderCode", "ExternalEventId" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "LedgerEntries", + schema: "payments"); + + migrationBuilder.DropTable( + name: "PaymentTransactions", + schema: "payments"); + + migrationBuilder.DropTable( + name: "PaymentWebhookEvents", + schema: "payments"); + + migrationBuilder.DropTable( + name: "PaymentGateways", + schema: "payments"); + } + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/ApplicationDbContextModelSnapshot.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/ApplicationDbContextModelSnapshot.cs index 349c462..158c4ec 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/ApplicationDbContextModelSnapshot.cs @@ -2675,6 +2675,280 @@ namespace Baya.Infrastructure.Persistence.Migrations b.ToTable("Notifications", "ops"); }); + modelBuilder.Entity("Baya.Domain.Entities.Payments.LedgerEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountType") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("AmountIrr") + .HasColumnType("bigint"); + + b.Property("BookingId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Direction") + .IsRequired() + .HasMaxLength(6) + .HasColumnType("nvarchar(6)"); + + b.Property("Memo") + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("NurseId") + .HasColumnType("bigint"); + + b.Property("SourceRefId") + .HasColumnType("bigint"); + + b.Property("SourceRefType") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("TransactionGroupId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("BookingId"); + + b.HasIndex("NurseId"); + + b.HasIndex("TransactionGroupId"); + + b.HasIndex("AccountType", "NurseId"); + + b.HasIndex("SourceRefType", "SourceRefId"); + + b.ToTable("LedgerEntries", "payments"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Payments.PaymentGateway", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConfigJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("Priority") + .HasColumnType("int"); + + b.Property("ProviderCode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("Type", "IsActive", "Priority"); + + b.ToTable("PaymentGateways", "payments"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Payments.PaymentTransaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Amount") + .HasColumnType("bigint"); + + b.Property("BookingId") + .HasColumnType("bigint"); + + b.Property("BookingRequestId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("Currency") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("nvarchar(3)"); + + b.Property("CustomerId") + .HasColumnType("bigint"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("GatewayId") + .HasColumnType("bigint"); + + b.Property("GatewayReferenceCode") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("GatewayResponseCode") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("GatewayResponseJson") + .HasColumnType("nvarchar(max)"); + + b.Property("GatewayTransactionId") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("IpAddress") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("IsInstallment") + .HasColumnType("bit"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("UserAgent") + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.HasKey("Id"); + + b.HasIndex("BookingId") + .IsUnique() + .HasFilter("[Status] = 'succeeded' AND [BookingId] IS NOT NULL"); + + b.HasIndex("CustomerId"); + + b.HasIndex("GatewayId"); + + b.HasIndex("GatewayReferenceCode") + .IsUnique() + .HasFilter("[GatewayReferenceCode] IS NOT NULL"); + + b.HasIndex("BookingId", "Status"); + + b.HasIndex("BookingRequestId", "Status"); + + b.ToTable("PaymentTransactions", "payments"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Payments.PaymentWebhookEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("ExternalEventId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("PayloadJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ProcessedAt") + .HasColumnType("datetime2"); + + b.Property("ProcessingStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("ProviderCode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ReceivedAt") + .HasColumnType("datetime2"); + + b.Property("RelatedPaymentTransactionId") + .HasColumnType("bigint"); + + b.Property("SignatureValid") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("ProviderCode", "ExternalEventId") + .IsUnique(); + + b.ToTable("PaymentWebhookEvents", "payments"); + }); + modelBuilder.Entity("Baya.Domain.Entities.Search.NurseSearchIndex", b => { b.Property("Id") @@ -3921,6 +4195,42 @@ namespace Baya.Infrastructure.Persistence.Migrations .IsRequired(); }); + modelBuilder.Entity("Baya.Domain.Entities.Payments.LedgerEntry", b => + { + b.HasOne("Baya.Domain.Entities.Booking.Booking", null) + .WithMany() + .HasForeignKey("BookingId"); + + b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", null) + .WithMany() + .HasForeignKey("NurseId"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Payments.PaymentTransaction", b => + { + b.HasOne("Baya.Domain.Entities.Booking.Booking", null) + .WithMany() + .HasForeignKey("BookingId"); + + b.HasOne("Baya.Domain.Entities.Booking.BookingRequest", null) + .WithMany() + .HasForeignKey("BookingRequestId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Identity.CustomerProfile", null) + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Payments.PaymentGateway", null) + .WithMany() + .HasForeignKey("GatewayId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + modelBuilder.Entity("Baya.Domain.Entities.Search.NurseSearchIndex", b => { b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", null) diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/BookingRepository.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/BookingRepository.cs index ff69455..e3796ca 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/BookingRepository.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/BookingRepository.cs @@ -2,6 +2,7 @@ using Baya.Application.Contracts.Persistence; using Baya.Application.Models.Booking; using Baya.Application.Models.Common; +using Baya.Application.Models.Payments; using Baya.Domain.Entities.Booking; using Baya.Domain.Entities.Identity; using Baya.Infrastructure.Persistence.Repositories.Common; @@ -24,6 +25,12 @@ internal sealed class BookingRepository : BaseAsyncRepository, IBooking .Select(b => (long?)b.Id) .FirstOrDefaultAsync(cancellationToken); + public Task GetLedgerAmountsAsync(long id, CancellationToken cancellationToken) + => TableNoTracking + .Where(b => b.Id == id) + .Select(b => new BookingLedgerAmounts(b.NurseId, b.GrossPriceIrr, b.BalinyaarCommissionIrr, b.NursePayoutAmount)) + .FirstOrDefaultAsync(cancellationToken); + public async Task GetDetailAsync(long id, CancellationToken cancellationToken) { var row = await ( diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/BookingRequestRepository.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/BookingRequestRepository.cs index 30ca875..18576ed 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/BookingRequestRepository.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/BookingRequestRepository.cs @@ -3,6 +3,7 @@ using Baya.Application.Contracts.Persistence; using Baya.Application.Models.Booking; using Baya.Application.Models.Catalog; using Baya.Application.Models.Common; +using Baya.Application.Models.Payments; using Baya.Domain.Entities.Booking; using Baya.Infrastructure.Persistence.Repositories.Common; using Microsoft.EntityFrameworkCore; @@ -222,6 +223,17 @@ internal sealed class BookingRequestRepository : BaseAsyncRepository GetPaymentContextAsync(long id, CancellationToken cancellationToken) + => TableNoTracking + .Where(r => r.Id == id) + .Select(r => new BookingPaymentContext( + r.Id, + r.CustomerId, + r.Status, + r.PaymentDeadlineAt, + r.Variant.Price * (r.Variant.SessionCount != null ? r.Variant.SessionCount.Value : 1))) + .FirstOrDefaultAsync(cancellationToken); + // Actionable (awaiting a party's action) rows float above terminal ones, then most-recent first. Recency // (id) rather than the deadline is the secondary key: for a given status the deadline tracks creation // time anyway, and DateTimeOffset is not sortable on the SQLite test provider. diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/Common/UnitOfWork.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/Common/UnitOfWork.cs index 5b35dbd..333d8a0 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/Common/UnitOfWork.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/Common/UnitOfWork.cs @@ -22,6 +22,7 @@ public class UnitOfWork : IUnitOfWork public IBookingRequestRepository BookingRequestRepository { get; } public IBookingRepository BookingRepository { get; } public ICancellationPolicyRepository CancellationPolicyRepository { get; } + public IPaymentRepository PaymentRepository { get; } public UnitOfWork(ApplicationDbContext db) { @@ -42,6 +43,7 @@ public class UnitOfWork : IUnitOfWork BookingRequestRepository = new BookingRequestRepository(_db); BookingRepository = new BookingRepository(_db); CancellationPolicyRepository = new CancellationPolicyRepository(_db); + PaymentRepository = new PaymentRepository(_db); } public Task CommitAsync() diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/PaymentRepository.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/PaymentRepository.cs new file mode 100644 index 0000000..0e19159 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/PaymentRepository.cs @@ -0,0 +1,67 @@ +#nullable enable +using Baya.Application.Contracts.Persistence; +using Baya.Domain.Entities.Payments; +using Baya.Infrastructure.Persistence.Repositories.Common; +using Microsoft.EntityFrameworkCore; + +namespace Baya.Infrastructure.Persistence.Repositories; + +internal sealed class PaymentRepository : BaseAsyncRepository, IPaymentRepository +{ + public PaymentRepository(ApplicationDbContext dbContext) : base(dbContext) + { + } + + public Task GetActiveGatewayIdAsync(string type, CancellationToken cancellationToken) + => DbContext.Set().AsNoTracking() + .Where(g => g.Type == type && g.IsActive) + .OrderBy(g => g.Priority) + .Select(g => (long?)g.Id) + .FirstOrDefaultAsync(cancellationToken); + + public Task AddGatewayAsync(PaymentGateway gateway, CancellationToken cancellationToken) + => DbContext.Set().AddAsync(gateway, cancellationToken).AsTask(); + + public Task AddTransactionAsync(PaymentTransaction transaction, CancellationToken cancellationToken) + => base.AddAsync(transaction); + + public Task HasSucceededTransactionForRequestAsync(long bookingRequestId, CancellationToken cancellationToken) + => TableNoTracking.AnyAsync( + t => t.BookingRequestId == bookingRequestId && t.Status == PaymentTransactionStatus.Succeeded, + cancellationToken); + + public Task GetTrackedTransactionByReferenceAsync(string gatewayReferenceCode, CancellationToken cancellationToken) + => Table.FirstOrDefaultAsync(t => t.GatewayReferenceCode == gatewayReferenceCode, cancellationToken); + + public Task GetTrackedTransactionByIdAsync(long id, CancellationToken cancellationToken) + => Table.FirstOrDefaultAsync(t => t.Id == id, cancellationToken); + + public Task GetWebhookEventByKeyAsync(string providerCode, string externalEventId, CancellationToken cancellationToken) + => DbContext.Set().AsNoTracking() + .FirstOrDefaultAsync(e => e.ProviderCode == providerCode && e.ExternalEventId == externalEventId, cancellationToken); + + public Task AddWebhookEventAsync(PaymentWebhookEvent webhookEvent, CancellationToken cancellationToken) + => DbContext.Set().AddAsync(webhookEvent, cancellationToken).AsTask(); + + public Task LedgerGroupExistsForTransactionAsync(long paymentTransactionId, CancellationToken cancellationToken) + => DbContext.Set().AsNoTracking().AnyAsync( + e => e.SourceRefType == LedgerSourceRefType.PaymentTransaction && e.SourceRefId == paymentTransactionId, + cancellationToken); + + public Task AddLedgerEntriesAsync(IEnumerable entries, CancellationToken cancellationToken) + => DbContext.Set().AddRangeAsync(entries, cancellationToken); + + public async Task GetNursePayableBalanceAsync(long nurseId, CancellationToken cancellationToken) + { + // Signed sum over the append-only ledger — credit adds, debit subtracts. No cached wallet column. + var query = DbContext.Set().AsNoTracking() + .Where(e => e.AccountType == LedgerAccountType.NursePayable && e.NurseId == nurseId); + + var credits = await query.Where(e => e.Direction == LedgerDirection.Credit) + .SumAsync(e => (long?)e.AmountIrr, cancellationToken) ?? 0; + var debits = await query.Where(e => e.Direction == LedgerDirection.Debit) + .SumAsync(e => (long?)e.AmountIrr, cancellationToken) ?? 0; + + return credits - debits; + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/ServiceConfiguration/ServiceCollectionExtensions.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/ServiceConfiguration/ServiceCollectionExtensions.cs index f0fb2aa..a881094 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Persistence/ServiceConfiguration/ServiceCollectionExtensions.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/ServiceConfiguration/ServiceCollectionExtensions.cs @@ -7,6 +7,7 @@ using Baya.Application.Contracts.Notifications; using Baya.Application.Contracts.Persistence; using Baya.Application.Contracts.Search; using Baya.Application.Contracts.SupportAlerts; +using Baya.Domain.Entities.Payments; using Baya.Infrastructure.Persistence.Interceptors; using Baya.Infrastructure.Persistence.Repositories.Common; using Baya.Infrastructure.Persistence.Services.Analytics; @@ -83,4 +84,31 @@ public static class ServiceCollectionExtensions await context.Database.MigrateAsync(); } + + /// + /// Idempotently seeds one active standard payment gateway so the b10 card rail has a selectable + /// provider out of the box. config_json is encrypted at rest by the EF converter on save (so it + /// must go through the DbContext, not HasData). Real merchant credentials come from user-secrets / + /// environment per deployment — this sandbox row is non-secret and only enables the local/dev flow. + /// + public static async Task SeedPaymentGatewaysAsync(this WebApplication app) + { + await using var scope = app.Services.CreateAsyncScope(); + var context = scope.ServiceProvider.GetRequiredService(); + + if (await context.Set().AnyAsync(g => g.Type == PaymentGatewayType.Standard)) + return; + + context.Set().Add(new PaymentGateway + { + ProviderCode = "zarinpal", + Type = PaymentGatewayType.Standard, + DisplayName = "ZarinPal (sandbox)", + ConfigJson = "{\"merchantId\":\"00000000-0000-0000-0000-000000000000\",\"baseUrl\":\"https://sandbox.zarinpal.com\",\"sandbox\":true}", + IsActive = true, + Priority = 0 + }); + + await context.SaveChangesAsync(); + } } \ No newline at end of file diff --git a/server/src/Tests/Baya.Test.Api/PaymentsApiTests.cs b/server/src/Tests/Baya.Test.Api/PaymentsApiTests.cs new file mode 100644 index 0000000..f2dd5b7 --- /dev/null +++ b/server/src/Tests/Baya.Test.Api/PaymentsApiTests.cs @@ -0,0 +1,187 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text; +using Baya.Application.Contracts.Identity; +using Baya.Domain.Entities.Booking; +using Baya.Domain.Entities.Catalog; +using Baya.Domain.Entities.Geography; +using Baya.Domain.Entities.Identity; +using Baya.Domain.Entities.Payments; +using Baya.Domain.Entities.User; +using Baya.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using BookingEntity = Baya.Domain.Entities.Booking.Booking; + +namespace Baya.Test.Api; + +public class PaymentsApiTests(BayaApiFactory factory) : IClassFixture +{ + [Fact] + public async Task Initiate_Unauthenticated_Returns401() + { + var client = factory.CreateClient(); + var response = await client.PostAsync("/api/v1/bookings/1/payments", null); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task Initiate_InvalidId_Returns400() + { + var client = factory.CreateClient(); + await ProfileTestClient.AuthenticateAsync(factory, client, "09131900201", "customer"); + + var response = await client.PostAsync("/api/v1/bookings/0/payments", null); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task Full_flow_initiate_then_webhook_confirms_booking_and_accrues_nurse_payable() + { + var client = factory.CreateClient(); + const string phone = "09131900202"; + await ProfileTestClient.AuthenticateAsync(factory, client, phone, "customer"); + + var (requestId, nurseId) = await SeedAcceptedRequestAsync(phone, price: 23_300_000); + + // 1. Initiate the card payment → pending transaction + a redirect + the gateway reference. + var initiate = await client.PostAsync($"/api/v1/bookings/{requestId}/payments", null); + Assert.Equal(HttpStatusCode.OK, initiate.StatusCode); + var initData = await AuthTestClient.ReadDataAsync(initiate); + var reference = initData.GetProperty("gatewayReferenceCode").GetString()!; + Assert.False(string.IsNullOrEmpty(initData.GetProperty("redirectUrl").GetString())); + + // 2. A signature-authenticated webhook confirms it (anonymous to the auth pipeline). + var webhook = await PostWebhookAsync(client, "zarinpal", SuccessBody(reference, "evt-flow-1")); + Assert.Equal(HttpStatusCode.OK, webhook.StatusCode); + var webhookData = await AuthTestClient.ReadDataAsync(webhook); + Assert.Equal(WebhookProcessingStatus.Processed, webhookData.GetProperty("processingStatus").GetString()); + + // 3. The booking converted/confirmed and the balanced capture group posted. + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var booking = db.Set().AsNoTracking().Single(b => b.BookingRequestId == requestId); + Assert.Equal(BookingStatus.Confirmed, booking.Status); + + var legs = db.Set().AsNoTracking().Where(l => l.BookingId == booking.Id).ToList(); + Assert.Equal(3, legs.Count); + Assert.Equal( + legs.Where(l => l.Direction == LedgerDirection.Debit).Sum(l => l.AmountIrr), + legs.Where(l => l.Direction == LedgerDirection.Credit).Sum(l => l.AmountIrr)); + + var payable = legs.Single(l => l.AccountType == LedgerAccountType.NursePayable); + Assert.Equal(nurseId, payable.NurseId); + Assert.Equal(19_805_000, payable.AmountIrr); + } + + [Fact] + public async Task Webhook_duplicate_replay_is_idempotent() + { + var client = factory.CreateClient(); + const string phone = "09131900203"; + await ProfileTestClient.AuthenticateAsync(factory, client, phone, "customer"); + + var (requestId, _) = await SeedAcceptedRequestAsync(phone, price: 5_000_000); + var initiate = await client.PostAsync($"/api/v1/bookings/{requestId}/payments", null); + var reference = (await AuthTestClient.ReadDataAsync(initiate)).GetProperty("gatewayReferenceCode").GetString()!; + + var body = SuccessBody(reference, "evt-dup-1"); + var first = await PostWebhookAsync(client, "zarinpal", body); + var replay = await PostWebhookAsync(client, "zarinpal", body); + + Assert.Equal(HttpStatusCode.OK, first.StatusCode); + Assert.Equal(HttpStatusCode.OK, replay.StatusCode); + Assert.True((await AuthTestClient.ReadDataAsync(replay)).GetProperty("duplicate").GetBoolean()); + + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + // Exactly one capture group for this booking, and one webhook event for the replayed key (the DB is + // shared across the class fixture, so scope both assertions to this test's own rows). + var booking = db.Set().AsNoTracking().Single(b => b.BookingRequestId == requestId); + Assert.Equal(3, db.Set().AsNoTracking().Count(l => l.BookingId == booking.Id)); + Assert.Equal(1, db.Set().AsNoTracking().Count(e => e.ExternalEventId == "evt-dup-1")); + } + + private static string SuccessBody(string reference, string eventId) + => $"{{\"external_event_id\":\"{eventId}\",\"event_type\":\"payment.succeeded\",\"gateway_reference_code\":\"{reference}\"}}"; + + private static Task PostWebhookAsync(HttpClient client, string provider, string body) + => client.PostAsync($"/api/v1/webhooks/payments/{provider}", new StringContent(body, Encoding.UTF8, "application/json")); + + /// Seeds the reference data + a bookable nurse + an accepted request owned by the authenticated + /// customer, plus an active standard gateway. Returns the request id and nurse profile id. + private async Task<(long RequestId, long NurseId)> SeedAcceptedRequestAsync(string customerPhone, long price) + { + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var userManager = scope.ServiceProvider.GetRequiredService(); + + var customerUser = await userManager.GetUserByPhoneNumber(customerPhone); + var customer = db.Set().FirstOrDefault(c => c.UserId == customerUser!.Id); + if (customer is null) + { + customer = new CustomerProfile { UserId = customerUser!.Id }; + db.Set().Add(customer); + db.SaveChanges(); + } + + if (!db.Set().Any(g => g.Type == PaymentGatewayType.Standard && g.IsActive)) + { + db.Set().Add(new PaymentGateway + { + ProviderCode = "zarinpal", Type = PaymentGatewayType.Standard, DisplayName = "ZarinPal", + ConfigJson = "{\"merchantId\":\"test\",\"sandbox\":true}", IsActive = true, Priority = 0 + }); + } + + var province = new Province { NameFa = "تهران", NameEn = "Tehran", SortOrder = 1, IsActive = true }; + db.Set().Add(province); + db.SaveChanges(); + var city = new City { ProvinceId = province.Id, NameFa = "تهران", NameEn = "Tehran", SortOrder = 1, IsActive = true }; + db.Set().Add(city); + var category = new ServiceCategory { NameFa = "سالمند", NameEn = "Elderly", SortOrder = 1, IsActive = true }; + db.Set().Add(category); + db.SaveChanges(); + + var patient = new Patient { CustomerId = customer.Id, DisplayName = "پدر", FirstName = "حسن", LastName = "رضایی", Gender = "male", IsActive = true }; + db.Set().Add(patient); + var address = new CustomerAddress + { + CustomerId = customer.Id, CityId = city.Id, Title = "خانه", AddressLine = "خیابان اول", + PostalCode = "1111111111", RecipientName = "علی", RecipientPhone = customerPhone, + Latitude = 35.6892m, Longitude = 51.3890m, IsPrimary = true + }; + db.Set().Add(address); + + var nurseUser = new User { UserName = $"nurse_{Guid.NewGuid():N}", PhoneNumber = $"0912{Random.Shared.Next(1000000, 9999999)}", Gender = "female", Name = "زهرا", FamilyName = "احمدی", IsActive = true }; + db.Users.Add(nurseUser); + db.SaveChanges(); + var nurse = new NurseProfile { UserId = nurseUser.Id }; + nurse.MarkVerified(); + nurse.SetAcceptingBookings(true); + db.Set().Add(nurse); + db.SaveChanges(); + + var variant = new NurseServiceVariant + { + NurseId = nurse.Id, ServiceCategoryId = category.Id, Price = price, PriceUnit = "per_day", + SessionCount = null, DisplayName = "مراقبت روزانه", OptionSetHash = $"hash-{Guid.NewGuid():N}", IsActive = true + }; + db.Set().Add(variant); + db.SaveChanges(); + + var request = new BookingRequest + { + CustomerId = customer.Id, NurseId = nurse.Id, PatientId = patient.Id, VariantId = variant.Id, + CustomerAddressId = address.Id, RequiredCaregiverGender = CaregiverGender.Any, + RequestedDate = new DateOnly(2026, 8, 1), RequestedTimeStart = new TimeOnly(9, 0), RequestedTimeEnd = new TimeOnly(13, 0), + CustomerNotes = "note", NurseResponseDeadlineAt = new DateTime(2030, 1, 1, 0, 0, 0, DateTimeKind.Utc) + }; + db.Set().Add(request); + db.SaveChanges(); + request.Accept(new DateTime(2030, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + db.SaveChanges(); + + return (request.Id, nurse.Id); + } +} diff --git a/server/src/Tests/Baya.Test.Foundation/Payments/InitiatePaymentTests.cs b/server/src/Tests/Baya.Test.Foundation/Payments/InitiatePaymentTests.cs new file mode 100644 index 0000000..cf7c66b --- /dev/null +++ b/server/src/Tests/Baya.Test.Foundation/Payments/InitiatePaymentTests.cs @@ -0,0 +1,54 @@ +using Baya.Application.Features.Payments.Commands.ConfirmPaymentAndPostLedger; +using Baya.Application.Features.Payments.Commands.InitiatePayment; +using Baya.Domain.Entities.Payments; +using Microsoft.EntityFrameworkCore; + +namespace Baya.Test.Foundation.Payments; + +public class InitiatePaymentTests +{ + private static readonly DateTimeOffset Now = new(2026, 7, 6, 10, 0, 0, TimeSpan.Zero); + + private static InitiatePaymentCommandHandler Initiate(PaymentsTestHost host) + => new(host.AsCustomer(), host.UnitOfWork, host.PaymentProvider, host.Clock(Now)); + + [Fact] + public async Task Initiate_creates_a_pending_transaction_with_the_frozen_gross_and_a_reference() + { + using var host = new PaymentsTestHost(); + var variantId = host.AddVariant(sessionCount: 2, price: 5_000_000); // gross = 10_000_000 + var requestId = host.AddAcceptedRequest(variantId); + + var result = await Initiate(host).Handle(new InitiatePaymentCommand(requestId, null), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.False(string.IsNullOrEmpty(result.Result.RedirectUrl)); + + var txn = host.Db.Set().AsNoTracking().Single(t => t.Id == result.Result.TransactionId); + Assert.Equal(PaymentTransactionStatus.Pending, txn.Status); + Assert.Equal(10_000_000, txn.Amount); + Assert.Null(txn.BookingId); + Assert.Equal(result.Result.GatewayReferenceCode, txn.GatewayReferenceCode); + + // No ledger rows and no booking yet. + Assert.Empty(host.Db.Set().AsNoTracking()); + } + + [Fact] + public async Task Initiate_after_capture_is_a_conflict() + { + using var host = new PaymentsTestHost(); + var variantId = host.AddVariant(sessionCount: 1, price: 5_000_000); + var requestId = host.AddAcceptedRequest(variantId); + + var first = await Initiate(host).Handle(new InitiatePaymentCommand(requestId, null), CancellationToken.None); + var confirm = new ConfirmPaymentAndPostLedgerCommandHandler( + host.UnitOfWork, host.Config(), host.Clock(Now), host.PaymentProvider, host.Settlement, host.Serializer, host.Notifications()); + await confirm.Handle(new ConfirmPaymentAndPostLedgerCommand(first.Result.TransactionId), CancellationToken.None); + + // A repeat initiate for an already-paid booking is a 409, not a second attempt. + var again = await Initiate(host).Handle(new InitiatePaymentCommand(requestId, "fresh-key"), CancellationToken.None); + Assert.False(again.IsSuccess); + Assert.True(again.IsConflict); + } +} diff --git a/server/src/Tests/Baya.Test.Foundation/Payments/LedgerPostingTests.cs b/server/src/Tests/Baya.Test.Foundation/Payments/LedgerPostingTests.cs new file mode 100644 index 0000000..d02d37b --- /dev/null +++ b/server/src/Tests/Baya.Test.Foundation/Payments/LedgerPostingTests.cs @@ -0,0 +1,48 @@ +using Baya.Domain.Entities.Payments; + +namespace Baya.Test.Foundation.Payments; + +public class LedgerPostingTests +{ + private static readonly DateTime Now = new(2026, 7, 6, 10, 0, 0, DateTimeKind.Utc); + + [Fact] + public void CardCapture_posts_three_legs_that_balance() + { + var legs = LedgerPosting.CardCapture( + bookingId: 42, nurseId: 7, grossIrr: 23_300_000, commissionIrr: 3_495_000, payoutIrr: 19_805_000, + paymentTransactionId: 100, createdAt: Now); + + Assert.Equal(3, legs.Count); + + var debits = legs.Where(l => l.Direction == LedgerDirection.Debit).Sum(l => l.AmountIrr); + var credits = legs.Where(l => l.Direction == LedgerDirection.Credit).Sum(l => l.AmountIrr); + Assert.Equal(debits, credits); + Assert.Equal(23_300_000, debits); + + var escrow = legs.Single(l => l.AccountType == LedgerAccountType.EscrowHeld); + Assert.Equal(LedgerDirection.Debit, escrow.Direction); + Assert.Equal(23_300_000, escrow.AmountIrr); + + var revenue = legs.Single(l => l.AccountType == LedgerAccountType.PlatformRevenue); + Assert.Equal(LedgerDirection.Credit, revenue.Direction); + Assert.Equal(3_495_000, revenue.AmountIrr); + + var payable = legs.Single(l => l.AccountType == LedgerAccountType.NursePayable); + Assert.Equal(LedgerDirection.Credit, payable.Direction); + Assert.Equal(19_805_000, payable.AmountIrr); + Assert.Equal(7, payable.NurseId); + + // One shared group; amounts positive; sign carried by direction. + Assert.Single(legs.Select(l => l.TransactionGroupId).Distinct()); + Assert.All(legs, l => Assert.True(l.AmountIrr > 0)); + } + + [Fact] + public void CardCapture_throws_when_amounts_do_not_reconcile() + { + Assert.Throws(() => LedgerPosting.CardCapture( + bookingId: 1, nurseId: 1, grossIrr: 100, commissionIrr: 10, payoutIrr: 80, // 10 + 80 != 100 + paymentTransactionId: 1, createdAt: Now)); + } +} diff --git a/server/src/Tests/Baya.Test.Foundation/Payments/NursePayableBalanceTests.cs b/server/src/Tests/Baya.Test.Foundation/Payments/NursePayableBalanceTests.cs new file mode 100644 index 0000000..82f7d35 --- /dev/null +++ b/server/src/Tests/Baya.Test.Foundation/Payments/NursePayableBalanceTests.cs @@ -0,0 +1,54 @@ +using System.Globalization; +using Baya.Application.Features.Payments.Commands.ConfirmPaymentAndPostLedger; +using Baya.Application.Features.Payments.Commands.InitiatePayment; +using Baya.Application.Features.Payments.Queries.GetNursePayableBalance; +using Baya.Domain.Entities.Payments; + +namespace Baya.Test.Foundation.Payments; + +public class NursePayableBalanceTests +{ + private static readonly DateTimeOffset Now = new(2026, 7, 6, 10, 0, 0, TimeSpan.Zero); + + [Fact] + public async Task Payable_balance_equals_the_signed_nurse_payable_ledger_sum() + { + using var host = new PaymentsTestHost(); + var variantId = host.AddVariant(sessionCount: 1, price: 23_300_000); + var requestId = host.AddAcceptedRequest(variantId); + + var initiate = new InitiatePaymentCommandHandler(host.AsCustomer(), host.UnitOfWork, host.PaymentProvider, host.Clock(Now)); + var init = await initiate.Handle(new InitiatePaymentCommand(requestId, null), CancellationToken.None); + + var confirm = new ConfirmPaymentAndPostLedgerCommandHandler( + host.UnitOfWork, host.Config(), host.Clock(Now), host.PaymentProvider, host.Settlement, host.Serializer, host.Notifications()); + await confirm.Handle(new ConfirmPaymentAndPostLedgerCommand(init.Result.TransactionId), CancellationToken.None); + + var query = new GetNursePayableBalanceQueryHandler(host.AsNurse(), host.UnitOfWork); + var result = await query.Handle(new GetNursePayableBalanceQuery(host.NurseId), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal("19805000", result.Result.BalanceIrr); + + // A debit (e.g. a later payout) reduces the derived balance — never a stored column. + host.Db.Set().Add(new LedgerEntry + { + TransactionGroupId = Guid.NewGuid(), AccountType = LedgerAccountType.NursePayable, NurseId = host.NurseId, + Direction = LedgerDirection.Debit, AmountIrr = 5_000_000, SourceRefType = LedgerSourceRefType.NursePayout, + SourceRefId = 1, CreatedAt = Now.UtcDateTime + }); + host.Db.SaveChanges(); + + var after = await query.Handle(new GetNursePayableBalanceQuery(host.NurseId), CancellationToken.None); + Assert.Equal((19_805_000 - 5_000_000).ToString(CultureInfo.InvariantCulture), after.Result.BalanceIrr); + } + + [Fact] + public async Task Non_owner_nurse_is_forbidden() + { + using var host = new PaymentsTestHost(); + var query = new GetNursePayableBalanceQueryHandler(host.AsCustomer(), host.UnitOfWork); // a customer, not the nurse + var result = await query.Handle(new GetNursePayableBalanceQuery(host.NurseId), CancellationToken.None); + Assert.True(result.IsForbidden); + } +} diff --git a/server/src/Tests/Baya.Test.Foundation/Payments/PaymentConfirmTests.cs b/server/src/Tests/Baya.Test.Foundation/Payments/PaymentConfirmTests.cs new file mode 100644 index 0000000..9d36cf5 --- /dev/null +++ b/server/src/Tests/Baya.Test.Foundation/Payments/PaymentConfirmTests.cs @@ -0,0 +1,113 @@ +using Baya.Application.Features.Payments.Commands.ConfirmPaymentAndPostLedger; +using Baya.Application.Features.Payments.Commands.InitiatePayment; +using Baya.Domain.Entities.Booking; +using Baya.Domain.Entities.Payments; +using Microsoft.EntityFrameworkCore; +using BookingEntity = Baya.Domain.Entities.Booking.Booking; + +namespace Baya.Test.Foundation.Payments; + +public class PaymentConfirmTests +{ + private static readonly DateTimeOffset Now = new(2026, 7, 6, 10, 0, 0, TimeSpan.Zero); + + // §7 worked example: gross 23_300_000, commission 3_495_000 (×0.15), payout 19_805_000. + private const long Price = 23_300_000; + + private static InitiatePaymentCommandHandler Initiate(PaymentsTestHost host) + => new(host.AsCustomer(), host.UnitOfWork, host.PaymentProvider, host.Clock(Now)); + + private static ConfirmPaymentAndPostLedgerCommandHandler Confirm(PaymentsTestHost host) + => new(host.UnitOfWork, host.Config(), host.Clock(Now), host.PaymentProvider, host.Settlement, host.Serializer, host.Notifications()); + + private static async Task InitiatePendingAsync(PaymentsTestHost host, long requestId) + { + var result = await Initiate(host).Handle(new InitiatePaymentCommand(requestId, null), CancellationToken.None); + Assert.True(result.IsSuccess); + return result.Result.TransactionId; + } + + [Fact] + public async Task Confirm_posts_balanced_card_capture_group_and_confirms_booking() + { + using var host = new PaymentsTestHost(); + var variantId = host.AddVariant(sessionCount: 1, price: Price); + var requestId = host.AddAcceptedRequest(variantId); + var txnId = await InitiatePendingAsync(host, requestId); + + var result = await Confirm(host).Handle(new ConfirmPaymentAndPostLedgerCommand(txnId), CancellationToken.None); + Assert.True(result.IsSuccess); + + // Transaction captured + bound to a booking. + var txn = host.Db.Set().AsNoTracking().Single(t => t.Id == txnId); + Assert.Equal(PaymentTransactionStatus.Succeeded, txn.Status); + Assert.NotNull(txn.BookingId); + + // Booking created + confirmed. + var booking = host.Db.Set().AsNoTracking().Single(); + Assert.Equal(BookingStatus.Confirmed, booking.Status); + + // Exactly one balanced card-capture group with the three correct legs. + var legs = host.Db.Set().AsNoTracking() + .Where(l => l.SourceRefType == LedgerSourceRefType.PaymentTransaction && l.SourceRefId == txnId) + .ToList(); + Assert.Equal(3, legs.Count); + Assert.Single(legs.Select(l => l.TransactionGroupId).Distinct()); + + var debits = legs.Where(l => l.Direction == LedgerDirection.Debit).Sum(l => l.AmountIrr); + var credits = legs.Where(l => l.Direction == LedgerDirection.Credit).Sum(l => l.AmountIrr); + Assert.Equal(debits, credits); + Assert.Equal(Price, legs.Single(l => l.AccountType == LedgerAccountType.EscrowHeld).AmountIrr); + Assert.Equal(3_495_000, legs.Single(l => l.AccountType == LedgerAccountType.PlatformRevenue).AmountIrr); + + var payable = legs.Single(l => l.AccountType == LedgerAccountType.NursePayable); + Assert.Equal(19_805_000, payable.AmountIrr); + Assert.Equal(host.NurseId, payable.NurseId); + } + + [Fact] + public async Task Confirm_is_idempotent_no_second_ledger_group() + { + using var host = new PaymentsTestHost(); + var variantId = host.AddVariant(sessionCount: 1, price: Price); + var requestId = host.AddAcceptedRequest(variantId); + var txnId = await InitiatePendingAsync(host, requestId); + + await Confirm(host).Handle(new ConfirmPaymentAndPostLedgerCommand(txnId), CancellationToken.None); + var second = await Confirm(host).Handle(new ConfirmPaymentAndPostLedgerCommand(txnId), CancellationToken.None); + + Assert.True(second.IsSuccess); + Assert.Equal(3, host.Db.Set().AsNoTracking().Count()); + Assert.Equal(1, host.Db.Set().AsNoTracking().Count()); + } + + [Fact] + public async Task Second_succeeded_transaction_for_a_booking_is_blocked_by_the_filtered_unique() + { + using var host = new PaymentsTestHost(); + var variantId = host.AddVariant(sessionCount: 1, price: Price); + var requestId = host.AddAcceptedRequest(variantId); + + var firstTxnId = await InitiatePendingAsync(host, requestId); + await Confirm(host).Handle(new ConfirmPaymentAndPostLedgerCommand(firstTxnId), CancellationToken.None); + + // A second, distinct pending attempt for the same (now-captured) booking. + var secondTxn = new PaymentTransaction + { + BookingRequestId = requestId, CustomerId = host.CustomerId, GatewayId = host.GatewayId, + Amount = Price, Currency = "IRR", GatewayReferenceCode = "second-attempt-ref" + }; + host.Db.Set().Add(secondTxn); + host.Db.SaveChanges(); + + // Confirming it must not create a second capture — the filtered UNIQUE(booking_id) WHERE succeeded + // backstops it into an idempotent no-op success. + var result = await Confirm(host).Handle(new ConfirmPaymentAndPostLedgerCommand(secondTxn.Id), CancellationToken.None); + Assert.True(result.IsSuccess); + + // Still exactly one capture group; the second attempt was rolled back (not succeeded). + Assert.Equal(3, host.Db.Set().AsNoTracking().Count()); + var reloaded = host.Db.Set().AsNoTracking().Single(t => t.Id == secondTxn.Id); + Assert.NotEqual(PaymentTransactionStatus.Succeeded, reloaded.Status); + } +} diff --git a/server/src/Tests/Baya.Test.Foundation/Payments/PaymentWebhookTests.cs b/server/src/Tests/Baya.Test.Foundation/Payments/PaymentWebhookTests.cs new file mode 100644 index 0000000..8b96715 --- /dev/null +++ b/server/src/Tests/Baya.Test.Foundation/Payments/PaymentWebhookTests.cs @@ -0,0 +1,112 @@ +using Baya.Application.Features.Payments.Commands.ConfirmPaymentAndPostLedger; +using Baya.Application.Features.Payments.Commands.HandlePaymentWebhook; +using Baya.Application.Features.Payments.Commands.InitiatePayment; +using Baya.Application.Models.Common; +using Baya.Domain.Entities.Booking; +using Baya.Domain.Entities.Payments; +using Mediator; +using Microsoft.EntityFrameworkCore; +using NSubstitute; +using BookingEntity = Baya.Domain.Entities.Booking.Booking; + +namespace Baya.Test.Foundation.Payments; + +public class PaymentWebhookTests +{ + private static readonly DateTimeOffset Now = new(2026, 7, 6, 10, 0, 0, TimeSpan.Zero); + private const long Price = 23_300_000; + + private static ISender SenderRoutingConfirmTo(ConfirmPaymentAndPostLedgerCommandHandler confirm) + { + var sender = Substitute.For(); + sender.Send(Arg.Any(), Arg.Any()) + .Returns(ci => confirm.Handle(ci.Arg(), ci.Arg())); + return sender; + } + + private static async Task<(long RequestId, string Reference)> SeedPendingAsync(PaymentsTestHost host) + { + var variantId = host.AddVariant(sessionCount: 1, price: Price); + var requestId = host.AddAcceptedRequest(variantId); + var initiate = new InitiatePaymentCommandHandler(host.AsCustomer(), host.UnitOfWork, host.PaymentProvider, host.Clock(Now)); + var init = await initiate.Handle(new InitiatePaymentCommand(requestId, null), CancellationToken.None); + return (requestId, init.Result.GatewayReferenceCode); + } + + private static string SuccessBody(string reference, string eventId) + => $"{{\"external_event_id\":\"{eventId}\",\"event_type\":\"payment.succeeded\",\"gateway_reference_code\":\"{reference}\"}}"; + + [Fact] + public async Task Webhook_success_confirms_the_booking_and_posts_one_balanced_group() + { + using var host = new PaymentsTestHost(); + var (_, reference) = await SeedPendingAsync(host); + + var confirm = new ConfirmPaymentAndPostLedgerCommandHandler( + host.UnitOfWork, host.Config(), host.Clock(Now), host.PaymentProvider, host.Settlement, host.Serializer, host.Notifications()); + var handler = new HandlePaymentWebhookCommandHandler( + SenderRoutingConfirmTo(confirm), host.UnitOfWork, host.Verifier, host.Lock, host.Clock(Now)); + + var result = await handler.Handle( + new HandlePaymentWebhookCommand("zarinpal", new Dictionary(), SuccessBody(reference, "evt-1")), + CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal(WebhookProcessingStatus.Processed, result.Result.ProcessingStatus); + Assert.False(result.Result.Duplicate); + + Assert.Equal(BookingStatus.Confirmed, host.Db.Set().AsNoTracking().Single().Status); + Assert.Equal(3, host.Db.Set().AsNoTracking().Count()); + Assert.Single(host.Db.Set().AsNoTracking()); + } + + [Fact] + public async Task Replayed_webhook_event_is_a_no_op() + { + using var host = new PaymentsTestHost(); + var (_, reference) = await SeedPendingAsync(host); + + var confirm = new ConfirmPaymentAndPostLedgerCommandHandler( + host.UnitOfWork, host.Config(), host.Clock(Now), host.PaymentProvider, host.Settlement, host.Serializer, host.Notifications()); + var sender = SenderRoutingConfirmTo(confirm); + var handler = new HandlePaymentWebhookCommandHandler(sender, host.UnitOfWork, host.Verifier, host.Lock, host.Clock(Now)); + + var body = SuccessBody(reference, "evt-1"); + await handler.Handle(new HandlePaymentWebhookCommand("zarinpal", new Dictionary(), body), CancellationToken.None); + var replay = await handler.Handle(new HandlePaymentWebhookCommand("zarinpal", new Dictionary(), body), CancellationToken.None); + + Assert.True(replay.IsSuccess); + Assert.True(replay.Result.Duplicate); + + // No second confirm, no second ledger group, one webhook event row. + await sender.Received(1).Send(Arg.Any(), Arg.Any()); + Assert.Equal(3, host.Db.Set().AsNoTracking().Count()); + Assert.Single(host.Db.Set().AsNoTracking()); + } + + [Fact] + public async Task Unverified_signature_callback_mutates_nothing() + { + using var host = new PaymentsTestHost(); + var (_, reference) = await SeedPendingAsync(host); + + var confirm = new ConfirmPaymentAndPostLedgerCommandHandler( + host.UnitOfWork, host.Config(), host.Clock(Now), host.PaymentProvider, host.Settlement, host.Serializer, host.Notifications()); + var sender = SenderRoutingConfirmTo(confirm); + var handler = new HandlePaymentWebhookCommandHandler(sender, host.UnitOfWork, host.Verifier, host.Lock, host.Clock(Now)); + + // The default MockWebhookVerifier marks a body carrying the invalid-signature marker as invalid. + var body = $"{{\"external_event_id\":\"evt-x\",\"event_type\":\"payment.succeeded\",\"gateway_reference_code\":\"{reference}\",\"note\":\"INVALID_SIGNATURE\"}}"; + var result = await handler.Handle( + new HandlePaymentWebhookCommand("zarinpal", new Dictionary(), body), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal(WebhookProcessingStatus.Ignored, result.Result.ProcessingStatus); + + await sender.DidNotReceive().Send(Arg.Any(), Arg.Any()); + Assert.Empty(host.Db.Set().AsNoTracking()); + Assert.Empty(host.Db.Set().AsNoTracking()); + var txn = host.Db.Set().AsNoTracking().Single(); + Assert.Equal(PaymentTransactionStatus.Pending, txn.Status); + } +} diff --git a/server/src/Tests/Baya.Test.Foundation/Payments/PaymentsTestHost.cs b/server/src/Tests/Baya.Test.Foundation/Payments/PaymentsTestHost.cs new file mode 100644 index 0000000..c3dcaa9 --- /dev/null +++ b/server/src/Tests/Baya.Test.Foundation/Payments/PaymentsTestHost.cs @@ -0,0 +1,188 @@ +using Baya.Application.Common; +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Configuration; +using Baya.Application.Contracts.Payments; +using Baya.Domain.Entities.Booking; +using Baya.Domain.Entities.Catalog; +using Baya.Domain.Entities.Geography; +using Baya.Domain.Entities.Identity; +using Baya.Domain.Entities.Payments; +using Baya.Domain.Entities.User; +using Baya.Infrastructure.CrossCutting.Seams; +using Baya.Infrastructure.Persistence; +using Baya.Infrastructure.Persistence.Repositories.Common; +using Baya.Tests.Setup.Setups; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using NSubstitute; + +namespace Baya.Test.Foundation.Payments; + +/// +/// A self-contained SQLite host exercising the real EF model (the two filtered uniques, the append-only +/// ledger, the webhook idempotency unique) for the b10 money core. Seeds one bookable nurse + one customer +/// with a geocoded address, an active standard gateway, and lets a test create an accepted request and drive +/// the real payment handlers against the real with faithful money-path seams. +/// +public sealed class PaymentsTestHost : IDisposable +{ + private readonly SqliteConnection _connection; + public ApplicationDbContext Db { get; } + public UnitOfWork UnitOfWork { get; } + + public long CustomerId { get; } + public int CustomerUserId { get; } + public long NurseId { get; } + public int NurseUserId { get; } + public long PatientId { get; } + public long AddressId { get; } + public long CategoryId { get; } + public long GatewayId { get; } + + public PaymentsTestHost() + { + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + + var options = new DbContextOptionsBuilder().UseSqlite(_connection).Options; + Db = new ApplicationDbContext(options, TestFieldEncryptor.Instance); + Db.Database.EnsureCreated(); + UnitOfWork = new UnitOfWork(Db); + + var province = new Province { NameFa = "تهران", NameEn = "Tehran", SortOrder = 1, IsActive = true }; + Db.Set().Add(province); + Db.SaveChanges(); + + var city = new City { ProvinceId = province.Id, NameFa = "تهران", NameEn = "Tehran", SortOrder = 1, IsActive = true }; + Db.Set().Add(city); + Db.SaveChanges(); + + var category = new ServiceCategory { NameFa = "سالمند", NameEn = "Elderly", SortOrder = 1, IsActive = true }; + Db.Set().Add(category); + Db.SaveChanges(); + CategoryId = category.Id; + + var customerUser = new User { UserName = "cust1", PhoneNumber = "09120000001", Gender = "male", Name = "علی", FamilyName = "رضایی", IsActive = true }; + Db.Users.Add(customerUser); + Db.SaveChanges(); + CustomerUserId = customerUser.Id; + + var customer = new CustomerProfile { UserId = customerUser.Id }; + Db.Set().Add(customer); + Db.SaveChanges(); + CustomerId = customer.Id; + + var patient = new Patient { CustomerId = customer.Id, DisplayName = "پدر", FirstName = "حسن", LastName = "رضایی", Gender = "male", IsActive = true }; + Db.Set().Add(patient); + Db.SaveChanges(); + PatientId = patient.Id; + + var address = new CustomerAddress + { + CustomerId = customer.Id, CityId = city.Id, Title = "خانه", + AddressLine = "خیابان اول", PostalCode = "1111111111", RecipientName = "علی", RecipientPhone = "09120000001", + Latitude = 35.6892m, Longitude = 51.3890m, IsPrimary = true + }; + Db.Set().Add(address); + Db.SaveChanges(); + AddressId = address.Id; + + var nurseUser = new User { UserName = "nurse1", PhoneNumber = "09120000002", Gender = "female", Name = "زهرا", FamilyName = "احمدی", IsActive = true }; + Db.Users.Add(nurseUser); + Db.SaveChanges(); + NurseUserId = nurseUser.Id; + + var nurse = new NurseProfile { UserId = nurseUser.Id }; + nurse.MarkVerified(); + nurse.SetAcceptingBookings(true); + Db.Set().Add(nurse); + Db.SaveChanges(); + NurseId = nurse.Id; + + var gateway = new PaymentGateway + { + ProviderCode = "zarinpal", Type = PaymentGatewayType.Standard, DisplayName = "ZarinPal", + ConfigJson = "{\"merchantId\":\"test\",\"sandbox\":true}", IsActive = true, Priority = 0 + }; + Db.Set().Add(gateway); + Db.SaveChanges(); + GatewayId = gateway.Id; + } + + public long AddVariant(int? sessionCount, long price) + { + var variant = new NurseServiceVariant + { + NurseId = NurseId, ServiceCategoryId = CategoryId, Price = price, PriceUnit = "per_day", + SessionCount = sessionCount, DisplayName = "مراقبت روزانه", OptionSetHash = $"hash-{Guid.NewGuid():N}", IsActive = true + }; + Db.Set().Add(variant); + Db.SaveChanges(); + return variant.Id; + } + + public long AddAcceptedRequest(long variantId) + { + var request = new BookingRequest + { + CustomerId = CustomerId, NurseId = NurseId, PatientId = PatientId, VariantId = variantId, + CustomerAddressId = AddressId, RequiredCaregiverGender = CaregiverGender.Any, + RequestedDate = new DateOnly(2026, 8, 1), RequestedTimeStart = new TimeOnly(9, 0), RequestedTimeEnd = new TimeOnly(13, 0), + CustomerNotes = "note", NurseResponseDeadlineAt = new DateTime(2030, 1, 1, 0, 0, 0, DateTimeKind.Utc) + }; + Db.Set().Add(request); + Db.SaveChanges(); + request.Accept(new DateTime(2030, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + Db.SaveChanges(); + return request.Id; + } + + // ---- seams ---- + public IVariantSnapshotSerializer Serializer { get; } = new VariantSnapshotSerializer(); + public IPaymentProvider PaymentProvider { get; } = new MockPaymentProvider(); + public ISettlementSplitProvider Settlement { get; } = new MockSettlementSplitProvider(); + public IDistributedLock Lock { get; } = new InProcessDistributedLock(); + public IWebhookVerifier Verifier { get; } = new MockWebhookVerifier(Options.Create(new SeamOptions())); + + public ICurrentUser AsCustomer() + { + var u = Substitute.For(); + u.UserId.Returns(CustomerUserId); + u.Roles.Returns(new[] { RoleNames.Customer }); + return u; + } + + public ICurrentUser AsNurse() + { + var u = Substitute.For(); + u.UserId.Returns(NurseUserId); + u.Roles.Returns(new[] { RoleNames.Nurse }); + return u; + } + + public IDateTimeProvider Clock(DateTimeOffset now) + { + var c = Substitute.For(); + c.UtcNow.Returns(now); + return c; + } + + public IPlatformConfig Config(decimal feeRate = 0.15m) + { + var cfg = Substitute.For(); + cfg.GetConfig("platform_fee_rate", Arg.Any()).Returns(feeRate); + return cfg; + } + + public INotificationDispatcher Notifications() => Substitute.For(); + + public string StatusOfBooking(long bookingId) + => Db.Set().AsNoTracking().Where(b => b.Id == bookingId).Select(b => b.Status).Single(); + + public void Dispose() + { + Db.Dispose(); + _connection.Dispose(); + } +}