diff --git a/dev/contracts/domains/refunds-invoices.md b/dev/contracts/domains/refunds-invoices.md new file mode 100644 index 0000000..831a995 --- /dev/null +++ b/dev/contracts/domains/refunds-invoices.md @@ -0,0 +1,127 @@ +# Contract — Refunds, clawbacks & invoices (backend phase b11) + +> One-line: the outbound money leg — an admin reverses a captured booking payment across both fee legs (posting +> the balanced ledger reversal, forking on whether the nurse was already paid), and issues the commission +> invoice with VAT. Customers can only **read** their refund status + invoice. 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-b11 · **Frontend consumer:** frontend-phase-f10-b11 + +All money is **IRR Rials, integer, on the wire as a string of digits** (`"10000000"`). Refunds are **admin-only** +— there is no customer refund-initiation path. Internal `account_type`s are never exposed. Timestamps are UTC +ISO-8601; `expected_customer_refund_eta` is a **date** (`"2026-08-24"`). + +## Enums used +- `refund_status` (`refunds.status`): `requested` | `approved` | `processing` | `succeeded` | `failed` | `rejected`. + A card refund goes `approved → succeeded` immediately; a BNPL/manual refund sits in `processing` until the + async customer cash-back reconciles. Forward-only. +- `refund_channel` (`refunds.refund_channel`): `psp_card` | `bnpl_revert` | `manual`. (The data-model's + `manual_bank` is stored/served as the canonical **`manual`**.) +- `clawback_status` (`nurse_clawbacks.status`): `pending` | `recovered` | `written_off`. This phase only ever + creates `pending` and supports `written_off`; `recovered` is set by b13 payout netting. +- `moadian_status` (`invoices.moadian_status`): `pending` | `submitted` | `registered` | `failed`. Mock leaves a + new invoice `pending`. + +## Endpoints + +### `POST api/v1/admin_refunds` +- **Purpose:** create (and immediately execute) a refund on a booking with a captured payment. +- **Auth:** admin (dynamic-permission) · **Rate-limited:** yes (sensitive) · **Idempotency key:** internal + (booking + transaction + cumulative amount) — a retried channel call never double-refunds. +- **Request body:** + ```json + { + "bookingId": 42, + "ticketId": null, + "refundPercentage": 1.0, + "platformFeeRefundedIrr": null, + "nursePayoutRefundedIrr": null, + "reasonCategory": "customer_request", + "reasonNotes": "shortened visit", + "adminNotes": null, + "manualBankReference": null + } + ``` + Supply **either** `refundPercentage` (0–1 fraction, pro-rata across the booking's commission/payout legs) **or** + the explicit `platformFeeRefundedIrr` + `nursePayoutRefundedIrr` legs (both together). If neither is given the + booking's b9 cancellation snapshot percentage is used. `manualBankReference` forces the `manual` channel. +- **Success `200` payload (`data`):** + ```json + { + "refundId": 7, + "bookingId": 42, + "status": "succeeded", + "refundChannel": "psp_card", + "amount": "10000000", + "platformFeeRefundedIrr": "1500000", + "nursePayoutRefundedIrr": "8500000", + "expectedCustomerRefundEta": null, + "clawbackId": null + } + ``` + For BNPL: `status: "processing"`, `refundChannel: "bnpl_revert"`, `expectedCustomerRefundEta: "2026-08-24"`. + Post-payout: `clawbackId` is set (a `pending` `nurse_clawbacks` row + a support alert were created). +- **Failure cases:** `400` invalid amount/legs or missing percentage · `401` unauth · `403` non-admin · + `404` no captured payment for the booking · `409` **`Σ refunded > captured`** (over-refund) · `400` channel refused. +- **Notes:** whole money-path runs under `lock(booking:{id}:refund)`; posts the balanced ledger reversal via + b10's helper; the `refund_payable ↔ escrow_held` clearing posts immediately for a succeeded card refund and is + deferred to reconciliation for BNPL/manual. `ticketId` is required only when `refund_ticket_required` config is + on (off until b15). Notifies the customer. + +### `GET api/v1/admin_refunds?booking_id=&status=&page=&pageSize=` +- **Purpose:** admin refund worklist — projected + paginated (`page` default 1, `pageSize` default 20 / max 100). +- **Auth:** admin · **Success `200` (`data`):** `PagedResult` (see shapes) — channel, decomposed + legs, status, `expectedCustomerRefundEta`, the policy snapshot. + +### `POST api/v1/admin_clawbacks/{id}/write_off` +- **Purpose:** mark a `pending` nurse clawback uncollectable; posts the balancing `DEBIT bad_debt / CREDIT + nurse_clawback_receivable` correction and sets `resolved_at`. +- **Auth:** admin · **Rate-limited:** yes · **Request body:** `{ "reason": "uncollectable" }` +- **Success `200` (`data`):** `true`. **Failure:** `404` not found · `409` not pending. + +### `POST api/v1/admin_invoices` +- **Purpose:** issue the booking's official commission invoice. Idempotent per booking (re-issue returns the same). +- **Auth:** admin · **Rate-limited:** yes · **Request body:** `{ "bookingId": 42 }` +- **Success `200` (`data`):** `Invoice` (see shapes) — sequential `invoiceNumber`, `vatIrr = round(commission × + vat_rate)` on the **commission line only**, `moadianStatus: "pending"`, `moadianReferenceNumber: null`. +- **Failure:** `404` booking not found. + +### `GET api/v1/refunds/{id}/status` *(customer-visible)* +- **Purpose:** the customer-facing status of **their own** refund. +- **Auth:** authenticated; **tenancy-scoped** to the booking's customer — another customer's refund is a clean `404`. +- **Success `200` (`data`):** + ```json + { "id": 7, "bookingId": 42, "status": "processing", "refundChannel": "bnpl_revert", + "amount": "10000000", "expectedCustomerRefundEta": "2026-08-24", "reference": "••••••ab12" } + ``` + The external reference is **masked** (last 4 only). +- **Failure:** `401` unauth · `404` not found / not the caller's. + +### `GET api/v1/invoices/{booking_id}` *(customer/admin)* +- **Purpose:** the booking's invoice. **Auth:** authenticated — the owning customer or an admin (else `404`). +- **Success `200` (`data`):** `Invoice` (see shapes), with `pdfUrl` when a PDF is stored. + +## Shared shapes +- `RefundListItem`: `id` (int), `bookingId` (int), `paymentTransactionId` (int), `amount` / `platformFeeRefundedIrr` + / `nursePayoutRefundedIrr` (IRR digit-strings), `refundChannel` (enum), `status` (enum), `refundPercentage` + (decimal), `reasonCategory` (string?), `cancellationPolicyCode` (string?), `refundPercentageApplied` (decimal?), + `expectedCustomerRefundEta` (date?), `gatewayRefundReference` (string?), `externalRevertReference` (string?), + `processedAt` (datetime?), `createdAt` (datetime). +- `Invoice`: `id` (int), `bookingId` (int), `invoiceNumber` (string, unique/sequential), `issuingEntityType` + (`platform`|`partner_center`), `grossIrr` / `platformCommissionIrr` (IRR digit-strings), `bnplCommissionIrr` + (digit-string?), `vatRate` (decimal), `vatIrr` (IRR digit-string), `moadianReferenceNumber` (string?), + `moadianStatus` (enum?), `pdfUrl` (string?), `issuedAt` (datetime). + +## 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. +- **Refunds are admin-only.** The only customer-visible surface is `refunds/{id}/status` — there is no + self-service refund initiation. +- **A card refund is immediate** (`succeeded`, no ETA); **a BNPL refund is `processing`** with an + `expectedCustomerRefundEta` ~7–10 business days out — surface it as "on its way, ~N days". +- **VAT is on the platform commission only** — never the nurse payout. +- **External references are masked** in the customer status view. + +## Changelog +- b11 — initial contract (create refund, list refunds, write-off clawback, issue invoice, refund status, get invoice). diff --git a/dev/contracts/openapi/swagger.v1.json b/dev/contracts/openapi/swagger.v1.json index 3e8e571..c8e78d8 100644 --- a/dev/contracts/openapi/swagger.v1.json +++ b/dev/contracts/openapi/swagger.v1.json @@ -7,7 +7,7 @@ }, "servers": [ { - "url": "https://localhost:5002" + "url": "http://localhost" } ], "paths": { @@ -805,6 +805,95 @@ ] } }, + "/api/v1/admin_clawbacks/{id}/write_off": { + "post": { + "tags": [ + "AdminClawbacks" + ], + "operationId": "AdminClawbacks_WriteOff", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + }, + "x-position": 1 + } + ], + "requestBody": { + "x-name": "body", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WriteOffClawbackBody" + } + } + }, + "required": true, + "x-position": 2 + }, + "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/ApiResultOfBoolean" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, "/api/v1/admin_evv/list": { "get": { "tags": [ @@ -1729,6 +1818,264 @@ ] } }, + "/api/v1/admin_invoices": { + "post": { + "tags": [ + "AdminInvoices" + ], + "operationId": "AdminInvoices_Issue", + "requestBody": { + "x-name": "command", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IssueInvoiceCommand" + } + } + }, + "required": true, + "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/ApiResultOfInvoiceDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/admin_refunds": { + "post": { + "tags": [ + "AdminRefunds" + ], + "summary": "Creates a AdminRefund", + "operationId": "AdminRefunds_Create", + "requestBody": { + "x-name": "command", + "description": "A AdminRefund representation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateRefundCommand" + } + } + }, + "required": true, + "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/ApiResultOfCreateRefundResult" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + }, + "get": { + "tags": [ + "AdminRefunds" + ], + "operationId": "AdminRefunds_List", + "parameters": [ + { + "name": "BookingId", + "in": "query", + "schema": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "x-position": 1 + }, + { + "name": "Status", + "in": "query", + "schema": { + "type": "string", + "nullable": true + }, + "x-position": 2 + }, + { + "name": "Page", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + }, + "x-position": 3 + }, + { + "name": "PageSize", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + }, + "x-position": 4 + } + ], + "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/ApiResultOfPagedResultOfRefundListItemDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, "/api/v1/admin_search/rebuild_index": { "post": { "tags": [ @@ -5627,6 +5974,85 @@ ] } }, + "/api/v1/invoices/{bookingId}": { + "get": { + "tags": [ + "Invoices" + ], + "summary": "Retrieves a Invoice by unique id", + "operationId": "Invoices_Get", + "parameters": [ + { + "name": "bookingId", + "in": "path", + "required": true, + "description": "A unique id for the Invoice", + "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/ApiResultOfInvoiceDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, "/api/v1/me": { "get": { "tags": [ @@ -8770,6 +9196,83 @@ ] } }, + "/api/v1/refunds/{id}/status": { + "get": { + "tags": [ + "Refunds" + ], + "operationId": "Refunds_Status", + "parameters": [ + { + "name": "id", + "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/ApiResultOfRefundStatusDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, "/api/v1/search/nurses": { "get": { "tags": [ @@ -9824,6 +10327,33 @@ } } }, + "ApiResultOfBoolean": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "type": "boolean" + } + } + } + ] + }, + "WriteOffClawbackBody": { + "type": "object", + "description": "The write-off body (the id comes from the route).", + "additionalProperties": false, + "properties": { + "reason": { + "type": "string", + "nullable": true + } + } + }, "ApiResultOfPagedResultOfAdminEvvItemDto": { "allOf": [ { @@ -10248,6 +10778,314 @@ } } }, + "ApiResultOfInvoiceDto": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/InvoiceDto" + } + ] + } + } + } + ] + }, + "InvoiceDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "bookingId": { + "type": "integer", + "format": "int64" + }, + "invoiceNumber": { + "type": "string" + }, + "issuingEntityType": { + "type": "string" + }, + "grossIrr": { + "type": "string" + }, + "platformCommissionIrr": { + "type": "string" + }, + "bnplCommissionIrr": { + "type": "string", + "nullable": true + }, + "vatRate": { + "type": "number", + "format": "decimal" + }, + "vatIrr": { + "type": "string" + }, + "moadianReferenceNumber": { + "type": "string", + "nullable": true + }, + "moadianStatus": { + "type": "string", + "nullable": true + }, + "pdfUrl": { + "type": "string", + "nullable": true + }, + "issuedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "IssueInvoiceCommand": { + "type": "object", + "additionalProperties": false, + "properties": { + "bookingId": { + "type": "integer", + "format": "int64" + } + } + }, + "ApiResultOfCreateRefundResult": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/CreateRefundResult" + } + ] + } + } + } + ] + }, + "CreateRefundResult": { + "type": "object", + "additionalProperties": false, + "properties": { + "refundId": { + "type": "integer", + "format": "int64" + }, + "bookingId": { + "type": "integer", + "format": "int64" + }, + "status": { + "type": "string" + }, + "refundChannel": { + "type": "string" + }, + "amount": { + "type": "string" + }, + "platformFeeRefundedIrr": { + "type": "string" + }, + "nursePayoutRefundedIrr": { + "type": "string" + }, + "expectedCustomerRefundEta": { + "type": "string", + "format": "date", + "nullable": true + }, + "clawbackId": { + "type": "integer", + "format": "int64", + "nullable": true + } + } + }, + "CreateRefundCommand": { + "type": "object", + "additionalProperties": false, + "properties": { + "bookingId": { + "type": "integer", + "format": "int64" + }, + "ticketId": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "refundPercentage": { + "type": "number", + "format": "decimal", + "nullable": true + }, + "platformFeeRefundedIrr": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "nursePayoutRefundedIrr": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "reasonCategory": { + "type": "string", + "nullable": true + }, + "reasonNotes": { + "type": "string", + "nullable": true + }, + "adminNotes": { + "type": "string", + "nullable": true + }, + "manualBankReference": { + "type": "string", + "nullable": true + } + } + }, + "ApiResultOfPagedResultOfRefundListItemDto": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/PagedResultOfRefundListItemDto" + } + ] + } + } + } + ] + }, + "PagedResultOfRefundListItemDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "items": { + "type": "array", + "nullable": true, + "items": { + "$ref": "#/components/schemas/RefundListItemDto" + } + }, + "total": { + "type": "integer", + "format": "int32" + }, + "page": { + "type": "integer", + "format": "int32" + }, + "pageSize": { + "type": "integer", + "format": "int32" + } + } + }, + "RefundListItemDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "bookingId": { + "type": "integer", + "format": "int64" + }, + "paymentTransactionId": { + "type": "integer", + "format": "int64" + }, + "amount": { + "type": "string" + }, + "platformFeeRefundedIrr": { + "type": "string" + }, + "nursePayoutRefundedIrr": { + "type": "string" + }, + "refundChannel": { + "type": "string" + }, + "status": { + "type": "string" + }, + "refundPercentage": { + "type": "number", + "format": "decimal" + }, + "reasonCategory": { + "type": "string", + "nullable": true + }, + "cancellationPolicyCode": { + "type": "string", + "nullable": true + }, + "refundPercentageApplied": { + "type": "number", + "format": "decimal", + "nullable": true + }, + "expectedCustomerRefundEta": { + "type": "string", + "format": "date", + "nullable": true + }, + "gatewayRefundReference": { + "type": "string", + "nullable": true + }, + "externalRevertReference": { + "type": "string", + "nullable": true + }, + "processedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + } + }, "ApiResultOfSearchIndexRebuildResult": { "allOf": [ { @@ -14008,6 +14846,59 @@ } } }, + "ApiResultOfRefundStatusDto": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/RefundStatusDto" + } + ] + } + } + } + ] + }, + "RefundStatusDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "bookingId": { + "type": "integer", + "format": "int64" + }, + "status": { + "type": "string" + }, + "refundChannel": { + "type": "string" + }, + "amount": { + "type": "string" + }, + "expectedCustomerRefundEta": { + "type": "string", + "format": "date", + "nullable": true + }, + "reference": { + "type": "string", + "nullable": true + } + } + }, "ApiResultOfPagedResultOfNurseSearchResultDto": { "allOf": [ { diff --git a/dev/shared-working-context/backend/STATUS.md b/dev/shared-working-context/backend/STATUS.md index 6f15d5f..9a85406 100644 --- a/dev/shared-working-context/backend/STATUS.md +++ b/dev/shared-working-context/backend/STATUS.md @@ -12,6 +12,28 @@ One block per completed backend phase. Newest at the top. Backend lane writes he - **Notes for frontend:** --> +## backend-phase-11 — Refunds, invoices & nurse clawbacks — 2026-07-09 +- **Shipped:** the reversal leg via one migration in the **`payments`** schema — **3 tables** `Refunds` + (fee-leg decomposition + `refund_channel` + `amount = fee_leg + payout_leg` CHECK + **nullable `ticket_id`, no FK** + until b15) / `NurseClawbacks` (nullable `original_payout_id`/`recovered_in_payout_id` until b13) / `Invoices` + (**UNIQUE `invoice_number`** sequential + UNIQUE `booking_id`), plus the `InvoiceNumberSequences` counter row. + Features: `CreateRefund` (whole money-path under `lock(booking:{id}:refund)`; decompose → Σ≤captured → channel → + balanced ledger reversal via b10's helper; pre-payout `nurse_payable` vs post-payout `nurse_clawback_receivable` + fork + `pending` clawback + support alert), `WriteOffClawback`, `ListRefunds`, `GetRefundStatus`, `IssueInvoice` + (VAT on commission only from config, sequential number, mocked مودیان), `GetInvoice`. Controllers: admin + `AdminRefunds`/`AdminClawbacks`/`AdminInvoices` (rate-limited) + customer `Refunds`/`Invoices`. +- **Contracts:** dev/contracts/domains/refunds-invoices.md + openapi snapshot refreshed — **yes**. +- **Mocked:** `IMoadianClient` (new), `IBnplProvider` (thin pre-b12 stub), `INursePayoutStatus` (interim derivation; + b13 owns real); reused `IPaymentProvider`/`IWebhookVerifier`/`IDistributedLock`/`INotificationDispatcher`. See + reports/mocks-registry.md. +- **Gate:** build clean (0 new warnings) / tests green (300 pass: 205 Foundation + 91 Api + 4 Identity). Migration + applied to the dev DB; swagger snapshot republished. +- **Handoff:** backend/handoff/after-backend-phase-11.md +- **Notes for frontend:** refunds are **admin-only** (no self-service); the only customer surfaces are + `GET refunds/{id}/status` (with the BNPL `expected_customer_refund_eta` date + masked reference) and + `GET invoices/{booking_id}`. Money is IRR digit-strings. Card refund = immediate `succeeded`; BNPL = `processing` + + ETA. + ## 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 diff --git a/dev/shared-working-context/backend/handoff/after-backend-phase-11.md b/dev/shared-working-context/backend/handoff/after-backend-phase-11.md new file mode 100644 index 0000000..941f365 --- /dev/null +++ b/dev/shared-working-context/backend/handoff/after-backend-phase-11.md @@ -0,0 +1,46 @@ +# Handoff — after backend-phase-11 (Refunds, clawbacks & invoices) + +**The reversal leg of the payments arc is live.** Money can now flow *backwards*: an admin reverses a captured +booking payment across both fee legs, the balanced reversal posts to the append-only ledger, and the flow forks +on whether the nurse was already paid (clean `nurse_payable` reversal vs a first-class `nurse_clawbacks` +receivable). The minimal commission **invoice** (VAT on the commission line, sequential number, mocked مودیان) +ships too. + +## What the frontend (f10-b11) can now build +- **Admin refund console** — create a refund (`POST admin_refunds`: full/partial by percentage or explicit legs), + the refund worklist (`GET admin_refunds` — channel, decomposed legs, status, ETA, policy snapshot), and the + clawback write-off (`POST admin_clawbacks/{id}/write_off`). +- **Admin invoice issuance** — `POST admin_invoices` (sequential number, VAT on commission, مودیان pending). +- **Customer cancellation/refund-status view** — `GET refunds/{id}/status` (tenancy-scoped; status, channel, + amount, **`expected_customer_refund_eta`** for the BNPL 7–10-business-day window, masked reference). This is the + **only** customer-visible refund surface — refunds are admin-initiated, there is no self-service initiation. +- **Customer/admin invoice view** — `GET invoices/{booking_id}` (number, gross, commission, VAT, مودیان status, + PDF URL when present). + +## Live endpoints / contract +- Contract: **`dev/contracts/domains/refunds-invoices.md`** (enums, DTO shapes, IRR digit-strings, masked + references, failure codes, side-effects). Machine schema: `dev/contracts/openapi/swagger.v1.json` **refreshed**. +- All money is IRR integer, on the wire as a **digit-string**. `expected_customer_refund_eta` is a **date**. +- Admin endpoints are behind the admin policy and **rate-limited** (sensitive). Refunds are **never** customer + self-service. + +## What is mocked / waiting +- **سامانه مودیان** is mocked behind **`IMoadianClient`** (pending/no-ref by default; a config switch forces a + registered 22-digit ref). See `reports/mocks-registry.md`. +- **BNPL revert** runs against **`IBnplProvider`** — a **thin local stub** now; **b12 owns the real seam** and its + adapter. The `bnpl_revert` ledger legs are already identical to the card path. +- **"Was the nurse paid?"** is derived from the booking's dispute-window close (`INursePayoutStatus`) until **b13** + ships `nurse_payouts`; **clawback recovery/netting is b13** (this phase only opens the `pending` receivable + + supports write-off). +- **`tickets`** arrives in **b15**: `refunds.ticket_id` is a nullable column (no FK yet); the "ticket required" + rule is config-gated off (`refund_ticket_required = false`). +- **Deferred crons** (thin/manual today): the مودیان reconciliation job (`pending → registered`) and the + BNPL-revert reconciliation job that clears `refund_payable ↔ escrow_held` for a `processing` refund. + +## Notes for the next backend phases +- **b12 (BNPL):** owns the real `IBnplProvider`; the refund `bnpl_revert` path already calls it through the seam. +- **b13 (payouts):** implement the real `INursePayoutStatus` (`nurse_payout_booking_links`) and net `pending` + `nurse_clawbacks` out of a payout batch (set `recovered_in_payout_id` / `original_payout_id`, post `DEBIT + nurse_payable / CREDIT nurse_clawback_receivable`). +- **b15 (tickets/partner centers):** wire the real `refunds.ticket_id` FK + flip `refund_ticket_required` on; + wire `invoices.partner_center_id` + `nurse_clawbacks` payout FKs. diff --git a/dev/shared-working-context/reports/backend-phase-11-report.md b/dev/shared-working-context/reports/backend-phase-11-report.md new file mode 100644 index 0000000..3f240db --- /dev/null +++ b/dev/shared-working-context/reports/backend-phase-11-report.md @@ -0,0 +1,90 @@ +# Backend Phase 11 Report — Refunds, invoices & nurse clawbacks + +**Mission:** make money flow *backwards* correctly — the admin-only refund engine that reverses a captured +booking payment across both fee legs, posts the balanced reversal into the append-only ledger, forks on whether +the nurse was already paid (clean reversal vs a `nurse_clawbacks` receivable), and issues the minimal commission +invoice with VAT. + +## What was built + +**Three tables (one migration `RefundsClawbacksInvoices`, `payments` schema) + a counter row:** +- `Refunds` — 1:N per `payment_transaction`; `amount = platform_fee_refunded_irr + nurse_payout_refunded_irr` + (DB CHECK); `refund_channel` (`psp_card`|`bnpl_revert`|`manual`), `external_revert_reference`, + `expected_customer_refund_eta` (DATE), `cancellation_policy_code` + `refund_percentage_applied` snapshot, + forward-only `status`. **`ticket_id` is a nullable column with NO FK** (tickets → b15). +- `NurseClawbacks` — `status` (`pending`|`recovered`|`written_off`; only `pending`/`written_off` here), + `amount_irr`, `refund_id` (1:1 UNIQUE), nullable `original_payout_id`/`recovered_in_payout_id` (no FK, → b13). +- `Invoices` — UNIQUE `invoice_number` (sequential) + UNIQUE `booking_id`; `vat_rate`/`vat_irr` on the commission + line; `moadian_reference_number`/`moadian_status`; nullable `partner_center_id` (no FK, → b15). +- `InvoiceNumberSequences` — single seeded counter row (id 1, next 1); locked + committed with the invoice so + numbers are gap-free and portable across SQL Server / SQLite (no DB sequence). + +**Features (CQRS, `OperationResult`, validators):** `CreateRefundCommand`, `WriteOffClawbackCommand`, +`ListRefundsQuery`, `GetRefundStatusQuery`, `IssueInvoiceCommand`, `GetInvoiceQuery`. The phase's +channel-execution / ledger-posting / clawback "internal step" commands are realized as cohesive **private steps** +inside `CreateRefundCommandHandler` under one lock + transaction (mirroring b10's `ConfirmPaymentAndPostLedger`) so +they stay atomic — this is the correct shape; separate dispatched commands would each commit and break atomicity. + +**Ledger postings** added to b10's `LedgerPosting` (balanced, append-only): `RefundReversalPrePayout`, +`ClawbackReversalPostPayout`, `RefundPayableClearing`, `ClawbackWriteOff`. + +**Controllers:** `AdminRefundsController` (`POST`/`GET admin_refunds`), `AdminClawbacksController` +(`POST admin_clawbacks/{id}/write_off`), `AdminInvoicesController` (`POST admin_invoices`) — all admin policy + +rate-limited; `RefundsController` (`GET refunds/{id}/status`), `InvoicesController` (`GET invoices/{bookingId}`) — +authenticated, tenancy-scoped. + +**Seams:** introduced `IMoadianClient` (+ `MockMoadianClient`) and, because b12 isn't merged, a thin +`IBnplProvider` (+ `MockBnplProvider`) stub; interim `INursePayoutStatus` (`NursePayoutStatusService`, Persistence). +Extended `IPaymentProvider.RefundAsync` to carry an `idempotencyKey` (updated the mock + b10 call sites — no b10 +behaviour change). Reused `IWebhookVerifier`/`IDistributedLock`/`INotificationDispatcher`/`ISupportAlertService`/ +`IObjectStorage`/`IPlatformConfig`. Added config rows `refund_ticket_required` (false), `bnpl_refund_eta_business_days` +(10), `refund_assume_nurse_paid` (false). + +## What is now testable and exactly how (mirrors the phase §7) + +Seed a confirmed booking with a captured card transaction (the `RefundsTestHost`/`AdminRefundsApiTests.SeedCapturedBookingAsync` +helpers do this; gross 10,000,000 / commission 1,500,000 / payout 8,500,000). + +1. **Pre-payout full refund** — `POST admin_refunds {bookingId, refundPercentage:1}` → `refund_channel psp_card`, + legs `1,500,000` + `8,500,000` summing to `10,000,000`; ledger shows a balanced `DEBIT platform_revenue` + + `DEBIT nurse_payable` / `CREDIT refund_payable`, and a `DEBIT refund_payable` / `CREDIT escrow_held` clearing + leg (5 legs, Σdebit=Σcredit); status `succeeded`. +2. **Partial + over-refund guard** — a 50% refund decomposes to `750,000` + `4,250,000`; a second refund that would + exceed the captured amount → **409**, no ledger posted. +3. **Invoice** — `POST admin_invoices {bookingId}` → sequential `INV-0000000001`, + `vat_irr = round(1,500,000 × 0.10) = 150,000` (on the commission, `0` when `vat_rate = 0`), `moadian_status + pending` / null ref; a second booking → `INV-0000000002` (gap-free). Re-issue returns the same invoice. +4. **Post-payout clawback** — with the nurse flagged paid (seed a past `dispute_window_ends_at`, or + `refund_assume_nurse_paid = true`) → the payout leg debits `nurse_clawback_receivable` (not `nurse_payable`), a + `pending` `nurse_clawbacks` row (`amount_irr = 8,500,000`) is created, and a `nurse_clawback` support alert is + raised. Not auto-recovered (b13). +5. **BNPL revert** — seed a `bnpl` gateway → `refund_channel bnpl_revert`, `IBnplProvider.RevertAsync` called, + `external_revert_reference` stored, `expected_customer_refund_eta ≈ now + 10 business days`, status `processing`; + the reversal ledger legs are identical to the card case. `GET refunds/{id}/status` shows the ETA. +6. **Write-off** — `POST admin_clawbacks/{id}/write_off` → `written_off` + `DEBIT bad_debt` / `CREDIT + nurse_clawback_receivable` + `resolved_at`. +7. **Worklist + tenancy** — `GET admin_refunds?status=…` lists channel/legs/ETA; `GET refunds/{id}/status` as a + different customer → **404**. + +**Tests:** 8 Foundation handler tests (`Refunds/RefundHandlerTests` + `InvoiceHandlerTests`) + 8 Api integration +tests (`AdminRefundsApiTests`, `AdminInvoicesApiTests`, `RefundStatusApiTests`). Full suite green (300). +`dotnet build Baya.sln` 0 new warnings. + +## What is mocked / how to make it real +See `reports/mocks-registry.md` — `IMoadianClient` (سامانه مودیان enrollment + submission API + 22-digit ref + +reconciliation callback), `IBnplProvider` (b12 owns the real adapter), `INursePayoutStatus` (b13 real lookup). + +## Contracts produced/consumed +- **Produced:** `dev/contracts/domains/refunds-invoices.md`; `dev/contracts/openapi/swagger.v1.json` refreshed. +- **Consumed:** b9 booking/cancellation snapshot, b10 ledger/transaction/gateway, b1 VAT config + notifications + + support alerts. + +## Follow-ups for later phases +- **مودیان reconciliation cron** — flip `moadian_status pending → registered` + fill the 22-digit ref (thin/manual + today). +- **BNPL-revert reconciliation cron** — clear `refund_payable ↔ escrow_held` for a `processing` refund when the + provider confirms the customer cash-back (webhook via `IWebhookVerifier`; deferred/manual today). +- **b12** — real `IBnplProvider`. +- **b13** — clawback netting/recovery + real `INursePayoutStatus`. +- **b15** — `tickets` FK on `refunds` (+ flip `refund_ticket_required` on), `partner_centers` on `invoices`, + `nurse_payouts` FKs on `nurse_clawbacks`. diff --git a/dev/shared-working-context/reports/mocks-registry.md b/dev/shared-working-context/reports/mocks-registry.md index 4fd624f..30f2633 100644 --- a/dev/shared-working-context/reports/mocks-registry.md +++ b/dev/shared-working-context/reports/mocks-registry.md @@ -35,12 +35,16 @@ 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 | 🟡 | +| `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(ref, amount, idempotencyKey, ct)` → always `Succeeded`, echoes a deterministic refund ref (b11 refunds carry the booking+refund idempotency key so a retry never double-refunds). 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) | +| `IMoadianClient` | backend-phase-11 | سامانه مودیان e-invoicing — `MockMoadianClient` (`Baya.Infrastructure.CrossCutting/Seams/`), **no external call**: `SubmitAsync` leaves a new invoice `moadian_status = pending` with `moadian_reference_number = null`; a config switch forces a deterministic `registered` result with a fake 22-digit reference so the reconciliation/registered path is testable. Registered singleton in `AddCrossCuttingSeams` | `Seams:Moadian:ForceRegistered` (default `false`) | 1) enroll the platform in سامانه مودیان (memory/economic code + signing certificate); 2) implement `SubmitAsync` to POST the معاملات/invoice (`صورتحساب`) to the مودیان API, sign the payload, map the 22-digit `reference_number`; 3) walk the async `pending → submitted → registered`/`failed` states via a reconciliation callback/poll (**cron deferred/manual today** — a job flips `moadian_status` + fills the ref); 4) swap the registration (config-selected) — the `IssueInvoice` handler is unchanged | 🟡 | +| `IBnplProvider` | **owned by backend-phase-12**; thin local stub added in **b11** | BNPL revert/update — `MockBnplProvider` (`Baya.Infrastructure.CrossCutting/Seams/`), a **temporary stub so b11's `bnpl_revert` refund path runs before b12 merges**. `RevertAsync`/`UpdateAsync` succeed, echo a deterministic `external_revert_reference`, and report a **nullable** `provider_commission_reversed_amount` (null by default — reconciled from the response, never hardcoded). Registered singleton in `AddCrossCuttingSeams` | `Seams:Bnpl:ForceFailure` (default `false`), `Seams:Bnpl:ReverseProviderCommission` (default `false`) | **b12 owns the real seam definition + adapter** (SnappPay/Tara): settle flow, order lifecycle, real `RevertAsync`(full)/`UpdateAsync`(strictly-lower partial). When b12 lands its real registration supersedes this stub and the refund `bnpl_revert` path calls the real client unchanged | 🟡 (pre-b12 stub) | +| `INursePayoutStatus` | backend-phase-11 (interim; **b13** owns the real impl) | "Was the nurse already paid for this booking?" — `NursePayoutStatusService` (`Persistence/Services/Payments/`) derives it from the booking's `dispute_window_ends_at` close (the same gate b13 pays out on), with a `refund_assume_nurse_paid` config override. Not a mock of an external — a **temporary derivation** standing in for the b13 `nurse_payout_booking_links` lookup. Registered scoped in `AddPersistenceServices` | `refund_assume_nurse_paid` (`platform_configs`, default `false`) | In b13: implement `IsNursePaidForBookingAsync` as a real `nurse_payout_booking_links` join (a booking linked to a paid-out `nurse_payouts` batch ⇒ paid), swap the registration — the refund pre-payout/clawback fork is unchanged | 🟡 | + > Exact config keys and file paths get filled in by the phase that builds each seam. Keep the > "Make it real →" column actionable enough that a developer can pick up any single row and ship it. diff --git a/product/business/07-cancellation-and-refunds.html b/product/business/07-cancellation-and-refunds.html index 4d447f9..66c56e3 100644 --- a/product/business/07-cancellation-and-refunds.html +++ b/product/business/07-cancellation-and-refunds.html @@ -44,6 +44,24 @@

(d) Supporting database entities

cancellation_policies, bookings (policy snapshot, dispute_window_ends_at), refunds (admin-only, ticket_id, fee-leg decomposition, refund_channel), tickets, nurse_clawbacks (post-payout case), ledger_entries.

+

(e) As built (backend-phase-11)

+

Decisions fixed while building the refund/clawback/invoice engine (config-driven where possible):

+ +

code), not manual_bank — they are the same channel. Full set: psp_card | bnpl_revert | manual.

+ +

(tickets arrives in b15). "A refund must link a ticket" is enforced by the refund_ticket_required config flag, default false so admin refunds are testable today; b15 wires the FK and flips it on.

+ +

surfaced as expected_customer_refund_eta; the refund sits in processing until reconciled. Card refunds are immediate (succeeded, no ETA). Card and BNPL post the same reversal ledger legs.

+ +

dispute_window_ends_at close until b13 ships nurse_payouts (a refund_assume_nurse_paid override exists). Clawback recovery/netting is b13; b11 only opens the pending receivable + supports admin write-off.

Related: Data model — Payments Ledger & Refunds.

↑ Back to top diff --git a/product/business/07-cancellation-and-refunds.md b/product/business/07-cancellation-and-refunds.md index 8d9731e..5c13f49 100644 --- a/product/business/07-cancellation-and-refunds.md +++ b/product/business/07-cancellation-and-refunds.md @@ -25,4 +25,18 @@ ## (d) Supporting database entities **`cancellation_policies`**, `bookings` (policy snapshot, `dispute_window_ends_at`), `refunds` (admin-only, `ticket_id`, fee-leg decomposition, `refund_channel`), `tickets`, `nurse_clawbacks` (post-payout case), `ledger_entries`. +## (e) As built (backend-phase-11) +Decisions fixed while building the refund/clawback/invoice engine (config-driven where possible): +- **Refund channel canonical code:** the out-of-band bank refund is stored and served as **`manual`** (the wire + code), not `manual_bank` — they are the same channel. Full set: `psp_card` | `bnpl_revert` | `manual`. +- **Ticket link is config-gated until b15.** `refunds.ticket_id` is a nullable column with **no FK yet** + (`tickets` arrives in b15). "A refund must link a ticket" is enforced by the `refund_ticket_required` config + flag, **default `false`** so admin refunds are testable today; b15 wires the FK and flips it on. +- **BNPL refund ETA** is `now + config(bnpl_refund_eta_business_days, default 10)` business days (Fridays skipped), + surfaced as `expected_customer_refund_eta`; the refund sits in `processing` until reconciled. Card refunds are + immediate (`succeeded`, no ETA). Card and BNPL post the **same** reversal ledger legs. +- **Pre-payout vs post-payout fork** is decided by whether the nurse was already paid — derived from the booking's + `dispute_window_ends_at` close until b13 ships `nurse_payouts` (a `refund_assume_nurse_paid` override exists). + Clawback **recovery/netting is b13**; b11 only opens the `pending` receivable + supports admin write-off. + > **Related:** Data model — [Payments Ledger & Refunds](../data-model/06-payments-ledger-and-refunds.md). diff --git a/product/data-model/06-payments-ledger-and-refunds.html b/product/data-model/06-payments-ledger-and-refunds.html index 54920a7..58bc4fb 100644 --- a/product/data-model/06-payments-ledger-and-refunds.html +++ b/product/data-model/06-payments-ledger-and-refunds.html @@ -111,6 +111,27 @@ issued_atDATETIME2

Relations: 1:1 → bookings; N:1 → partner_centers (when issuer).

+

As built (backend-phase-11)

+ +

the two are the same channel; manual is the value stored and served. Set: psp_card | bnpl_revert | manual.

+ +

"ticket required" rule is gated by the refund_ticket_required config flag (default off). nurse_clawbacks.original_payout_id / recovered_in_payout_id and invoices.partner_center_id are likewise nullable, FK-less join points that b13 / b15 fill.

+ +

vat_rate = 0 exemption yields vat_irr = 0.

+ +

and committed with the invoice insert — gap-free, unique, portable across SQL Server / SQLite (no DB sequence), never random/timestamp-derived. One issued invoice per booking (idempotent).

+ +

posts immediately for a succeeded card refund and is deferred to reconciliation for a processing BNPL/manual refund. Card and BNPL post the same reversal legs.

↑ Back to top diff --git a/product/data-model/06-payments-ledger-and-refunds.md b/product/data-model/06-payments-ledger-and-refunds.md index cf0d245..d1e1a4d 100644 --- a/product/data-model/06-payments-ledger-and-refunds.md +++ b/product/data-model/06-payments-ledger-and-refunds.md @@ -118,3 +118,19 @@ This is the most-changed domain. The previous model **inferred** money state fro | `issued_at` | DATETIME2 | | **Relations:** 1:1 → `bookings`; N:1 → `partner_centers` (when issuer). + +### As built (backend-phase-11) +- **`refunds.refund_channel` canonical code is `manual`** (not `manual_bank`) for the out-of-band bank refund — + the two are the same channel; `manual` is the value stored and served. Set: `psp_card` | `bnpl_revert` | `manual`. +- **`refunds.ticket_id`** ships as a **nullable column with no FK** (the `tickets` table arrives in b15); the + "ticket required" rule is gated by the `refund_ticket_required` config flag (default off). + **`nurse_clawbacks.original_payout_id` / `recovered_in_payout_id`** and **`invoices.partner_center_id`** are + likewise nullable, FK-less join points that b13 / b15 fill. +- **`invoices.vat_irr = round(platform_commission_irr × vat_rate)`**, integer-only, on the commission line only; a + `vat_rate = 0` exemption yields `vat_irr = 0`. +- **`invoices.invoice_number`** is drawn from a single-row **counter table** (`invoice_number_sequences`), locked + and committed with the invoice insert — gap-free, unique, portable across SQL Server / SQLite (no DB sequence), + never random/timestamp-derived. One issued invoice per booking (idempotent). +- The refund posts the balanced reversal via the b10 ledger helper; the **`refund_payable ↔ escrow_held` clearing** + posts immediately for a succeeded card refund and is deferred to reconciliation for a `processing` BNPL/manual + refund. Card and BNPL post the **same** reversal legs. diff --git a/server/CLAUDE.md b/server/CLAUDE.md index 76a6dcc..4058814 100644 --- a/server/CLAUDE.md +++ b/server/CLAUDE.md @@ -82,14 +82,14 @@ projects/assemblies, Clean-Architecture layers, and cross-layer dependencies. src/ ├── Core/ │ ├── Baya.Domain Entities (User, Role, UserSession, RoleNames…, Identity/ (NurseProfile, CustomerProfile, Patient, NurseBankAccount, CustomerAddress), Geography/ (Province, City, District, NurseServiceArea), Catalog/ (ServiceCategory, ServiceOptionGroup, ServiceOptionValue, NurseServiceVariant, NurseServiceVariantOption, PriceUnits), Verification/ (NurseVerification, VerificationStepType, VerificationStep, VerificationDocument, NurseCredential + VerificationStatus/VerificationStepStatus enums), Search/ (NurseSearchIndex — the denormalized search projection), Booking/ (BookingRequest — the money-free pre-payment intent + BookingRequestStatus/BookingRequestTransitions forward-only status guard + CaregiverGender codes; b9 adds Booking/BookingSession/BookingCareInstruction/VisitVerification/CancellationPolicy + their status/transition tables + BookingAmounts money split), + Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts), BaseEntity, IEntity, ITimeModification, IAuditableEntity, IAuditable (audit-row marker) -│ └── Baya.Application Features/ (Commands & Queries; Identity area = auth + profiles/patients/nurse-bank-accounts; Geography/ServiceAreas/Addresses areas = geo hierarchy + nurse service areas + customer addresses; Catalog/Variants areas = admin catalog skeleton + nurse pricing variants; Verification area = the b6 nurse-verification pipeline (submit/status/uploads/automated runs + admin review/suspend/scan + public trust badge); Search area = the b7 discovery query + admin index-rebuild; Booking area = the b8 booking-request lifecycle (create/accept/reject/cancel + role-scoped inbox/detail + the expiry sweep command); Bookings area = the b9 booking engine (convert/detail/list/transition, care-instructions submit+gated read, EVV check-in/out + today's sessions + admin EVV queue, cancel booking/session, no-show sweep, cancellation-policy CRUD); + Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts areas), Contracts/ (incl. Contracts/Common cross-cutting seams incl. IBankAccountOwnershipVerifier + IGeocoder + IVariantSnapshotSerializer + IShahkarVerifier + IIdentityKycProvider + ICredentialVerifier + the platform-signal facade contracts + Contracts/Search (INurseSearch read seam + ISearchIndexMaintainer write seam) + Contracts/Persistence per-domain repositories on IUnitOfWork incl. IVerificationRepository), Models/, pipeline behaviors (Common/ — validators auto-registered from this assembly; VerificationAggregator + IdentityNameMatch helpers) +│ └── Baya.Application Features/ (Commands & Queries; Identity area = auth + profiles/patients/nurse-bank-accounts; Geography/ServiceAreas/Addresses areas = geo hierarchy + nurse service areas + customer addresses; Catalog/Variants areas = admin catalog skeleton + nurse pricing variants; Verification area = the b6 nurse-verification pipeline (submit/status/uploads/automated runs + admin review/suspend/scan + public trust badge); Search area = the b7 discovery query + admin index-rebuild; Booking area = the b8 booking-request lifecycle (create/accept/reject/cancel + role-scoped inbox/detail + the expiry sweep command); Bookings area = the b9 booking engine (convert/detail/list/transition, care-instructions submit+gated read, EVV check-in/out + today's sessions + admin EVV queue, cancel booking/session, no-show sweep, cancellation-policy CRUD); Payments area = the b10 money core (initiate/webhook/confirm-post-ledger/nurse-payable-balance); Refunds + Invoices areas = the b11 reversal leg (create refund/write-off clawback/list/refund-status; issue invoice/get invoice); + Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts areas), Contracts/ (incl. Contracts/Common cross-cutting seams incl. IBankAccountOwnershipVerifier + IGeocoder + IVariantSnapshotSerializer + IShahkarVerifier + IIdentityKycProvider + ICredentialVerifier + the platform-signal facade contracts + Contracts/Search (INurseSearch read seam + ISearchIndexMaintainer write seam) + Contracts/Persistence per-domain repositories on IUnitOfWork incl. IVerificationRepository), Models/, pipeline behaviors (Common/ — validators auto-registered from this assembly; VerificationAggregator + IdentityNameMatch helpers) ├── Infrastructure/ │ ├── Baya.Infrastructure.Persistence ApplicationDbContext (+ encrypted-PII value converters & phone-hash sync), ValueConversion/, Repositories/, Configuration/ (per-area EF config incl. SearchConfig/ + BookingConfig/ — b8 BookingRequest + b9 bookings/sessions/care/EVV/cancellation-policy configs & seed), Repositories/ (incl. b9 BookingRepository + CancellationPolicyRepository), Migrations/, Interceptors/ (AuditFieldInterceptor — audit-fields + audit-log rows), Services/ (DB-backed platform-signal facades + notification-retention hosted service + Search/ = SearchIndexMaintainer + SqlNurseSearch + Booking/ = BookingRequestExpiryHostedService) │ ├── Baya.Infrastructure.Identity Jwt/, Identity/ (Managers, Stores, PermissionManager, Seed, CurrentUser/) │ ├── Baya.Infrastructure.CrossCutting Serilog wiring + Seams/ (mock impls of the cross-cutting seams incl. LoggingSmsSender + MockBankAccountOwnershipVerifier + MockShahkarVerifier + MockIdentityKycProvider + MockCredentialVerifier + MockPaymentCaptureSimulator) + AddCrossCuttingSeams │ └── Baya.Infrastructure.Monitoring HealthChecks, OpenTelemetry, prometheus-net ├── API/ -│ ├── Baya.Web.Api Program.cs, Controllers/V1/ (Ping + Auth/Me phone-OTP surface + admin PlatformConfig/Holidays/Audit/SupportAlerts + current-user Notifications + public Geo + admin AdminGeo + nurse NurseServiceAreas + customer CustomerAddresses + public Catalog + admin AdminCatalog + nurse NurseVariants + nurse NurseVerification + admin AdminVerificationStepTypes/AdminVerifications + public Nurses (trust badge) + public Search + admin AdminSearch + customer/nurse BookingRequests + admin AdminBookingRequests + customer/nurse/admin Bookings + nurse/admin BookingSessions + admin AdminEvv + admin AdminCancellationPolicies), appsettings*.json +│ ├── Baya.Web.Api Program.cs, Controllers/V1/ (Ping + Auth/Me phone-OTP surface + admin PlatformConfig/Holidays/Audit/SupportAlerts + current-user Notifications + public Geo + admin AdminGeo + nurse NurseServiceAreas + customer CustomerAddresses + public Catalog + admin AdminCatalog + nurse NurseVariants + nurse NurseVerification + admin AdminVerificationStepTypes/AdminVerifications + public Nurses (trust badge) + public Search + admin AdminSearch + customer/nurse BookingRequests + admin AdminBookingRequests + customer/nurse/admin Bookings + nurse/admin BookingSessions + admin AdminEvv + admin AdminCancellationPolicies + customer PaymentsController + public WebhooksController + admin AdminRefunds/AdminClawbacks/AdminInvoices + customer Refunds/Invoices), appsettings*.json │ ├── Baya.WebFramework BaseController (incl. 401/403 OperationResult mapping), Filters/, Middlewares/, Swagger/, Routing/, ServiceConfiguration/ (rate limiting) │ └── Plugins/Baya.Web.Plugins.Grpc gRPC services + .proto models (User only) ├── Shared/Baya.SharedKernel Extensions + validation base @@ -323,6 +323,46 @@ one migration (`PaymentsCoreLedger`). Features under `Baya.Application/Features/ `InProcessDistributedLock`), registered by `AddCrossCuttingSeams`. `payment_gateways.config_json` is encrypted through the b0 `IFieldEncryptor` (converter wired in `ApplicationDbContext`). +**Refunds, clawbacks & invoices (backend-phase-11).** The `payments` schema gains three tables — `Refunds`, +`NurseClawbacks`, `Invoices` (+ the single-row `InvoiceNumberSequences` counter) — entities in +`Domain/Entities/Refunds/` + `…/Invoices/`, configs in `Persistence/Configuration/{RefundsConfig|InvoicesConfig}/`, +one migration (`RefundsClawbacksInvoices`). Features under `Baya.Application/Features/{Refunds|Invoices}/`; +per-domain repos `IRefundRepository` + `IInvoiceRepository` on `IUnitOfWork`; controllers `AdminRefundsController` +/ `AdminClawbacksController` / `AdminInvoicesController` (admin policy, rate-limited) + customer-facing +`RefundsController` (`refunds/{id}/status`) / `InvoicesController` (`invoices/{booking_id}`). Load-bearing rules: +- **A refund decomposes across both fee legs and reverses the ledger.** `CreateRefundCommand` (the whole + money-path under `lock(booking:{id}:refund)`) reads the booking's frozen split + b9 cancellation snapshot + + captured transaction (`IRefundRepository.GetRefundContextAsync`), splits `amount = platform_fee_refunded_irr + + nurse_payout_refunded_irr` pro-rata at the resolved %, enforces **`Σ refunded ≤ captured`** (handler backstop), + executes the channel behind its seam, and posts the balanced reversal via **b10's `LedgerPosting`** helper + (extended with `RefundReversalPrePayout` / `ClawbackReversalPostPayout` / `RefundPayableClearing` / + `ClawbackWriteOff`). The channel-execution/ledger "internal step" commands from the phase are cohesive private + steps in the handler (mirroring b10's `ConfirmPaymentAndPostLedger`) so they stay atomic. +- **Pre-payout reversal vs post-payout clawback fork.** `INursePayoutStatus` (Application `Contracts/Payments`; + DB-backed `NursePayoutStatusService` in `Persistence/Services/Payments`) answers "was the nurse already paid?" + — pre-payout debits `nurse_payable` (clean reversal); post-payout debits `nurse_clawback_receivable` **and** + opens a `pending` `nurse_clawbacks` row + raises a `nurse_clawback` support alert, because an Iranian IBAN + transfer is irreversible. Until b13 ships `nurse_payouts`, "paid?" is derived from the booking's + `dispute_window_ends_at` close (+ a `refund_assume_nurse_paid` config override); b13 swaps the registration. + Clawback **recovery/netting is b13** — this phase only opens the receivable + supports admin `write_off`. +- **Channel parity.** `psp_card` and `bnpl_revert` post the **same** reversal legs — only the channel, the + external reference (`gateway_refund_reference` vs `external_revert_reference`), and the ETA differ (card = + immediate `succeeded` + clearing posts now; BNPL = `processing` + `expected_customer_refund_eta` ≈ now + config + business days, clearing deferred to reconciliation). The `refund_payable ↔ escrow_held` clearing posts only + once the customer cash-back confirms. +- **Invoices: VAT on the commission line only, sequential number.** `IssueInvoiceCommand` computes + `vat_irr = round(platform_commission_irr × vat_rate)` (config `vat_rate`, default 0.10; `vat_rate = 0` ⇒ 0), + never on the nurse payout, and draws a gap-free `invoice_number` from the `InvoiceNumberSequences` counter row + (locked + committed with the invoice, portable across SQL Server/SQLite — no DB sequence). Idempotent per + booking (`UNIQUE(booking_id)`). `IMoadianClient` (introduced here; `MockMoadianClient` in CrossCutting) submits + to سامانه مودیان — mock leaves `moadian_status = pending` / no ref (config can force `registered`). +- **Forward-deps as nullable columns, no FK.** `refunds.ticket_id` (tickets → b15; "ticket required" is the + config-gated `refund_ticket_required` rule, off by default), `nurse_clawbacks.original_payout_id` / + `recovered_in_payout_id` (nurse_payouts → b13), `invoices.partner_center_id` (partner_centers → b15). The + data-model's `manual_bank` channel is stored/served as the canonical wire code **`manual`**. `IBnplProvider` is + introduced here as a **thin local stub** so the `bnpl_revert` path runs before b12 merges — **b12 owns the real + seam definition**. + **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/AdminClawbacksController.cs b/server/src/API/Baya.Web.Api/Controllers/V1/AdminClawbacksController.cs new file mode 100644 index 0000000..3748ac4 --- /dev/null +++ b/server/src/API/Baya.Web.Api/Controllers/V1/AdminClawbacksController.cs @@ -0,0 +1,32 @@ +using System.ComponentModel.DataAnnotations; +using Asp.Versioning; +using Baya.Application.Features.Refunds.Commands.WriteOffClawback; +using Baya.Infrastructure.Identity.Identity.PermissionManager; +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; + +/// Admin-only nurse-clawback management. Write-off marks a pending receivable uncollectable and posts +/// the balancing bad-debt correction; recovery via payout netting is b13. +[ApiVersion("1")] +[ApiController] +[Route("api/v{version:apiVersion}/admin_clawbacks")] +[Authorize(ConstantPolicies.DynamicPermission)] +[EnableRateLimiting(RateLimitingServiceExtension.SensitivePolicy)] +[Display(Description = "Admin nurse-clawback write-off")] +public sealed class AdminClawbacksController(ISender sender) : BaseController +{ + [HttpPost("{id}/[action]")] + [ProducesOkApiResponseType] + public async Task WriteOff(long id, WriteOffClawbackBody body, CancellationToken cancellationToken) + => OperationResult(await sender.Send(new WriteOffClawbackCommand(id, body.Reason), cancellationToken)); +} + +/// The write-off body (the id comes from the route). +public record WriteOffClawbackBody(string Reason); diff --git a/server/src/API/Baya.Web.Api/Controllers/V1/AdminInvoicesController.cs b/server/src/API/Baya.Web.Api/Controllers/V1/AdminInvoicesController.cs new file mode 100644 index 0000000..732974b --- /dev/null +++ b/server/src/API/Baya.Web.Api/Controllers/V1/AdminInvoicesController.cs @@ -0,0 +1,30 @@ +using System.ComponentModel.DataAnnotations; +using Asp.Versioning; +using Baya.Application.Features.Invoices.Commands.IssueInvoice; +using Baya.Application.Models.Invoices; +using Baya.Infrastructure.Identity.Identity.PermissionManager; +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; + +/// Admin-only invoice issuance. Issuing an invoice draws the next sequential number, computes VAT on +/// the commission line from config, and submits to (mocked) مودیان. Idempotent per booking. +[ApiVersion("1")] +[ApiController] +[Route("api/v{version:apiVersion}/admin_invoices")] +[Authorize(ConstantPolicies.DynamicPermission)] +[EnableRateLimiting(RateLimitingServiceExtension.SensitivePolicy)] +[Display(Description = "Admin invoice issuance")] +public sealed class AdminInvoicesController(ISender sender) : BaseController +{ + [HttpPost] + [ProducesOkApiResponseType] + public async Task Issue(IssueInvoiceCommand command, CancellationToken cancellationToken) + => OperationResult(await sender.Send(command, cancellationToken)); +} diff --git a/server/src/API/Baya.Web.Api/Controllers/V1/AdminRefundsController.cs b/server/src/API/Baya.Web.Api/Controllers/V1/AdminRefundsController.cs new file mode 100644 index 0000000..f0acd25 --- /dev/null +++ b/server/src/API/Baya.Web.Api/Controllers/V1/AdminRefundsController.cs @@ -0,0 +1,40 @@ +using System.ComponentModel.DataAnnotations; +using Asp.Versioning; +using Baya.Application.Features.Refunds.Commands.CreateRefund; +using Baya.Application.Features.Refunds.Queries.ListRefunds; +using Baya.Application.Models.Common; +using Baya.Application.Models.Refunds; +using Baya.Infrastructure.Identity.Identity.PermissionManager; +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; + +/// +/// Admin-only refund console. Creating a refund reverses a captured booking payment across both fee legs, posts +/// the balanced ledger reversal, forks on whether the nurse was already paid (clawback), and is rate-limited as +/// a money endpoint. There is no customer refund-initiation path. +/// +[ApiVersion("1")] +[ApiController] +[Route("api/v{version:apiVersion}/admin_refunds")] +[Authorize(ConstantPolicies.DynamicPermission)] +[EnableRateLimiting(RateLimitingServiceExtension.SensitivePolicy)] +[Display(Description = "Admin refund creation + worklist")] +public sealed class AdminRefundsController(ISender sender) : BaseController +{ + [HttpPost] + [ProducesOkApiResponseType] + public async Task Create(CreateRefundCommand command, CancellationToken cancellationToken) + => OperationResult(await sender.Send(command, cancellationToken)); + + [HttpGet] + [ProducesOkApiResponseType>] + public async Task List([FromQuery] ListRefundsQuery query, CancellationToken cancellationToken) + => OperationResult(await sender.Send(query, cancellationToken)); +} diff --git a/server/src/API/Baya.Web.Api/Controllers/V1/InvoicesController.cs b/server/src/API/Baya.Web.Api/Controllers/V1/InvoicesController.cs new file mode 100644 index 0000000..53d6b83 --- /dev/null +++ b/server/src/API/Baya.Web.Api/Controllers/V1/InvoicesController.cs @@ -0,0 +1,25 @@ +using System.ComponentModel.DataAnnotations; +using Asp.Versioning; +using Baya.Application.Features.Invoices.Queries.GetInvoice; +using Baya.Application.Models.Invoices; +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 booking's invoice for the customer (tenancy-scoped) or an admin. Read-only. +[ApiVersion("1")] +[ApiController] +[Route("api/v{version:apiVersion}/invoices")] +[Authorize] +[Display(Description = "Booking invoice (customer/admin)")] +public sealed class InvoicesController(ISender sender) : BaseController +{ + [HttpGet("{bookingId}")] + [ProducesOkApiResponseType] + public async Task Get(long bookingId, CancellationToken cancellationToken) + => OperationResult(await sender.Send(new GetInvoiceQuery(bookingId), cancellationToken)); +} diff --git a/server/src/API/Baya.Web.Api/Controllers/V1/RefundsController.cs b/server/src/API/Baya.Web.Api/Controllers/V1/RefundsController.cs new file mode 100644 index 0000000..d64a418 --- /dev/null +++ b/server/src/API/Baya.Web.Api/Controllers/V1/RefundsController.cs @@ -0,0 +1,26 @@ +using System.ComponentModel.DataAnnotations; +using Asp.Versioning; +using Baya.Application.Features.Refunds.Queries.GetRefundStatus; +using Baya.Application.Models.Refunds; +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 customer-facing refund status — the only customer-visible refund surface (there is no +/// self-service refund initiation). Tenancy-scoped: a customer sees only their own refund. +[ApiVersion("1")] +[ApiController] +[Route("api/v{version:apiVersion}/refunds")] +[Authorize] +[Display(Description = "Customer refund status")] +public sealed class RefundsController(ISender sender) : BaseController +{ + [HttpGet("{id}/status")] + [ProducesOkApiResponseType] + public async Task Status(long id, CancellationToken cancellationToken) + => OperationResult(await sender.Send(new GetRefundStatusQuery(id), cancellationToken)); +} diff --git a/server/src/Core/Baya.Application/Common/BusinessDays.cs b/server/src/Core/Baya.Application/Common/BusinessDays.cs new file mode 100644 index 0000000..d4b988e --- /dev/null +++ b/server/src/Core/Baya.Application/Common/BusinessDays.cs @@ -0,0 +1,23 @@ +namespace Baya.Application.Common; + +/// +/// Business-day arithmetic for customer-facing ETAs (the BNPL 7–10-day refund window). Fridays are skipped as +/// the Iranian bank weekend; Thursday half-days and public holidays are not modelled here (the ETA is an +/// estimate surfaced in the UI, not a settlement guarantee — the authoritative confirmation is the provider +/// reconciliation callback). A later refinement can route this through IHolidayCalendar. +/// +public static class BusinessDays +{ + public static DateOnly Add(DateOnly start, int businessDays) + { + var date = start; + var added = 0; + while (added < businessDays) + { + date = date.AddDays(1); + if (date.DayOfWeek != DayOfWeek.Friday) + added++; + } + return date; + } +} diff --git a/server/src/Core/Baya.Application/Contracts/Invoices/IMoadianClient.cs b/server/src/Core/Baya.Application/Contracts/Invoices/IMoadianClient.cs new file mode 100644 index 0000000..5009062 --- /dev/null +++ b/server/src/Core/Baya.Application/Contracts/Invoices/IMoadianClient.cs @@ -0,0 +1,33 @@ +#nullable enable +namespace Baya.Application.Contracts.Invoices; + +/// +/// The سامانه مودیان e-invoicing rail seam (introduced by b11). Handlers depend only on this contract; the +/// mock leaves a newly issued invoice at moadian_status = pending with no reference (no external call). +/// A config switch can force a deterministic registered result (with a fake 22-digit reference) so the +/// reconciliation/registered path is testable. The real مودیان adapter — enrollment, the معاملات/invoice +/// submission API, the pending → submitted → registered reconciliation callback — is a drop-in that +/// swaps only this registration. +/// +public interface IMoadianClient +{ + ValueTask SubmitAsync(InvoiceSubmission submission, CancellationToken cancellationToken = default); +} + +/// The minimal facts مودیان needs to register a commission invoice. Money is IRR long. +/// The platform's own sequential number. +/// The booking the invoice is for. +/// The booking gross. +/// The VAT-relevant commission line. +/// The computed VAT on the commission. +public sealed record InvoiceSubmission( + string InvoiceNumber, + long BookingId, + long GrossIrr, + long PlatformCommissionIrr, + long VatIrr); + +/// The outcome of a مودیان submission. +/// A moadian_status code — pending from the mock by default. +/// The 22-digit مودیان reference when registered; null otherwise. +public sealed record MoadianSubmissionResult(string Status, string? ReferenceNumber); diff --git a/server/src/Core/Baya.Application/Contracts/Payments/IBnplProvider.cs b/server/src/Core/Baya.Application/Contracts/Payments/IBnplProvider.cs new file mode 100644 index 0000000..de903a5 --- /dev/null +++ b/server/src/Core/Baya.Application/Contracts/Payments/IBnplProvider.cs @@ -0,0 +1,29 @@ +#nullable enable +namespace Baya.Application.Contracts.Payments; + +/// +/// The Buy-Now-Pay-Later provider seam (SnappPay / Tara / …). b12 owns the real, full definition of this +/// seam; b11 introduces this minimal shape (revert/update) plus a thin local mock so the bnpl_revert +/// refund path is exercised before b12 merges. Money always flows customer ↔ provider ↔ Balinyaar +/// — never nurse→customer or Balinyaar→customer direct. A full reversal is ; a +/// partial/shortened one is with a strictly-lower amount. Every amount is IRR +/// long; the makes a retried revert a no-op rather than a double refund. +/// +public interface IBnplProvider +{ + /// Full reversal of a BNPL order back through the provider. + ValueTask RevertAsync(string providerOrderReference, long amountIrr, string idempotencyKey, CancellationToken cancellationToken = default); + + /// Partial revert — reduces the order to a strictly-lower . + ValueTask UpdateAsync(string providerOrderReference, long newAmountIrr, string idempotencyKey, CancellationToken cancellationToken = default); +} + +/// The outcome of a BNPL revert/update. +/// Whether the provider accepted the reversal. +/// The provider's revert id, persisted on the refund. +/// The provider's own commission it returned — nullable, +/// reconciled from the response, never hardcoded (some providers keep their fee on a refund). +public sealed record BnplRevertResult( + PaymentProviderStatus Status, + string? ExternalRevertReference, + long? ProviderCommissionReversedAmount); diff --git a/server/src/Core/Baya.Application/Contracts/Payments/INursePayoutStatus.cs b/server/src/Core/Baya.Application/Contracts/Payments/INursePayoutStatus.cs new file mode 100644 index 0000000..e055b2b --- /dev/null +++ b/server/src/Core/Baya.Application/Contracts/Payments/INursePayoutStatus.cs @@ -0,0 +1,18 @@ +#nullable enable +namespace Baya.Application.Contracts.Payments; + +/// +/// Answers the one question a refund forks on: has the nurse already been paid for this booking? If not +/// (the common case — b13 gates payout on dispute_window_ends_at), a refund is a clean nurse_payable +/// reversal. If so, the money is gone to an irreversible IBAN transfer, so the refund opens a +/// nurse_clawbacks receivable instead. +/// +/// b13 owns the authoritative implementation (a nurse_payout_booking_links lookup). Until then +/// this is derived from the booking's dispute-window close (the same gate b13 pays out on) with a config +/// override — a DB-backed facade, registered in Persistence, that b13 swaps in place. +/// +/// +public interface INursePayoutStatus +{ + ValueTask IsNursePaidForBookingAsync(long bookingId, 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 index 24802c5..2a6e0b1 100644 --- a/server/src/Core/Baya.Application/Contracts/Payments/IPaymentProvider.cs +++ b/server/src/Core/Baya.Application/Contracts/Payments/IPaymentProvider.cs @@ -18,9 +18,9 @@ public interface IPaymentProvider /// 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); + /// Reverses a captured payment (partial or full). The makes a + /// retried refund a no-op rather than a double reversal (b11 refunds carry the booking+refund key). + ValueTask RefundAsync(string gatewayReferenceCode, long amountIrr, string idempotencyKey, CancellationToken cancellationToken = default); } /// The outcome verb of a provider verify/refund — mirrors the wire payment status codes. diff --git a/server/src/Core/Baya.Application/Contracts/Persistence/IInvoiceRepository.cs b/server/src/Core/Baya.Application/Contracts/Persistence/IInvoiceRepository.cs new file mode 100644 index 0000000..c8a8b77 --- /dev/null +++ b/server/src/Core/Baya.Application/Contracts/Persistence/IInvoiceRepository.cs @@ -0,0 +1,31 @@ +#nullable enable +using Baya.Application.Models.Invoices; +using Baya.Domain.Entities.Invoices; + +namespace Baya.Application.Contracts.Persistence; + +/// +/// The invoices aggregate. One issued invoice per booking (idempotent). The sequential invoice_number is +/// drawn from a concurrency-safe counter row via , whose increment +/// commits in the same transaction as the invoice insert. Money is IRR long. +/// +public interface IInvoiceRepository +{ + /// The booking facts the invoice copies + the owning customer's user id (tenancy). Null when the + /// booking is absent. + Task GetBookingAmountsAsync(long bookingId, CancellationToken cancellationToken); + + /// The existing issued invoice for a booking, if any — the idempotency check (re-issue returns it). + Task GetTrackedByBookingIdAsync(long bookingId, CancellationToken cancellationToken); + + /// The booking's invoice projected for the read, with the owning customer's user id for tenancy. + /// Null when none is issued yet. + Task GetByBookingIdAsync(long bookingId, CancellationToken cancellationToken); + + Task AddInvoiceAsync(Invoice invoice, CancellationToken cancellationToken); + + /// Reads and increments the tracked counter row and returns the reserved value. The increment is + /// not committed here — the caller commits it alongside the invoice insert so numbers stay gap-free + /// under a rollback. Call under the invoice-number lock. + Task ReserveNextInvoiceNumberAsync(CancellationToken cancellationToken); +} diff --git a/server/src/Core/Baya.Application/Contracts/Persistence/IRefundRepository.cs b/server/src/Core/Baya.Application/Contracts/Persistence/IRefundRepository.cs new file mode 100644 index 0000000..1f3c2dc --- /dev/null +++ b/server/src/Core/Baya.Application/Contracts/Persistence/IRefundRepository.cs @@ -0,0 +1,37 @@ +#nullable enable +using Baya.Application.Models.Common; +using Baya.Application.Models.Refunds; +using Baya.Domain.Entities.Refunds; + +namespace Baya.Application.Contracts.Persistence; + +/// +/// The refunds/clawbacks aggregate. Writes load tracked rows; reads project to DTOs. The ledger legs +/// themselves are appended through (b10's helper) — this +/// repo owns only the refund/clawback rows and the money facts a refund is validated against. Money is IRR +/// long; the Σ refunded ≤ captured invariant is enforced in the handler from +/// under the booking refund lock. +/// +public interface IRefundRepository +{ + /// The booking's frozen money split + b9 cancellation snapshot + its captured (succeeded) + /// transaction — everything a refund is decomposed and channel-picked from. Null when the booking has no + /// captured payment (refund is impossible). + Task GetRefundContextAsync(long bookingId, CancellationToken cancellationToken); + + /// The sum of prior non-failed/-rejected refund amount for a transaction — the authoritative + /// backstop for Σ refunded ≤ captured (this refund's amount is added on top in the handler). + Task GetRefundedSumForTransactionAsync(long paymentTransactionId, CancellationToken cancellationToken); + + Task AddRefundAsync(Refund refund, CancellationToken cancellationToken); + Task AddClawbackAsync(NurseClawback clawback, CancellationToken cancellationToken); + + /// Tracked pending clawback — for the admin write-off. Null when absent or already resolved. + Task GetTrackedClawbackByIdAsync(long id, CancellationToken cancellationToken); + + Task> ListAsync(long? bookingId, string? status, int page, int pageSize, CancellationToken cancellationToken); + + /// The customer-facing status of a single refund, with the owning customer's user id for tenancy. + /// Null when absent. + Task GetStatusAsync(long id, 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 2af6d1b..a965dff 100644 --- a/server/src/Core/Baya.Application/Contracts/Persistence/IUnitOfWork.cs +++ b/server/src/Core/Baya.Application/Contracts/Persistence/IUnitOfWork.cs @@ -19,6 +19,8 @@ public interface IUnitOfWork public IBookingRepository BookingRepository { get; } public ICancellationPolicyRepository CancellationPolicyRepository { get; } public IPaymentRepository PaymentRepository { get; } + public IRefundRepository RefundRepository { get; } + public IInvoiceRepository InvoiceRepository { get; } Task CommitAsync(); ValueTask RollBackAsync(); } diff --git a/server/src/Core/Baya.Application/Features/Invoices/Commands/IssueInvoice/IssueInvoiceCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Invoices/Commands/IssueInvoice/IssueInvoiceCommand.Handler.cs new file mode 100644 index 0000000..ae426e5 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Invoices/Commands/IssueInvoice/IssueInvoiceCommand.Handler.cs @@ -0,0 +1,90 @@ +#nullable enable +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Configuration; +using Baya.Application.Contracts.Invoices; +using Baya.Application.Contracts.Payments; +using Baya.Application.Contracts.Persistence; +using Baya.Application.Models.Common; +using Baya.Application.Models.Invoices; +using Baya.Domain.Entities.Invoices; +using Mediator; +using Microsoft.EntityFrameworkCore; + +namespace Baya.Application.Features.Invoices.Commands.IssueInvoice; + +internal sealed class IssueInvoiceCommandHandler( + IUnitOfWork unitOfWork, + IPlatformConfig platformConfig, + IDateTimeProvider dateTimeProvider, + IDistributedLock distributedLock, + IMoadianClient moadianClient, + IObjectStorage objectStorage) + : IRequestHandler> +{ + public async ValueTask> Handle(IssueInvoiceCommand request, CancellationToken cancellationToken) + { + // Idempotent per booking — re-issue returns the existing invoice, never a second number. + var existing = await unitOfWork.InvoiceRepository.GetTrackedByBookingIdAsync(request.BookingId, cancellationToken); + if (existing is not null) + return OperationResult.SuccessResult(InvoiceDtoFactory.FromEntity(existing, PdfUrl(existing))); + + var amounts = await unitOfWork.InvoiceRepository.GetBookingAmountsAsync(request.BookingId, cancellationToken); + if (amounts is null) + return OperationResult.NotFoundResult("Booking not found."); + + // VAT applies to the commission line ONLY — never the nurse payout — at a config-driven rate. A + // vat_rate = 0 exemption yields vat_irr = 0. Integer-only, no float path. + var vatRate = await platformConfig.GetConfig("vat_rate", cancellationToken); + var vatIrr = (long)Math.Round(amounts.PlatformCommissionIrr * vatRate, MidpointRounding.AwayFromZero); + var now = dateTimeProvider.UtcNow.UtcDateTime; + + await using var _ = await distributedLock.AcquireAsync("invoice:number", cancellationToken); + + // Re-check under the lock in case a concurrent issue won the race. + existing = await unitOfWork.InvoiceRepository.GetTrackedByBookingIdAsync(request.BookingId, cancellationToken); + if (existing is not null) + return OperationResult.SuccessResult(InvoiceDtoFactory.FromEntity(existing, PdfUrl(existing))); + + var sequence = await unitOfWork.InvoiceRepository.ReserveNextInvoiceNumberAsync(cancellationToken); + var invoiceNumber = $"INV-{sequence:D10}"; + + var submission = new InvoiceSubmission(invoiceNumber, request.BookingId, amounts.GrossIrr, amounts.PlatformCommissionIrr, vatIrr); + var moadian = await moadianClient.SubmitAsync(submission, cancellationToken); + + var invoice = new Invoice + { + BookingId = request.BookingId, + InvoiceNumber = invoiceNumber, + IssuingEntityType = InvoiceIssuingEntityType.Platform, + GrossIrr = amounts.GrossIrr, + PlatformCommissionIrr = amounts.PlatformCommissionIrr, + BnplCommissionIrr = amounts.BnplCommissionIrr, + VatRate = vatRate, + VatIrr = vatIrr, + IssuedAt = now + }; + invoice.ApplyMoadianResult(moadian.Status, moadian.ReferenceNumber); + + await unitOfWork.InvoiceRepository.AddInvoiceAsync(invoice, cancellationToken); + + try + { + // Commits the counter increment + the invoice row together, so the sequence stays gap-free. + await unitOfWork.CommitAsync(); + } + catch (DbUpdateException) + { + // Lost the UNIQUE(booking_id) race — return the invoice the winner created (idempotent no-op). + await unitOfWork.RollBackAsync(); + var winner = await unitOfWork.InvoiceRepository.GetTrackedByBookingIdAsync(request.BookingId, cancellationToken); + return winner is null + ? OperationResult.ConflictResult("An invoice already exists for this booking.") + : OperationResult.SuccessResult(InvoiceDtoFactory.FromEntity(winner, PdfUrl(winner))); + } + + return OperationResult.SuccessResult(InvoiceDtoFactory.FromEntity(invoice, PdfUrl(invoice))); + } + + private string? PdfUrl(Invoice invoice) + => invoice.PdfStorageKey is { } key ? objectStorage.GetUrl(key) : null; +} diff --git a/server/src/Core/Baya.Application/Features/Invoices/Commands/IssueInvoice/IssueInvoiceCommand.Validator.cs b/server/src/Core/Baya.Application/Features/Invoices/Commands/IssueInvoice/IssueInvoiceCommand.Validator.cs new file mode 100644 index 0000000..b3bf936 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Invoices/Commands/IssueInvoice/IssueInvoiceCommand.Validator.cs @@ -0,0 +1,11 @@ +using FluentValidation; + +namespace Baya.Application.Features.Invoices.Commands.IssueInvoice; + +public sealed class IssueInvoiceCommandValidator : AbstractValidator +{ + public IssueInvoiceCommandValidator() + { + RuleFor(x => x.BookingId).GreaterThan(0); + } +} diff --git a/server/src/Core/Baya.Application/Features/Invoices/Commands/IssueInvoice/IssueInvoiceCommand.cs b/server/src/Core/Baya.Application/Features/Invoices/Commands/IssueInvoice/IssueInvoiceCommand.cs new file mode 100644 index 0000000..379ff3b --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Invoices/Commands/IssueInvoice/IssueInvoiceCommand.cs @@ -0,0 +1,10 @@ +using Baya.Application.Models.Common; +using Baya.Application.Models.Invoices; +using Mediator; + +namespace Baya.Application.Features.Invoices.Commands.IssueInvoice; + +/// Issues the booking's official commission invoice — a sequential number, config-driven VAT on the +/// commission line only, and a (mocked) مودیان submission. Idempotent per booking: re-issue returns the +/// existing invoice. +public record IssueInvoiceCommand(long BookingId) : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Invoices/InvoiceDtoFactory.cs b/server/src/Core/Baya.Application/Features/Invoices/InvoiceDtoFactory.cs new file mode 100644 index 0000000..f853d81 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Invoices/InvoiceDtoFactory.cs @@ -0,0 +1,25 @@ +#nullable enable +using Baya.Application.Models.Invoices; +using Baya.Domain.Entities.Invoices; + +namespace Baya.Application.Features.Invoices; + +/// Maps an to its wire DTO, stringifying every IRR amount per the money +/// convention. The is resolved by the handler through IObjectStorage. +internal static class InvoiceDtoFactory +{ + public static InvoiceDto FromEntity(Invoice invoice, string? pdfUrl) => new( + invoice.Id, + invoice.BookingId, + invoice.InvoiceNumber, + invoice.IssuingEntityType, + invoice.GrossIrr.ToString(), + invoice.PlatformCommissionIrr.ToString(), + invoice.BnplCommissionIrr?.ToString(), + invoice.VatRate, + invoice.VatIrr.ToString(), + invoice.MoadianReferenceNumber, + invoice.MoadianStatus, + pdfUrl, + invoice.IssuedAt); +} diff --git a/server/src/Core/Baya.Application/Features/Invoices/Queries/GetInvoice/GetInvoiceQuery.Handler.cs b/server/src/Core/Baya.Application/Features/Invoices/Queries/GetInvoice/GetInvoiceQuery.Handler.cs new file mode 100644 index 0000000..72ebfe7 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Invoices/Queries/GetInvoice/GetInvoiceQuery.Handler.cs @@ -0,0 +1,32 @@ +#nullable enable +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Persistence; +using Baya.Application.Models.Common; +using Baya.Application.Models.Invoices; +using Baya.Domain.Entities.User; +using Mediator; + +namespace Baya.Application.Features.Invoices.Queries.GetInvoice; + +internal sealed class GetInvoiceQueryHandler( + IUnitOfWork unitOfWork, + ICurrentUser currentUser, + IObjectStorage objectStorage) + : IRequestHandler> +{ + public async ValueTask> Handle(GetInvoiceQuery request, CancellationToken cancellationToken) + { + var projection = await unitOfWork.InvoiceRepository.GetByBookingIdAsync(request.BookingId, cancellationToken); + if (projection is null) + return OperationResult.NotFoundResult("Invoice not found."); + + // Admins read any invoice; a customer reads only their own booking's — a cross-customer read is a + // clean not-found, never a leak. + var isAdmin = currentUser.Roles.Contains(RoleNames.Admin); + if (!isAdmin && projection.CustomerUserId != currentUser.UserId) + return OperationResult.NotFoundResult("Invoice not found."); + + var pdfUrl = projection.PdfStorageKey is { } key ? objectStorage.GetUrl(key) : null; + return OperationResult.SuccessResult(projection.Invoice with { PdfUrl = pdfUrl }); + } +} diff --git a/server/src/Core/Baya.Application/Features/Invoices/Queries/GetInvoice/GetInvoiceQuery.cs b/server/src/Core/Baya.Application/Features/Invoices/Queries/GetInvoice/GetInvoiceQuery.cs new file mode 100644 index 0000000..ab9c644 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Invoices/Queries/GetInvoice/GetInvoiceQuery.cs @@ -0,0 +1,9 @@ +using Baya.Application.Models.Common; +using Baya.Application.Models.Invoices; +using Mediator; + +namespace Baya.Application.Features.Invoices.Queries.GetInvoice; + +/// The booking's invoice for the customer (tenancy-scoped) or an admin. Surfaces the number, amounts, +/// VAT, مودیان status and a PDF download URL when present. +public record GetInvoiceQuery(long BookingId) : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Refunds/Commands/CreateRefund/CreateRefundCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Refunds/Commands/CreateRefund/CreateRefundCommand.Handler.cs new file mode 100644 index 0000000..2ed0061 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Refunds/Commands/CreateRefund/CreateRefundCommand.Handler.cs @@ -0,0 +1,244 @@ +#nullable enable +using System.Text.Json; +using Baya.Application.Common; +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Configuration; +using Baya.Application.Contracts.Payments; +using Baya.Application.Contracts.Persistence; +using Baya.Application.Contracts.SupportAlerts; +using Baya.Application.Models.Common; +using Baya.Application.Models.Refunds; +using Baya.Domain.Entities.Payments; +using Baya.Domain.Entities.Refunds; +using Baya.Domain.Entities.SupportAlerts; +using Mediator; + +namespace Baya.Application.Features.Refunds.Commands.CreateRefund; + +/// +/// The money-side of a cancellation/dispute refund. Runs the whole reversal under +/// lock(booking:{id}:refund) so a cancellation-driven and a webhook-driven refund can't both fire and +/// breach Σ refunded ≤ captured. It decomposes the refund across both fee legs, forks on whether the +/// nurse was already paid (clean nurse_payable reversal vs a nurse_clawbacks receivable), executes +/// the channel behind its seam, and posts the balanced ledger group(s) via b10's helper. The channel-execution +/// and ledger-posting "internal steps" from the phase are cohesive private steps here (mirroring b10's +/// ConfirmPaymentAndPostLedger) so they stay atomic under one lock. +/// +internal sealed class CreateRefundCommandHandler( + IUnitOfWork unitOfWork, + IDistributedLock distributedLock, + IPlatformConfig platformConfig, + IDateTimeProvider dateTimeProvider, + ICurrentUser currentUser, + IPaymentProvider paymentProvider, + IBnplProvider bnplProvider, + INursePayoutStatus nursePayoutStatus, + ISupportAlertService supportAlerts, + INotificationDispatcher notifications) + : IRequestHandler> +{ + public async ValueTask> Handle(CreateRefundCommand request, CancellationToken cancellationToken) + { + // Ticket link is required, but config-gated off until b15 ships the tickets table (the FK is nullable + // now for exactly this forward-dep). Read the gate through the typed config accessor, never hardcoded. + var ticketRequired = await platformConfig.GetConfig("refund_ticket_required", cancellationToken); + if (ticketRequired && request.TicketId is null) + return OperationResult.FailureResult("ticket_id", "A support ticket is required to issue a refund."); + + await using var _ = await distributedLock.AcquireAsync($"booking:{request.BookingId}:refund", cancellationToken); + + var context = await unitOfWork.RefundRepository.GetRefundContextAsync(request.BookingId, cancellationToken); + if (context is null) + return OperationResult.NotFoundResult("No captured payment exists for this booking to refund."); + + var decomposition = ResolveDecomposition(request, context); + if (decomposition is null) + return OperationResult.FailureResult( + "refund_percentage", "Provide a refund percentage, explicit legs, or a booking cancellation snapshot."); + + var (platformFeeRefunded, nursePayoutRefunded, resolvedPct) = decomposition.Value; + var amount = platformFeeRefunded + nursePayoutRefunded; + + if (amount <= 0) + return OperationResult.FailureResult("amount", "The refund amount must be positive."); + if (platformFeeRefunded > context.CommissionIrr || nursePayoutRefunded > context.PayoutIrr) + return OperationResult.FailureResult("amount", "A refund leg exceeds the captured booking leg."); + + // Σ refunded ≤ captured — the authoritative backstop, summed under the lock. + var priorRefunded = await unitOfWork.RefundRepository.GetRefundedSumForTransactionAsync(context.PaymentTransactionId, cancellationToken); + if (priorRefunded + amount > context.CapturedAmount) + return OperationResult.ConflictResult( + "This refund would push total refunds over the captured amount."); + + var now = dateTimeProvider.UtcNow.UtcDateTime; + var channel = SelectChannel(request, context.GatewayType); + var idempotencyKey = $"booking:{request.BookingId}:refund:{context.PaymentTransactionId}:{priorRefunded + amount}"; + + var refund = new Refund + { + PaymentTransactionId = context.PaymentTransactionId, + BookingId = context.BookingId, + RequestedByCustomerId = context.CustomerId, + TicketId = request.TicketId, + Amount = amount, + PlatformFeeRefundedIrr = platformFeeRefunded, + NursePayoutRefundedIrr = nursePayoutRefunded, + RefundPercentage = resolvedPct, + RefundChannel = channel, + ReasonCategory = request.ReasonCategory, + ReasonNotes = request.ReasonNotes, + AdminNotes = request.AdminNotes, + ApprovedByAdminId = currentUser.UserId, + CancellationPolicyCode = context.CancellationPolicyCode, + RefundPercentageApplied = context.CancellationRefundPercentage + }; + + // Execute the channel (external, behind its seam) BEFORE persisting, so a provider refusal leaves the + // refund row failed with no ledger. The idempotency key makes a retried channel call a no-op. + var executed = await ExecuteChannelAsync(refund, channel, context, request.ManualBankReference, amount, priorRefunded, idempotencyKey, now, cancellationToken); + + await unitOfWork.RefundRepository.AddRefundAsync(refund, cancellationToken); + await unitOfWork.CommitAsync(); + + if (!executed) + return OperationResult.FailureResult("channel", "The refund channel refused the reversal."); + + // Pre-payout (clean reversal) vs post-payout (clawback receivable) fork — an Iranian IBAN transfer is + // irreversible, so a paid-out nurse's payout leg becomes owed-back, never silently absorbed. + var isNursePaid = await nursePayoutStatus.IsNursePaidForBookingAsync(context.BookingId, cancellationToken); + long? clawbackId = null; + + var reversal = isNursePaid + ? LedgerPosting.ClawbackReversalPostPayout(context.BookingId, context.NurseId, platformFeeRefunded, nursePayoutRefunded, refund.Id, now) + : LedgerPosting.RefundReversalPrePayout(context.BookingId, context.NurseId, platformFeeRefunded, nursePayoutRefunded, refund.Id, now); + await unitOfWork.PaymentRepository.AddLedgerEntriesAsync(reversal, cancellationToken); + + NurseClawback? clawback = null; + if (isNursePaid && nursePayoutRefunded > 0) + { + clawback = new NurseClawback + { + NurseId = context.NurseId, + BookingId = context.BookingId, + RefundId = refund.Id, + AmountIrr = nursePayoutRefunded + }; + await unitOfWork.RefundRepository.AddClawbackAsync(clawback, cancellationToken); + } + + // The customer cash-back clears refund_payable ↔ escrow_held only once confirmed — immediate for a + // succeeded card refund; deferred to reconciliation while a BNPL/manual refund sits in processing. + if (refund.Status == RefundStatus.Succeeded) + { + var clearing = LedgerPosting.RefundPayableClearing(context.BookingId, amount, refund.Id, now); + await unitOfWork.PaymentRepository.AddLedgerEntriesAsync(clearing, cancellationToken); + } + + await unitOfWork.CommitAsync(); + + // Self-committing facades run only after the atomic commit (they flush the shared DbContext). + if (clawback is not null) + { + clawbackId = clawback.Id; + await supportAlerts.RaiseAsync( + SupportAlertType.NurseClawback, entityType: "refund", entityId: refund.Id.ToString(), + severity: SupportAlertSeverity.High, bookingId: context.BookingId, cancellationToken: cancellationToken); + } + + await NotifyCustomerAsync(context, refund, cancellationToken); + + return OperationResult.SuccessResult(new CreateRefundResult( + refund.Id, context.BookingId, refund.Status, channel, + amount.ToString(), platformFeeRefunded.ToString(), nursePayoutRefunded.ToString(), + refund.ExpectedCustomerRefundEta, clawbackId)); + } + + private static (long PlatformFee, long NursePayout, decimal Pct)? ResolveDecomposition(CreateRefundCommand request, RefundMoneyContext context) + { + // Admin-supplied explicit legs take precedence; they must sum to the amount (validator ensures both set). + if (request.PlatformFeeRefundedIrr is { } fee && request.NursePayoutRefundedIrr is { } payout) + { + var amount = fee + payout; + var impliedPct = context.GrossPriceIrr > 0 ? (decimal)amount / context.GrossPriceIrr : 0m; + return (fee, payout, impliedPct); + } + + // Otherwise pro-rata each booking leg at the resolved fraction: the command's, else the b9 snapshot's + // (percentage stored 0–100 on the booking) — never re-resolved from live config. + var pct = request.RefundPercentage + ?? (context.CancellationRefundPercentage is { } snap ? snap / 100m : (decimal?)null); + if (pct is not { } fraction || fraction <= 0) + return null; + + var feeLeg = (long)Math.Round(context.CommissionIrr * fraction, MidpointRounding.AwayFromZero); + var payoutLeg = (long)Math.Round(context.PayoutIrr * fraction, MidpointRounding.AwayFromZero); + return (feeLeg, payoutLeg, fraction); + } + + private static string SelectChannel(CreateRefundCommand request, string gatewayType) + { + if (!string.IsNullOrWhiteSpace(request.ManualBankReference)) + return RefundChannel.Manual; + return gatewayType == PaymentGatewayType.Bnpl ? RefundChannel.BnplRevert : RefundChannel.PspCard; + } + + private async Task ExecuteChannelAsync( + Refund refund, string channel, RefundMoneyContext context, string? manualBankReference, + long amount, long priorRefunded, string idempotencyKey, DateTime now, CancellationToken cancellationToken) + { + switch (channel) + { + case RefundChannel.PspCard: + { + var result = await paymentProvider.RefundAsync( + context.GatewayReferenceCode ?? string.Empty, amount, idempotencyKey, cancellationToken); + if (result.Status != PaymentProviderStatus.Succeeded) + { + refund.MarkFailed(result.Status.ToString()); + return false; + } + refund.MarkSucceededCard(result.GatewayRefundReference ?? idempotencyKey, now); + return true; + } + case RefundChannel.BnplRevert: + { + // Full = revert; partial/shortened = update to the strictly-lower remaining amount. + var isFull = priorRefunded == 0 && amount == context.CapturedAmount; + var result = isFull + ? await bnplProvider.RevertAsync(context.GatewayReferenceCode ?? string.Empty, amount, idempotencyKey, cancellationToken) + : await bnplProvider.UpdateAsync(context.GatewayReferenceCode ?? string.Empty, context.CapturedAmount - (priorRefunded + amount), idempotencyKey, cancellationToken); + if (result.Status == PaymentProviderStatus.Failed) + { + refund.MarkFailed(result.Status.ToString()); + return false; + } + var eta = await ExpectedEtaAsync(now, cancellationToken); + refund.MarkProcessing(result.ExternalRevertReference, eta); + return true; + } + default: // manual out-of-band bank refund — admin records the bank ref; the cash-back confirms later. + { + refund.MarkProcessing(manualBankReference, null); + return true; + } + } + } + + private async Task ExpectedEtaAsync(DateTime now, CancellationToken cancellationToken) + { + var days = await platformConfig.GetConfig("bnpl_refund_eta_business_days", cancellationToken); + return BusinessDays.Add(DateOnly.FromDateTime(now), days); + } + + private async Task NotifyCustomerAsync(RefundMoneyContext context, Refund refund, CancellationToken cancellationToken) + { + var payload = JsonSerializer.Serialize(new { booking_id = context.BookingId, refund_id = refund.Id, refund.Status }); + var body = refund.Status == RefundStatus.Succeeded + ? "Your refund has been processed." + : "Your refund is on its way and should arrive within a few business days."; + + await notifications.DispatchAsync( + new Notification(context.CustomerUserId, "refund_issued", "Refund issued", body, payload), + cancellationToken); + } +} diff --git a/server/src/Core/Baya.Application/Features/Refunds/Commands/CreateRefund/CreateRefundCommand.Validator.cs b/server/src/Core/Baya.Application/Features/Refunds/Commands/CreateRefund/CreateRefundCommand.Validator.cs new file mode 100644 index 0000000..6489873 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Refunds/Commands/CreateRefund/CreateRefundCommand.Validator.cs @@ -0,0 +1,35 @@ +using FluentValidation; + +namespace Baya.Application.Features.Refunds.Commands.CreateRefund; + +public sealed class CreateRefundCommandValidator : AbstractValidator +{ + public CreateRefundCommandValidator() + { + RuleFor(x => x.BookingId).GreaterThan(0); + + When(x => x.RefundPercentage.HasValue, () => + { + RuleFor(x => x.RefundPercentage!.Value) + .GreaterThan(0m).LessThanOrEqualTo(1m) + .WithMessage("refund_percentage must be a fraction in (0, 1]."); + }); + + // Explicit legs are all-or-nothing and non-negative; the handler enforces amount = fee + payout and the + // Σ refunded ≤ captured invariant under the booking refund lock. + When(x => x.PlatformFeeRefundedIrr.HasValue || x.NursePayoutRefundedIrr.HasValue, () => + { + RuleFor(x => x.PlatformFeeRefundedIrr) + .NotNull().GreaterThanOrEqualTo(0) + .WithMessage("Both refund legs must be supplied together."); + RuleFor(x => x.NursePayoutRefundedIrr) + .NotNull().GreaterThanOrEqualTo(0) + .WithMessage("Both refund legs must be supplied together."); + }); + + RuleFor(x => x.ReasonCategory).MaximumLength(50); + RuleFor(x => x.ReasonNotes).MaximumLength(1000); + RuleFor(x => x.AdminNotes).MaximumLength(1000); + RuleFor(x => x.ManualBankReference).MaximumLength(200); + } +} diff --git a/server/src/Core/Baya.Application/Features/Refunds/Commands/CreateRefund/CreateRefundCommand.cs b/server/src/Core/Baya.Application/Features/Refunds/Commands/CreateRefund/CreateRefundCommand.cs new file mode 100644 index 0000000..57e1dbb --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Refunds/Commands/CreateRefund/CreateRefundCommand.cs @@ -0,0 +1,26 @@ +#nullable enable +using Baya.Application.Models.Common; +using Baya.Application.Models.Refunds; +using Mediator; + +namespace Baya.Application.Features.Refunds.Commands.CreateRefund; + +/// +/// Admin-initiated, ticket-linked reversal of a captured booking payment. Either supply +/// (a 0–1 fraction applied pro-rata to the booking's commission/payout legs) or the explicit +/// + legs (which must still sum to the +/// refunded amount). When neither the command nor the booking's cancellation snapshot resolves a percentage the +/// request is rejected. Providing forces the manual (out-of-band bank) +/// channel; otherwise the channel is derived from the original payment type (card ⇒ psp_card, BNPL ⇒ +/// bnpl_revert). +/// +public record CreateRefundCommand( + long BookingId, + long? TicketId, + decimal? RefundPercentage, + long? PlatformFeeRefundedIrr, + long? NursePayoutRefundedIrr, + string? ReasonCategory, + string? ReasonNotes, + string? AdminNotes, + string? ManualBankReference) : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Refunds/Commands/WriteOffClawback/WriteOffClawbackCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Refunds/Commands/WriteOffClawback/WriteOffClawbackCommand.Handler.cs new file mode 100644 index 0000000..8369cff --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Refunds/Commands/WriteOffClawback/WriteOffClawbackCommand.Handler.cs @@ -0,0 +1,33 @@ +#nullable enable +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Persistence; +using Baya.Application.Models.Common; +using Baya.Domain.Entities.Payments; +using Mediator; + +namespace Baya.Application.Features.Refunds.Commands.WriteOffClawback; + +internal sealed class WriteOffClawbackCommandHandler( + IUnitOfWork unitOfWork, + IDateTimeProvider dateTimeProvider) + : IRequestHandler> +{ + public async ValueTask> Handle(WriteOffClawbackCommand request, CancellationToken cancellationToken) + { + var clawback = await unitOfWork.RefundRepository.GetTrackedClawbackByIdAsync(request.ClawbackId, cancellationToken); + if (clawback is null) + return OperationResult.NotFoundResult("Clawback not found."); + if (!clawback.IsPending) + return OperationResult.ConflictResult("Only a pending clawback can be written off."); + + var now = dateTimeProvider.UtcNow.UtcDateTime; + clawback.WriteOff(request.Reason, now); + + var correction = LedgerPosting.ClawbackWriteOff( + clawback.BookingId, clawback.NurseId, clawback.AmountIrr, clawback.Id, now); + await unitOfWork.PaymentRepository.AddLedgerEntriesAsync(correction, cancellationToken); + + await unitOfWork.CommitAsync(); + return OperationResult.SuccessResult(true); + } +} diff --git a/server/src/Core/Baya.Application/Features/Refunds/Commands/WriteOffClawback/WriteOffClawbackCommand.Validator.cs b/server/src/Core/Baya.Application/Features/Refunds/Commands/WriteOffClawback/WriteOffClawbackCommand.Validator.cs new file mode 100644 index 0000000..4a8ce65 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Refunds/Commands/WriteOffClawback/WriteOffClawbackCommand.Validator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace Baya.Application.Features.Refunds.Commands.WriteOffClawback; + +public sealed class WriteOffClawbackCommandValidator : AbstractValidator +{ + public WriteOffClawbackCommandValidator() + { + RuleFor(x => x.ClawbackId).GreaterThan(0); + RuleFor(x => x.Reason).NotEmpty().MaximumLength(500); + } +} diff --git a/server/src/Core/Baya.Application/Features/Refunds/Commands/WriteOffClawback/WriteOffClawbackCommand.cs b/server/src/Core/Baya.Application/Features/Refunds/Commands/WriteOffClawback/WriteOffClawbackCommand.cs new file mode 100644 index 0000000..384ced1 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Refunds/Commands/WriteOffClawback/WriteOffClawbackCommand.cs @@ -0,0 +1,8 @@ +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Refunds.Commands.WriteOffClawback; + +/// Admin marks a pending nurse clawback uncollectable, posting the balancing +/// DEBIT bad_debt / CREDIT nurse_clawback_receivable correction. Recovery via payout netting is b13. +public record WriteOffClawbackCommand(long ClawbackId, string Reason) : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Refunds/Queries/GetRefundStatus/GetRefundStatusQuery.Handler.cs b/server/src/Core/Baya.Application/Features/Refunds/Queries/GetRefundStatus/GetRefundStatusQuery.Handler.cs new file mode 100644 index 0000000..d8c5290 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Refunds/Queries/GetRefundStatus/GetRefundStatusQuery.Handler.cs @@ -0,0 +1,25 @@ +#nullable enable +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Persistence; +using Baya.Application.Models.Common; +using Baya.Application.Models.Refunds; +using Mediator; + +namespace Baya.Application.Features.Refunds.Queries.GetRefundStatus; + +internal sealed class GetRefundStatusQueryHandler( + IUnitOfWork unitOfWork, + ICurrentUser currentUser) + : IRequestHandler> +{ + public async ValueTask> Handle(GetRefundStatusQuery request, CancellationToken cancellationToken) + { + var projection = await unitOfWork.RefundRepository.GetStatusAsync(request.RefundId, cancellationToken); + + // A cross-customer access is indistinguishable from "not found" — never confirm the refund exists. + if (projection is null || projection.CustomerUserId != currentUser.UserId) + return OperationResult.NotFoundResult("Refund not found."); + + return OperationResult.SuccessResult(projection.Refund); + } +} diff --git a/server/src/Core/Baya.Application/Features/Refunds/Queries/GetRefundStatus/GetRefundStatusQuery.cs b/server/src/Core/Baya.Application/Features/Refunds/Queries/GetRefundStatus/GetRefundStatusQuery.cs new file mode 100644 index 0000000..80bd689 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Refunds/Queries/GetRefundStatus/GetRefundStatusQuery.cs @@ -0,0 +1,9 @@ +using Baya.Application.Models.Common; +using Baya.Application.Models.Refunds; +using Mediator; + +namespace Baya.Application.Features.Refunds.Queries.GetRefundStatus; + +/// The customer-facing status of their own refund — status, channel, amount and the BNPL ETA window. +/// Tenancy-scoped to the booking's customer via ICurrentUser; another customer's refund is not visible. +public record GetRefundStatusQuery(long RefundId) : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Refunds/Queries/ListRefunds/ListRefundsQuery.Handler.cs b/server/src/Core/Baya.Application/Features/Refunds/Queries/ListRefunds/ListRefundsQuery.Handler.cs new file mode 100644 index 0000000..c15f22f --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Refunds/Queries/ListRefunds/ListRefundsQuery.Handler.cs @@ -0,0 +1,20 @@ +#nullable enable +using Baya.Application.Contracts.Persistence; +using Baya.Application.Models.Common; +using Baya.Application.Models.Refunds; +using Mediator; + +namespace Baya.Application.Features.Refunds.Queries.ListRefunds; + +internal sealed class ListRefundsQueryHandler(IUnitOfWork unitOfWork) + : IRequestHandler>> +{ + public async ValueTask>> Handle(ListRefundsQuery request, CancellationToken cancellationToken) + { + var page = request.Page < 1 ? 1 : request.Page; + var pageSize = request.PageSize is < 1 or > 100 ? 20 : request.PageSize; + + var result = await unitOfWork.RefundRepository.ListAsync(request.BookingId, request.Status, page, pageSize, cancellationToken); + return OperationResult>.SuccessResult(result); + } +} diff --git a/server/src/Core/Baya.Application/Features/Refunds/Queries/ListRefunds/ListRefundsQuery.cs b/server/src/Core/Baya.Application/Features/Refunds/Queries/ListRefunds/ListRefundsQuery.cs new file mode 100644 index 0000000..9c48b35 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Refunds/Queries/ListRefunds/ListRefundsQuery.cs @@ -0,0 +1,10 @@ +using Baya.Application.Models.Common; +using Baya.Application.Models.Refunds; +using Mediator; + +namespace Baya.Application.Features.Refunds.Queries.ListRefunds; + +/// Admin refund worklist — projected + paginated; surfaces channel, decomposed legs, status, the BNPL +/// ETA and the policy snapshot. Optional / filters. +public record ListRefundsQuery(long? BookingId = null, string? Status = null, int Page = 1, int PageSize = 20) + : IRequest>>; diff --git a/server/src/Core/Baya.Application/Models/Invoices/InvoiceProjections.cs b/server/src/Core/Baya.Application/Models/Invoices/InvoiceProjections.cs new file mode 100644 index 0000000..174f6e7 --- /dev/null +++ b/server/src/Core/Baya.Application/Models/Invoices/InvoiceProjections.cs @@ -0,0 +1,32 @@ +#nullable enable +namespace Baya.Application.Models.Invoices; + +/// The booking facts IssueInvoiceCommand copies onto the invoice, plus the owning customer's +/// user id for the tenancy-scoped read. Money is IRR long. +public record InvoiceBookingAmounts( + long BookingId, + int CustomerUserId, + long GrossIrr, + long PlatformCommissionIrr, + long? BnplCommissionIrr); + +/// The booking's official invoice. Money crosses the wire as digit strings; the PDF URL is derived +/// from the stored object key when present. +public record InvoiceDto( + long Id, + long BookingId, + string InvoiceNumber, + string IssuingEntityType, + string GrossIrr, + string PlatformCommissionIrr, + string? BnplCommissionIrr, + decimal VatRate, + string VatIrr, + string? MoadianReferenceNumber, + string? MoadianStatus, + string? PdfUrl, + DateTime IssuedAt); + +/// The tenancy envelope for the customer invoice read: the owning customer's user id plus the DTO +/// (with the raw PDF key so the handler can turn it into a URL through IObjectStorage). +public record InvoiceProjection(int CustomerUserId, string? PdfStorageKey, InvoiceDto Invoice); diff --git a/server/src/Core/Baya.Application/Models/Refunds/RefundProjections.cs b/server/src/Core/Baya.Application/Models/Refunds/RefundProjections.cs new file mode 100644 index 0000000..7a1faca --- /dev/null +++ b/server/src/Core/Baya.Application/Models/Refunds/RefundProjections.cs @@ -0,0 +1,72 @@ +#nullable enable +namespace Baya.Application.Models.Refunds; + +/// +/// Everything CreateRefundCommand needs in one read to decompose, pick the channel and enforce +/// Σ refunded ≤ captured: the booking's frozen three-amount split, the b9 cancellation snapshot (never +/// re-resolved live), and the captured transaction it reverses. Money is IRR long. +/// +public record RefundMoneyContext( + long BookingId, + long CustomerId, + int CustomerUserId, + long NurseId, + long GrossPriceIrr, + long CommissionIrr, + long PayoutIrr, + string? CancellationPolicyCode, + decimal? CancellationRefundPercentage, + long? RefundableAmountIrr, + long PaymentTransactionId, + string? GatewayReferenceCode, + long CapturedAmount, + string GatewayType); + +/// The admin refund worklist row — channel, decomposed legs, status, policy snapshot and the BNPL ETA. +/// Money crosses the wire as digit strings. +public record RefundListItemDto( + long Id, + long BookingId, + long PaymentTransactionId, + string Amount, + string PlatformFeeRefundedIrr, + string NursePayoutRefundedIrr, + string RefundChannel, + string Status, + decimal RefundPercentage, + string? ReasonCategory, + string? CancellationPolicyCode, + decimal? RefundPercentageApplied, + DateOnly? ExpectedCustomerRefundEta, + string? GatewayRefundReference, + string? ExternalRevertReference, + DateTime? ProcessedAt, + DateTimeOffset CreatedAt); + +/// The customer-facing status of their own refund — the reference is masked, money is a digit string, +/// and the BNPL 7–10-business-day window is surfaced so the UI can show "on its way, ~N days". +public record RefundStatusDto( + long Id, + long BookingId, + string Status, + string RefundChannel, + string Amount, + DateOnly? ExpectedCustomerRefundEta, + string? Reference); + +/// The tenancy envelope for the customer refund-status read: the owning customer's user id (compared +/// to ICurrentUser) plus the DTO. A cross-customer access is a clean not-found, never a leak. +public record RefundStatusProjection(int CustomerUserId, RefundStatusDto Refund); + +/// What CreateRefundCommand returns — the created refund's identity, channel, terminal-ish +/// status, decomposed legs and (BNPL) ETA, and whether it opened a clawback. Money is a digit string. +public record CreateRefundResult( + long RefundId, + long BookingId, + string Status, + string RefundChannel, + string Amount, + string PlatformFeeRefundedIrr, + string NursePayoutRefundedIrr, + DateOnly? ExpectedCustomerRefundEta, + long? ClawbackId); diff --git a/server/src/Core/Baya.Domain/Entities/Invoices/Invoice.cs b/server/src/Core/Baya.Domain/Entities/Invoices/Invoice.cs new file mode 100644 index 0000000..a79229f --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/Invoices/Invoice.cs @@ -0,0 +1,62 @@ +#nullable enable +using Baya.Domain.Common; + +namespace Baya.Domain.Entities.Invoices; + +/// +/// The minimal official receipt per booking. VAT applies to the platform commission line only +/// () — never the nurse's earnings — following the Snapp/Tapsi precedent +/// that the nurse is the taxable seller of the care service and the platform's commission is its own taxable +/// revenue. is computed integer-only from a config-driven +/// (default 0.10); a vat_rate = 0 exemption yields = 0. +/// +/// is UNIQUE and sequential, drawn from a concurrency-safe counter row +/// (never random/timestamp-derived). One issued invoice per booking (idempotent). +/// +/// +public class Invoice : BaseEntity +{ + public long BookingId { get; set; } + + /// Official sequential number (UNIQUE) — drawn from . + public string InvoiceNumber { get; set; } = null!; + + /// An code. + public string IssuingEntityType { get; set; } = InvoiceIssuingEntityType.Platform; + + /// The licensed center that is merchant-of-record, when the issuer is a partner center (b15). + public long? PartnerCenterId { get; set; } + + public long GrossIrr { get; set; } + + /// The VAT-relevant line — VAT is computed on this, never on the nurse payout. + public long PlatformCommissionIrr { get; set; } + + public long? BnplCommissionIrr { get; set; } + + /// Config-driven VAT rate snapshot (default 0.10) frozen at issue time. + public decimal VatRate { get; set; } + + /// round( × ) — integer-only, no float path. + public long VatIrr { get; set; } + + /// The سامانه مودیان 22-digit reference when registered; null until then. + public string? MoadianReferenceNumber { get; set; } + + /// A code. + public string? MoadianStatus { get; set; } + + /// An IObjectStorage key for the optional invoice PDF. + public string? PdfStorageKey { get; set; } + + public DateTime IssuedAt { get; set; } + + public DateTimeOffset? DeletedAt { get; set; } + + /// Records the outcome of a مودیان submission (mock: pending/no-ref; forced: registered/ref). + public void ApplyMoadianResult(string status, string? referenceNumber) + { + MoadianStatus = status; + MoadianReferenceNumber = referenceNumber; + } +} diff --git a/server/src/Core/Baya.Domain/Entities/Invoices/InvoiceIssuingEntityType.cs b/server/src/Core/Baya.Domain/Entities/Invoices/InvoiceIssuingEntityType.cs new file mode 100644 index 0000000..b803061 --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/Invoices/InvoiceIssuingEntityType.cs @@ -0,0 +1,9 @@ +namespace Baya.Domain.Entities.Invoices; + +/// The closed invoices.issuing_entity_type code set — who issues the receipt. A partner center +/// (b15) is the merchant-of-record for its employees' bookings; otherwise the platform issues it. +public static class InvoiceIssuingEntityType +{ + public const string Platform = "platform"; + public const string PartnerCenter = "partner_center"; +} diff --git a/server/src/Core/Baya.Domain/Entities/Invoices/InvoiceNumberSequence.cs b/server/src/Core/Baya.Domain/Entities/Invoices/InvoiceNumberSequence.cs new file mode 100644 index 0000000..5cf9049 --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/Invoices/InvoiceNumberSequence.cs @@ -0,0 +1,19 @@ +using Baya.Domain.Common; + +namespace Baya.Domain.Entities.Invoices; + +/// +/// The gap-free, concurrency-safe counter behind invoices.invoice_number. A single seeded row holds the +/// next value; issuing an invoice takes the money-path lock, reads and increments , and +/// commits the increment in the same transaction as the invoice insert — so a rollback rolls both back +/// and numbers stay sequential and unique. A dedicated counter row (not MAX()+1) keeps this correct under +/// concurrency and portable across SQL Server and the SQLite test provider (no provider-specific sequence). +/// +public class InvoiceNumberSequence : IEntity +{ + /// Fixed singleton key — there is exactly one counter row (id 1). + public int Id { get; set; } + + /// The next number to hand out. Incremented under the invoice lock. + public long NextValue { get; set; } +} diff --git a/server/src/Core/Baya.Domain/Entities/Invoices/MoadianStatus.cs b/server/src/Core/Baya.Domain/Entities/Invoices/MoadianStatus.cs new file mode 100644 index 0000000..5220258 --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/Invoices/MoadianStatus.cs @@ -0,0 +1,22 @@ +namespace Baya.Domain.Entities.Invoices; + +/// +/// The closed invoices.moadian_status code set — the سامانه مودیان e-invoicing submission state. The mock +/// IMoadianClient leaves a newly issued invoice at with no reference; a config +/// switch can force (with a fake 22-digit ref) so the reconciliation path is testable. +/// The real adapter walks . +/// +public static class MoadianStatus +{ + /// Issued locally; not yet sent to مودیان. + public const string Pending = "pending"; + + /// Sent to مودیان; awaiting the registered reference. + public const string Submitted = "submitted"; + + /// مودیان returned the 22-digit reference — the invoice is officially registered. + public const string Registered = "registered"; + + /// مودیان rejected the submission. + public const string Failed = "failed"; +} diff --git a/server/src/Core/Baya.Domain/Entities/Payments/LedgerPosting.cs b/server/src/Core/Baya.Domain/Entities/Payments/LedgerPosting.cs index f1a2806..d62dfe8 100644 --- a/server/src/Core/Baya.Domain/Entities/Payments/LedgerPosting.cs +++ b/server/src/Core/Baya.Domain/Entities/Payments/LedgerPosting.cs @@ -28,25 +28,119 @@ public static class LedgerPosting $"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) + Leg(group, LedgerAccountType.EscrowHeld, LedgerDirection.Debit, grossIrr, null, bookingId, LedgerSourceRefType.PaymentTransaction, paymentTransactionId, createdAt), + Leg(group, LedgerAccountType.PlatformRevenue, LedgerDirection.Credit, commissionIrr, null, bookingId, LedgerSourceRefType.PaymentTransaction, paymentTransactionId, createdAt), + Leg(group, LedgerAccountType.NursePayable, LedgerDirection.Credit, payoutIrr, nurseId, bookingId, LedgerSourceRefType.PaymentTransaction, paymentTransactionId, createdAt) ]; } + + /// + /// The pre-payout refund reversal (the nurse has not been paid, the common case): DEBIT + /// platform_revenue fee + DEBIT nurse_payable payout / CREDIT refund_payable (sum) under one group. + /// Simply un-accrues what capture posted. The customer cash-back is cleared separately by + /// once the provider confirms it. + /// + public static IReadOnlyList RefundReversalPrePayout( + long bookingId, + long nurseId, + long platformFeeRefundedIrr, + long nursePayoutRefundedIrr, + long refundId, + DateTime createdAt) + { + var group = Guid.NewGuid(); + var total = platformFeeRefundedIrr + nursePayoutRefundedIrr; + var legs = new List(); + + if (platformFeeRefundedIrr > 0) + legs.Add(Leg(group, LedgerAccountType.PlatformRevenue, LedgerDirection.Debit, platformFeeRefundedIrr, null, bookingId, LedgerSourceRefType.Refund, refundId, createdAt)); + if (nursePayoutRefundedIrr > 0) + legs.Add(Leg(group, LedgerAccountType.NursePayable, LedgerDirection.Debit, nursePayoutRefundedIrr, nurseId, bookingId, LedgerSourceRefType.Refund, refundId, createdAt)); + + legs.Add(Leg(group, LedgerAccountType.RefundPayable, LedgerDirection.Credit, total, null, bookingId, LedgerSourceRefType.Refund, refundId, createdAt)); + return legs; + } + + /// + /// The post-payout clawback reversal (the nurse was already paid — an irreversible IBAN transfer): + /// identical to the pre-payout group except the payout leg debits nurse_clawback_receivable (money + /// owed back by the nurse) instead of un-accruing nurse_payable: DEBIT platform_revenue fee + + /// DEBIT nurse_clawback_receivable payout / CREDIT refund_payable (sum). A nurse_clawbacks row + /// tracks the workflow; b13 nets the receivable out of a later payout. + /// + public static IReadOnlyList ClawbackReversalPostPayout( + long bookingId, + long nurseId, + long platformFeeRefundedIrr, + long nursePayoutRefundedIrr, + long refundId, + DateTime createdAt) + { + var group = Guid.NewGuid(); + var total = platformFeeRefundedIrr + nursePayoutRefundedIrr; + var legs = new List(); + + if (platformFeeRefundedIrr > 0) + legs.Add(Leg(group, LedgerAccountType.PlatformRevenue, LedgerDirection.Debit, platformFeeRefundedIrr, null, bookingId, LedgerSourceRefType.Refund, refundId, createdAt)); + if (nursePayoutRefundedIrr > 0) + legs.Add(Leg(group, LedgerAccountType.NurseClawbackReceivable, LedgerDirection.Debit, nursePayoutRefundedIrr, nurseId, bookingId, LedgerSourceRefType.Refund, refundId, createdAt)); + + legs.Add(Leg(group, LedgerAccountType.RefundPayable, LedgerDirection.Credit, total, null, bookingId, LedgerSourceRefType.Refund, refundId, createdAt)); + return legs; + } + + /// + /// Clears the customer cash-back once the provider confirms it (card immediately; BNPL/manual on + /// reconciliation): DEBIT refund_payable / CREDIT escrow_held for the refunded total. Path- and + /// channel-independent — the same second leg follows either the pre-payout or the clawback reversal. + /// + public static IReadOnlyList RefundPayableClearing( + long bookingId, + long totalRefundedIrr, + long refundId, + DateTime createdAt) + { + var group = Guid.NewGuid(); + return + [ + Leg(group, LedgerAccountType.RefundPayable, LedgerDirection.Debit, totalRefundedIrr, null, bookingId, LedgerSourceRefType.Refund, refundId, createdAt), + Leg(group, LedgerAccountType.EscrowHeld, LedgerDirection.Credit, totalRefundedIrr, null, bookingId, LedgerSourceRefType.Refund, refundId, createdAt) + ]; + } + + /// + /// The clawback write-off correction: DEBIT bad_debt / CREDIT nurse_clawback_receivable for + /// the amount, when an admin declares the receivable uncollectable. A new balancing group — never an edit. + /// + public static IReadOnlyList ClawbackWriteOff( + long bookingId, + long nurseId, + long amountIrr, + long clawbackId, + DateTime createdAt) + { + var group = Guid.NewGuid(); + return + [ + Leg(group, LedgerAccountType.BadDebt, LedgerDirection.Debit, amountIrr, null, bookingId, LedgerSourceRefType.Clawback, clawbackId, createdAt), + Leg(group, LedgerAccountType.NurseClawbackReceivable, LedgerDirection.Credit, amountIrr, nurseId, bookingId, LedgerSourceRefType.Clawback, clawbackId, createdAt) + ]; + } + + private static LedgerEntry Leg( + Guid group, string account, string direction, long amount, long? nurse, + long bookingId, string sourceType, long sourceId, DateTime createdAt) => new() + { + TransactionGroupId = group, + AccountType = account, + Direction = direction, + AmountIrr = amount, + NurseId = nurse, + BookingId = bookingId, + SourceRefType = sourceType, + SourceRefId = sourceId, + CreatedAt = createdAt + }; } diff --git a/server/src/Core/Baya.Domain/Entities/Refunds/ClawbackStatus.cs b/server/src/Core/Baya.Domain/Entities/Refunds/ClawbackStatus.cs new file mode 100644 index 0000000..7992abb --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/Refunds/ClawbackStatus.cs @@ -0,0 +1,18 @@ +namespace Baya.Domain.Entities.Refunds; + +/// +/// The closed nurse_clawbacks.status code set. This phase only ever creates rows in +/// (and supports an admin ); is set by b13's payout +/// netting — recovery is not implemented here. Persisted as these stable snake_case codes. +/// +public static class ClawbackStatus +{ + /// Open receivable owed by the nurse. The only state this phase creates. + public const string Pending = "pending"; + + /// Netted out of a later payout batch. Set by b13 — never here. + public const string Recovered = "recovered"; + + /// Admin-declared uncollectable; balanced by a bad_debt posting. + public const string WrittenOff = "written_off"; +} diff --git a/server/src/Core/Baya.Domain/Entities/Refunds/NurseClawback.cs b/server/src/Core/Baya.Domain/Entities/Refunds/NurseClawback.cs new file mode 100644 index 0000000..e45a916 --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/Refunds/NurseClawback.cs @@ -0,0 +1,52 @@ +#nullable enable +using Baya.Domain.Common; + +namespace Baya.Domain.Entities.Refunds; + +/// +/// A first-class receivable opened when a booking is refunded/disputed after the nurse was already paid. +/// Because an Iranian IBAN transfer is effectively irreversible, that money is already gone and must be +/// recorded as owed-back — never silently absorbed. The receivable's ledger leg is +/// DEBIT nurse_clawback_receivable; the balance derives from the ledger, this row tracks the workflow. +/// +/// This phase only ever creates rows in and supports an admin write-off. +/// / are the (nullable) join points b13 fills +/// when a payout batch nets the clawback out — nurse_payouts arrives in b13. +/// +/// +public class NurseClawback : BaseEntity +{ + public long NurseId { get; set; } + public long BookingId { get; set; } + public long RefundId { get; set; } + + /// The payout that already paid the nurse. FK target (nurse_payouts) arrives in b13; the + /// column/index are in place now and the value is set once b13 exists. + public long? OriginalPayoutId { get; set; } + + /// Equals the refund's nurse_payout_refunded_irr leg (IRR). + public long AmountIrr { get; set; } + + public string Status { get; private set; } = ClawbackStatus.Pending; + + /// The batch that netted it out. Set by b13 only — always null here. + public long? RecoveredInPayoutId { get; set; } + + public DateTime? ResolvedAt { get; private set; } + public string? ResolutionNotes { get; private set; } + + public DateTimeOffset? DeletedAt { get; set; } + + public bool IsPending => Status == ClawbackStatus.Pending; + + /// Admin declares the receivable uncollectable. The balancing bad_debt posting is the + /// handler's job; this records the workflow outcome. + public void WriteOff(string notes, DateTime now) + { + if (Status != ClawbackStatus.Pending) + throw new InvalidOperationException($"Only a pending clawback can be written off (was {Status})."); + Status = ClawbackStatus.WrittenOff; + ResolutionNotes = notes; + ResolvedAt = now; + } +} diff --git a/server/src/Core/Baya.Domain/Entities/Refunds/Refund.cs b/server/src/Core/Baya.Domain/Entities/Refunds/Refund.cs new file mode 100644 index 0000000..78b977c --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/Refunds/Refund.cs @@ -0,0 +1,116 @@ +#nullable enable +using Baya.Domain.Common; + +namespace Baya.Domain.Entities.Refunds; + +/// +/// An admin-initiated, ticket-linked reversal of a captured booking payment. Refunds are 1:N per +/// payment_transaction — partials exist (a shortened visit) — so the "Σ refunded ≤ captured" invariant +/// is a handler check under the booking refund lock, not a single-row constraint. +/// +/// A refund decomposes across both fee legs: = +/// (portion of the platform commission reversed) + (portion of the nurse +/// payout reversed — this leg drives a nurse_clawbacks receivable when the nurse was already paid). +/// Money is IRR BIGINT only. The channel is chosen from the original payment type; the ledger legs are +/// the same across channels — only , the external reference and the customer ETA +/// differ. +/// +/// +public class Refund : BaseEntity +{ + /// The captured transaction being reversed (N:1 — a transaction may have several partial refunds). + public long PaymentTransactionId { get; set; } + + public long BookingId { get; set; } + + /// The customer the refund is for (not the admin actor). + public long RequestedByCustomerId { get; set; } + + /// Forward-dep on tickets (b15). Nullable now; "ticket required" is a config-gated + /// handler/validator rule so admin refunds are testable before b15 wires the real FK target. + public long? TicketId { get; set; } + + /// Total refunded (IRR) = + . + public long Amount { get; set; } + + /// Portion of balinyaar_commission_irr being reversed (IRR). + public long PlatformFeeRefundedIrr { get; set; } + + /// Portion of nurse_payout_amount being reversed (IRR) — drives a clawback if already paid. + public long NursePayoutRefundedIrr { get; set; } + + /// The resolved refund fraction applied to the booking amounts to derive the legs (0–1). + public decimal RefundPercentage { get; set; } + + /// A code — how the money physically flows back. + public string RefundChannel { get; set; } = null!; + + public string? ReasonCategory { get; set; } + public string? ReasonNotes { get; set; } + + /// Guarded — mutated only through the cohesive methods so every write goes through the machine. + public string Status { get; private set; } = RefundStatus.Approved; + + public int? ApprovedByAdminId { get; set; } + public string? RejectedReason { get; private set; } + public string? AdminNotes { get; set; } + + /// The PSP card-refund reference, when the channel is psp_card. + public string? GatewayRefundReference { get; private set; } + + /// The BNPL provider revert id, when the channel is bnpl_revert (or the manual bank ref). + public string? ExternalRevertReference { get; private set; } + + /// The ~7–10 business-day BNPL customer window; null for instant card refunds. + public DateOnly? ExpectedCustomerRefundEta { get; private set; } + + /// Snapshot of the b9 cancellation policy code that produced this refund — never re-resolved live. + public string? CancellationPolicyCode { get; set; } + + /// Snapshot of the resolved refund percentage (0–100) frozen at cancel time. + public decimal? RefundPercentageApplied { get; set; } + + public DateTime? ProcessedAt { get; private set; } + + public DateTimeOffset? DeletedAt { get; set; } + + public bool CanTransitionTo(string target) => RefundTransitions.CanTransition(Status, target); + + private void Transition(string target) + { + if (!RefundTransitions.CanTransition(Status, target)) + throw new InvalidOperationException($"Illegal refund transition {Status} → {target}."); + Status = target; + } + + /// Card path — the reversal is effectively immediate; no customer-facing ETA. + public void MarkSucceededCard(string gatewayRefundReference, DateTime now) + { + Transition(RefundStatus.Succeeded); + GatewayRefundReference = gatewayRefundReference; + ExpectedCustomerRefundEta = null; + ProcessedAt = now; + } + + /// BNPL/manual path — the provider revert is accepted but the customer cash-back is async; the + /// refund waits in processing until reconciliation confirms it and surfaces the ETA meanwhile. + public void MarkProcessing(string? externalRevertReference, DateOnly? expectedCustomerRefundEta) + { + Transition(RefundStatus.Processing); + ExternalRevertReference = externalRevertReference; + ExpectedCustomerRefundEta = expectedCustomerRefundEta; + } + + /// Reconciliation confirmed the customer cash-back for a processing refund (BNPL/manual). + public void MarkSucceededAsync(DateTime now) + { + Transition(RefundStatus.Succeeded); + ProcessedAt = now; + } + + public void MarkFailed(string? reason) + { + Transition(RefundStatus.Failed); + RejectedReason = reason; + } +} diff --git a/server/src/Core/Baya.Domain/Entities/Refunds/RefundChannel.cs b/server/src/Core/Baya.Domain/Entities/Refunds/RefundChannel.cs new file mode 100644 index 0000000..021a720 --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/Refunds/RefundChannel.cs @@ -0,0 +1,20 @@ +namespace Baya.Domain.Entities.Refunds; + +/// +/// The closed refunds.refund_channel code set — how the money physically flows back. The ledger +/// legs are identical across channels (see LedgerPosting.RefundReversal); only the execution + metadata +/// differ. The data-model doc writes the out-of-band bank code as manual_bank; the canonical wire +/// code is (per dev/contracts/conventions/money-and-types.md) — they are the same +/// channel, and this is the value stored and serialized. +/// +public static class RefundChannel +{ + /// PSP card reversal — effectively immediate, no customer-facing ETA. + public const string PspCard = "psp_card"; + + /// BNPL provider revert/update — async, surfaces a ~7–10 business-day customer ETA. + public const string BnplRevert = "bnpl_revert"; + + /// Out-of-band bank refund the admin executes and records the reference for (data-model: manual_bank). + public const string Manual = "manual"; +} diff --git a/server/src/Core/Baya.Domain/Entities/Refunds/RefundStatus.cs b/server/src/Core/Baya.Domain/Entities/Refunds/RefundStatus.cs new file mode 100644 index 0000000..97355ab --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/Refunds/RefundStatus.cs @@ -0,0 +1,28 @@ +namespace Baya.Domain.Entities.Refunds; + +/// +/// The closed refunds.status code set — a forward-only lifecycle. A card refund is effectively +/// immediate (); a BNPL revert sits in +/// until the async 7–10-business-day customer cash-back is reconciled. Persisted as these stable snake_case +/// codes; the allowed edges live in . +/// +public static class RefundStatus +{ + /// Recorded but not yet approved. Reserved — admin refunds are created already approved today. + public const string Requested = "requested"; + + /// Admin-approved; the channel execution + ledger posting run under the same lock. + public const string Approved = "approved"; + + /// Channel accepted but the customer cash-back is not yet confirmed (the BNPL/manual wait state). + public const string Processing = "processing"; + + /// The customer refund is confirmed (card immediately; BNPL on reconciliation). Terminal. + public const string Succeeded = "succeeded"; + + /// The channel refused the reversal. Terminal — a fresh attempt is a new refund row. + public const string Failed = "failed"; + + /// The refund was declined by the admin before any money moved. Terminal. + public const string Rejected = "rejected"; +} diff --git a/server/src/Core/Baya.Domain/Entities/Refunds/RefundTransitions.cs b/server/src/Core/Baya.Domain/Entities/Refunds/RefundTransitions.cs new file mode 100644 index 0000000..8be2b21 --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/Refunds/RefundTransitions.cs @@ -0,0 +1,24 @@ +namespace Baya.Domain.Entities.Refunds; + +/// +/// The forward-only allowed-edge table for the machine. Every write goes through +/// 's cohesive methods, which assert the edge here — an illegal transition is a +/// programming error (the handler pre-checks the expected cases), so the entity fails fast rather than +/// silently overwriting a terminal state. +/// +public static class RefundTransitions +{ + private static readonly IReadOnlyDictionary> Allowed = + new Dictionary> + { + [RefundStatus.Requested] = [RefundStatus.Approved, RefundStatus.Rejected], + [RefundStatus.Approved] = [RefundStatus.Processing, RefundStatus.Succeeded, RefundStatus.Failed, RefundStatus.Rejected], + [RefundStatus.Processing] = [RefundStatus.Succeeded, RefundStatus.Failed], + [RefundStatus.Succeeded] = [], + [RefundStatus.Failed] = [], + [RefundStatus.Rejected] = [] + }; + + public static bool CanTransition(string from, string to) + => Allowed.TryGetValue(from, out var targets) && targets.Contains(to); +} diff --git a/server/src/Core/Baya.Domain/Entities/SupportAlerts/SupportAlert.cs b/server/src/Core/Baya.Domain/Entities/SupportAlerts/SupportAlert.cs index e1712de..4d10ee8 100644 --- a/server/src/Core/Baya.Domain/Entities/SupportAlerts/SupportAlert.cs +++ b/server/src/Core/Baya.Domain/Entities/SupportAlerts/SupportAlert.cs @@ -45,9 +45,13 @@ public static class SupportAlertType public const string PaymentAnomaly = "payment_anomaly"; public const string FraudSignal = "fraud_signal"; + /// A refund on an already-paid booking opened a nurse clawback receivable (b11) — staff must + /// track recovery. Iranian IBAN transfers are irreversible, so this is always worth a human look. + public const string NurseClawback = "nurse_clawback"; + public static readonly IReadOnlyList All = [ - LowRating, EvvNoShow, EvvLocationMismatch, VerificationExpired, SharedSim, PaymentAnomaly, FraudSignal + LowRating, EvvNoShow, EvvLocationMismatch, VerificationExpired, SharedSim, PaymentAnomaly, FraudSignal, NurseClawback ]; } diff --git a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/MockBnplProvider.cs b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/MockBnplProvider.cs new file mode 100644 index 0000000..f86300f --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/MockBnplProvider.cs @@ -0,0 +1,34 @@ +#nullable enable +using Baya.Application.Contracts.Payments; +using Microsoft.Extensions.Options; + +namespace Baya.Infrastructure.CrossCutting.Seams; + +/// +/// A thin, deterministic mock so b11's bnpl_revert refund path is exercised +/// before b12 merges — b12 owns the real seam definition and its full adapter (SnappPay/Tara). Revert +/// and update both succeed, echo a deterministic external_revert_reference derived from the order + +/// idempotency key, and report a nullable provider commission reversal (null by default — some providers keep +/// their fee on a refund; the amount is reconciled from the response, never hardcoded). +/// +public sealed class MockBnplProvider(IOptions options) : IBnplProvider +{ + private readonly BnplOptions _options = options.Value.Bnpl; + + public ValueTask RevertAsync(string providerOrderReference, long amountIrr, string idempotencyKey, CancellationToken cancellationToken = default) + => Result(providerOrderReference, idempotencyKey); + + public ValueTask UpdateAsync(string providerOrderReference, long newAmountIrr, string idempotencyKey, CancellationToken cancellationToken = default) + => Result(providerOrderReference, idempotencyKey); + + private ValueTask Result(string providerOrderReference, string idempotencyKey) + { + if (_options.ForceFailure) + return ValueTask.FromResult(new BnplRevertResult(PaymentProviderStatus.Failed, null, null)); + + return ValueTask.FromResult(new BnplRevertResult( + PaymentProviderStatus.Succeeded, + ExternalRevertReference: $"mock-bnpl-revert-{providerOrderReference}-{idempotencyKey}", + ProviderCommissionReversedAmount: _options.ReverseProviderCommission ? 0 : null)); + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/MockMoadianClient.cs b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/MockMoadianClient.cs new file mode 100644 index 0000000..d1b8296 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/MockMoadianClient.cs @@ -0,0 +1,28 @@ +#nullable enable +using Baya.Application.Contracts.Invoices; +using Microsoft.Extensions.Options; + +namespace Baya.Infrastructure.CrossCutting.Seams; + +/// +/// Deterministic mock (b11) — no external call. By default a submission leaves the +/// invoice at moadian_status = pending with no reference (the real reconciliation later flips it to +/// registered). Set to have it return registered with +/// a deterministic fake 22-digit reference so the registered/reconciliation path is testable. The real +/// سامانه مودیان adapter (enrollment, the معاملات/invoice submission API, the 22-digit reference) swaps only this +/// registration. +/// +public sealed class MockMoadianClient(IOptions options) : IMoadianClient +{ + private readonly MoadianOptions _options = options.Value.Moadian; + + public ValueTask SubmitAsync(InvoiceSubmission submission, CancellationToken cancellationToken = default) + { + if (!_options.ForceRegistered) + return ValueTask.FromResult(new MoadianSubmissionResult(Baya.Domain.Entities.Invoices.MoadianStatus.Pending, null)); + + // A deterministic 22-digit reference derived from the booking id (right-aligned, zero-padded). + var reference = submission.BookingId.ToString().PadLeft(22, '0'); + return ValueTask.FromResult(new MoadianSubmissionResult(Baya.Domain.Entities.Invoices.MoadianStatus.Registered, reference)); + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/MockPaymentProvider.cs b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/MockPaymentProvider.cs index 5ceb96c..a174c79 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/MockPaymentProvider.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/MockPaymentProvider.cs @@ -24,6 +24,6 @@ public sealed class MockPaymentProvider : IPaymentProvider 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}")); + public ValueTask RefundAsync(string gatewayReferenceCode, long amountIrr, string idempotencyKey, CancellationToken cancellationToken = default) + => ValueTask.FromResult(new PaymentRefundResult(PaymentProviderStatus.Succeeded, $"mock-refund-{gatewayReferenceCode}-{idempotencyKey}")); } diff --git a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/SeamOptions.cs b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/SeamOptions.cs index e850c54..f9677a3 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/SeamOptions.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/SeamOptions.cs @@ -16,6 +16,34 @@ public sealed class SeamOptions public IdentityKycOptions IdentityKyc { get; set; } = new(); public PaymentCaptureOptions PaymentCapture { get; set; } = new(); public PaymentsOptions Payments { get; set; } = new(); + public MoadianOptions Moadian { get; set; } = new(); + public BnplOptions Bnpl { get; set; } = new(); +} + +/// +/// Tunes the mock IMoadianClient (b11 e-invoicing). By default a submission stays pending with no +/// reference. Set to make it return registered with a fake 22-digit ref so +/// the reconciliation/registered path is testable. The real سامانه مودیان adapter ignores these. +/// +public sealed class MoadianOptions +{ + /// When true, a submission returns registered + a deterministic fake 22-digit reference. + public bool ForceRegistered { get; set; } +} + +/// +/// Tunes the thin local mock IBnplProvider b11 registers until b12 ships the real seam. By default a +/// revert/update succeeds and the provider keeps its commission (null reversal). The real b12 adapter ignores +/// these. +/// +public sealed class BnplOptions +{ + /// When true, every revert/update fails so the refund-channel-refused path is testable. + public bool ForceFailure { get; set; } + + /// When true, the mock reports the provider returned its commission (a non-null, zero reversal + /// placeholder) so the provider_commission_reversed_amount reconciliation is exercised. + public bool ReverseProviderCommission { get; set; } } /// diff --git a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/ServiceConfiguration/ServiceCollectionExtension.cs b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/ServiceConfiguration/ServiceCollectionExtension.cs index d073a84..4af9f25 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.Invoices; using Baya.Application.Contracts.Payments; using Baya.Infrastructure.CrossCutting.Seams; using Microsoft.Extensions.Configuration; @@ -58,6 +59,12 @@ public static class ServiceCollectionExtension services.AddSingleton(); services.AddSingleton(); + // Refunds/invoices seams (backend-phase-11). سامانه مودیان e-invoicing is mocked (pending/no-ref by + // default; config can force registered). IBnplProvider is a thin local stub so the bnpl_revert refund + // path runs before b12 merges — b12 owns the real seam. Both swap in by a registration change only. + services.AddSingleton(); + services.AddSingleton(); + return services; } } diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/ConfigurationConfig/PlatformConfigConfig.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/ConfigurationConfig/PlatformConfigConfig.cs index f2b469f..a0f061b 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/ConfigurationConfig/PlatformConfigConfig.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/ConfigurationConfig/PlatformConfigConfig.cs @@ -46,6 +46,9 @@ internal sealed class PlatformConfigConfig : IEntityTypeConfiguration +/// invoices — one issued invoice per booking (UNIQUE booking_id) with a UNIQUE, sequential +/// invoice_number drawn from . VAT (vat_irr) is computed on the +/// commission line only. partner_center_id is a nullable column with no FKpartner_centers +/// is a forward-dep on b15. +/// +internal sealed class InvoiceConfig : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("Invoices", "payments"); + + builder.Property(i => i.InvoiceNumber).HasMaxLength(40).IsRequired(); + builder.Property(i => i.IssuingEntityType).HasMaxLength(20).IsRequired(); + builder.Property(i => i.MoadianReferenceNumber).HasMaxLength(40); + builder.Property(i => i.MoadianStatus).HasMaxLength(20); + builder.Property(i => i.PdfStorageKey).HasMaxLength(512); + builder.Property(i => i.VatRate).HasPrecision(5, 4); + + builder.HasIndex(i => i.InvoiceNumber).IsUnique(); + builder.HasIndex(i => i.BookingId).IsUnique(); + + builder.HasOne().WithMany().HasForeignKey(i => i.BookingId).IsRequired(); + + builder.HasQueryFilter(i => i.DeletedAt == null); + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/InvoicesConfig/InvoiceNumberSequenceConfig.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/InvoicesConfig/InvoiceNumberSequenceConfig.cs new file mode 100644 index 0000000..968e074 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/InvoicesConfig/InvoiceNumberSequenceConfig.cs @@ -0,0 +1,23 @@ +using Baya.Domain.Entities.Invoices; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Baya.Infrastructure.Persistence.Configuration.InvoicesConfig; + +/// +/// The single-row counter behind the sequential invoice_number. Seeded with one row (id 1, next = 1) so +/// EnsureCreated (tests) and the migration both start the sequence. The id is fixed (never generated) — +/// there is exactly one counter. Portable across SQL Server and SQLite (no provider-specific DB sequence). +/// +internal sealed class InvoiceNumberSequenceConfig : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("InvoiceNumberSequences", "payments"); + + builder.HasKey(s => s.Id); + builder.Property(s => s.Id).ValueGeneratedNever(); + + builder.HasData(new InvoiceNumberSequence { Id = 1, NextValue = 1 }); + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/RefundsConfig/NurseClawbackConfig.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/RefundsConfig/NurseClawbackConfig.cs new file mode 100644 index 0000000..d40d3c4 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/RefundsConfig/NurseClawbackConfig.cs @@ -0,0 +1,36 @@ +using Baya.Domain.Entities.Booking; +using Baya.Domain.Entities.Identity; +using Baya.Domain.Entities.Refunds; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Baya.Infrastructure.Persistence.Configuration.RefundsConfig; + +/// +/// nurse_clawbacks — a first-class receivable opened when a booking is refunded after the nurse was +/// already paid. original_payout_id / recovered_in_payout_id are nullable columns + indexes now +/// with no FKnurse_payouts is a forward-dep on b13, which sets the values and wires the FKs. +/// +internal sealed class NurseClawbackConfig : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("NurseClawbacks", "payments"); + + builder.Property(c => c.Status).HasMaxLength(30).IsRequired(); + builder.Property(c => c.ResolutionNotes).HasMaxLength(500); + + builder.HasIndex(c => c.NurseId); + builder.HasIndex(c => c.BookingId); + builder.HasIndex(c => c.RefundId).IsUnique(); + builder.HasIndex(c => c.Status); + builder.HasIndex(c => c.OriginalPayoutId); + builder.HasIndex(c => c.RecoveredInPayoutId); + + builder.HasOne().WithMany().HasForeignKey(c => c.NurseId).IsRequired(); + builder.HasOne().WithMany().HasForeignKey(c => c.BookingId).IsRequired(); + builder.HasOne().WithMany().HasForeignKey(c => c.RefundId).IsRequired(); + + builder.HasQueryFilter(c => c.DeletedAt == null); + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/RefundsConfig/RefundConfig.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/RefundsConfig/RefundConfig.cs new file mode 100644 index 0000000..a1114b3 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/RefundsConfig/RefundConfig.cs @@ -0,0 +1,50 @@ +using Baya.Domain.Entities.Booking; +using Baya.Domain.Entities.Identity; +using Baya.Domain.Entities.Payments; +using Baya.Domain.Entities.Refunds; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Baya.Infrastructure.Persistence.Configuration.RefundsConfig; + +/// +/// refunds — 1:N per payment_transaction. The amount = fee_leg + payout_leg reconciliation +/// is a DB CHECK (the "Σ refunded ≤ captured" invariant is a handler check under the booking refund lock, not a +/// single-row constraint). ticket_id is a nullable column + index now with no FK — the +/// tickets table is a forward-dep on b15, which wires the real FK target. +/// +internal sealed class RefundConfig : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("Refunds", "payments", t => t.HasCheckConstraint( + "CK_Refunds_LegSplit", + "[Amount] = [PlatformFeeRefundedIrr] + [NursePayoutRefundedIrr] " + + "AND [Amount] >= 0 AND [PlatformFeeRefundedIrr] >= 0 AND [NursePayoutRefundedIrr] >= 0")); + + builder.Property(r => r.RefundChannel).HasMaxLength(20).IsRequired(); + builder.Property(r => r.Status).HasMaxLength(20).IsRequired(); + builder.Property(r => r.ReasonCategory).HasMaxLength(50); + builder.Property(r => r.ReasonNotes).HasMaxLength(1000); + builder.Property(r => r.AdminNotes).HasMaxLength(1000); + builder.Property(r => r.RejectedReason).HasMaxLength(500); + builder.Property(r => r.GatewayRefundReference).HasMaxLength(200); + builder.Property(r => r.ExternalRevertReference).HasMaxLength(200); + builder.Property(r => r.CancellationPolicyCode).HasMaxLength(50); + builder.Property(r => r.RefundPercentage).HasPrecision(6, 4); + builder.Property(r => r.RefundPercentageApplied).HasPrecision(5, 2); + + builder.HasIndex(r => r.PaymentTransactionId); + builder.HasIndex(r => r.BookingId); + builder.HasIndex(r => r.RequestedByCustomerId); + builder.HasIndex(r => r.Status); + // Index in place for the b15 tickets wire-up; no FK yet (tickets does not exist). + builder.HasIndex(r => r.TicketId); + + builder.HasOne().WithMany().HasForeignKey(r => r.PaymentTransactionId).IsRequired(); + builder.HasOne().WithMany().HasForeignKey(r => r.BookingId).IsRequired(); + builder.HasOne().WithMany().HasForeignKey(r => r.RequestedByCustomerId).IsRequired(); + + builder.HasQueryFilter(r => r.DeletedAt == null); + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260708220745_RefundsClawbacksInvoices.Designer.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260708220745_RefundsClawbacksInvoices.Designer.cs new file mode 100644 index 0000000..1f48276 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260708220745_RefundsClawbacksInvoices.Designer.cs @@ -0,0 +1,4869 @@ +// +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("20260708220745_RefundsClawbacksInvoices")] + partial class RefundsClawbacksInvoices + { + /// + 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" + }, + new + { + Id = 19L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "bool", + Description = "Whether a refund must link a support ticket (b11). Off until b15 ships the tickets table.", + Key = "refund_ticket_required", + Value = "false" + }, + new + { + Id = 20L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Business days shown as the customer BNPL refund ETA (b11).", + Key = "bnpl_refund_eta_business_days", + Value = "10" + }, + new + { + Id = 21L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "bool", + Description = "Ops/testing override that forces the post-payout clawback path for refunds (b11); b13 replaces the derivation.", + Key = "refund_assume_nurse_paid", + Value = "false" + }); + }); + + 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.Invoices.Invoice", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BnplCommissionIrr") + .HasColumnType("bigint"); + + b.Property("BookingId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("GrossIrr") + .HasColumnType("bigint"); + + b.Property("InvoiceNumber") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("IssuedAt") + .HasColumnType("datetime2"); + + b.Property("IssuingEntityType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("MoadianReferenceNumber") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("MoadianStatus") + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("PartnerCenterId") + .HasColumnType("bigint"); + + b.Property("PdfStorageKey") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("PlatformCommissionIrr") + .HasColumnType("bigint"); + + b.Property("VatIrr") + .HasColumnType("bigint"); + + b.Property("VatRate") + .HasPrecision(5, 4) + .HasColumnType("decimal(5,4)"); + + b.HasKey("Id"); + + b.HasIndex("BookingId") + .IsUnique(); + + b.HasIndex("InvoiceNumber") + .IsUnique(); + + b.ToTable("Invoices", "payments"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Invoices.InvoiceNumberSequence", b => + { + b.Property("Id") + .HasColumnType("int"); + + b.Property("NextValue") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.ToTable("InvoiceNumberSequences", "payments"); + + b.HasData( + new + { + Id = 1, + NextValue = 1L + }); + }); + + 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.Refunds.NurseClawback", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AmountIrr") + .HasColumnType("bigint"); + + b.Property("BookingId") + .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("NurseId") + .HasColumnType("bigint"); + + b.Property("OriginalPayoutId") + .HasColumnType("bigint"); + + b.Property("RecoveredInPayoutId") + .HasColumnType("bigint"); + + b.Property("RefundId") + .HasColumnType("bigint"); + + b.Property("ResolutionNotes") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("ResolvedAt") + .HasColumnType("datetime2"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("nvarchar(30)"); + + b.HasKey("Id"); + + b.HasIndex("BookingId"); + + b.HasIndex("NurseId"); + + b.HasIndex("OriginalPayoutId"); + + b.HasIndex("RecoveredInPayoutId"); + + b.HasIndex("RefundId") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("NurseClawbacks", "payments"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Refunds.Refund", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AdminNotes") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("Amount") + .HasColumnType("bigint"); + + b.Property("ApprovedByAdminId") + .HasColumnType("int"); + + b.Property("BookingId") + .HasColumnType("bigint"); + + b.Property("CancellationPolicyCode") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ExpectedCustomerRefundEta") + .HasColumnType("date"); + + b.Property("ExternalRevertReference") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("GatewayRefundReference") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NursePayoutRefundedIrr") + .HasColumnType("bigint"); + + b.Property("PaymentTransactionId") + .HasColumnType("bigint"); + + b.Property("PlatformFeeRefundedIrr") + .HasColumnType("bigint"); + + b.Property("ProcessedAt") + .HasColumnType("datetime2"); + + b.Property("ReasonCategory") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ReasonNotes") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("RefundChannel") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("RefundPercentage") + .HasPrecision(6, 4) + .HasColumnType("decimal(6,4)"); + + b.Property("RefundPercentageApplied") + .HasPrecision(5, 2) + .HasColumnType("decimal(5,2)"); + + b.Property("RejectedReason") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("RequestedByCustomerId") + .HasColumnType("bigint"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("TicketId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("BookingId"); + + b.HasIndex("PaymentTransactionId"); + + b.HasIndex("RequestedByCustomerId"); + + b.HasIndex("Status"); + + b.HasIndex("TicketId"); + + b.ToTable("Refunds", "payments", t => + { + t.HasCheckConstraint("CK_Refunds_LegSplit", "[Amount] = [PlatformFeeRefundedIrr] + [NursePayoutRefundedIrr] AND [Amount] >= 0 AND [PlatformFeeRefundedIrr] >= 0 AND [NursePayoutRefundedIrr] >= 0"); + }); + }); + + 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.Invoices.Invoice", b => + { + b.HasOne("Baya.Domain.Entities.Booking.Booking", null) + .WithMany() + .HasForeignKey("BookingId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + 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.Refunds.NurseClawback", b => + { + b.HasOne("Baya.Domain.Entities.Booking.Booking", null) + .WithMany() + .HasForeignKey("BookingId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", null) + .WithMany() + .HasForeignKey("NurseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Refunds.Refund", null) + .WithMany() + .HasForeignKey("RefundId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Refunds.Refund", b => + { + b.HasOne("Baya.Domain.Entities.Booking.Booking", null) + .WithMany() + .HasForeignKey("BookingId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Payments.PaymentTransaction", null) + .WithMany() + .HasForeignKey("PaymentTransactionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Identity.CustomerProfile", null) + .WithMany() + .HasForeignKey("RequestedByCustomerId") + .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/20260708220745_RefundsClawbacksInvoices.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260708220745_RefundsClawbacksInvoices.cs new file mode 100644 index 0000000..64086c5 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260708220745_RefundsClawbacksInvoices.cs @@ -0,0 +1,313 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional + +namespace Baya.Infrastructure.Persistence.Migrations +{ + /// + public partial class RefundsClawbacksInvoices : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "InvoiceNumberSequences", + schema: "payments", + columns: table => new + { + Id = table.Column(type: "int", nullable: false), + NextValue = table.Column(type: "bigint", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_InvoiceNumberSequences", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Invoices", + schema: "payments", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + BookingId = table.Column(type: "bigint", nullable: false), + InvoiceNumber = table.Column(type: "nvarchar(40)", maxLength: 40, nullable: false), + IssuingEntityType = table.Column(type: "nvarchar(20)", maxLength: 20, nullable: false), + PartnerCenterId = table.Column(type: "bigint", nullable: true), + GrossIrr = table.Column(type: "bigint", nullable: false), + PlatformCommissionIrr = table.Column(type: "bigint", nullable: false), + BnplCommissionIrr = table.Column(type: "bigint", nullable: true), + VatRate = table.Column(type: "decimal(5,4)", precision: 5, scale: 4, nullable: false), + VatIrr = table.Column(type: "bigint", nullable: false), + MoadianReferenceNumber = table.Column(type: "nvarchar(40)", maxLength: 40, nullable: true), + MoadianStatus = table.Column(type: "nvarchar(20)", maxLength: 20, nullable: true), + PdfStorageKey = table.Column(type: "nvarchar(512)", maxLength: 512, nullable: true), + IssuedAt = table.Column(type: "datetime2", 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_Invoices", x => x.Id); + table.ForeignKey( + name: "FK_Invoices_Bookings_BookingId", + column: x => x.BookingId, + principalSchema: "booking", + principalTable: "Bookings", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "Refunds", + schema: "payments", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + PaymentTransactionId = table.Column(type: "bigint", nullable: false), + BookingId = table.Column(type: "bigint", nullable: false), + RequestedByCustomerId = table.Column(type: "bigint", nullable: false), + TicketId = table.Column(type: "bigint", nullable: true), + Amount = table.Column(type: "bigint", nullable: false), + PlatformFeeRefundedIrr = table.Column(type: "bigint", nullable: false), + NursePayoutRefundedIrr = table.Column(type: "bigint", nullable: false), + RefundPercentage = table.Column(type: "decimal(6,4)", precision: 6, scale: 4, nullable: false), + RefundChannel = table.Column(type: "nvarchar(20)", maxLength: 20, nullable: false), + ReasonCategory = table.Column(type: "nvarchar(50)", maxLength: 50, nullable: true), + ReasonNotes = table.Column(type: "nvarchar(1000)", maxLength: 1000, nullable: true), + Status = table.Column(type: "nvarchar(20)", maxLength: 20, nullable: false), + ApprovedByAdminId = table.Column(type: "int", nullable: true), + RejectedReason = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: true), + AdminNotes = table.Column(type: "nvarchar(1000)", maxLength: 1000, nullable: true), + GatewayRefundReference = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: true), + ExternalRevertReference = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: true), + ExpectedCustomerRefundEta = table.Column(type: "date", nullable: true), + CancellationPolicyCode = table.Column(type: "nvarchar(50)", maxLength: 50, nullable: true), + RefundPercentageApplied = table.Column(type: "decimal(5,2)", precision: 5, scale: 2, nullable: true), + ProcessedAt = table.Column(type: "datetime2", 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_Refunds", x => x.Id); + table.CheckConstraint("CK_Refunds_LegSplit", "[Amount] = [PlatformFeeRefundedIrr] + [NursePayoutRefundedIrr] AND [Amount] >= 0 AND [PlatformFeeRefundedIrr] >= 0 AND [NursePayoutRefundedIrr] >= 0"); + table.ForeignKey( + name: "FK_Refunds_Bookings_BookingId", + column: x => x.BookingId, + principalSchema: "booking", + principalTable: "Bookings", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_Refunds_CustomerProfiles_RequestedByCustomerId", + column: x => x.RequestedByCustomerId, + principalSchema: "usr", + principalTable: "CustomerProfiles", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_Refunds_PaymentTransactions_PaymentTransactionId", + column: x => x.PaymentTransactionId, + principalSchema: "payments", + principalTable: "PaymentTransactions", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "NurseClawbacks", + schema: "payments", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + NurseId = table.Column(type: "bigint", nullable: false), + BookingId = table.Column(type: "bigint", nullable: false), + RefundId = table.Column(type: "bigint", nullable: false), + OriginalPayoutId = table.Column(type: "bigint", nullable: true), + AmountIrr = table.Column(type: "bigint", nullable: false), + Status = table.Column(type: "nvarchar(30)", maxLength: 30, nullable: false), + RecoveredInPayoutId = table.Column(type: "bigint", nullable: true), + ResolvedAt = table.Column(type: "datetime2", nullable: true), + ResolutionNotes = table.Column(type: "nvarchar(500)", maxLength: 500, 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_NurseClawbacks", x => x.Id); + table.ForeignKey( + name: "FK_NurseClawbacks_Bookings_BookingId", + column: x => x.BookingId, + principalSchema: "booking", + principalTable: "Bookings", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_NurseClawbacks_NurseProfiles_NurseId", + column: x => x.NurseId, + principalSchema: "usr", + principalTable: "NurseProfiles", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_NurseClawbacks_Refunds_RefundId", + column: x => x.RefundId, + principalSchema: "payments", + principalTable: "Refunds", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.InsertData( + schema: "payments", + table: "InvoiceNumberSequences", + columns: new[] { "Id", "NextValue" }, + values: new object[] { 1, 1L }); + + migrationBuilder.InsertData( + schema: "ops", + table: "PlatformConfigs", + columns: new[] { "Id", "CreatedAt", "CreatedById", "DataType", "Description", "Key", "ModifiedAt", "ModifiedById", "Value" }, + values: new object[,] + { + { 19L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, "bool", "Whether a refund must link a support ticket (b11). Off until b15 ships the tickets table.", "refund_ticket_required", null, null, "false" }, + { 20L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, "int", "Business days shown as the customer BNPL refund ETA (b11).", "bnpl_refund_eta_business_days", null, null, "10" }, + { 21L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, "bool", "Ops/testing override that forces the post-payout clawback path for refunds (b11); b13 replaces the derivation.", "refund_assume_nurse_paid", null, null, "false" } + }); + + migrationBuilder.CreateIndex( + name: "IX_Invoices_BookingId", + schema: "payments", + table: "Invoices", + column: "BookingId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Invoices_InvoiceNumber", + schema: "payments", + table: "Invoices", + column: "InvoiceNumber", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_NurseClawbacks_BookingId", + schema: "payments", + table: "NurseClawbacks", + column: "BookingId"); + + migrationBuilder.CreateIndex( + name: "IX_NurseClawbacks_NurseId", + schema: "payments", + table: "NurseClawbacks", + column: "NurseId"); + + migrationBuilder.CreateIndex( + name: "IX_NurseClawbacks_OriginalPayoutId", + schema: "payments", + table: "NurseClawbacks", + column: "OriginalPayoutId"); + + migrationBuilder.CreateIndex( + name: "IX_NurseClawbacks_RecoveredInPayoutId", + schema: "payments", + table: "NurseClawbacks", + column: "RecoveredInPayoutId"); + + migrationBuilder.CreateIndex( + name: "IX_NurseClawbacks_RefundId", + schema: "payments", + table: "NurseClawbacks", + column: "RefundId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_NurseClawbacks_Status", + schema: "payments", + table: "NurseClawbacks", + column: "Status"); + + migrationBuilder.CreateIndex( + name: "IX_Refunds_BookingId", + schema: "payments", + table: "Refunds", + column: "BookingId"); + + migrationBuilder.CreateIndex( + name: "IX_Refunds_PaymentTransactionId", + schema: "payments", + table: "Refunds", + column: "PaymentTransactionId"); + + migrationBuilder.CreateIndex( + name: "IX_Refunds_RequestedByCustomerId", + schema: "payments", + table: "Refunds", + column: "RequestedByCustomerId"); + + migrationBuilder.CreateIndex( + name: "IX_Refunds_Status", + schema: "payments", + table: "Refunds", + column: "Status"); + + migrationBuilder.CreateIndex( + name: "IX_Refunds_TicketId", + schema: "payments", + table: "Refunds", + column: "TicketId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "InvoiceNumberSequences", + schema: "payments"); + + migrationBuilder.DropTable( + name: "Invoices", + schema: "payments"); + + migrationBuilder.DropTable( + name: "NurseClawbacks", + schema: "payments"); + + migrationBuilder.DropTable( + name: "Refunds", + schema: "payments"); + + migrationBuilder.DeleteData( + schema: "ops", + table: "PlatformConfigs", + keyColumn: "Id", + keyValue: 19L); + + migrationBuilder.DeleteData( + schema: "ops", + table: "PlatformConfigs", + keyColumn: "Id", + keyValue: 20L); + + migrationBuilder.DeleteData( + schema: "ops", + table: "PlatformConfigs", + keyColumn: "Id", + keyValue: 21L); + } + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/ApplicationDbContextModelSnapshot.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/ApplicationDbContextModelSnapshot.cs index 158c4ec..eab920c 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/ApplicationDbContextModelSnapshot.cs @@ -1155,6 +1155,33 @@ namespace Baya.Infrastructure.Persistence.Migrations 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" + }, + new + { + Id = 19L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "bool", + Description = "Whether a refund must link a support ticket (b11). Off until b15 ships the tickets table.", + Key = "refund_ticket_required", + Value = "false" + }, + new + { + Id = 20L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Business days shown as the customer BNPL refund ETA (b11).", + Key = "bnpl_refund_eta_business_days", + Value = "10" + }, + new + { + Id = 21L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "bool", + Description = "Ops/testing override that forces the post-payout clawback path for refunds (b11); b13 replaces the derivation.", + Key = "refund_assume_nurse_paid", + Value = "false" }); }); @@ -2630,6 +2657,107 @@ namespace Baya.Infrastructure.Persistence.Migrations b.ToTable("Patients", "usr"); }); + modelBuilder.Entity("Baya.Domain.Entities.Invoices.Invoice", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BnplCommissionIrr") + .HasColumnType("bigint"); + + b.Property("BookingId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("GrossIrr") + .HasColumnType("bigint"); + + b.Property("InvoiceNumber") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("IssuedAt") + .HasColumnType("datetime2"); + + b.Property("IssuingEntityType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("MoadianReferenceNumber") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("MoadianStatus") + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("PartnerCenterId") + .HasColumnType("bigint"); + + b.Property("PdfStorageKey") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("PlatformCommissionIrr") + .HasColumnType("bigint"); + + b.Property("VatIrr") + .HasColumnType("bigint"); + + b.Property("VatRate") + .HasPrecision(5, 4) + .HasColumnType("decimal(5,4)"); + + b.HasKey("Id"); + + b.HasIndex("BookingId") + .IsUnique(); + + b.HasIndex("InvoiceNumber") + .IsUnique(); + + b.ToTable("Invoices", "payments"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Invoices.InvoiceNumberSequence", b => + { + b.Property("Id") + .HasColumnType("int"); + + b.Property("NextValue") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.ToTable("InvoiceNumberSequences", "payments"); + + b.HasData( + new + { + Id = 1, + NextValue = 1L + }); + }); + modelBuilder.Entity("Baya.Domain.Entities.Notifications.Notification", b => { b.Property("Id") @@ -2949,6 +3077,194 @@ namespace Baya.Infrastructure.Persistence.Migrations b.ToTable("PaymentWebhookEvents", "payments"); }); + modelBuilder.Entity("Baya.Domain.Entities.Refunds.NurseClawback", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AmountIrr") + .HasColumnType("bigint"); + + b.Property("BookingId") + .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("NurseId") + .HasColumnType("bigint"); + + b.Property("OriginalPayoutId") + .HasColumnType("bigint"); + + b.Property("RecoveredInPayoutId") + .HasColumnType("bigint"); + + b.Property("RefundId") + .HasColumnType("bigint"); + + b.Property("ResolutionNotes") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("ResolvedAt") + .HasColumnType("datetime2"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("nvarchar(30)"); + + b.HasKey("Id"); + + b.HasIndex("BookingId"); + + b.HasIndex("NurseId"); + + b.HasIndex("OriginalPayoutId"); + + b.HasIndex("RecoveredInPayoutId"); + + b.HasIndex("RefundId") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("NurseClawbacks", "payments"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Refunds.Refund", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AdminNotes") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("Amount") + .HasColumnType("bigint"); + + b.Property("ApprovedByAdminId") + .HasColumnType("int"); + + b.Property("BookingId") + .HasColumnType("bigint"); + + b.Property("CancellationPolicyCode") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ExpectedCustomerRefundEta") + .HasColumnType("date"); + + b.Property("ExternalRevertReference") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("GatewayRefundReference") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NursePayoutRefundedIrr") + .HasColumnType("bigint"); + + b.Property("PaymentTransactionId") + .HasColumnType("bigint"); + + b.Property("PlatformFeeRefundedIrr") + .HasColumnType("bigint"); + + b.Property("ProcessedAt") + .HasColumnType("datetime2"); + + b.Property("ReasonCategory") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ReasonNotes") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("RefundChannel") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("RefundPercentage") + .HasPrecision(6, 4) + .HasColumnType("decimal(6,4)"); + + b.Property("RefundPercentageApplied") + .HasPrecision(5, 2) + .HasColumnType("decimal(5,2)"); + + b.Property("RejectedReason") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("RequestedByCustomerId") + .HasColumnType("bigint"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("TicketId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("BookingId"); + + b.HasIndex("PaymentTransactionId"); + + b.HasIndex("RequestedByCustomerId"); + + b.HasIndex("Status"); + + b.HasIndex("TicketId"); + + b.ToTable("Refunds", "payments", t => + { + t.HasCheckConstraint("CK_Refunds_LegSplit", "[Amount] = [PlatformFeeRefundedIrr] + [NursePayoutRefundedIrr] AND [Amount] >= 0 AND [PlatformFeeRefundedIrr] >= 0 AND [NursePayoutRefundedIrr] >= 0"); + }); + }); + modelBuilder.Entity("Baya.Domain.Entities.Search.NurseSearchIndex", b => { b.Property("Id") @@ -4186,6 +4502,15 @@ namespace Baya.Infrastructure.Persistence.Migrations b.Navigation("Customer"); }); + modelBuilder.Entity("Baya.Domain.Entities.Invoices.Invoice", b => + { + b.HasOne("Baya.Domain.Entities.Booking.Booking", null) + .WithMany() + .HasForeignKey("BookingId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + modelBuilder.Entity("Baya.Domain.Entities.Notifications.Notification", b => { b.HasOne("Baya.Domain.Entities.User.User", null) @@ -4231,6 +4556,48 @@ namespace Baya.Infrastructure.Persistence.Migrations .IsRequired(); }); + modelBuilder.Entity("Baya.Domain.Entities.Refunds.NurseClawback", b => + { + b.HasOne("Baya.Domain.Entities.Booking.Booking", null) + .WithMany() + .HasForeignKey("BookingId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", null) + .WithMany() + .HasForeignKey("NurseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Refunds.Refund", null) + .WithMany() + .HasForeignKey("RefundId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Refunds.Refund", b => + { + b.HasOne("Baya.Domain.Entities.Booking.Booking", null) + .WithMany() + .HasForeignKey("BookingId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Payments.PaymentTransaction", null) + .WithMany() + .HasForeignKey("PaymentTransactionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Identity.CustomerProfile", null) + .WithMany() + .HasForeignKey("RequestedByCustomerId") + .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/Common/UnitOfWork.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/Common/UnitOfWork.cs index 333d8a0..047fda9 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/Common/UnitOfWork.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/Common/UnitOfWork.cs @@ -23,6 +23,8 @@ public class UnitOfWork : IUnitOfWork public IBookingRepository BookingRepository { get; } public ICancellationPolicyRepository CancellationPolicyRepository { get; } public IPaymentRepository PaymentRepository { get; } + public IRefundRepository RefundRepository { get; } + public IInvoiceRepository InvoiceRepository { get; } public UnitOfWork(ApplicationDbContext db) { @@ -44,6 +46,8 @@ public class UnitOfWork : IUnitOfWork BookingRepository = new BookingRepository(_db); CancellationPolicyRepository = new CancellationPolicyRepository(_db); PaymentRepository = new PaymentRepository(_db); + RefundRepository = new RefundRepository(_db); + InvoiceRepository = new InvoiceRepository(_db); } public Task CommitAsync() diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/InvoiceRepository.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/InvoiceRepository.cs new file mode 100644 index 0000000..d7bf364 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/InvoiceRepository.cs @@ -0,0 +1,67 @@ +#nullable enable +using Baya.Application.Contracts.Persistence; +using Baya.Application.Models.Invoices; +using Baya.Domain.Entities.Booking; +using Baya.Domain.Entities.Identity; +using Baya.Domain.Entities.Invoices; +using Baya.Infrastructure.Persistence.Repositories.Common; +using Microsoft.EntityFrameworkCore; + +namespace Baya.Infrastructure.Persistence.Repositories; + +internal sealed class InvoiceRepository : BaseAsyncRepository, IInvoiceRepository +{ + public InvoiceRepository(ApplicationDbContext dbContext) : base(dbContext) + { + } + + public Task GetBookingAmountsAsync(long bookingId, CancellationToken cancellationToken) + => (from b in DbContext.Set().AsNoTracking() + where b.Id == bookingId + join c in DbContext.Set() on b.CustomerId equals c.Id + select new InvoiceBookingAmounts( + b.Id, + c.UserId, + b.GrossPriceIrr, + b.BalinyaarCommissionIrr, + (long?)null)) + .FirstOrDefaultAsync(cancellationToken); + + public Task GetTrackedByBookingIdAsync(long bookingId, CancellationToken cancellationToken) + => Table.FirstOrDefaultAsync(i => i.BookingId == bookingId, cancellationToken); + + public async Task GetByBookingIdAsync(long bookingId, CancellationToken cancellationToken) + { + var row = await (from i in TableNoTracking + where i.BookingId == bookingId + join b in DbContext.Set() on i.BookingId equals b.Id + join c in DbContext.Set() on b.CustomerId equals c.Id + select new { CustomerUserId = c.UserId, Invoice = i }) + .FirstOrDefaultAsync(cancellationToken); + + if (row is null) + return null; + + // The PDF URL is resolved by the handler through IObjectStorage — the repo carries the raw key. + var dto = new InvoiceDto( + row.Invoice.Id, row.Invoice.BookingId, row.Invoice.InvoiceNumber, row.Invoice.IssuingEntityType, + row.Invoice.GrossIrr.ToString(), row.Invoice.PlatformCommissionIrr.ToString(), + row.Invoice.BnplCommissionIrr?.ToString(), row.Invoice.VatRate, row.Invoice.VatIrr.ToString(), + row.Invoice.MoadianReferenceNumber, row.Invoice.MoadianStatus, PdfUrl: null, row.Invoice.IssuedAt); + + return new InvoiceProjection(row.CustomerUserId, row.Invoice.PdfStorageKey, dto); + } + + public Task AddInvoiceAsync(Invoice invoice, CancellationToken cancellationToken) + => base.AddAsync(invoice); + + public async Task ReserveNextInvoiceNumberAsync(CancellationToken cancellationToken) + { + var counter = await DbContext.Set().FirstOrDefaultAsync(s => s.Id == 1, cancellationToken) + ?? throw new InvalidOperationException("The invoice-number counter row is missing."); + + var reserved = counter.NextValue; + counter.NextValue = reserved + 1; + return reserved; + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/RefundRepository.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/RefundRepository.cs new file mode 100644 index 0000000..07d431e --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/RefundRepository.cs @@ -0,0 +1,144 @@ +#nullable enable +using Baya.Application.Contracts.Persistence; +using Baya.Application.Models.Common; +using Baya.Application.Models.Refunds; +using Baya.Domain.Entities.Booking; +using Baya.Domain.Entities.Identity; +using Baya.Domain.Entities.Payments; +using Baya.Domain.Entities.Refunds; +using Baya.Infrastructure.Persistence.Repositories.Common; +using Microsoft.EntityFrameworkCore; + +namespace Baya.Infrastructure.Persistence.Repositories; + +internal sealed class RefundRepository : BaseAsyncRepository, IRefundRepository +{ + public RefundRepository(ApplicationDbContext dbContext) : base(dbContext) + { + } + + public Task GetRefundContextAsync(long bookingId, CancellationToken cancellationToken) + => (from t in DbContext.Set().AsNoTracking() + where t.BookingId == bookingId && t.Status == PaymentTransactionStatus.Succeeded + join b in DbContext.Set() on t.BookingId equals b.Id + join c in DbContext.Set() on b.CustomerId equals c.Id + join g in DbContext.Set() on t.GatewayId equals g.Id + select new RefundMoneyContext( + b.Id, + b.CustomerId, + c.UserId, + b.NurseId, + b.GrossPriceIrr, + b.BalinyaarCommissionIrr, + b.NursePayoutAmount, + b.CancellationPolicyCode, + b.CancellationRefundPercentage, + b.RefundableAmountIrr, + t.Id, + t.GatewayReferenceCode, + t.Amount, + g.Type)) + .FirstOrDefaultAsync(cancellationToken); + + public async Task GetRefundedSumForTransactionAsync(long paymentTransactionId, CancellationToken cancellationToken) + => await TableNoTracking + .Where(r => r.PaymentTransactionId == paymentTransactionId + && r.Status != RefundStatus.Failed && r.Status != RefundStatus.Rejected) + .SumAsync(r => (long?)r.Amount, cancellationToken) ?? 0; + + public Task AddRefundAsync(Refund refund, CancellationToken cancellationToken) + => base.AddAsync(refund); + + public Task AddClawbackAsync(NurseClawback clawback, CancellationToken cancellationToken) + => DbContext.Set().AddAsync(clawback, cancellationToken).AsTask(); + + public Task GetTrackedClawbackByIdAsync(long id, CancellationToken cancellationToken) + => DbContext.Set().FirstOrDefaultAsync(c => c.Id == id, cancellationToken); + + public async Task> ListAsync(long? bookingId, string? status, int page, int pageSize, CancellationToken cancellationToken) + { + var query = TableNoTracking; + if (bookingId is { } bid) + query = query.Where(r => r.BookingId == bid); + if (!string.IsNullOrWhiteSpace(status)) + query = query.Where(r => r.Status == status); + + var total = await query.CountAsync(cancellationToken); + + var rows = await query + .OrderByDescending(r => r.Id) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .Select(r => new + { + r.Id, + r.BookingId, + r.PaymentTransactionId, + r.Amount, + r.PlatformFeeRefundedIrr, + r.NursePayoutRefundedIrr, + r.RefundChannel, + r.Status, + r.RefundPercentage, + r.ReasonCategory, + r.CancellationPolicyCode, + r.RefundPercentageApplied, + r.ExpectedCustomerRefundEta, + r.GatewayRefundReference, + r.ExternalRevertReference, + r.ProcessedAt, + r.CreatedAt + }) + .ToListAsync(cancellationToken); + + var items = rows + .Select(r => new RefundListItemDto( + r.Id, r.BookingId, r.PaymentTransactionId, + r.Amount.ToString(), r.PlatformFeeRefundedIrr.ToString(), r.NursePayoutRefundedIrr.ToString(), + r.RefundChannel, r.Status, r.RefundPercentage, r.ReasonCategory, + r.CancellationPolicyCode, r.RefundPercentageApplied, r.ExpectedCustomerRefundEta, + r.GatewayRefundReference, r.ExternalRevertReference, r.ProcessedAt, r.CreatedAt)) + .ToList(); + + return new PagedResult(items, total, page, pageSize); + } + + public async Task GetStatusAsync(long id, CancellationToken cancellationToken) + { + var row = await (from r in TableNoTracking + where r.Id == id + join b in DbContext.Set() on r.BookingId equals b.Id + join c in DbContext.Set() on b.CustomerId equals c.Id + select new + { + c.UserId, + r.Id, + r.BookingId, + r.Status, + r.RefundChannel, + r.Amount, + r.ExpectedCustomerRefundEta, + r.GatewayRefundReference, + r.ExternalRevertReference + }) + .FirstOrDefaultAsync(cancellationToken); + + if (row is null) + return null; + + var reference = Mask(row.GatewayRefundReference ?? row.ExternalRevertReference); + var dto = new RefundStatusDto(row.Id, row.BookingId, row.Status, row.RefundChannel, row.Amount.ToString(), + row.ExpectedCustomerRefundEta, reference); + return new RefundStatusProjection(row.UserId, dto); + } + + // Show only the last 4 characters of an external reference to the customer — never the full PSP/BNPL id. + private static string? Mask(string? reference) + { + if (string.IsNullOrEmpty(reference)) + return reference; + return reference.Length <= 4 + ? new string('•', reference.Length) + : $"{new string('•', reference.Length - 4)}{reference[^4..]}"; + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/ServiceConfiguration/ServiceCollectionExtensions.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/ServiceConfiguration/ServiceCollectionExtensions.cs index a881094..63e125e 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Persistence/ServiceConfiguration/ServiceCollectionExtensions.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/ServiceConfiguration/ServiceCollectionExtensions.cs @@ -4,6 +4,7 @@ using Baya.Application.Contracts.Common; using Baya.Application.Contracts.Configuration; using Baya.Application.Contracts.Holidays; using Baya.Application.Contracts.Notifications; +using Baya.Application.Contracts.Payments; using Baya.Application.Contracts.Persistence; using Baya.Application.Contracts.Search; using Baya.Application.Contracts.SupportAlerts; @@ -16,6 +17,7 @@ using Baya.Infrastructure.Persistence.Services.Booking; using Baya.Infrastructure.Persistence.Services.Configuration; using Baya.Infrastructure.Persistence.Services.Holidays; using Baya.Infrastructure.Persistence.Services.Notifications; +using Baya.Infrastructure.Persistence.Services.Payments; using Baya.Infrastructure.Persistence.Services.Search; using Baya.Infrastructure.Persistence.Services.SupportAlerts; using Microsoft.AspNetCore.Builder; @@ -52,6 +54,11 @@ public static class ServiceCollectionExtensions // Supersedes the b0 log/no-op stub with the real in-app notifications write. services.AddScoped(); + // "Has the nurse been paid?" — the refund pre-payout/clawback fork (b11). DB-backed because b13's real + // impl reads nurse_payout_booking_links; until then it derives from the dispute-window close. b13 swaps + // this registration for the authoritative payout-link lookup. + services.AddScoped(); + // Retention job seam (mock = in-process interval runner; real Hangfire/Quartz deferred). services.AddHostedService(); diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Payments/NursePayoutStatusService.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Payments/NursePayoutStatusService.cs new file mode 100644 index 0000000..88e1f6b --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Payments/NursePayoutStatusService.cs @@ -0,0 +1,35 @@ +#nullable enable +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Configuration; +using Baya.Application.Contracts.Payments; +using Microsoft.EntityFrameworkCore; +using BookingEntity = Baya.Domain.Entities.Booking.Booking; + +namespace Baya.Infrastructure.Persistence.Services.Payments; + +/// +/// The interim implementation of until b13 ships nurse_payouts / +/// nurse_payout_booking_links. It derives "already paid?" from the booking's dispute-window close — the +/// exact gate b13 pays out on — so the pre-payout (clean reversal) path is the common one and the clawback path +/// is the fallback. A refund_assume_nurse_paid config switch forces the paid answer for ops/testing. b13 +/// swaps this registration for the authoritative payout-link lookup. +/// +internal sealed class NursePayoutStatusService( + ApplicationDbContext dbContext, + IDateTimeProvider dateTimeProvider, + IPlatformConfig platformConfig) : INursePayoutStatus +{ + public async ValueTask IsNursePaidForBookingAsync(long bookingId, CancellationToken cancellationToken = default) + { + if (await platformConfig.GetConfig("refund_assume_nurse_paid", cancellationToken)) + return true; + + var now = dateTimeProvider.UtcNow.UtcDateTime; + var windowEnd = await dbContext.Set().AsNoTracking() + .Where(b => b.Id == bookingId) + .Select(b => b.DisputeWindowEndsAt) + .FirstOrDefaultAsync(cancellationToken); + + return windowEnd is { } endsAt && endsAt <= now; + } +} diff --git a/server/src/Tests/Baya.Test.Api/AdminInvoicesApiTests.cs b/server/src/Tests/Baya.Test.Api/AdminInvoicesApiTests.cs new file mode 100644 index 0000000..4dec090 --- /dev/null +++ b/server/src/Tests/Baya.Test.Api/AdminInvoicesApiTests.cs @@ -0,0 +1,41 @@ +using System.Net; +using System.Net.Http.Json; + +namespace Baya.Test.Api; + +public class AdminInvoicesApiTests(BayaApiFactory factory) : IClassFixture +{ + [Fact] + public async Task Issue_Unauthenticated_Returns401() + { + var client = factory.CreateClient(); + var response = await client.PostAsJsonAsync("/api/v1/admin_invoices", new { bookingId = 1 }); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task Issue_computes_vat_on_commission_and_returns_sequential_number() + { + var admin = factory.CreateClient(); + await AdminTestClient.AuthenticateAsync(factory, admin, "09131901501"); + var bookingId = await AdminRefundsApiTests.SeedCapturedBookingAsync(factory, "09131901502"); + + var issue = await admin.PostAsJsonAsync("/api/v1/admin_invoices", new { bookingId }); + Assert.Equal(HttpStatusCode.OK, issue.StatusCode); + var data = await AuthTestClient.ReadDataAsync(issue); + + Assert.False(string.IsNullOrEmpty(data.GetProperty("invoiceNumber").GetString())); + Assert.Equal("1500000", data.GetProperty("platformCommissionIrr").GetString()); + Assert.Equal("150000", data.GetProperty("vatIrr").GetString()); // 10% of the commission line + Assert.Equal("pending", data.GetProperty("moadianStatus").GetString()); + + // Re-issue is idempotent — same number, no second invoice. + var reissue = await admin.PostAsJsonAsync("/api/v1/admin_invoices", new { bookingId }); + var reissued = await AuthTestClient.ReadDataAsync(reissue); + Assert.Equal(data.GetProperty("invoiceNumber").GetString(), reissued.GetProperty("invoiceNumber").GetString()); + + // The booking's invoice is readable via the customer/admin GET. + var get = await admin.GetAsync($"/api/v1/invoices/{bookingId}"); + Assert.Equal(HttpStatusCode.OK, get.StatusCode); + } +} diff --git a/server/src/Tests/Baya.Test.Api/AdminRefundsApiTests.cs b/server/src/Tests/Baya.Test.Api/AdminRefundsApiTests.cs new file mode 100644 index 0000000..9a42997 --- /dev/null +++ b/server/src/Tests/Baya.Test.Api/AdminRefundsApiTests.cs @@ -0,0 +1,175 @@ +using System.Net; +using System.Net.Http.Json; +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 AdminRefundsApiTests(BayaApiFactory factory) : IClassFixture +{ + [Fact] + public async Task Create_Unauthenticated_Returns401() + { + var client = factory.CreateClient(); + var response = await client.PostAsJsonAsync("/api/v1/admin_refunds", new { bookingId = 1, refundPercentage = 1m }); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task Create_InvalidBookingId_Returns400() + { + var admin = factory.CreateClient(); + await AdminTestClient.AuthenticateAsync(factory, admin, "09131901401"); + + var response = await admin.PostAsJsonAsync("/api/v1/admin_refunds", new { bookingId = 0, refundPercentage = 1m }); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task Create_full_refund_posts_balanced_reversal_then_over_refund_conflicts() + { + var admin = factory.CreateClient(); + await AdminTestClient.AuthenticateAsync(factory, admin, "09131901402"); + var bookingId = await SeedCapturedBookingAsync(factory, "09131901403"); + + var create = await admin.PostAsJsonAsync("/api/v1/admin_refunds", new { bookingId, refundPercentage = 1m }); + Assert.Equal(HttpStatusCode.OK, create.StatusCode); + var data = await AuthTestClient.ReadDataAsync(create); + Assert.Equal("succeeded", data.GetProperty("status").GetString()); + Assert.Equal("10000000", data.GetProperty("amount").GetString()); + + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var legs = db.Set().AsNoTracking() + .Where(l => l.BookingId == bookingId && l.SourceRefType == LedgerSourceRefType.Refund).ToList(); + Assert.Equal(5, 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)); + } + + // A second refund on the fully-refunded booking pushes over captured → 409. + var again = await admin.PostAsJsonAsync("/api/v1/admin_refunds", new { bookingId, refundPercentage = 0.5m }); + Assert.Equal(HttpStatusCode.Conflict, again.StatusCode); + } + + [Fact] + public async Task List_AsAdmin_ReturnsPagedEnvelope() + { + var admin = factory.CreateClient(); + await AdminTestClient.AuthenticateAsync(factory, admin, "09131901404"); + + var response = await admin.GetAsync("/api/v1/admin_refunds?status=succeeded&page=1&pageSize=20"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var data = await AuthTestClient.ReadDataAsync(response); + Assert.True(data.GetProperty("total").GetInt32() >= 0); + } + + /// Seeds a confirmed booking (gross 10M, commission 1.5M) with a captured card transaction, owned + /// by the given customer phone. Returns the booking id. + internal static async Task SeedCapturedBookingAsync(BayaApiFactory factory, string customerPhone) + { + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var userManager = scope.ServiceProvider.GetRequiredService(); + + var customerUser = await userManager.GetUserByPhoneNumber(customerPhone); + if (customerUser is null) + { + await userManager.CreateUser(new User { UserName = $"cust_{Guid.NewGuid():N}", PhoneNumber = customerPhone }); + 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(); + } + + var province = new Province { NameFa = "ت", NameEn = "T", SortOrder = 1, IsActive = true }; + db.Set().Add(province); + db.SaveChanges(); + var city = new City { ProvinceId = province.Id, NameFa = "ت", NameEn = "T", SortOrder = 1, IsActive = true }; + db.Set().Add(city); + var category = new ServiceCategory { NameFa = "س", NameEn = "E", 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, 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 = 10_000_000, 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 = "n", 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(); + + var booking = new BookingEntity + { + BookingRequestId = request.Id, CustomerId = customer.Id, NurseId = nurse.Id, PatientId = patient.Id, + VariantId = variant.Id, CustomerAddressId = address.Id, VariantSnapshotJson = "{}", AddressSnapshotJson = "{}", + GrossPriceIrr = 10_000_000, BalinyaarCommissionIrr = 1_500_000, PlatformFeeRate = 0.15m, + NursePayoutAmount = 8_500_000, SessionCount = 1, + ScheduledDate = new DateOnly(2026, 8, 1), ScheduledTimeStart = new TimeOnly(9, 0), ScheduledTimeEnd = new TimeOnly(13, 0) + }; + booking.TransitionTo(BookingStatus.Confirmed, new DateTime(2026, 8, 1, 0, 0, 0, DateTimeKind.Utc)); + db.Set().Add(booking); + db.SaveChanges(); + + var gateway = new PaymentGateway { ProviderCode = "zarinpal", Type = PaymentGatewayType.Standard, DisplayName = "GW", ConfigJson = "{}", IsActive = true, Priority = 0 }; + db.Set().Add(gateway); + db.SaveChanges(); + + var txn = new PaymentTransaction + { + BookingRequestId = request.Id, CustomerId = customer.Id, GatewayId = gateway.Id, + Amount = 10_000_000, GatewayReferenceCode = $"ref-{Guid.NewGuid():N}" + }; + txn.MarkSucceeded(booking.Id, "ok", null); + db.Set().Add(txn); + db.SaveChanges(); + + return booking.Id; + } +} diff --git a/server/src/Tests/Baya.Test.Api/RefundStatusApiTests.cs b/server/src/Tests/Baya.Test.Api/RefundStatusApiTests.cs new file mode 100644 index 0000000..4ecc9c8 --- /dev/null +++ b/server/src/Tests/Baya.Test.Api/RefundStatusApiTests.cs @@ -0,0 +1,41 @@ +using System.Net; +using System.Net.Http.Json; + +namespace Baya.Test.Api; + +public class RefundStatusApiTests(BayaApiFactory factory) : IClassFixture +{ + [Fact] + public async Task RefundStatus_is_visible_to_owner_and_hidden_from_another_customer() + { + var admin = factory.CreateClient(); + await AdminTestClient.AuthenticateAsync(factory, admin, "09131901601"); + var bookingId = await AdminRefundsApiTests.SeedCapturedBookingAsync(factory, "09131901602"); + + var create = await admin.PostAsJsonAsync("/api/v1/admin_refunds", new { bookingId, refundPercentage = 1m }); + Assert.Equal(HttpStatusCode.OK, create.StatusCode); + var refundId = (await AuthTestClient.ReadDataAsync(create)).GetProperty("refundId").GetInt64(); + + // The owning customer sees their refund status + amount. + var owner = factory.CreateClient(); + await ProfileTestClient.AuthenticateAsync(factory, owner, "09131901602", "customer"); + var ownerResponse = await owner.GetAsync($"/api/v1/refunds/{refundId}/status"); + Assert.Equal(HttpStatusCode.OK, ownerResponse.StatusCode); + var ownerData = await AuthTestClient.ReadDataAsync(ownerResponse); + Assert.Equal("10000000", ownerData.GetProperty("amount").GetString()); + + // A different customer cannot — a cross-customer read is a clean not-found. + var other = factory.CreateClient(); + await ProfileTestClient.AuthenticateAsync(factory, other, "09131901603", "customer"); + var otherResponse = await other.GetAsync($"/api/v1/refunds/{refundId}/status"); + Assert.Equal(HttpStatusCode.NotFound, otherResponse.StatusCode); + } + + [Fact] + public async Task RefundStatus_Unauthenticated_Returns401() + { + var client = factory.CreateClient(); + var response = await client.GetAsync("/api/v1/refunds/1/status"); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } +} diff --git a/server/src/Tests/Baya.Test.Foundation/Refunds/InvoiceHandlerTests.cs b/server/src/Tests/Baya.Test.Foundation/Refunds/InvoiceHandlerTests.cs new file mode 100644 index 0000000..bb617c3 --- /dev/null +++ b/server/src/Tests/Baya.Test.Foundation/Refunds/InvoiceHandlerTests.cs @@ -0,0 +1,73 @@ +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Invoices; +using Baya.Application.Features.Invoices.Commands.IssueInvoice; +using Baya.Domain.Entities.Invoices; +using NSubstitute; + +namespace Baya.Test.Foundation.Refunds; + +public class InvoiceHandlerTests +{ + private static readonly DateTimeOffset Now = new(2026, 8, 10, 10, 0, 0, TimeSpan.Zero); + + private static IssueInvoiceCommandHandler Handler(RefundsTestHost host, decimal vatRate = 0.10m) + { + var moadian = Substitute.For(); + moadian.SubmitAsync(Arg.Any(), Arg.Any()) + .Returns(new MoadianSubmissionResult(MoadianStatus.Pending, null)); + + var storage = Substitute.For(); + + return new IssueInvoiceCommandHandler( + host.UnitOfWork, host.Config(vatRate: vatRate), host.Clock(Now), host.Lock(), moadian, storage); + } + + [Fact] + public async Task Issue_computes_vat_on_commission_and_numbers_sequentially() + { + using var host = new RefundsTestHost(); + var (bookingA, _) = host.SeedCapturedBooking(gross: 10_000_000, commission: 1_500_000); + var (bookingB, _) = host.SeedCapturedBooking(gross: 20_000_000, commission: 3_000_000); + var handler = Handler(host); + + var a = await handler.Handle(new IssueInvoiceCommand(bookingA), CancellationToken.None); + var b = await handler.Handle(new IssueInvoiceCommand(bookingB), CancellationToken.None); + + Assert.True(a.IsSuccess); + Assert.Equal("INV-0000000001", a.Result.InvoiceNumber); + Assert.Equal("1500000", a.Result.PlatformCommissionIrr); + Assert.Equal("150000", a.Result.VatIrr); // 10% of the commission line, integer-only + Assert.Equal(MoadianStatus.Pending, a.Result.MoadianStatus); + Assert.Null(a.Result.MoadianReferenceNumber); + + // Gap-free next number, and VAT on that booking's own commission. + Assert.Equal("INV-0000000002", b.Result.InvoiceNumber); + Assert.Equal("300000", b.Result.VatIrr); + } + + [Fact] + public async Task Reissue_is_idempotent_and_returns_the_same_invoice() + { + using var host = new RefundsTestHost(); + var (bookingId, _) = host.SeedCapturedBooking(); + var handler = Handler(host); + + var first = await handler.Handle(new IssueInvoiceCommand(bookingId), CancellationToken.None); + var second = await handler.Handle(new IssueInvoiceCommand(bookingId), CancellationToken.None); + + Assert.Equal(first.Result.Id, second.Result.Id); + Assert.Equal(first.Result.InvoiceNumber, second.Result.InvoiceNumber); + } + + [Fact] + public async Task Zero_vat_rate_yields_zero_vat() + { + using var host = new RefundsTestHost(); + var (bookingId, _) = host.SeedCapturedBooking(gross: 10_000_000, commission: 1_500_000); + + var result = await Handler(host, vatRate: 0m).Handle(new IssueInvoiceCommand(bookingId), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal("0", result.Result.VatIrr); + } +} diff --git a/server/src/Tests/Baya.Test.Foundation/Refunds/RefundHandlerTests.cs b/server/src/Tests/Baya.Test.Foundation/Refunds/RefundHandlerTests.cs new file mode 100644 index 0000000..7fbf756 --- /dev/null +++ b/server/src/Tests/Baya.Test.Foundation/Refunds/RefundHandlerTests.cs @@ -0,0 +1,138 @@ +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.SupportAlerts; +using Baya.Application.Features.Refunds.Commands.CreateRefund; +using Baya.Domain.Entities.Payments; +using Baya.Domain.Entities.Refunds; +using Microsoft.EntityFrameworkCore; +using NSubstitute; + +namespace Baya.Test.Foundation.Refunds; + +public class RefundHandlerTests +{ + private static readonly DateTimeOffset Now = new(2026, 8, 10, 10, 0, 0, TimeSpan.Zero); + + private static CreateRefundCommandHandler Handler(RefundsTestHost host, bool nursePaid) + => new( + host.UnitOfWork, host.Lock(), host.Config(), host.Clock(Now), host.AsAdmin(), + host.Card(), host.Bnpl(), host.PayoutStatus(nursePaid), + Substitute.For(), Substitute.For()); + + private static CreateRefundCommand FullRefund(long bookingId) + => new(bookingId, TicketId: null, RefundPercentage: 1m, null, null, "customer_request", null, null, null); + + [Fact] + public async Task PrePayout_full_refund_posts_balanced_reversal_and_clearing() + { + using var host = new RefundsTestHost(); + var (bookingId, _) = host.SeedCapturedBooking(gross: 10_000_000, commission: 1_500_000); + + var result = await Handler(host, nursePaid: false).Handle(FullRefund(bookingId), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal(RefundStatus.Succeeded, result.Result.Status); + Assert.Equal("10000000", result.Result.Amount); + Assert.Equal("1500000", result.Result.PlatformFeeRefundedIrr); + Assert.Equal("8500000", result.Result.NursePayoutRefundedIrr); + Assert.Null(result.Result.ClawbackId); + + var legs = host.LedgerFor(bookingId); + Assert.Equal(5, legs.Count); // reversal (3) + clearing (2) + Assert.Equal( + legs.Where(l => l.Direction == LedgerDirection.Debit).Sum(l => l.AmountIrr), + legs.Where(l => l.Direction == LedgerDirection.Credit).Sum(l => l.AmountIrr)); + + Assert.Equal(1_500_000, Leg(legs, LedgerAccountType.PlatformRevenue, LedgerDirection.Debit)); + Assert.Equal(8_500_000, Leg(legs, LedgerAccountType.NursePayable, LedgerDirection.Debit)); + Assert.Equal(10_000_000, Leg(legs, LedgerAccountType.RefundPayable, LedgerDirection.Credit)); + Assert.Equal(10_000_000, Leg(legs, LedgerAccountType.RefundPayable, LedgerDirection.Debit)); + Assert.Equal(10_000_000, Leg(legs, LedgerAccountType.EscrowHeld, LedgerDirection.Credit)); + + // No clawback pre-payout. + Assert.Empty(host.Db.Set().AsNoTracking().ToList()); + } + + [Fact] + public async Task Partial_refund_decomposes_legs_and_second_over_refund_is_rejected() + { + using var host = new RefundsTestHost(); + var (bookingId, _) = host.SeedCapturedBooking(gross: 10_000_000, commission: 1_500_000); + var handler = Handler(host, nursePaid: false); + + var half = new CreateRefundCommand(bookingId, null, RefundPercentage: 0.5m, null, null, "shortened_visit", null, null, null); + var first = await handler.Handle(half, CancellationToken.None); + + Assert.True(first.IsSuccess); + Assert.Equal("5000000", first.Result.Amount); + Assert.Equal("750000", first.Result.PlatformFeeRefundedIrr); + Assert.Equal("4250000", first.Result.NursePayoutRefundedIrr); + + // A second 60% refund would push the total to 11M > 10M captured → rejected, no new ledger. + var ledgerBefore = host.LedgerFor(bookingId).Count; + var tooMuch = new CreateRefundCommand(bookingId, null, RefundPercentage: 0.6m, null, null, "again", null, null, null); + var second = await handler.Handle(tooMuch, CancellationToken.None); + + Assert.False(second.IsSuccess); + Assert.True(second.IsConflict); + Assert.Equal(ledgerBefore, host.LedgerFor(bookingId).Count); + } + + [Fact] + public async Task PostPayout_refund_opens_clawback_and_posts_receivable_leg() + { + using var host = new RefundsTestHost(); + var (bookingId, _) = host.SeedCapturedBooking(gross: 10_000_000, commission: 1_500_000); + + var result = await Handler(host, nursePaid: true).Handle(FullRefund(bookingId), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.NotNull(result.Result.ClawbackId); + + var legs = host.LedgerFor(bookingId); + // The payout leg debits the receivable (not nurse_payable); the platform fee leg is unchanged. + Assert.Equal(8_500_000, Leg(legs, LedgerAccountType.NurseClawbackReceivable, LedgerDirection.Debit)); + Assert.Equal(0, legs.Count(l => l.AccountType == LedgerAccountType.NursePayable)); + 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 clawback = Assert.Single(host.Db.Set().AsNoTracking().ToList()); + Assert.Equal(ClawbackStatus.Pending, clawback.Status); + Assert.Equal(8_500_000, clawback.AmountIrr); + Assert.Equal(host.NurseId, clawback.NurseId); + } + + [Fact] + public async Task Card_and_bnpl_post_the_same_reversal_legs() + { + using var cardHost = new RefundsTestHost(); + var (cardBooking, _) = cardHost.SeedCapturedBooking(gross: 10_000_000, commission: 1_500_000, gatewayType: PaymentGatewayType.Standard); + var cardResult = await Handler(cardHost, nursePaid: false).Handle(FullRefund(cardBooking), CancellationToken.None); + + using var bnplHost = new RefundsTestHost(); + var (bnplBooking, _) = bnplHost.SeedCapturedBooking(gross: 10_000_000, commission: 1_500_000, gatewayType: PaymentGatewayType.Bnpl); + var bnplResult = await Handler(bnplHost, nursePaid: false).Handle(FullRefund(bnplBooking), CancellationToken.None); + + Assert.Equal(RefundChannel.PspCard, cardResult.Result.RefundChannel); + Assert.Equal(RefundChannel.BnplRevert, bnplResult.Result.RefundChannel); + // The card refund is immediate (succeeded, no ETA); the BNPL revert waits in processing with an ETA. + Assert.Equal(RefundStatus.Succeeded, cardResult.Result.Status); + Assert.Null(cardResult.Result.ExpectedCustomerRefundEta); + Assert.Equal(RefundStatus.Processing, bnplResult.Result.Status); + Assert.NotNull(bnplResult.Result.ExpectedCustomerRefundEta); + + // Reversal legs (account × direction × amount) are identical across channels. + var cardReversal = Reversal(cardHost.LedgerFor(cardBooking)); + var bnplReversal = Reversal(bnplHost.LedgerFor(bnplBooking)); + Assert.Equal(cardReversal, bnplReversal); + } + + private static long Leg(IReadOnlyList legs, string account, string direction) + => legs.Where(l => l.AccountType == account && l.Direction == direction).Sum(l => l.AmountIrr); + + private static IReadOnlyList<(string, string, long)> Reversal(IReadOnlyList legs) + => legs.Where(l => l.SourceRefType == LedgerSourceRefType.Refund && l.AccountType != LedgerAccountType.EscrowHeld) + .Where(l => !(l.AccountType == LedgerAccountType.RefundPayable && l.Direction == LedgerDirection.Debit)) + .Select(l => (l.AccountType, l.Direction, l.AmountIrr)) + .OrderBy(t => t.Item1).ThenBy(t => t.Item2).ToList(); +} diff --git a/server/src/Tests/Baya.Test.Foundation/Refunds/RefundsTestHost.cs b/server/src/Tests/Baya.Test.Foundation/Refunds/RefundsTestHost.cs new file mode 100644 index 0000000..2680201 --- /dev/null +++ b/server/src/Tests/Baya.Test.Foundation/Refunds/RefundsTestHost.cs @@ -0,0 +1,225 @@ +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.Persistence; +using Baya.Infrastructure.Persistence.Repositories.Common; +using Baya.Tests.Setup.Setups; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using NSubstitute; +using BookingEntity = Baya.Domain.Entities.Booking.Booking; + +namespace Baya.Test.Foundation.Refunds; + +/// +/// A self-contained SQLite host exercising the real EF model (schema, CHECK, filtered indexes, query filters, +/// the invoice-number counter seed) for the b11 refund/clawback/invoice engine. Seeds one bookable nurse + one +/// customer, and can create a confirmed booking with a captured (succeeded) card/BNPL transaction so a test can +/// drive the real against the real handlers with substituted seams. +/// +public sealed class RefundsTestHost : 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; } + private readonly long _cityId; + private readonly long _categoryId; + private readonly long _patientId; + private readonly long _addressId; + + public RefundsTestHost() + { + _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); + var category = new ServiceCategory { NameFa = "سالمند", NameEn = "Elderly", SortOrder = 1, IsActive = true }; + Db.Set().Add(category); + Db.SaveChanges(); + _cityId = city.Id; + _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); + 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(); + _patientId = patient.Id; + _addressId = address.Id; + + var nurseUser = new User { UserName = "nurse1", PhoneNumber = "09120000002", 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(); + NurseId = nurse.Id; + } + + /// Seeds a confirmed booking + its captured (succeeded) transaction. Amounts satisfy + /// gross = commission + payout. sets the paid-proxy window. + public (long BookingId, long TransactionId) SeedCapturedBooking( + long gross = 10_000_000, long commission = 1_500_000, string gatewayType = PaymentGatewayType.Standard, + DateTime? disputeWindowEndsAt = null) + { + var variant = new NurseServiceVariant + { + NurseId = NurseId, ServiceCategoryId = _categoryId, Price = gross, PriceUnit = "per_day", + SessionCount = null, DisplayName = "مراقبت روزانه", OptionSetHash = $"hash-{Guid.NewGuid():N}", IsActive = true + }; + Db.Set().Add(variant); + Db.SaveChanges(); + + var request = new BookingRequest + { + CustomerId = CustomerId, NurseId = NurseId, PatientId = _patientId, VariantId = variant.Id, + 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(); + + var booking = new BookingEntity + { + BookingRequestId = request.Id, CustomerId = CustomerId, NurseId = NurseId, PatientId = _patientId, + VariantId = variant.Id, CustomerAddressId = _addressId, VariantSnapshotJson = "{}", AddressSnapshotJson = "{}", + GrossPriceIrr = gross, BalinyaarCommissionIrr = commission, PlatformFeeRate = 0.15m, + NursePayoutAmount = gross - commission, SessionCount = 1, + ScheduledDate = new DateOnly(2026, 8, 1), ScheduledTimeStart = new TimeOnly(9, 0), ScheduledTimeEnd = new TimeOnly(13, 0) + }; + booking.TransitionTo(BookingStatus.Confirmed, new DateTime(2026, 8, 1, 0, 0, 0, DateTimeKind.Utc)); + if (disputeWindowEndsAt is { } d) + booking.SetDisputeWindow(d); + Db.Set().Add(booking); + Db.SaveChanges(); + + var gateway = new PaymentGateway + { + ProviderCode = gatewayType == PaymentGatewayType.Bnpl ? "snapppay" : "zarinpal", + Type = gatewayType, DisplayName = "GW", ConfigJson = "{}", IsActive = true, Priority = 0 + }; + Db.Set().Add(gateway); + Db.SaveChanges(); + + var txn = new PaymentTransaction + { + BookingRequestId = request.Id, CustomerId = CustomerId, GatewayId = gateway.Id, + Amount = gross, GatewayReferenceCode = $"ref-{Guid.NewGuid():N}" + }; + txn.MarkSucceeded(booking.Id, "ok", null); + Db.Set().Add(txn); + Db.SaveChanges(); + + return (booking.Id, txn.Id); + } + + public ICurrentUser AsAdmin(int userId = 9999) + { + var u = Substitute.For(); + u.UserId.Returns(userId); + u.Roles.Returns(new[] { RoleNames.Admin }); + return u; + } + + public IDateTimeProvider Clock(DateTimeOffset now) + { + var c = Substitute.For(); + c.UtcNow.Returns(now); + return c; + } + + public IPlatformConfig Config(decimal vatRate = 0.10m, bool ticketRequired = false, int bnplEtaDays = 10) + { + var cfg = Substitute.For(); + cfg.GetConfig("vat_rate", Arg.Any()).Returns(vatRate); + cfg.GetConfig("platform_fee_rate", Arg.Any()).Returns(0.15m); + cfg.GetConfig("refund_ticket_required", Arg.Any()).Returns(ticketRequired); + cfg.GetConfig("bnpl_refund_eta_business_days", Arg.Any()).Returns(bnplEtaDays); + return cfg; + } + + public INursePayoutStatus PayoutStatus(bool paid) + { + var s = Substitute.For(); + s.IsNursePaidForBookingAsync(Arg.Any(), Arg.Any()).Returns(paid); + return s; + } + + public IPaymentProvider Card() + { + var p = Substitute.For(); + p.RefundAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => new PaymentRefundResult(PaymentProviderStatus.Succeeded, $"card-refund-{ci.ArgAt(0)}")); + return p; + } + + public IBnplProvider Bnpl() + { + var p = Substitute.For(); + p.RevertAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new BnplRevertResult(PaymentProviderStatus.Succeeded, "bnpl-revert", null)); + p.UpdateAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new BnplRevertResult(PaymentProviderStatus.Succeeded, "bnpl-update", null)); + return p; + } + + public IDistributedLock Lock() => new NoOpLock(); + + public IReadOnlyList LedgerFor(long bookingId) + => Db.Set().AsNoTracking().Where(l => l.BookingId == bookingId).OrderBy(l => l.Id).ToList(); + + public void Dispose() + { + Db.Dispose(); + _connection.Dispose(); + } + + private sealed class NoOpLock : IDistributedLock + { + public ValueTask AcquireAsync(string key, CancellationToken cancellationToken = default) + => ValueTask.FromResult(new Handle()); + + private sealed class Handle : IAsyncDisposable + { + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } + } +}