diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..e53e4b5 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "liveServer.settings.proxy": { + "proxyUri": "http://127.0.0.1:2085" + } +} \ No newline at end of file diff --git a/dev/contracts/domains/bookings-evv.md b/dev/contracts/domains/bookings-evv.md new file mode 100644 index 0000000..21a1932 --- /dev/null +++ b/dev/contracts/domains/bookings-evv.md @@ -0,0 +1,132 @@ +# Contract — Bookings, Sessions, EVV & Cancellation (backend phase b9) + +> One-line: the post-payment engagement — convert a paid request into a booking + N sessions, the two-stage +> care-instructions boundary, per-session EVV check-in/out, dispute-window gating, and cancellation. 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-b9 · **Frontend consumer:** frontend-phase-f8-b9 + +All money is **IRR Rials, integer, on the wire as a string of digits** (`"15000000"`). The three booking +amounts always satisfy `gross_price_irr = balinyaar_commission_irr + nurse_payout_amount`. Timestamps are UTC +ISO-8601. Enums cross as their stable string codes. + +## Enums used +- `BookingStatus`: `pending_payment` | `confirmed` | `in_progress` | `completed` | `disputed` | `closed` | `cancelled`. +- `BookingSessionStatus`: `scheduled` | `in_progress` | `completed` | `missed` | `cancelled`. +- `VisitVerificationStatus`: `pending` | `checked_in` | `completed`. +- `CancellationActor` (`applies_to` / `cancelled_by`): `customer` | `nurse` | `admin`. + +## Endpoints + +### `POST api/v1/bookings/convert` +- **Purpose:** The (mock) payment-capture conversion — creates the booking 1:1 from an + `accepted_awaiting_payment` request. In b10 the real card capture calls this path directly. +- **Auth:** authenticated (owning customer or admin) · **Rate-limited:** yes (sensitive) · **Idempotent:** yes + (replay returns the existing booking). +- **Request body:** `{ "bookingRequestId": 123 }` +- **Success `200` (`data`):** the `Booking` shape (below), `status = confirmed`. +- **Failure:** `400` bad id, `401` unauth, `404` request not found / not the caller's, `409` request not + awaiting payment / no longer convertible, `400` capture failed (no booking created). +- **Notes:** computes the three amounts (commission = round(`gross × platform_fee_rate`), rate snapshotted), + writes `variant_snapshot_json` + **encrypted** `address_snapshot_json`, generates ≥ 1 session with + `Σ visit_payout_amount = nurse_payout_amount`, flips the request → `converted`, notifies both parties. + +### `GET api/v1/bookings/get/{id}` +- **Purpose:** Booking header + money summary + sessions + timeline. **Auth:** authenticated, tenancy-scoped + (customer own / nurse assigned / admin all). The **nurse** view omits `addressSnapshotJson`. Never includes + care-instruction clinical fields. **Failure:** `401`, `404` (not found / not a party → no leak). + +### `GET api/v1/bookings/list?role=customer|nurse|all&status=&page=&page_size=` +- **Purpose:** role-scoped "My bookings" (paginated, projected). `role=all` is admin-only (`403` otherwise). + **Success:** `PagedResult`. + +### `POST api/v1/bookings/transition/{id}` +- **Purpose:** admin/dispute status move. **Auth:** admin (`403` otherwise). **Body:** + `{ "targetStatus": "disputed", "reason": "…" }`. **Failure:** `409` (illegal edge, or contradicts EVV — + e.g. `in_progress` with no session checked in, `completed` with a live session), `400` (use `cancel` for + cancellation). Completing here also opens the dispute window. + +### `POST api/v1/bookings/cancel/{id}` +- **Purpose:** cancel a whole booking. **Auth:** owning customer / assigned nurse / admin · **Rate-limited:** yes. + **Body:** `{ "reason": "…" }`. **Success:** `CancellationResult`. Resolves + **snapshots** the policy + (`code` + `refund_percentage`) onto the booking, cancels only un-started (`scheduled`) sessions, computes the + refundable amount. **No refund ledger is posted (b11).** **Failure:** `400` no reason, `409` not cancellable. + +### `POST api/v1/bookings/submit_care_instructions/{id}` +- **Purpose:** write/update the encrypted stage-2 `booking_care_instructions`. **Auth:** owning customer or + admin, booking must be `confirmed`+. **Body:** `CareInstructions` (all optional strings). **Failure:** `409` + not confirmed, `404` not found. + +### `GET api/v1/bookings/care_instructions/{id}` +- **Purpose:** the **gated** stage-2 read. **Auth:** **assigned nurse or admin only, post-confirmation.** Any + other caller (customer, unassigned nurse, pre-confirmation) → `404` (never leaks). Returns decrypted + `CareInstructions`. **This is the two-stage disclosure boundary.** + +### `POST api/v1/booking_sessions/check_in/{id}` +- **Purpose:** assigned nurse clocks in. **Auth:** assigned nurse. **Body:** `{ "latitude": 35.6892, + "longitude": 51.389 }` (both nullable — GPS-denied still checks in, flagged). Moves the session + booking to + `in_progress`; computes the **advisory** address match against `evv_location_tolerance_meters`. A mismatch + raises a `location_mismatch` support alert + notifies **without blocking**. **Success:** `VisitVerification`. + **Failure:** `401`, `403` (not a nurse), `404` (not the nurse's session), `409` (not startable). + +### `POST api/v1/booking_sessions/check_out/{id}` +- **Purpose:** assigned nurse clocks out — must follow an open check-in. Completes the session's EVV, sets its + `payout_eligible_at`, and — when all sessions are settled — completes the booking + sets + `dispute_window_ends_at`. **Failure:** `400` no open check-in, `409` not checkout-able. + +### `GET api/v1/booking_sessions/today?date=&page=&page_size=` +- **Purpose:** the nurse's sessions for a day (default all), with check-in/out CTA state. **Auth:** nurse, + tenancy-scoped. **Success:** `PagedResult`. + +### `GET api/v1/booking_sessions/evv/{id}` +- **Purpose:** per-session EVV detail. **Auth:** owning nurse + admin only (raw GPS gated); others → `404`. + +### `POST api/v1/booking_sessions/cancel/{id}` +- **Purpose:** cancel a single un-started session · **Rate-limited:** yes. Snapshots the policy + computes the + session's refundable share. **Failure:** `409` if the session already started. + +### `GET api/v1/admin_evv/list?type=mismatch|no_show&page=&page_size=` +- **Purpose:** admin EVV-review queue. **Auth:** admin policy · **Rate-limited:** yes. **Success:** + `PagedResult`. + +### `POST api/v1/admin_evv/detect_no_shows` +- **Purpose:** the manual no-show sweep trigger (the recurring cron is DEFERRED). **Auth:** admin. Marks + overdue scheduled sessions `missed`, raises `no_show` alerts + notifies. **Success:** `{ "missed": N }`. + +### `POST api/v1/admin_cancellation_policies/upsert` · `GET api/v1/admin_cancellation_policies/list` +- **Purpose:** admin CRUD of cancellation tiers (keyed by unique `code`). **Auth:** admin policy. Editing a + policy never mutates an already-snapshotted cancellation. **Failure:** `400` (percentage not 0–100, bad + actor, min ≥ max). + +## Shared shapes +- **`Booking`** (`bookings/get`, `convert`, `transition`): `id`, `bookingRequestId`, `status` (`BookingStatus`), + `nurseId`, `nurseName`, `patientId`, `patientName`, `variantId`, `variantSnapshotJson` (string), + `customerAddressId`, `addressSnapshotJson` (string, **null for the nurse view**), `grossPriceIrr`, + `balinyaarCommissionIrr`, `nursePayoutAmount`, `pspFeeAmount` (money strings; psp nullable), + `platformFeeRate` (decimal), `sessionCount` (int), `scheduledDate`/`scheduledTimeStart`/`scheduledTimeEnd`, + `confirmedAt`/`completedAt`/`cancelledAt` (nullable), `cancelledBy`/`cancellationReason`/ + `cancellationPolicyCode` (nullable), `cancellationRefundPercentage` (nullable decimal), `refundableAmountIrr` + (nullable money string), `disputeWindowEndsAt` (nullable), `createdAt`, `sessions[]`. +- **`BookingSessionSummary`** (embedded): `id`, `sessionIndex`, schedule, `status` (`BookingSessionStatus`), + `visitPayoutAmount` (money string), `payoutEligibleAt` (nullable), `evvStatus` (`VisitVerificationStatus`), + `checkInAt`/`checkOutAt` (nullable), `checkInAddressMatch` (nullable bool). +- **`BookingListItem`**: `id`, `status`, `counterpartyName`, `scheduledDate`, `sessionCount`, `amountIrr` + (gross for customer / payout for nurse), `disputeWindowEndsAt`, `createdAt`. +- **`BookingSessionListItem`**: `sessionId`, `bookingId`, `sessionIndex`, `patientName`, schedule, `status`, + `evvStatus`. +- **`CareInstructions`**: `bookingId` + `currentConditions`/`medications`/`allergies`/`specialInstructions`/ + `emergencyContactName`/`emergencyContactPhone` (all nullable). **Encrypted at rest; gated read.** +- **`VisitVerification`**: `id`, `bookingSessionId`, `status`, `checkInAt`/`checkInLat`/`checkInLng`, + `checkOutAt`/`checkOutLat`/`checkOutLng`, `checkInAddressMatch`, `checkInDistanceMeters` (raw GPS gated). +- **`AdminEvvItem`**: `sessionId`, `bookingId`, `nurseId`, `sessionStatus`, `scheduledDate`, + `scheduledTimeStart`, `checkInAt`, `checkInAddressMatch`, `checkInDistanceMeters`. +- **`CancellationResult`**: `bookingId`, `sessionId` (nullable), `bookingStatus`, `policyCode`, + `refundPercentage`, `refundableAmountIrr` (money string). +- **`CancellationPolicy`**: `id`, `code`, `appliesTo`, `hoursBeforeStartMin`/`Max` (nullable), `refundPercentage`, + `feeAmountIrr` (money string), `feeRate` (nullable), `isActive`. + +## Changelog +- b9 — initial contract (bookings + sessions + care instructions + EVV + cancellation; capture mocked via + `IPaymentCaptureSimulator`, real trigger arrives with b10). diff --git a/dev/contracts/openapi/swagger.v1.json b/dev/contracts/openapi/swagger.v1.json index 5ffee71..35c0fc7 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": { @@ -76,6 +76,148 @@ ] } }, + "/api/v1/admin_cancellation_policies/upsert": { + "post": { + "tags": [ + "AdminCancellationPolicies" + ], + "operationId": "AdminCancellationPolicies_Upsert", + "requestBody": { + "x-name": "command", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpsertCancellationPolicyCommand" + } + } + }, + "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/ApiResultOfCancellationPolicyDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/admin_cancellation_policies/list": { + "get": { + "tags": [ + "AdminCancellationPolicies" + ], + "operationId": "AdminCancellationPolicies_List", + "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/ApiResultOfIReadOnlyListOfCancellationPolicyDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, "/api/v1/admin_catalog/create_category": { "post": { "tags": [ @@ -663,6 +805,165 @@ ] } }, + "/api/v1/admin_evv/list": { + "get": { + "tags": [ + "AdminEvv" + ], + "operationId": "AdminEvv_List", + "parameters": [ + { + "name": "Type", + "in": "query", + "schema": { + "type": "string", + "nullable": true + }, + "x-position": 1 + }, + { + "name": "Page", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + }, + "x-position": 2 + }, + { + "name": "PageSize", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + }, + "x-position": 3 + } + ], + "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/ApiResultOfPagedResultOfAdminEvvItemDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/admin_evv/detect_no_shows": { + "post": { + "tags": [ + "AdminEvv" + ], + "operationId": "AdminEvv_DetectNoShows", + "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/ApiResultOfNoShowSweepResult" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, "/api/v1/admin_geo/create_province": { "post": { "tags": [ @@ -3053,6 +3354,1048 @@ ] } }, + "/api/v1/bookings/convert": { + "post": { + "tags": [ + "Bookings" + ], + "operationId": "Bookings_Convert", + "requestBody": { + "x-name": "command", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConvertRequestToBookingCommand" + } + } + }, + "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/ApiResultOfBookingDetailDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/bookings/get/{id}": { + "get": { + "tags": [ + "Bookings" + ], + "summary": "Retrieves a Booking by unique id", + "operationId": "Bookings_Get", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "A unique id for the Booking", + "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/ApiResultOfBookingDetailDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/bookings/list": { + "get": { + "tags": [ + "Bookings" + ], + "operationId": "Bookings_List", + "parameters": [ + { + "name": "Role", + "in": "query", + "schema": { + "type": "string", + "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/ApiResultOfPagedResultOfBookingListItemDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/bookings/transition/{id}": { + "post": { + "tags": [ + "Bookings" + ], + "operationId": "Bookings_Transition", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + }, + "x-position": 1 + } + ], + "requestBody": { + "x-name": "command", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TransitionBookingStatusCommand" + } + } + }, + "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/ApiResultOfBookingDetailDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/bookings/cancel/{id}": { + "post": { + "tags": [ + "Bookings" + ], + "operationId": "Bookings_Cancel", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + }, + "x-position": 1 + } + ], + "requestBody": { + "x-name": "command", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CancelBookingCommand" + } + } + }, + "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/ApiResultOfCancellationResultDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/bookings/submit_care_instructions/{id}": { + "post": { + "tags": [ + "Bookings" + ], + "operationId": "Bookings_SubmitCareInstructions", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + }, + "x-position": 1 + } + ], + "requestBody": { + "x-name": "command", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubmitCareInstructionsCommand" + } + } + }, + "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/ApiResultOfCareInstructionsDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/bookings/care_instructions/{id}": { + "get": { + "tags": [ + "Bookings" + ], + "operationId": "Bookings_CareInstructions", + "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/ApiResultOfCareInstructionsDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/booking_sessions/check_in/{id}": { + "post": { + "tags": [ + "BookingSessions" + ], + "operationId": "BookingSessions_CheckIn", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + }, + "x-position": 1 + } + ], + "requestBody": { + "x-name": "command", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CheckInVisitCommand" + } + } + }, + "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/ApiResultOfVisitVerificationDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/booking_sessions/check_out/{id}": { + "post": { + "tags": [ + "BookingSessions" + ], + "operationId": "BookingSessions_CheckOut", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + }, + "x-position": 1 + } + ], + "requestBody": { + "x-name": "command", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CheckOutVisitCommand" + } + } + }, + "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/ApiResultOfVisitVerificationDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/booking_sessions/today": { + "get": { + "tags": [ + "BookingSessions" + ], + "operationId": "BookingSessions_Today", + "parameters": [ + { + "name": "Date", + "in": "query", + "schema": { + "type": "string", + "format": "date", + "nullable": true + }, + "x-position": 1 + }, + { + "name": "Page", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + }, + "x-position": 2 + }, + { + "name": "PageSize", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + }, + "x-position": 3 + } + ], + "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/ApiResultOfPagedResultOfBookingSessionListItemDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/booking_sessions/evv/{id}": { + "get": { + "tags": [ + "BookingSessions" + ], + "operationId": "BookingSessions_Evv", + "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/ApiResultOfVisitVerificationDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/booking_sessions/cancel/{id}": { + "post": { + "tags": [ + "BookingSessions" + ], + "operationId": "BookingSessions_Cancel", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + }, + "x-position": 1 + } + ], + "requestBody": { + "x-name": "command", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CancelSessionCommand" + } + } + }, + "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/ApiResultOfCancellationResultDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, "/api/v1/catalog/categories": { "get": { "tags": [ @@ -4355,7 +5698,7 @@ "tags": [ "Me" ], - "description": "Role claims live inside the access token — after selecting a role the client should\n refresh its tokens to pick the new role up.", + "description": "Role claims live inside the access token \u2014 after selecting a role the client should\n refresh its tokens to pick the new role up.", "operationId": "Me_SelectRole", "requestBody": { "x-name": "command", @@ -7804,6 +9147,126 @@ } } }, + "ApiResultOfCancellationPolicyDto": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/CancellationPolicyDto" + } + ] + } + } + } + ] + }, + "CancellationPolicyDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "code": { + "type": "string" + }, + "appliesTo": { + "type": "string" + }, + "hoursBeforeStartMin": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "hoursBeforeStartMax": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "refundPercentage": { + "type": "number", + "format": "decimal" + }, + "feeAmountIrr": { + "type": "string" + }, + "feeRate": { + "type": "number", + "format": "decimal", + "nullable": true + }, + "isActive": { + "type": "boolean" + } + } + }, + "UpsertCancellationPolicyCommand": { + "type": "object", + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "appliesTo": { + "type": "string" + }, + "hoursBeforeStartMin": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "hoursBeforeStartMax": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "refundPercentage": { + "type": "number", + "format": "decimal" + }, + "feeAmountIrr": { + "type": "integer", + "format": "int64" + }, + "feeRate": { + "type": "number", + "format": "decimal", + "nullable": true + }, + "isActive": { + "type": "boolean" + } + } + }, + "ApiResultOfIReadOnlyListOfCancellationPolicyDto": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "type": "array", + "nullable": true, + "items": { + "$ref": "#/components/schemas/CancellationPolicyDto" + } + } + } + } + ] + }, "ApiResultOfServiceCategoryDto": { "allOf": [ { @@ -8136,6 +9599,126 @@ } } }, + "ApiResultOfPagedResultOfAdminEvvItemDto": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/PagedResultOfAdminEvvItemDto" + } + ] + } + } + } + ] + }, + "PagedResultOfAdminEvvItemDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "items": { + "type": "array", + "nullable": true, + "items": { + "$ref": "#/components/schemas/AdminEvvItemDto" + } + }, + "total": { + "type": "integer", + "format": "int32" + }, + "page": { + "type": "integer", + "format": "int32" + }, + "pageSize": { + "type": "integer", + "format": "int32" + } + } + }, + "AdminEvvItemDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "sessionId": { + "type": "integer", + "format": "int64" + }, + "bookingId": { + "type": "integer", + "format": "int64" + }, + "nurseId": { + "type": "integer", + "format": "int64" + }, + "sessionStatus": { + "type": "string" + }, + "scheduledDate": { + "type": "string", + "format": "date" + }, + "scheduledTimeStart": { + "type": "string", + "format": "time" + }, + "checkInAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "checkInAddressMatch": { + "type": "boolean", + "nullable": true + }, + "checkInDistanceMeters": { + "type": "number", + "format": "decimal", + "nullable": true + } + } + }, + "ApiResultOfNoShowSweepResult": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/NoShowSweepResult" + } + ] + } + } + } + ] + }, + "NoShowSweepResult": { + "type": "object", + "additionalProperties": false, + "properties": { + "missed": { + "type": "integer", + "format": "int32" + } + } + }, "ApiResultOfProvinceDto": { "allOf": [ { @@ -9482,6 +11065,686 @@ } } }, + "ApiResultOfBookingDetailDto": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/BookingDetailDto" + } + ] + } + } + } + ] + }, + "BookingDetailDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "bookingRequestId": { + "type": "integer", + "format": "int64" + }, + "status": { + "type": "string" + }, + "nurseId": { + "type": "integer", + "format": "int64" + }, + "nurseName": { + "type": "string" + }, + "patientId": { + "type": "integer", + "format": "int64" + }, + "patientName": { + "type": "string" + }, + "variantId": { + "type": "integer", + "format": "int64" + }, + "variantSnapshotJson": { + "type": "string" + }, + "customerAddressId": { + "type": "integer", + "format": "int64" + }, + "addressSnapshotJson": { + "type": "string", + "nullable": true + }, + "grossPriceIrr": { + "type": "string" + }, + "balinyaarCommissionIrr": { + "type": "string" + }, + "platformFeeRate": { + "type": "number", + "format": "decimal" + }, + "nursePayoutAmount": { + "type": "string" + }, + "pspFeeAmount": { + "type": "string", + "nullable": true + }, + "sessionCount": { + "type": "integer" + }, + "scheduledDate": { + "type": "string", + "format": "date" + }, + "scheduledTimeStart": { + "type": "string", + "format": "time" + }, + "scheduledTimeEnd": { + "type": "string", + "format": "time" + }, + "confirmedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "completedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "cancelledAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "cancelledBy": { + "type": "string", + "nullable": true + }, + "cancellationReason": { + "type": "string", + "nullable": true + }, + "cancellationPolicyCode": { + "type": "string", + "nullable": true + }, + "cancellationRefundPercentage": { + "type": "number", + "format": "decimal", + "nullable": true + }, + "refundableAmountIrr": { + "type": "string", + "nullable": true + }, + "disputeWindowEndsAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "sessions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BookingSessionSummaryDto" + } + } + } + }, + "BookingSessionSummaryDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "sessionIndex": { + "type": "integer", + "format": "int32" + }, + "scheduledDate": { + "type": "string", + "format": "date" + }, + "scheduledTimeStart": { + "type": "string", + "format": "time" + }, + "scheduledTimeEnd": { + "type": "string", + "format": "time" + }, + "status": { + "type": "string" + }, + "visitPayoutAmount": { + "type": "string" + }, + "payoutEligibleAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "evvStatus": { + "type": "string" + }, + "checkInAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "checkOutAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "checkInAddressMatch": { + "type": "boolean", + "nullable": true + } + } + }, + "ConvertRequestToBookingCommand": { + "type": "object", + "additionalProperties": false, + "properties": { + "bookingRequestId": { + "type": "integer", + "format": "int64" + } + } + }, + "ApiResultOfPagedResultOfBookingListItemDto": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/PagedResultOfBookingListItemDto" + } + ] + } + } + } + ] + }, + "PagedResultOfBookingListItemDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "items": { + "type": "array", + "nullable": true, + "items": { + "$ref": "#/components/schemas/BookingListItemDto" + } + }, + "total": { + "type": "integer", + "format": "int32" + }, + "page": { + "type": "integer", + "format": "int32" + }, + "pageSize": { + "type": "integer", + "format": "int32" + } + } + }, + "BookingListItemDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "status": { + "type": "string" + }, + "counterpartyName": { + "type": "string" + }, + "scheduledDate": { + "type": "string", + "format": "date" + }, + "sessionCount": { + "type": "integer" + }, + "amountIrr": { + "type": "string" + }, + "disputeWindowEndsAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + } + }, + "TransitionBookingStatusCommand": { + "type": "object", + "additionalProperties": false, + "properties": { + "targetStatus": { + "type": "string" + }, + "reason": { + "type": "string", + "nullable": true + }, + "bookingId": { + "type": "integer", + "format": "int64" + } + } + }, + "ApiResultOfCancellationResultDto": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/CancellationResultDto" + } + ] + } + } + } + ] + }, + "CancellationResultDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "bookingId": { + "type": "integer", + "format": "int64" + }, + "sessionId": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "bookingStatus": { + "type": "string" + }, + "policyCode": { + "type": "string" + }, + "refundPercentage": { + "type": "number", + "format": "decimal" + }, + "refundableAmountIrr": { + "type": "string" + } + } + }, + "CancelBookingCommand": { + "type": "object", + "additionalProperties": false, + "properties": { + "reason": { + "type": "string", + "nullable": true + }, + "bookingId": { + "type": "integer", + "format": "int64" + } + } + }, + "ApiResultOfCareInstructionsDto": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/CareInstructionsDto" + } + ] + } + } + } + ] + }, + "CareInstructionsDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "bookingId": { + "type": "integer", + "format": "int64" + }, + "currentConditions": { + "type": "string", + "nullable": true + }, + "medications": { + "type": "string", + "nullable": true + }, + "allergies": { + "type": "string", + "nullable": true + }, + "specialInstructions": { + "type": "string", + "nullable": true + }, + "emergencyContactName": { + "type": "string", + "nullable": true + }, + "emergencyContactPhone": { + "type": "string", + "nullable": true + } + } + }, + "SubmitCareInstructionsCommand": { + "type": "object", + "additionalProperties": false, + "properties": { + "currentConditions": { + "type": "string", + "nullable": true + }, + "medications": { + "type": "string", + "nullable": true + }, + "allergies": { + "type": "string", + "nullable": true + }, + "specialInstructions": { + "type": "string", + "nullable": true + }, + "emergencyContactName": { + "type": "string", + "nullable": true + }, + "emergencyContactPhone": { + "type": "string", + "nullable": true + }, + "bookingId": { + "type": "integer", + "format": "int64" + } + } + }, + "ApiResultOfVisitVerificationDto": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/VisitVerificationDto" + } + ] + } + } + } + ] + }, + "VisitVerificationDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "bookingSessionId": { + "type": "integer", + "format": "int64" + }, + "status": { + "type": "string" + }, + "checkInAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "checkInLat": { + "type": "number", + "format": "decimal", + "nullable": true + }, + "checkInLng": { + "type": "number", + "format": "decimal", + "nullable": true + }, + "checkOutAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "checkOutLat": { + "type": "number", + "format": "decimal", + "nullable": true + }, + "checkOutLng": { + "type": "number", + "format": "decimal", + "nullable": true + }, + "checkInAddressMatch": { + "type": "boolean", + "nullable": true + }, + "checkInDistanceMeters": { + "type": "number", + "format": "decimal", + "nullable": true + } + } + }, + "CheckInVisitCommand": { + "type": "object", + "additionalProperties": false, + "properties": { + "latitude": { + "type": "number", + "format": "decimal", + "nullable": true + }, + "longitude": { + "type": "number", + "format": "decimal", + "nullable": true + }, + "sessionId": { + "type": "integer", + "format": "int64" + } + } + }, + "CheckOutVisitCommand": { + "type": "object", + "additionalProperties": false, + "properties": { + "latitude": { + "type": "number", + "format": "decimal", + "nullable": true + }, + "longitude": { + "type": "number", + "format": "decimal", + "nullable": true + }, + "sessionId": { + "type": "integer", + "format": "int64" + } + } + }, + "ApiResultOfPagedResultOfBookingSessionListItemDto": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/PagedResultOfBookingSessionListItemDto" + } + ] + } + } + } + ] + }, + "PagedResultOfBookingSessionListItemDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "items": { + "type": "array", + "nullable": true, + "items": { + "$ref": "#/components/schemas/BookingSessionListItemDto" + } + }, + "total": { + "type": "integer", + "format": "int32" + }, + "page": { + "type": "integer", + "format": "int32" + }, + "pageSize": { + "type": "integer", + "format": "int32" + } + } + }, + "BookingSessionListItemDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "sessionId": { + "type": "integer", + "format": "int64" + }, + "bookingId": { + "type": "integer", + "format": "int64" + }, + "sessionIndex": { + "type": "integer", + "format": "int32" + }, + "patientName": { + "type": "string" + }, + "scheduledDate": { + "type": "string", + "format": "date" + }, + "scheduledTimeStart": { + "type": "string", + "format": "time" + }, + "scheduledTimeEnd": { + "type": "string", + "format": "time" + }, + "status": { + "type": "string" + }, + "evvStatus": { + "type": "string" + } + } + }, + "CancelSessionCommand": { + "type": "object", + "additionalProperties": false, + "properties": { + "reason": { + "type": "string", + "nullable": true + }, + "sessionId": { + "type": "integer", + "format": "int64" + } + } + }, "ApiResultOfPagedResultOfServiceCategoryDto": { "allOf": [ { diff --git a/dev/shared-working-context/backend/STATUS.md b/dev/shared-working-context/backend/STATUS.md index 407e7ed..7f33fed 100644 --- a/dev/shared-working-context/backend/STATUS.md +++ b/dev/shared-working-context/backend/STATUS.md @@ -12,6 +12,25 @@ One block per completed backend phase. Newest at the top. Backend lane writes he - **Notes for frontend:** --> +## backend-phase-9 — Bookings, sessions, care instructions & EVV — 2026-07-06 +- **Shipped:** the post-payment engine via one additive migration in the **`booking`** schema — **5 tables** + `Bookings` / `BookingSessions` / `BookingCareInstructions` / `VisitVerifications` / `CancellationPolicies` + (seeded 4 tiers) + 2 config rows (`no_show_threshold_minutes`, `no_show_scan_cadence_hours`). `Convert` + (mock-capture → booking 1:1, three-amount split, snapshots, N sessions), care-instructions submit + **gated** + read, EVV `check_in`/`check_out` (advisory address match, dispute window on completion), `transition`, + `cancel` booking/session (policy snapshot + un-started refund), `detect_no_shows`, cancellation-policy CRUD. + Controllers: `Bookings` / `BookingSessions` / `AdminEvv` / `AdminCancellationPolicies`. +- **Contracts:** dev/contracts/domains/bookings-evv.md + openapi snapshot refreshed (yes). +- **Mocked:** **`IPaymentCaptureSimulator`** introduced (🟡) — the temporary conversion trigger; reuses + `IGeocoder`/`IFieldEncryptor`/`INotificationDispatcher`/`ISupportAlertService`. Real capture trigger = b10. +- **Gate:** build clean (0 new code warnings) / tests green (269: 186 foundation + 4 identity + 79 API). +- **Handoff:** backend/handoff/after-backend-phase-9.md +- **Notes for frontend (f8-b9):** money is an **IRR digit-string** (three amounts reconcile). The nurse view + omits `addressSnapshotJson`; care-instruction clinical fields are **assigned-nurse/admin only, post-confirmation** + (`GET bookings/care_instructions/{id}`); raw EVV GPS is gated to owning nurse + admin. Routes are action-style + (`bookings/get/{id}`, `booking_sessions/check_in/{id}`, `booking_sessions/today`, `admin_evv/list`). Payout + eligibility comes from `disputeWindowEndsAt`/`payoutEligibleAt`, never `completed` alone. + ## backend-phase-8 — Booking requests (pre-payment intent) — 2026-07-06 - **Shipped:** the money-free request lifecycle via one additive migration — new **`booking`** schema, **1 table** `BookingRequests`. The customer-side (`CreateBookingRequest` — tenancy invariant diff --git a/dev/shared-working-context/backend/handoff/after-backend-phase-9.md b/dev/shared-working-context/backend/handoff/after-backend-phase-9.md new file mode 100644 index 0000000..96018f7 --- /dev/null +++ b/dev/shared-working-context/backend/handoff/after-backend-phase-9.md @@ -0,0 +1,46 @@ +# Handoff — after backend phase 9 (Bookings, sessions, care instructions & EVV) + +**The booking engine is live.** A paid request now becomes a real engagement: `bookings` + N `booking_sessions`, +encrypted `booking_care_instructions`, per-session `visit_verifications` (EVV), and the dispute-window gate. +Capture is **mocked** behind `IPaymentCaptureSimulator`; the real conversion trigger arrives with **b10 payments**. + +## What f8-b9 can now build +- **Booking detail & "My bookings"** — `GET bookings/get/{id}`, `GET bookings/list?role=customer|nurse|all&status=`. + Header + money summary (three amounts as **digit-strings**, `platformFeeRate`, `pspFeeAmount`) + sessions + + status timeline. The **nurse view omits `addressSnapshotJson`**. +- **Nurse EVV** — `GET booking_sessions/today?date=` (today's visits + CTA state), `POST booking_sessions/check_in/{id}` + and `POST booking_sessions/check_out/{id}` (send `{latitude, longitude}`; both nullable if GPS denied), + `GET booking_sessions/evv/{id}` (raw GPS, owning nurse + admin only). +- **Care instructions** — `POST bookings/submit_care_instructions/{id}` (customer/admin, booking must be confirmed+), + `GET bookings/care_instructions/{id}` (**assigned nurse + admin only, post-confirmation** — the two-stage + disclosure boundary; everyone else gets 404). +- **Status timeline & cancel** — `POST bookings/cancel/{id}` (customer/nurse/admin; returns the policy snapshot + + refundable amount), `POST booking_sessions/cancel/{id}` (single un-started session). +- **Admin** — `GET admin_evv/list?type=mismatch|no_show`, `POST admin_evv/detect_no_shows`, + `POST admin_cancellation_policies/upsert` + `GET admin_cancellation_policies/list`, `POST bookings/transition/{id}`. + +## Live endpoints / contracts +- Contract: [`dev/contracts/domains/bookings-evv.md`](../../contracts/domains/bookings-evv.md); machine schema in + the refreshed [`swagger.v1.json`](../../contracts/openapi/swagger.v1.json). +- Enums: `BookingStatus`, `BookingSessionStatus`, `VisitVerificationStatus`, `CancellationActor`. + +## Load-bearing rules the client must honour +- **Money is IRR integer, on the wire as a digit-string.** `grossPriceIrr = balinyaarCommissionIrr + nursePayoutAmount`. + Never coerce to a JS number for math. +- **Payout eligibility is derived from `disputeWindowEndsAt` / per-session `payoutEligibleAt`, not from `completed`.** +- **EVV address mismatch is advisory** — a flagged check-in still succeeds; surface it, don't block. +- **Care-instruction clinical fields never appear in a list or the booking detail** — only via the gated read. + +## Mocked here → make real later +- **`IPaymentCaptureSimulator`** (🟡) — the temporary conversion trigger. In **b10**, the real card capture calls + `ConvertRequestToBooking` directly on a `payment_transactions.succeeded`; this seam is then removed. A config + switch (`Seams:PaymentCapture:ForceFailure`) exercises the "capture failed → no booking" path today. +- The **no-show cron** is DEFERRED — `POST admin_evv/detect_no_shows` runs the same idempotent command a scheduler + will later call (config `no_show_scan_cadence_hours`). + +## Consumed by later backend phases +- **b10** — real capture posts the ledger and calls the conversion; sets the real `psp_fee_amount`. +- **b11** — refund execution consumes the frozen `cancellationPolicyCode` + `refundableAmountIrr` (no ledger posted here). +- **b13** — payout batching consumes `disputeWindowEndsAt` / `payoutEligibleAt`. +- **b14** — reviews on a completed booking. +- **b15** — `partner_centers` wires the nullable `partner_center_id`. diff --git a/dev/shared-working-context/reports/backend-phase-9-report.md b/dev/shared-working-context/reports/backend-phase-9-report.md new file mode 100644 index 0000000..a728d41 --- /dev/null +++ b/dev/shared-working-context/reports/backend-phase-9-report.md @@ -0,0 +1,78 @@ +# Backend phase 9 report — Bookings, sessions, care instructions & EVV + +## What was built +- **Domain (`Baya.Domain/Entities/Booking/`):** `Booking`, `BookingSession`, `BookingCareInstruction`, + `VisitVerification`, `CancellationPolicy` (+ `CancellationPolicyCode` seed codes), the `BookingStatus` / + `BookingSessionStatus` / `VisitVerificationStatus` / `CancellationActor` code sets, the `BookingTransitions` + / `BookingSessionTransitions` guards, and `BookingAmounts` (the pure integer-money split/reconciliation). +- **Persistence:** one migration `BookingsSessionsEvvCancellation` — the five `booking`-schema tables with the + `gross = commission + payout` (all ≥ 0) **DB CHECK**, the `booking_request_id` / `booking_id` (care) / + `booking_session_id` (EVV) UNIQUE 1:1 indexes, encrypted `address_snapshot_json` + care columns, seeded + cancellation tiers + 2 new `platform_configs` rows. `BookingConfig/` configs; `BookingRepository` + + `CancellationPolicyRepository` on `IUnitOfWork`; `IBookingRequestRepository` gained + `GetTrackedByIdAsync` + `GetConversionSourceAsync`. +- **Application (`Features/Bookings/`):** `ConvertRequestToBooking`, `SubmitCareInstructions`, `CheckInVisit`, + `CheckOutVisit`, `TransitionBookingStatus`, `CancelBooking`, `CancelSession`, `DetectNoShowSessions`, + `UpsertCancellationPolicy` (commands) + `GetBookingDetail`, `ListBookings`, `ListSessionsForNurse`, + `GetCareInstructions`, `GetVisitVerification`, `ListAdminEvv`, `ListCancellationPolicies` (queries), plus + `BookingMapper`, `CancellationHelper`, `GeoDistance`. +- **Seam:** `IPaymentCaptureSimulator` (Application `Contracts/Common`) + `MockPaymentCaptureSimulator` + (CrossCutting), registered in `AddCrossCuttingSeams`, config `Seams:PaymentCapture`. +- **API:** `BookingsController`, `BookingSessionsController`, `AdminEvvController`, + `AdminCancellationPoliciesController` (convert/cancel + admin EVV/policies are rate-limited). + +## What is now testable, and exactly how (per §7 of the phase) +1. **Convert** — `POST bookings/convert` (as the owning customer) with an `accepted_awaiting_payment` request → + a `confirmed` booking whose three amounts sum, snapshots are populated (address encrypted at rest), N + sessions reconcile (`Σ visit_payout = nurse_payout`), request → `converted`. Re-convert → same booking. +2. **Single-visit** — a `session_count=1` request → exactly one session. +3. **Care disclosure** — submit as customer, then `GET bookings/care_instructions/{id}`: assigned nurse + admin + get the decrypted fields; customer / unassigned nurse / pre-confirmation → 404. Never in list/detail. +4. **EVV** — `check_in` (in-range GPS) → session + booking `in_progress`; `check_out` → session `completed`, + EVV `completed`. +5. **Mismatch** — `check_in` out-of-range → still succeeds, `check_in_address_match=false`, a + `evv_location_mismatch` support alert + notification; visible in `admin_evv/list?type=mismatch`. +6. **Completion** — last `check_out` → booking `completed`, `dispute_window_ends_at = completed_at + 72h` + (config), each completed session's `payout_eligible_at` set; not payout-eligible before that. +7. **Cancellation** — `bookings/cancel/{id}` resolves the tier by lead-time + actor, snapshots `code` + + `refund_percentage`, refunds only un-started sessions; a later policy edit leaves the snapshot unchanged. +8. **Transition guard** — an illegal/EVV-contradicting transition → `OperationResult` failure, no state change. +9. **No-show** — `admin_evv/detect_no_shows` for an overdue scheduled session → `missed` + `no_show` alert + + family notification. + +Automated coverage: 42 booking foundation tests (SQLite host over the real EF model + real handlers) — the +three-amount split + session reconciliation, the transition guards, the two-stage disclosure gate, the +advisory-mismatch-raises-alert-without-blocking path, `SetDisputeWindow` on completion, and the +policy-snapshot immutability — plus one WebApplicationFactory integration test per controller (happy path / +401 / 400 / disclosure not-found). Full suite green (269 tests). `dotnet build` zero new code warnings. + +## What is mocked, and how to make it real +- **`IPaymentCaptureSimulator`** — see `reports/mocks-registry.md`. In b10 the real card capture calls + `ConvertRequestToBooking` directly on a `payment_transactions.succeeded`; remove the seam + mock. A config + switch forces a failed capture today so the "no booking on failure" path is covered. + +## Decisions recorded (not in the product docs before) +- The `visit_payout_amount` split places the remainder of integer division on the **last** session so + `Σ = nurse_payout_amount` exactly. +- The EVV-state ↔ booking-state mapping (`checked_in` ↔ session `in_progress` ↔ booking `in_progress`; all + sessions settled ↔ booking `completed`). +- Seeded cancellation tiers: `standard_24h` (customer ≥24h → 100%), `standard_inside_24h` (customer <24h → + 50%, open lower bound so an already-started cancel still resolves), `nurse_no_show` (nurse → 100% + a + modelled penalty whose posting is deferred to b13), `admin_cancellation` (admin → 100%). +- `no_show_threshold_minutes` default **60**; `no_show_scan_cadence_hours` default **1**. +- The cancellation snapshot (`cancellation_policy_code` / `cancellation_refund_percentage` / + `refundable_amount_irr`) lives on the `bookings` row for MVP — the typed per-event/refund record lands in b11; + `booking_sessions.cancellation_event_id` is a nullable column left unset until then. + +## Contracts produced / consumed +- Produced: `dev/contracts/domains/bookings-evv.md` + refreshed `swagger.v1.json`. Consumes b8's + `booking_requests`, b5's `IVariantSnapshotSerializer`, b4's `IGeocoder` + address coords, b1's config / + `support_alerts` / `INotificationDispatcher`, b0's `IFieldEncryptor` / `ICurrentUser` / `OperationResult`. + +## Follow-ups +- **b10** — real card capture (`payment_transactions`, ledger) → replaces the `IPaymentCaptureSimulator` trigger. +- **b11** — refund execution consumes the frozen policy snapshot + `refundable_amount_irr`; adds the + cancellation-event/refund records that `booking_sessions.cancellation_event_id` will reference. +- **b13** — payout batching consumes `dispute_window_ends_at` / `payout_eligible_at`; posts the nurse penalty. +- **b14** — reviews on a completed booking. **b15** — `partner_centers` wires `partner_center_id`. +- The **no-show cron** and a **recurring dispute-window/close sweep** remain DEFERRED (hosted-scheduler pattern). diff --git a/dev/shared-working-context/reports/mocks-registry.md b/dev/shared-working-context/reports/mocks-registry.md index c399b7b..4a3d88c 100644 --- a/dev/shared-working-context/reports/mocks-registry.md +++ b/dev/shared-working-context/reports/mocks-registry.md @@ -33,6 +33,7 @@ Status legend: 🔴 not built · 🟡 mocked (seam + fake impl in place) · 🟢 | `IFieldEncryptor` | backend-phase-0 | PII encryption — AES-256-CBC + HMAC hash from a local symmetric key (`SymmetricFieldEncryptor`, `Baya.Infrastructure.CrossCutting/Seams/`) | `Seams:FieldEncryption:Key`, `Seams:FieldEncryption:HashKey` | KMS / column encryption / Key Vault / HSM | 🟡 | | `INotificationDispatcher` | backend-phase-0/**1** | Notification channels — **in-app write is now real** (`InAppNotificationDispatcher`, `Persistence/Services/Notifications/`, writes an `ops.Notifications` row); b0 log stub removed. SMS/push channels still deferred (no-op) behind the same seam | _none_ | Add SMS (`ISmsSender`) / push (FCM) channels; polling → Redis pub/sub or SignalR later | 🟡 | | `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 🟡 | | `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) | diff --git a/server/CLAUDE.md b/server/CLAUDE.md index e3d1ac5..52b1729 100644 --- a/server/CLAUDE.md +++ b/server/CLAUDE.md @@ -81,15 +81,15 @@ 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), + 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); + 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.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) ├── Infrastructure/ -│ ├── Baya.Infrastructure.Persistence ApplicationDbContext (+ encrypted-PII value converters & phone-hash sync), ValueConversion/, Repositories/, Configuration/ (per-area EF config incl. SearchConfig/ + BookingConfig/), 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.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) + AddCrossCuttingSeams +│ ├── 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), 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), 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 @@ -107,7 +107,7 @@ Application reference Infrastructure or the API — this is a hard rule. **Cross-cutting seams.** Application defines mock-able external dependencies as interfaces in `Contracts/Common/` (`IDateTimeProvider`, `IFieldEncryptor`, `ICacheService`, `IObjectStorage`, `INotificationDispatcher`, `IGeocoder`, `IShahkarVerifier`, `IIdentityKycProvider`, `ICredentialVerifier`, -plus `ICurrentUser`). Their in-memory/local mock implementations live in +`IPaymentCaptureSimulator`, plus `ICurrentUser`). Their in-memory/local mock implementations live in `Baya.Infrastructure.CrossCutting/Seams/` and are registered by `AddCrossCuttingSeams(configuration)` (config section `Seams`); `ICurrentUser` is registered in the Identity layer. Swapping a mock for a real provider is a registration change — handlers depend only on the contract. Audit fields are @@ -251,6 +251,42 @@ the recurring sweep is `Persistence/Services/Booking/BookingRequestExpiryHostedS 409, terminal states have no outgoing edge; the expiry sweep's `WHERE status = …` predicate is the concurrency guard (a row a racing accept/cancel moved is simply not reloaded). See CONVENTIONS §6. +**Bookings, sessions, EVV & cancellation (backend-phase-9).** The `booking` schema gains the five post-payment +tables — `Bookings`, `BookingSessions`, `BookingCareInstructions`, `VisitVerifications`, `CancellationPolicies` +(entities in `Domain/Entities/Booking/`, configs in `Persistence/Configuration/BookingConfig/`, one migration). +A `bookings` row exists **only** when the nurse accepted **and** payment was captured: `ConvertRequestToBooking` +reads an `accepted_awaiting_payment` request, confirms a capture, and creates the booking 1:1 (`pending_payment → +confirmed`), fanning out N `booking_sessions`. Features under `Baya.Application/Features/Bookings/{Commands|Queries}/` +(namespace **plural** `Bookings` — distinct from b8's singular `Booking`; the entity type `Booking` is aliased where +the two collide); per-domain repos `IBookingRepository` + `ICancellationPolicyRepository` on `IUnitOfWork`; +controllers `BookingsController` / `BookingSessionsController` / `AdminEvvController` / `AdminCancellationPoliciesController`. +Load-bearing rules: +- **Money is IRR `BIGINT`, three amounts reconcile.** `gross_price_irr = balinyaar_commission_irr + nurse_payout_amount` + (all ≥ 0) is a **DB CHECK** and handler invariant; commission = integer-round(`gross × platform_fee_rate`) with the + rate **snapshotted** onto the booking; `nurse_payout_amount` is derived, never free-entered. `Σ(visit_payout_amount) + = nurse_payout_amount` exactly (integer split, remainder on the last session — `BookingAmounts`). The + `payout_released` boolean was **cut** — paid-ness is derived later (b13). On the wire money is a **digit string**. +- **Snapshots freeze history.** `variant_snapshot_json` (via `IVariantSnapshotSerializer`), the **encrypted** + `address_snapshot_json`, `platform_fee_rate`, and the resolved cancellation `code` + `refund_percentage` are frozen + at their moment; later edits to the source variant/address/policy never mutate an existing booking. +- **Two-stage clinical disclosure (stage 2).** `booking_care_instructions` (all fields **encrypted** through + `IFieldEncryptor`) are readable **only post-confirmation** and **only** by the **assigned nurse + admin** — + `GetCareInstructionsQuery` enforces it; the fields are never projected into a list or logged. +- **EVV is per session; mismatch is advisory.** `visit_verifications` FK is on `booking_session_id`. Check-in computes + the distance to the frozen booking address (reusing `IGeocoder` + `GeoDistance` haversine) against + `evv_location_tolerance_meters`; a mismatch raises a `location_mismatch` `support_alerts` + notifies **without + blocking**. GPS-denied still checks in (flagged null). +- **`SetDisputeWindow` is the only payout-eligibility trigger.** Booking completion (last check-out, or all sessions + settled) sets `dispute_window_ends_at = completed_at + config(dispute_window_hours, 72)` and each completed session's + `payout_eligible_at`; b13 gates payout on those, never on `completed` alone. +- **Cancellation snapshots the policy + refunds only un-started sessions.** The applicable `cancellation_policies` tier + is resolved by `(actor, lead-time bucket)` and its `code` + `refund_percentage` + computed refundable amount are + frozen onto the booking; only still-`scheduled` sessions are refundable; **no refund ledger is posted (b11)**. +- **`IPaymentCaptureSimulator`** (Application `Contracts/Common`; mock `MockPaymentCaptureSimulator` in CrossCutting, + registered in `AddCrossCuttingSeams`, config `Seams:PaymentCapture`) is the **temporary conversion trigger** — b10's + real card capture replaces it by calling `ConvertRequestToBooking` directly on a `succeeded` transaction. The no-show + sweep (`DetectNoShowSessions`) is admin/test-triggered; its recurring cron is DEFERRED (like b8's expiry sweep). + **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/AdminCancellationPoliciesController.cs b/server/src/API/Baya.Web.Api/Controllers/V1/AdminCancellationPoliciesController.cs new file mode 100644 index 0000000..27afebc --- /dev/null +++ b/server/src/API/Baya.Web.Api/Controllers/V1/AdminCancellationPoliciesController.cs @@ -0,0 +1,38 @@ +using System.ComponentModel.DataAnnotations; +using Asp.Versioning; +using Baya.Application.Features.Bookings.Commands.UpsertCancellationPolicy; +using Baya.Application.Features.Bookings.Queries.ListCancellationPolicies; +using Baya.Application.Models.Booking; +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 management of the cancellation/refund tiers. Editing a policy never mutates an already-snapshotted +/// cancellation — past cancellations froze the code + percentage onto the booking. +/// +[ApiVersion("1")] +[ApiController] +[Route("api/v{version:apiVersion}/[controller]")] +[Authorize(ConstantPolicies.DynamicPermission)] +[EnableRateLimiting(RateLimitingServiceExtension.SensitivePolicy)] +[Display(Description = "Admin cancellation-policy management (upsert + list)")] +public sealed class AdminCancellationPoliciesController(ISender sender) : BaseController +{ + [HttpPost("[action]")] + [ProducesOkApiResponseType] + public async Task Upsert(UpsertCancellationPolicyCommand command, CancellationToken cancellationToken) + => OperationResult(await sender.Send(command, cancellationToken)); + + [HttpGet("[action]")] + [ProducesOkApiResponseType>] + public async Task List(CancellationToken cancellationToken) + => OperationResult(await sender.Send(new ListCancellationPoliciesQuery(), cancellationToken)); +} diff --git a/server/src/API/Baya.Web.Api/Controllers/V1/AdminEvvController.cs b/server/src/API/Baya.Web.Api/Controllers/V1/AdminEvvController.cs new file mode 100644 index 0000000..fd0c949 --- /dev/null +++ b/server/src/API/Baya.Web.Api/Controllers/V1/AdminEvvController.cs @@ -0,0 +1,39 @@ +using System.ComponentModel.DataAnnotations; +using Asp.Versioning; +using Baya.Application.Features.Bookings.Commands.DetectNoShowSessions; +using Baya.Application.Features.Bookings.Queries.ListAdminEvv; +using Baya.Application.Models.Booking; +using Baya.Application.Models.Common; +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; + +/// +/// The admin EVV-review surface: the mismatch/no-show queue and the manual no-show sweep trigger (the same +/// idempotent command the deferred cron will call). +/// +[ApiVersion("1")] +[ApiController] +[Route("api/v{version:apiVersion}/[controller]")] +[Authorize(ConstantPolicies.DynamicPermission)] +[EnableRateLimiting(RateLimitingServiceExtension.SensitivePolicy)] +[Display(Description = "Admin EVV review queue (location mismatch / no-show) + manual no-show sweep")] +public sealed class AdminEvvController(ISender sender) : BaseController +{ + [HttpGet("[action]")] + [ProducesOkApiResponseType>] + public async Task List([FromQuery] ListAdminEvvQuery query, CancellationToken cancellationToken) + => OperationResult(await sender.Send(query, cancellationToken)); + + [HttpPost("[action]")] + [ProducesOkApiResponseType] + public async Task DetectNoShows(CancellationToken cancellationToken) + => OperationResult(await sender.Send(new DetectNoShowSessionsCommand(), cancellationToken)); +} diff --git a/server/src/API/Baya.Web.Api/Controllers/V1/BookingSessionsController.cs b/server/src/API/Baya.Web.Api/Controllers/V1/BookingSessionsController.cs new file mode 100644 index 0000000..095f0e6 --- /dev/null +++ b/server/src/API/Baya.Web.Api/Controllers/V1/BookingSessionsController.cs @@ -0,0 +1,57 @@ +using System.ComponentModel.DataAnnotations; +using Asp.Versioning; +using Baya.Application.Features.Bookings.Commands.CancelSession; +using Baya.Application.Features.Bookings.Commands.CheckInVisit; +using Baya.Application.Features.Bookings.Commands.CheckOutVisit; +using Baya.Application.Features.Bookings.Queries.GetVisitVerification; +using Baya.Application.Features.Bookings.Queries.ListSessionsForNurse; +using Baya.Application.Models.Booking; +using Baya.Application.Models.Common; +using Baya.WebFramework.Attributes; +using Baya.WebFramework.BaseController; +using Baya.WebFramework.ServiceConfiguration; +using Mediator; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; + +namespace Baya.Web.Api.Controllers.V1; + +/// +/// The per-visit surface: the assigned nurse's EVV check-in/out, their "today" session list, per-session EVV +/// detail (raw GPS gated to the owning nurse + admin), and a single-session cancel. Tenancy is enforced in +/// the handlers via ICurrentUser. +/// +[ApiVersion("1")] +[ApiController] +[Route("api/v{version:apiVersion}/[controller]")] +[Authorize] +[Display(Description = "Booking sessions & EVV (check-in/out, today's visits, EVV detail, session cancel)")] +public sealed class BookingSessionsController(ISender sender) : BaseController +{ + [HttpPost("[action]/{id}")] + [ProducesOkApiResponseType] + public async Task CheckIn(long id, CheckInVisitCommand command, CancellationToken cancellationToken) + => OperationResult(await sender.Send(command with { SessionId = id }, cancellationToken)); + + [HttpPost("[action]/{id}")] + [ProducesOkApiResponseType] + public async Task CheckOut(long id, CheckOutVisitCommand command, CancellationToken cancellationToken) + => OperationResult(await sender.Send(command with { SessionId = id }, cancellationToken)); + + [HttpGet("[action]")] + [ProducesOkApiResponseType>] + public async Task Today([FromQuery] ListSessionsForNurseQuery query, CancellationToken cancellationToken) + => OperationResult(await sender.Send(query, cancellationToken)); + + [HttpGet("[action]/{id}")] + [ProducesOkApiResponseType] + public async Task Evv(long id, CancellationToken cancellationToken) + => OperationResult(await sender.Send(new GetVisitVerificationQuery(id), cancellationToken)); + + [HttpPost("[action]/{id}")] + [EnableRateLimiting(RateLimitingServiceExtension.SensitivePolicy)] + [ProducesOkApiResponseType] + public async Task Cancel(long id, CancelSessionCommand command, CancellationToken cancellationToken) + => OperationResult(await sender.Send(command with { SessionId = id }, cancellationToken)); +} diff --git a/server/src/API/Baya.Web.Api/Controllers/V1/BookingsController.cs b/server/src/API/Baya.Web.Api/Controllers/V1/BookingsController.cs new file mode 100644 index 0000000..5f2786e --- /dev/null +++ b/server/src/API/Baya.Web.Api/Controllers/V1/BookingsController.cs @@ -0,0 +1,73 @@ +using System.ComponentModel.DataAnnotations; +using Asp.Versioning; +using Baya.Application.Features.Bookings.Commands.CancelBooking; +using Baya.Application.Features.Bookings.Commands.ConvertRequestToBooking; +using Baya.Application.Features.Bookings.Commands.SubmitCareInstructions; +using Baya.Application.Features.Bookings.Commands.TransitionBookingStatus; +using Baya.Application.Features.Bookings.Queries.GetBookingDetail; +using Baya.Application.Features.Bookings.Queries.GetCareInstructions; +using Baya.Application.Features.Bookings.Queries.ListBookings; +using Baya.Application.Models.Booking; +using Baya.Application.Models.Common; +using Baya.WebFramework.Attributes; +using Baya.WebFramework.BaseController; +using Baya.WebFramework.ServiceConfiguration; +using Mediator; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; + +namespace Baya.Web.Api.Controllers.V1; + +/// +/// The confirmed-booking surface: the (mock) payment-capture conversion, role-scoped detail + list, the +/// admin/dispute status transition, and the stage-2 care-instructions submit (customer/admin) + gated read +/// (assigned nurse/admin, post-confirmation). Tenancy is enforced in the handlers; money is IRR digit-strings. +/// +[ApiVersion("1")] +[ApiController] +[Route("api/v{version:apiVersion}/[controller]")] +[Authorize] +[Display(Description = "Confirmed bookings (convert/detail/list, transition, care instructions)")] +public sealed class BookingsController(ISender sender) : BaseController +{ + // The mock/test conversion path — b10's real card capture calls ConvertRequestToBooking directly. + [HttpPost("[action]")] + [EnableRateLimiting(RateLimitingServiceExtension.SensitivePolicy)] + [ProducesOkApiResponseType] + public async Task Convert(ConvertRequestToBookingCommand command, CancellationToken cancellationToken) + => OperationResult(await sender.Send(command, cancellationToken)); + + [HttpGet("[action]/{id}")] + [ProducesOkApiResponseType] + public async Task Get(long id, CancellationToken cancellationToken) + => OperationResult(await sender.Send(new GetBookingDetailQuery(id), cancellationToken)); + + [HttpGet("[action]")] + [ProducesOkApiResponseType>] + public async Task List([FromQuery] ListBookingsQuery query, CancellationToken cancellationToken) + => OperationResult(await sender.Send(query, cancellationToken)); + + // Admin/dispute moves only (enforced in the handler); cancellation goes through Cancel below. + [HttpPost("[action]/{id}")] + [ProducesOkApiResponseType] + public async Task Transition(long id, TransitionBookingStatusCommand command, CancellationToken cancellationToken) + => OperationResult(await sender.Send(command with { BookingId = id }, cancellationToken)); + + [HttpPost("[action]/{id}")] + [EnableRateLimiting(RateLimitingServiceExtension.SensitivePolicy)] + [ProducesOkApiResponseType] + public async Task Cancel(long id, CancelBookingCommand command, CancellationToken cancellationToken) + => OperationResult(await sender.Send(command with { BookingId = id }, cancellationToken)); + + [HttpPost("[action]/{id}")] + [ProducesOkApiResponseType] + public async Task SubmitCareInstructions(long id, SubmitCareInstructionsCommand command, CancellationToken cancellationToken) + => OperationResult(await sender.Send(command with { BookingId = id }, cancellationToken)); + + // Gated stage-2 read — assigned nurse + admin only, post-confirmation. + [HttpGet("[action]/{id}")] + [ProducesOkApiResponseType] + public async Task CareInstructions(long id, CancellationToken cancellationToken) + => OperationResult(await sender.Send(new GetCareInstructionsQuery(id), cancellationToken)); +} diff --git a/server/src/Core/Baya.Application/Common/GeoDistance.cs b/server/src/Core/Baya.Application/Common/GeoDistance.cs new file mode 100644 index 0000000..5b55e4e --- /dev/null +++ b/server/src/Core/Baya.Application/Common/GeoDistance.cs @@ -0,0 +1,26 @@ +namespace Baya.Application.Common; + +/// +/// Great-circle distance between two lat/lng points, in metres (haversine). Used by the EVV check-in +/// address-match: the nurse's captured GPS is compared against the booking address coordinates, and the +/// result is advisory only — a mismatch flags admin review, never blocks the visit. +/// +public static class GeoDistance +{ + private const double EarthRadiusMeters = 6_371_000d; + + public static double HaversineMeters(double lat1, double lng1, double lat2, double lng2) + { + var dLat = ToRadians(lat2 - lat1); + var dLng = ToRadians(lng2 - lng1); + + var a = Math.Sin(dLat / 2) * Math.Sin(dLat / 2) + + Math.Cos(ToRadians(lat1)) * Math.Cos(ToRadians(lat2)) + * Math.Sin(dLng / 2) * Math.Sin(dLng / 2); + + var c = 2 * Math.Atan2(Math.Sqrt(a), Math.Sqrt(1 - a)); + return EarthRadiusMeters * c; + } + + private static double ToRadians(double degrees) => degrees * Math.PI / 180d; +} diff --git a/server/src/Core/Baya.Application/Contracts/Common/IPaymentCaptureSimulator.cs b/server/src/Core/Baya.Application/Contracts/Common/IPaymentCaptureSimulator.cs new file mode 100644 index 0000000..110c2c3 --- /dev/null +++ b/server/src/Core/Baya.Application/Contracts/Common/IPaymentCaptureSimulator.cs @@ -0,0 +1,21 @@ +#nullable enable +namespace Baya.Application.Contracts.Common; + +/// +/// Temporary seam standing in for b10's real card capture, so ConvertRequestToBookingCommand is +/// testable now. returns a deterministic succeeded capture (a fake +/// gateway reference + optional PSP fee); a config switch can force a failed capture so the +/// "capture failed → no booking" path is testable. This is the temporary conversion trigger, not a +/// parallel money path: in b10 the real capture calls ConvertRequestToBooking directly after a +/// real payment_transactions.succeeded and this seam is removed. +/// +public interface IPaymentCaptureSimulator +{ + ValueTask ConfirmCaptureAsync(long bookingRequestId, CancellationToken cancellationToken = default); +} + +/// The outcome of a (mock) payment capture. On failure, is empty. +/// Whether the capture succeeded — conversion runs only when true. +/// The gateway's capture reference (fake in the mock). +/// The gateway cost on this payment (IRR), for true margin. Null when unknown. +public sealed record PaymentCaptureResult(bool Succeeded, string GatewayReference, long? PspFeeAmount); diff --git a/server/src/Core/Baya.Application/Contracts/Persistence/IBookingRepository.cs b/server/src/Core/Baya.Application/Contracts/Persistence/IBookingRepository.cs new file mode 100644 index 0000000..5e0c1f6 --- /dev/null +++ b/server/src/Core/Baya.Application/Contracts/Persistence/IBookingRepository.cs @@ -0,0 +1,56 @@ +#nullable enable +using Baya.Application.Models.Booking; +using Baya.Application.Models.Common; +using Baya.Domain.Entities.Booking; + +namespace Baya.Application.Contracts.Persistence; + +/// +/// The post-payment bookings aggregate (bookings + sessions + care instructions + EVV). Writes load +/// tracked rows; reads project to role-scoped DTOs. Money is IRR long throughout; the encrypted +/// address snapshot and care fields decrypt only on the gated read paths. Tenancy is enforced in the +/// handlers from the ids these projections carry — a cross-party access is a clean not-found, never a leak. +/// +public interface IBookingRepository +{ + // ---- conversion ---- + Task AddAsync(Booking booking, CancellationToken cancellationToken); + + /// The booking id already created from this request, if any — the idempotency check that makes + /// a replayed conversion return the existing booking instead of creating a second one. + Task GetBookingIdByRequestIdAsync(long bookingRequestId, CancellationToken cancellationToken); + + // ---- detail + lists ---- + Task GetDetailAsync(long id, CancellationToken cancellationToken); + Task> ListForCustomerAsync(long customerId, string? status, int page, int pageSize, CancellationToken cancellationToken); + Task> ListForNurseAsync(long nurseId, string? status, int page, int pageSize, CancellationToken cancellationToken); + Task> ListAllAsync(string? status, int page, int pageSize, CancellationToken cancellationToken); + + /// Both participant user ids for a booking — the notification recipients. Null when absent. + Task GetParticipantsAsync(long bookingId, CancellationToken cancellationToken); + + // ---- tracked loads for writes ---- + /// Tracked booking with its sessions loaded — for the transition guard and whole-booking cancel. + Task GetTrackedWithSessionsAsync(long id, CancellationToken cancellationToken); + + /// Tracked booking with its 1:1 care instructions loaded — for the care-instructions upsert. + Task GetTrackedWithCareAsync(long id, CancellationToken cancellationToken); + + /// The tracked booking that owns , with all its sessions and their + /// EVV records loaded, so a check-in/out/cancel can mutate the target session, create/complete its EVV, + /// and evaluate the sibling sessions for booking completion. Null if the session is absent. + Task GetTrackedBookingBySessionAsync(long sessionId, CancellationToken cancellationToken); + + // ---- care instructions (gated read) ---- + Task GetCareInstructionsGateAsync(long bookingId, CancellationToken cancellationToken); + + // ---- sessions + EVV ---- + Task> ListSessionsForNurseAsync(long nurseId, DateOnly? date, int page, int pageSize, CancellationToken cancellationToken); + Task GetEvvForSessionAsync(long sessionId, CancellationToken cancellationToken); + Task> ListAdminEvvAsync(string type, int page, int pageSize, CancellationToken cancellationToken); + + /// Tracked scheduled sessions on or before with their booking loaded — + /// the no-show sweep filters by the exact start-plus-threshold instant in memory (a DateOnly+TimeOnly + /// combine is not translatable), then marks the overdue ones missed. + Task> GetNoShowCandidatesAsync(DateOnly today, int batchSize, CancellationToken cancellationToken); +} diff --git a/server/src/Core/Baya.Application/Contracts/Persistence/IBookingRequestRepository.cs b/server/src/Core/Baya.Application/Contracts/Persistence/IBookingRequestRepository.cs index c1aa610..1a54af9 100644 --- a/server/src/Core/Baya.Application/Contracts/Persistence/IBookingRequestRepository.cs +++ b/server/src/Core/Baya.Application/Contracts/Persistence/IBookingRequestRepository.cs @@ -39,4 +39,14 @@ public interface IBookingRequestRepository /// Role-agnostic detail projection (carries both party ids for authorization + the full address /// for the customer/admin path). NULL when absent. Task GetDetailAsync(long id, CancellationToken cancellationToken); + + /// Tracked lookup by id with no tenancy scope — the b9 conversion runs from a payment + /// capture (a system/admin path), and flips the request to converted in the same unit of work. + /// NULL when absent. + Task GetTrackedByIdAsync(long id, CancellationToken cancellationToken); + + /// Everything b9 needs to convert an accepted_awaiting_payment request into a booking in + /// one projected read: ids + participant user ids, the engagement schedule, and the source data for the + /// two frozen snapshots (variant + decrypted address). NULL when absent. + Task GetConversionSourceAsync(long id, CancellationToken cancellationToken); } diff --git a/server/src/Core/Baya.Application/Contracts/Persistence/ICancellationPolicyRepository.cs b/server/src/Core/Baya.Application/Contracts/Persistence/ICancellationPolicyRepository.cs new file mode 100644 index 0000000..fa0a6f1 --- /dev/null +++ b/server/src/Core/Baya.Application/Contracts/Persistence/ICancellationPolicyRepository.cs @@ -0,0 +1,23 @@ +#nullable enable +using Baya.Application.Models.Booking; +using Baya.Domain.Entities.Booking; + +namespace Baya.Application.Contracts.Persistence; + +/// +/// Admin-managed cancellation/refund tiers. The resolver picks the active tier for an +/// (applies_to, lead-time bucket) at cancel time; its code + refund_percentage are then +/// frozen onto the booking (a later edit here never mutates a past cancellation). +/// +public interface ICancellationPolicyRepository +{ + /// The active tiers for an actor, ordered so the resolver can pick the first covering bucket. + Task> GetActiveForActorAsync(string appliesTo, CancellationToken cancellationToken); + + Task> ListAsync(CancellationToken cancellationToken); + + /// Tracked lookup by unique code for the admin upsert; null when the code is new. + Task GetTrackedByCodeAsync(string code, CancellationToken cancellationToken); + + Task AddAsync(CancellationPolicy policy, 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 a0866b0..6e3ff0d 100644 --- a/server/src/Core/Baya.Application/Contracts/Persistence/IUnitOfWork.cs +++ b/server/src/Core/Baya.Application/Contracts/Persistence/IUnitOfWork.cs @@ -16,6 +16,8 @@ public interface IUnitOfWork public INurseServiceVariantRepository NurseServiceVariantRepository { get; } public IVerificationRepository VerificationRepository { get; } public IBookingRequestRepository BookingRequestRepository { get; } + public IBookingRepository BookingRepository { get; } + public ICancellationPolicyRepository CancellationPolicyRepository { get; } Task CommitAsync(); ValueTask RollBackAsync(); } diff --git a/server/src/Core/Baya.Application/Features/Bookings/BookingMapper.cs b/server/src/Core/Baya.Application/Features/Bookings/BookingMapper.cs new file mode 100644 index 0000000..03fc0cd --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Bookings/BookingMapper.cs @@ -0,0 +1,66 @@ +#nullable enable +using System.Globalization; +using Baya.Application.Models.Booking; + +namespace Baya.Application.Features.Bookings; + +/// +/// Maps the role-agnostic booking projection to the wire DTO. Money crosses as strings of IRR-Rial digits. +/// is the disclosure switch: the owning customer (and admin) receive the +/// decrypted address snapshot; the nurse view omits it (a booking's coarse location suffices for the nurse, +/// the full address is surfaced only through EVV/session flows). Care-instruction clinical fields are never +/// part of this DTO. +/// +internal static class BookingMapper +{ + public static BookingDetailDto ToDetailDto(BookingDetailProjection p, bool includeAddress) + => new( + p.Id, + p.BookingRequestId, + p.Status, + p.NurseId, + p.NurseName, + p.PatientId, + p.PatientName, + p.VariantId, + p.VariantSnapshotJson, + p.CustomerAddressId, + includeAddress ? p.AddressSnapshotJson : null, + Money(p.GrossPriceIrr), + Money(p.BalinyaarCommissionIrr), + p.PlatformFeeRate, + Money(p.NursePayoutAmount), + p.PspFeeAmount is { } psp ? Money(psp) : null, + p.SessionCount, + p.ScheduledDate, + p.ScheduledTimeStart, + p.ScheduledTimeEnd, + p.ConfirmedAt, + p.CompletedAt, + p.CancelledAt, + p.CancelledBy, + p.CancellationReason, + p.CancellationPolicyCode, + p.CancellationRefundPercentage, + p.RefundableAmountIrr is { } refund ? Money(refund) : null, + p.DisputeWindowEndsAt, + p.CreatedAt, + p.Sessions.Select(ToSessionSummary).ToList()); + + private static BookingSessionSummaryDto ToSessionSummary(BookingSessionProjection s) + => new( + s.Id, + s.SessionIndex, + s.ScheduledDate, + s.ScheduledTimeStart, + s.ScheduledTimeEnd, + s.Status, + Money(s.VisitPayoutAmount), + s.PayoutEligibleAt, + s.EvvStatus ?? Domain.Entities.Booking.VisitVerificationStatus.Pending, + s.CheckInAt, + s.CheckOutAt, + s.CheckInAddressMatch); + + private static string Money(long irr) => irr.ToString(CultureInfo.InvariantCulture); +} diff --git a/server/src/Core/Baya.Application/Features/Bookings/BookingRoles.cs b/server/src/Core/Baya.Application/Features/Bookings/BookingRoles.cs new file mode 100644 index 0000000..79fb507 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Bookings/BookingRoles.cs @@ -0,0 +1,10 @@ +using Baya.Domain.Entities.User; + +namespace Baya.Application.Features.Bookings; + +/// The admin role set that may read any booking / EVV detail and drive admin-only transitions. +internal static class BookingRoles +{ + public static readonly string[] Admin = + [RoleNames.Admin, RoleNames.SuperAdmin, RoleNames.Support, RoleNames.Finance, RoleNames.Moderation]; +} diff --git a/server/src/Core/Baya.Application/Features/Bookings/CancellationHelper.cs b/server/src/Core/Baya.Application/Features/Bookings/CancellationHelper.cs new file mode 100644 index 0000000..a03739d --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Bookings/CancellationHelper.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Baya.Domain.Entities.Booking; + +namespace Baya.Application.Features.Bookings; + +/// +/// Shared cancellation resolution + refund arithmetic for the whole-booking and single-session cancel paths. +/// The applicable policy is resolved by (actor, lead-time bucket); only un-started (still +/// scheduled) sessions are refundable; the refund is a percentage of those sessions' share of gross. +/// +internal static class CancellationHelper +{ + public static string ResolveActor(bool isAdmin, bool isAssignedNurse) + => isAdmin ? CancellationActor.Admin + : isAssignedNurse ? CancellationActor.Nurse + : CancellationActor.Customer; + + /// Hours from to the scheduled start (negative once it has started). + public static double HoursBeforeStart(DateOnly date, TimeOnly start, DateTime nowUtc) + => (date.ToDateTime(start, DateTimeKind.Utc) - nowUtc).TotalHours; + + public static CancellationPolicy ResolvePolicy(IReadOnlyList policies, double hoursBeforeStart) + => policies.FirstOrDefault(p => p.Covers(hoursBeforeStart)); + + /// The refundable amount (IRR) = of the un-started sessions' + /// share of gross, rounded half away from zero. + public static long Refundable(long refundableGrossBase, decimal refundPercentage) + => (long)decimal.Round(refundableGrossBase * refundPercentage / 100m, MidpointRounding.AwayFromZero); +} diff --git a/server/src/Core/Baya.Application/Features/Bookings/Commands/CancelBooking/CancelBookingCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Bookings/Commands/CancelBooking/CancelBookingCommand.Handler.cs new file mode 100644 index 0000000..d686405 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Bookings/Commands/CancelBooking/CancelBookingCommand.Handler.cs @@ -0,0 +1,69 @@ +#nullable enable +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Persistence; +using Baya.Application.Models.Booking; +using Baya.Application.Models.Common; +using Baya.Domain.Entities.Booking; +using Mediator; + +namespace Baya.Application.Features.Bookings.Commands.CancelBooking; + +internal sealed class CancelBookingCommandHandler( + ICurrentUser currentUser, + IUnitOfWork unitOfWork, + IDateTimeProvider dateTimeProvider) + : IRequestHandler> +{ + public async ValueTask> Handle(CancelBookingCommand request, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return OperationResult.UnauthorizedResult("Not authenticated."); + + var booking = await unitOfWork.BookingRepository.GetTrackedWithSessionsAsync(request.BookingId, cancellationToken); + if (booking is null) + return OperationResult.NotFoundResult("Booking not found."); + + var isAdmin = currentUser.Roles?.Any(BookingRoles.Admin.Contains) == true; + var customerId = await unitOfWork.CustomerProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken); + var nurseId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken); + var isOwningCustomer = customerId == booking.CustomerId; + var isAssignedNurse = nurseId == booking.NurseId; + + if (!isAdmin && !isOwningCustomer && !isAssignedNurse) + return OperationResult.NotFoundResult("Booking not found."); + + if (!booking.CanTransitionTo(BookingStatus.Cancelled)) + return OperationResult.ConflictResult($"A booking in '{booking.Status}' can no longer be cancelled."); + + var now = dateTimeProvider.UtcNow.UtcDateTime; + var actor = CancellationHelper.ResolveActor(isAdmin, isAssignedNurse && !isOwningCustomer); + + var policies = await unitOfWork.CancellationPolicyRepository.GetActiveForActorAsync(actor, cancellationToken); + var hoursBefore = CancellationHelper.HoursBeforeStart(booking.ScheduledDate, booking.ScheduledTimeStart, now); + var policy = CancellationHelper.ResolvePolicy(policies, hoursBefore); + if (policy is null) + return OperationResult.FailureResult("No cancellation policy applies to this booking."); + + // Only un-started (still scheduled) sessions are refundable; started/completed ones are not. + var grossShares = BookingAmounts.SplitPayout(booking.GrossPriceIrr, booking.SessionCount); + long refundableBase = 0; + foreach (var session in booking.Sessions) + { + if (session.Status != BookingSessionStatus.Scheduled) + continue; + + refundableBase += grossShares[session.SessionIndex - 1]; + session.TransitionTo(BookingSessionStatus.Cancelled); + } + + var refundable = CancellationHelper.Refundable(refundableBase, policy.RefundPercentage); + + booking.TransitionTo(BookingStatus.Cancelled, now, actor, request.Reason); + booking.RecordCancellationSnapshot(policy.Code, policy.RefundPercentage, refundable); + + await unitOfWork.CommitAsync(); + + return OperationResult.SuccessResult(new CancellationResultDto( + booking.Id, null, booking.Status, policy.Code, policy.RefundPercentage, refundable.ToString())); + } +} diff --git a/server/src/Core/Baya.Application/Features/Bookings/Commands/CancelBooking/CancelBookingCommand.Validator.cs b/server/src/Core/Baya.Application/Features/Bookings/Commands/CancelBooking/CancelBookingCommand.Validator.cs new file mode 100644 index 0000000..ceac799 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Bookings/Commands/CancelBooking/CancelBookingCommand.Validator.cs @@ -0,0 +1,11 @@ +using FluentValidation; + +namespace Baya.Application.Features.Bookings.Commands.CancelBooking; + +public sealed class CancelBookingCommandValidator : AbstractValidator +{ + public CancelBookingCommandValidator() + { + RuleFor(x => x.Reason).NotEmpty().MaximumLength(500); + } +} diff --git a/server/src/Core/Baya.Application/Features/Bookings/Commands/CancelBooking/CancelBookingCommand.cs b/server/src/Core/Baya.Application/Features/Bookings/Commands/CancelBooking/CancelBookingCommand.cs new file mode 100644 index 0000000..2bbed6e --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Bookings/Commands/CancelBooking/CancelBookingCommand.cs @@ -0,0 +1,12 @@ +using Baya.Application.Models.Booking; +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Bookings.Commands.CancelBooking; + +/// Cancels a whole booking: resolves the applicable cancellation policy by lead time + actor, +/// freezes its code + refund_percentage onto the booking, marks every un-started session +/// cancelled, and moves the booking to cancelled. Only un-started (still scheduled) sessions are +/// refundable. No refund ledger is posted — that is b11. The booking id comes from the route. +public record CancelBookingCommand(string Reason, long BookingId = 0) + : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Bookings/Commands/CancelSession/CancelSessionCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Bookings/Commands/CancelSession/CancelSessionCommand.Handler.cs new file mode 100644 index 0000000..0997c67 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Bookings/Commands/CancelSession/CancelSessionCommand.Handler.cs @@ -0,0 +1,62 @@ +#nullable enable +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Persistence; +using Baya.Application.Models.Booking; +using Baya.Application.Models.Common; +using Baya.Domain.Entities.Booking; +using Mediator; + +namespace Baya.Application.Features.Bookings.Commands.CancelSession; + +internal sealed class CancelSessionCommandHandler( + ICurrentUser currentUser, + IUnitOfWork unitOfWork, + IDateTimeProvider dateTimeProvider) + : IRequestHandler> +{ + public async ValueTask> Handle(CancelSessionCommand request, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return OperationResult.UnauthorizedResult("Not authenticated."); + + var booking = await unitOfWork.BookingRepository.GetTrackedBookingBySessionAsync(request.SessionId, cancellationToken); + var session = booking?.Sessions.FirstOrDefault(s => s.Id == request.SessionId); + if (booking is null || session is null) + return OperationResult.NotFoundResult("Session not found."); + + var isAdmin = currentUser.Roles?.Any(BookingRoles.Admin.Contains) == true; + var customerId = await unitOfWork.CustomerProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken); + var nurseId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken); + var isOwningCustomer = customerId == booking.CustomerId; + var isAssignedNurse = nurseId == booking.NurseId; + + if (!isAdmin && !isOwningCustomer && !isAssignedNurse) + return OperationResult.NotFoundResult("Session not found."); + + // Only an un-started session can be cancelled (and refunded); a started/finished visit cannot. + if (session.Status != BookingSessionStatus.Scheduled) + return OperationResult.ConflictResult("Only an un-started session can be cancelled."); + + var now = dateTimeProvider.UtcNow.UtcDateTime; + var actor = CancellationHelper.ResolveActor(isAdmin, isAssignedNurse && !isOwningCustomer); + + var policies = await unitOfWork.CancellationPolicyRepository.GetActiveForActorAsync(actor, cancellationToken); + var hoursBefore = CancellationHelper.HoursBeforeStart(session.ScheduledDate, session.ScheduledTimeStart, now); + var policy = CancellationHelper.ResolvePolicy(policies, hoursBefore); + if (policy is null) + return OperationResult.FailureResult("No cancellation policy applies to this session."); + + var grossShares = BookingAmounts.SplitPayout(booking.GrossPriceIrr, booking.SessionCount); + var refundableBase = grossShares[session.SessionIndex - 1]; + var refundable = CancellationHelper.Refundable(refundableBase, policy.RefundPercentage); + + session.TransitionTo(BookingSessionStatus.Cancelled); + // The booking keeps the most recent cancellation snapshot; the typed per-event record lands in b11. + booking.RecordCancellationSnapshot(policy.Code, policy.RefundPercentage, refundable); + + await unitOfWork.CommitAsync(); + + return OperationResult.SuccessResult(new CancellationResultDto( + booking.Id, session.Id, booking.Status, policy.Code, policy.RefundPercentage, refundable.ToString())); + } +} diff --git a/server/src/Core/Baya.Application/Features/Bookings/Commands/CancelSession/CancelSessionCommand.Validator.cs b/server/src/Core/Baya.Application/Features/Bookings/Commands/CancelSession/CancelSessionCommand.Validator.cs new file mode 100644 index 0000000..3147023 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Bookings/Commands/CancelSession/CancelSessionCommand.Validator.cs @@ -0,0 +1,11 @@ +using FluentValidation; + +namespace Baya.Application.Features.Bookings.Commands.CancelSession; + +public sealed class CancelSessionCommandValidator : AbstractValidator +{ + public CancelSessionCommandValidator() + { + RuleFor(x => x.Reason).NotEmpty().MaximumLength(500); + } +} diff --git a/server/src/Core/Baya.Application/Features/Bookings/Commands/CancelSession/CancelSessionCommand.cs b/server/src/Core/Baya.Application/Features/Bookings/Commands/CancelSession/CancelSessionCommand.cs new file mode 100644 index 0000000..40ce746 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Bookings/Commands/CancelSession/CancelSessionCommand.cs @@ -0,0 +1,12 @@ +using Baya.Application.Models.Booking; +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Bookings.Commands.CancelSession; + +/// Cancels a single un-started session mid-engagement: resolves + snapshots the applicable policy, +/// computes the refundable amount for that session's share of gross, and marks the session cancelled. +/// A session already in_progress/completed is not refundable. No refund ledger is posted (b11). +/// The session id comes from the route. +public record CancelSessionCommand(string Reason, long SessionId = 0) + : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Bookings/Commands/CheckInVisit/CheckInVisitCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Bookings/Commands/CheckInVisit/CheckInVisitCommand.Handler.cs new file mode 100644 index 0000000..80529ef --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Bookings/Commands/CheckInVisit/CheckInVisitCommand.Handler.cs @@ -0,0 +1,154 @@ +#nullable enable +using System.Text.Json; +using Baya.Application.Common; +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Configuration; +using Baya.Application.Contracts.Persistence; +using Baya.Application.Contracts.SupportAlerts; +using Baya.Application.Models.Booking; +using Baya.Application.Models.Common; +using Baya.Domain.Entities.Booking; +using Baya.Domain.Entities.SupportAlerts; +using Baya.Domain.Entities.User; +using Mediator; + +namespace Baya.Application.Features.Bookings.Commands.CheckInVisit; + +internal sealed class CheckInVisitCommandHandler( + ICurrentUser currentUser, + IUnitOfWork unitOfWork, + IPlatformConfig platformConfig, + IDateTimeProvider dateTimeProvider, + IGeocoder geocoder, + ISupportAlertService supportAlerts, + INotificationDispatcher notifications) + : IRequestHandler> +{ + private static readonly JsonSerializerOptions SnapshotJson = new() { PropertyNameCaseInsensitive = true }; + + public async ValueTask> Handle(CheckInVisitCommand request, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return OperationResult.UnauthorizedResult("Not authenticated."); + + if (currentUser.Roles?.Contains(RoleNames.Nurse) != true) + return OperationResult.ForbiddenResult("Only a nurse can check in."); + + var nurseId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken); + if (nurseId is not { } nid) + return OperationResult.ForbiddenResult("No nurse profile exists yet."); + + var booking = await unitOfWork.BookingRepository.GetTrackedBookingBySessionAsync(request.SessionId, cancellationToken); + var session = booking?.Sessions.FirstOrDefault(s => s.Id == request.SessionId); + if (booking is null || session is null) + return OperationResult.NotFoundResult("Session not found."); + + if (booking.NurseId != nid) + return OperationResult.NotFoundResult("Session not found."); + + if (booking.Status is not (BookingStatus.Confirmed or BookingStatus.InProgress)) + return OperationResult.ConflictResult("This booking is not in a state where a visit can start."); + + if (!session.CanTransitionTo(BookingSessionStatus.InProgress)) + return OperationResult.ConflictResult("This session cannot be checked in."); + + var now = dateTimeProvider.UtcNow.UtcDateTime; + + var verification = session.Verification ?? new VisitVerification { BookingSessionId = session.Id }; + session.Verification = verification; + + verification.CheckInAt = now; + verification.CheckInLat = request.Latitude; + verification.CheckInLng = request.Longitude; + + // Advisory address match — computed only when GPS is present. GPS-denied still checks in (flagged null). + bool mismatch = false; + if (request.Latitude is { } lat && request.Longitude is { } lng) + { + var addressPoint = await ResolveAddressPointAsync(booking.AddressSnapshotJson, cancellationToken); + if (addressPoint is { } point) + { + var toleranceMeters = await platformConfig.GetConfig("evv_location_tolerance_meters", cancellationToken); + var distance = GeoDistance.HaversineMeters((double)point.Lat, (double)point.Lng, (double)lat, (double)lng); + + verification.CheckInDistanceMeters = (decimal)Math.Round(distance, 2); + verification.CheckInAddressMatch = distance <= toleranceMeters; + mismatch = distance > toleranceMeters; + } + } + + verification.MarkCheckedIn(); + session.TransitionTo(BookingSessionStatus.InProgress); + + // First relevant check-in moves the booking to in_progress; subsequent check-ins leave it as-is. + if (booking.CanTransitionTo(BookingStatus.InProgress)) + booking.TransitionTo(BookingStatus.InProgress, now); + + await unitOfWork.CommitAsync(); + + // A mismatch is advisory: raise an admin alert + notify the family — never block, never cancel. + if (mismatch) + { + await supportAlerts.RaiseAsync( + SupportAlertType.EvvLocationMismatch, + entityType: "booking_session", + entityId: session.Id.ToString(), + severity: SupportAlertSeverity.Medium, + bookingId: booking.Id, + cancellationToken: cancellationToken); + + var participants = await unitOfWork.BookingRepository.GetParticipantsAsync(booking.Id, cancellationToken); + if (participants is not null) + await notifications.DispatchAsync( + new Notification( + participants.CustomerUserId, + "evv_location_mismatch", + "Check-in location flagged", + "The nurse checked in outside the expected area. Our team will review it — the visit continues normally.", + JsonSerializer.Serialize(new { booking_id = booking.Id, session_id = session.Id })), + cancellationToken); + } + + var gate = await unitOfWork.BookingRepository.GetEvvForSessionAsync(session.Id, cancellationToken); + return OperationResult.SuccessResult(gate!.Evv!); + } + + // The booking address is frozen in the (decrypted) snapshot; reuse IGeocoder to resolve its coordinates, + // falling back to the coordinates already frozen into the snapshot. Returns null when neither is available. + private async Task<(decimal Lat, decimal Lng)?> ResolveAddressPointAsync(string addressSnapshotJson, CancellationToken cancellationToken) + { + AddressSnapshotJsonModel? snapshot; + try + { + snapshot = JsonSerializer.Deserialize(addressSnapshotJson, SnapshotJson); + } + catch (JsonException) + { + snapshot = null; + } + + if (snapshot is null) + return null; + + var geo = await geocoder.GeocodeAsync( + snapshot.AddressLine ?? string.Empty, + snapshot.CityNameEn ?? string.Empty, + snapshot.DistrictNameEn, + cancellationToken); + + if (geo.Latitude is { } gLat && geo.Longitude is { } gLng) + return (gLat, gLng); + + if (snapshot.Latitude is { } sLat && snapshot.Longitude is { } sLng) + return (sLat, sLng); + + return null; + } + + private sealed record AddressSnapshotJsonModel( + string? AddressLine, + string? CityNameEn, + string? DistrictNameEn, + decimal? Latitude, + decimal? Longitude); +} diff --git a/server/src/Core/Baya.Application/Features/Bookings/Commands/CheckInVisit/CheckInVisitCommand.cs b/server/src/Core/Baya.Application/Features/Bookings/Commands/CheckInVisit/CheckInVisitCommand.cs new file mode 100644 index 0000000..f651127 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Bookings/Commands/CheckInVisit/CheckInVisitCommand.cs @@ -0,0 +1,13 @@ +#nullable enable +using Baya.Application.Models.Booking; +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Bookings.Commands.CheckInVisit; + +/// The assigned nurse clocks in: captures GPS + timestamp into the session's EVV record, computes +/// the advisory address-match against evv_location_tolerance_meters, and moves the session (and the +/// booking) to in_progress. A location mismatch raises an admin alert + notifies — it never blocks. +/// GPS-denied (null coordinates) still checks in, flagged. The session id comes from the route. +public record CheckInVisitCommand(decimal? Latitude, decimal? Longitude, long SessionId = 0) + : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Bookings/Commands/CheckOutVisit/CheckOutVisitCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Bookings/Commands/CheckOutVisit/CheckOutVisitCommand.Handler.cs new file mode 100644 index 0000000..f0e83be --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Bookings/Commands/CheckOutVisit/CheckOutVisitCommand.Handler.cs @@ -0,0 +1,77 @@ +#nullable enable +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Configuration; +using Baya.Application.Contracts.Persistence; +using Baya.Application.Models.Booking; +using Baya.Application.Models.Common; +using Baya.Domain.Entities.Booking; +using Baya.Domain.Entities.User; +using Mediator; + +namespace Baya.Application.Features.Bookings.Commands.CheckOutVisit; + +internal sealed class CheckOutVisitCommandHandler( + ICurrentUser currentUser, + IUnitOfWork unitOfWork, + IPlatformConfig platformConfig, + IDateTimeProvider dateTimeProvider) + : IRequestHandler> +{ + // A session is "settled" for booking-completion purposes when it can no longer become in_progress. + private static readonly string[] TerminalSessionStatuses = + [BookingSessionStatus.Completed, BookingSessionStatus.Missed, BookingSessionStatus.Cancelled]; + + public async ValueTask> Handle(CheckOutVisitCommand request, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return OperationResult.UnauthorizedResult("Not authenticated."); + + if (currentUser.Roles?.Contains(RoleNames.Nurse) != true) + return OperationResult.ForbiddenResult("Only a nurse can check out."); + + var nurseId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken); + if (nurseId is not { } nid) + return OperationResult.ForbiddenResult("No nurse profile exists yet."); + + var booking = await unitOfWork.BookingRepository.GetTrackedBookingBySessionAsync(request.SessionId, cancellationToken); + var session = booking?.Sessions.FirstOrDefault(s => s.Id == request.SessionId); + if (booking is null || session is null) + return OperationResult.NotFoundResult("Session not found."); + + if (booking.NurseId != nid) + return OperationResult.NotFoundResult("Session not found."); + + var verification = session.Verification; + if (verification is null || verification.Status != VisitVerificationStatus.CheckedIn) + return OperationResult.FailureResult("Check-out must follow an open check-in."); + + if (!session.CanTransitionTo(BookingSessionStatus.Completed)) + return OperationResult.ConflictResult("This session cannot be checked out."); + + var now = dateTimeProvider.UtcNow.UtcDateTime; + var disputeWindowHours = await platformConfig.GetConfig("dispute_window_hours", cancellationToken); + + verification.CheckOutAt = now; + verification.CheckOutLat = request.Latitude; + verification.CheckOutLng = request.Longitude; + verification.MarkCompleted(); + + session.TransitionTo(BookingSessionStatus.Completed); + // Per-session payout gate — the ONLY thing that makes this session payout-eligible (b13). Never the + // completed status alone. + session.SetPayoutEligible(now.AddHours(disputeWindowHours)); + + // When every session is settled, the booking completes and its dispute window opens. + var allSettled = booking.Sessions.All(s => TerminalSessionStatuses.Contains(s.Status)); + if (allSettled && booking.CanTransitionTo(BookingStatus.Completed)) + { + booking.TransitionTo(BookingStatus.Completed, now); + booking.SetDisputeWindow(now.AddHours(disputeWindowHours)); + } + + await unitOfWork.CommitAsync(); + + var gate = await unitOfWork.BookingRepository.GetEvvForSessionAsync(session.Id, cancellationToken); + return OperationResult.SuccessResult(gate!.Evv!); + } +} diff --git a/server/src/Core/Baya.Application/Features/Bookings/Commands/CheckOutVisit/CheckOutVisitCommand.cs b/server/src/Core/Baya.Application/Features/Bookings/Commands/CheckOutVisit/CheckOutVisitCommand.cs new file mode 100644 index 0000000..51031a8 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Bookings/Commands/CheckOutVisit/CheckOutVisitCommand.cs @@ -0,0 +1,13 @@ +#nullable enable +using Baya.Application.Models.Booking; +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Bookings.Commands.CheckOutVisit; + +/// The assigned nurse clocks out — must follow an open check-in. Captures GPS + timestamp, completes +/// the session's EVV, sets the session's per-session payout-eligibility window, and — when every session of +/// the booking is terminal — completes the booking and sets its dispute window. The session id comes from the +/// route. +public record CheckOutVisitCommand(decimal? Latitude, decimal? Longitude, long SessionId = 0) + : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Bookings/Commands/ConvertRequestToBooking/ConvertRequestToBookingCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Bookings/Commands/ConvertRequestToBooking/ConvertRequestToBookingCommand.Handler.cs new file mode 100644 index 0000000..9216664 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Bookings/Commands/ConvertRequestToBooking/ConvertRequestToBookingCommand.Handler.cs @@ -0,0 +1,164 @@ +#nullable enable +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Unicode; +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Configuration; +using Baya.Application.Contracts.Persistence; +using Baya.Application.Models.Booking; +using Baya.Application.Models.Common; +using Baya.Domain.Entities.Booking; +using Mediator; +// The singular b8 namespace Baya.Application.Features.Booking shadows the entity type name `Booking` when +// referenced unqualified from this (plural) Features.Bookings area — alias it to disambiguate. +using BookingEntity = Baya.Domain.Entities.Booking.Booking; + +namespace Baya.Application.Features.Bookings.Commands.ConvertRequestToBooking; + +internal sealed class ConvertRequestToBookingCommandHandler( + ICurrentUser currentUser, + IUnitOfWork unitOfWork, + IPlatformConfig platformConfig, + IDateTimeProvider dateTimeProvider, + IPaymentCaptureSimulator paymentCapture, + IVariantSnapshotSerializer variantSnapshotSerializer, + INotificationDispatcher notifications) + : IRequestHandler> +{ + public async ValueTask> Handle(ConvertRequestToBookingCommand request, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return OperationResult.UnauthorizedResult("Not authenticated."); + + var source = await unitOfWork.BookingRequestRepository.GetConversionSourceAsync(request.BookingRequestId, cancellationToken); + if (source is null) + return OperationResult.NotFoundResult("Booking request not found."); + + // The payer (owning customer) or an admin may trigger the (mock) capture. Any other caller must not + // even learn the request exists. + var isAdmin = currentUser.Roles?.Any(BookingRoles.Admin.Contains) == true; + var callerCustomerId = await unitOfWork.CustomerProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken); + if (!isAdmin && callerCustomerId != source.CustomerId) + return OperationResult.NotFoundResult("Booking request not found."); + + // Idempotency: the UNIQUE booking_request_id means a replay can't create a second booking — return + // the one already created. + var existingId = await unitOfWork.BookingRepository.GetBookingIdByRequestIdAsync(source.RequestId, cancellationToken); + if (existingId is { } existing) + { + var existingDetail = await unitOfWork.BookingRepository.GetDetailAsync(existing, cancellationToken); + return OperationResult.SuccessResult(BookingMapper.ToDetailDto(existingDetail!, includeAddress: true)); + } + + if (source.Status != BookingRequestStatus.AcceptedAwaitingPayment) + return OperationResult.ConflictResult("This request is not awaiting payment and cannot be converted."); + + // A booking exists ONLY on a successful capture — a failed capture creates nothing. + var capture = await paymentCapture.ConfirmCaptureAsync(source.RequestId, cancellationToken); + if (!capture.Succeeded) + return OperationResult.FailureResult("Payment capture failed; no booking was created."); + + var now = dateTimeProvider.UtcNow.UtcDateTime; + + // session_count comes from the variant (a single visit is 1); gross = price × sessions. + var sessionCount = source.VariantSnapshot.SessionCount is { } sc && sc > 0 ? sc : 1; + var gross = source.VariantSnapshot.Price * sessionCount; + + var rate = await platformConfig.GetConfig("platform_fee_rate", cancellationToken); + var (commission, payout) = BookingAmounts.Split(gross, rate); + + var booking = new BookingEntity + { + BookingRequestId = source.RequestId, + CustomerId = source.CustomerId, + NurseId = source.NurseId, + PatientId = source.PatientId, + VariantId = source.VariantId, + CustomerAddressId = source.CustomerAddressId, + VariantSnapshotJson = variantSnapshotSerializer.Serialize(source.VariantSnapshot), + AddressSnapshotJson = SerializeAddress(source.AddressSnapshot), + GrossPriceIrr = gross, + BalinyaarCommissionIrr = commission, + PlatformFeeRate = rate, + NursePayoutAmount = payout, + PspFeeAmount = capture.PspFeeAmount, + SessionCount = (short)sessionCount, + ScheduledDate = source.RequestedDate, + ScheduledTimeStart = source.RequestedTimeStart, + ScheduledTimeEnd = source.RequestedTimeEnd + }; + + // Always ≥ 1 session; Σ(visit_payout_amount) reconciles exactly to nurse_payout_amount. + var visitPayouts = BookingAmounts.SplitPayout(payout, sessionCount); + for (var i = 0; i < sessionCount; i++) + { + booking.Sessions.Add(new BookingSession + { + SessionIndex = i + 1, + ScheduledDate = source.RequestedDate, + ScheduledTimeStart = source.RequestedTimeStart, + ScheduledTimeEnd = source.RequestedTimeEnd, + VisitPayoutAmount = visitPayouts[i] + }); + } + + booking.TransitionTo(BookingStatus.Confirmed, now); + + // Flip the request → converted in the same unit of work. Re-check the tracked state so a racing + // cancel/expiry that already moved it is a clean conflict, not a double conversion. + var trackedRequest = await unitOfWork.BookingRequestRepository.GetTrackedByIdAsync(source.RequestId, cancellationToken); + if (trackedRequest is null || !trackedRequest.CanTransitionTo(BookingRequestStatus.Converted)) + return OperationResult.ConflictResult("This request can no longer be converted."); + + trackedRequest.MarkConverted(); + + await unitOfWork.BookingRepository.AddAsync(booking, cancellationToken); + await unitOfWork.CommitAsync(); + + await notifications.DispatchAsync( + new Notification( + source.CustomerUserId, + "booking_confirmed", + "Booking confirmed", + "Your payment was captured and your booking is confirmed.", + JsonSerializer.Serialize(new { booking_id = booking.Id })), + cancellationToken); + + await notifications.DispatchAsync( + new Notification( + source.NurseUserId, + "booking_confirmed_nurse", + "New confirmed booking", + "A booking has been confirmed and paid. The care instructions and schedule are now available.", + JsonSerializer.Serialize(new { booking_id = booking.Id })), + cancellationToken); + + var detail = await unitOfWork.BookingRepository.GetDetailAsync(booking.Id, cancellationToken); + return OperationResult.SuccessResult(BookingMapper.ToDetailDto(detail!, includeAddress: true)); + } + + // Persian labels land in the snapshot as readable text (not \uXXXX escapes), mirroring the variant + // snapshot serializer; the encoder still escapes HTML-sensitive ASCII so the stored JSON stays safe. + private static readonly JsonSerializerOptions AddressJson = new() + { + Encoder = JavaScriptEncoder.Create(UnicodeRanges.All) + }; + + private static string SerializeAddress(AddressSnapshot a) => JsonSerializer.Serialize(new + { + addressId = a.AddressId, + title = a.Title, + cityId = a.CityId, + cityNameFa = a.CityNameFa, + cityNameEn = a.CityNameEn, + districtId = a.DistrictId, + districtNameFa = a.DistrictNameFa, + districtNameEn = a.DistrictNameEn, + addressLine = a.AddressLine, + postalCode = a.PostalCode, + recipientName = a.RecipientName, + recipientPhone = a.RecipientPhone, + latitude = a.Latitude, + longitude = a.Longitude + }, AddressJson); +} diff --git a/server/src/Core/Baya.Application/Features/Bookings/Commands/ConvertRequestToBooking/ConvertRequestToBookingCommand.Validator.cs b/server/src/Core/Baya.Application/Features/Bookings/Commands/ConvertRequestToBooking/ConvertRequestToBookingCommand.Validator.cs new file mode 100644 index 0000000..5636237 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Bookings/Commands/ConvertRequestToBooking/ConvertRequestToBookingCommand.Validator.cs @@ -0,0 +1,11 @@ +using FluentValidation; + +namespace Baya.Application.Features.Bookings.Commands.ConvertRequestToBooking; + +public sealed class ConvertRequestToBookingCommandValidator : AbstractValidator +{ + public ConvertRequestToBookingCommandValidator() + { + RuleFor(x => x.BookingRequestId).GreaterThan(0); + } +} diff --git a/server/src/Core/Baya.Application/Features/Bookings/Commands/ConvertRequestToBooking/ConvertRequestToBookingCommand.cs b/server/src/Core/Baya.Application/Features/Bookings/Commands/ConvertRequestToBooking/ConvertRequestToBookingCommand.cs new file mode 100644 index 0000000..922434f --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Bookings/Commands/ConvertRequestToBooking/ConvertRequestToBookingCommand.cs @@ -0,0 +1,15 @@ +using Baya.Application.Models.Booking; +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Bookings.Commands.ConvertRequestToBooking; + +/// +/// The conversion engine — invoked by payment capture (mocked via IPaymentCaptureSimulator +/// now, real card capture in b10). It loads an accepted_awaiting_payment booking request, verifies a +/// successful capture, then creates the 1:1 bookings row (pending_payment → confirmed), writes +/// the variant + encrypted address snapshots, computes the three amounts, generates ≥ 1 reconciling session, +/// and flips the request to converted — all in one unit of work. Idempotent: the unique +/// booking_request_id means a replay returns the existing booking rather than creating a second one. +/// +public record ConvertRequestToBookingCommand(long BookingRequestId) : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Bookings/Commands/DetectNoShowSessions/DetectNoShowSessionsCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Bookings/Commands/DetectNoShowSessions/DetectNoShowSessionsCommand.Handler.cs new file mode 100644 index 0000000..2f4efd4 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Bookings/Commands/DetectNoShowSessions/DetectNoShowSessionsCommand.Handler.cs @@ -0,0 +1,76 @@ +#nullable enable +using System.Text.Json; +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Configuration; +using Baya.Application.Contracts.Persistence; +using Baya.Application.Contracts.SupportAlerts; +using Baya.Application.Models.Booking; +using Baya.Application.Models.Common; +using Baya.Domain.Entities.Booking; +using Baya.Domain.Entities.SupportAlerts; +using Mediator; + +namespace Baya.Application.Features.Bookings.Commands.DetectNoShowSessions; + +internal sealed class DetectNoShowSessionsCommandHandler( + IUnitOfWork unitOfWork, + IPlatformConfig platformConfig, + IDateTimeProvider dateTimeProvider, + ISupportAlertService supportAlerts, + INotificationDispatcher notifications) + : IRequestHandler> +{ + private const int BatchSize = 200; + + public async ValueTask> Handle(DetectNoShowSessionsCommand request, CancellationToken cancellationToken) + { + var now = dateTimeProvider.UtcNow.UtcDateTime; + var thresholdMinutes = await platformConfig.GetConfig("no_show_threshold_minutes", cancellationToken); + var today = DateOnly.FromDateTime(now); + + var candidates = await unitOfWork.BookingRepository.GetNoShowCandidatesAsync(today, BatchSize, cancellationToken); + + var missed = new List(); + foreach (var session in candidates) + { + var start = session.ScheduledDate.ToDateTime(session.ScheduledTimeStart, DateTimeKind.Utc); + if (start.AddMinutes(thresholdMinutes) > now) + continue; + + if (!session.CanTransitionTo(BookingSessionStatus.Missed)) + continue; + + session.TransitionTo(BookingSessionStatus.Missed); + missed.Add(session); + } + + if (missed.Count == 0) + return OperationResult.SuccessResult(new NoShowSweepResult(0)); + + await unitOfWork.CommitAsync(); + + foreach (var session in missed) + { + await supportAlerts.RaiseAsync( + SupportAlertType.EvvNoShow, + entityType: "booking_session", + entityId: session.Id.ToString(), + severity: SupportAlertSeverity.High, + bookingId: session.BookingId, + cancellationToken: cancellationToken); + + var participants = await unitOfWork.BookingRepository.GetParticipantsAsync(session.BookingId, cancellationToken); + if (participants is not null) + await notifications.DispatchAsync( + new Notification( + participants.CustomerUserId, + "evv_no_show", + "Nurse did not check in", + "The nurse did not check in for a scheduled visit. Our team has been alerted.", + JsonSerializer.Serialize(new { booking_id = session.BookingId, session_id = session.Id })), + cancellationToken); + } + + return OperationResult.SuccessResult(new NoShowSweepResult(missed.Count)); + } +} diff --git a/server/src/Core/Baya.Application/Features/Bookings/Commands/DetectNoShowSessions/DetectNoShowSessionsCommand.cs b/server/src/Core/Baya.Application/Features/Bookings/Commands/DetectNoShowSessions/DetectNoShowSessionsCommand.cs new file mode 100644 index 0000000..f0b5888 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Bookings/Commands/DetectNoShowSessions/DetectNoShowSessionsCommand.cs @@ -0,0 +1,11 @@ +using Baya.Application.Models.Booking; +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Bookings.Commands.DetectNoShowSessions; + +/// The no-show sweep unit of work: any session still scheduled past +/// scheduled_start + no_show_threshold_minutes with no check-in is marked missed, a +/// no_show support alert is raised, and the family is notified. Bounded + idempotent. The recurring +/// scheduler is DEFERRED — this is the command the cron will call, reachable now via an admin/test trigger. +public record DetectNoShowSessionsCommand : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Bookings/Commands/SubmitCareInstructions/SubmitCareInstructionsCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Bookings/Commands/SubmitCareInstructions/SubmitCareInstructionsCommand.Handler.cs new file mode 100644 index 0000000..81df004 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Bookings/Commands/SubmitCareInstructions/SubmitCareInstructionsCommand.Handler.cs @@ -0,0 +1,63 @@ +#nullable enable +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Persistence; +using Baya.Application.Models.Booking; +using Baya.Application.Models.Common; +using Baya.Domain.Entities.Booking; +using Mediator; + +namespace Baya.Application.Features.Bookings.Commands.SubmitCareInstructions; + +internal sealed class SubmitCareInstructionsCommandHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork) + : IRequestHandler> +{ + private static readonly string[] WritableStatuses = + [ + BookingStatus.Confirmed, BookingStatus.InProgress, + BookingStatus.Completed, BookingStatus.Disputed, BookingStatus.Closed + ]; + + public async ValueTask> Handle(SubmitCareInstructionsCommand request, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return OperationResult.UnauthorizedResult("Not authenticated."); + + var booking = await unitOfWork.BookingRepository.GetTrackedWithCareAsync(request.BookingId, cancellationToken); + if (booking is null) + return OperationResult.NotFoundResult("Booking not found."); + + // Author = the owning customer, or an admin acting on their behalf. + var isAdmin = currentUser.Roles?.Any(BookingRoles.Admin.Contains) == true; + var customerId = await unitOfWork.CustomerProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken); + if (!isAdmin && customerId != booking.CustomerId) + return OperationResult.NotFoundResult("Booking not found."); + + if (!WritableStatuses.Contains(booking.Status)) + return OperationResult.ConflictResult("Care instructions can only be added once the booking is confirmed."); + + var care = booking.CareInstructions; + if (care is null) + { + care = new BookingCareInstruction { BookingId = booking.Id }; + booking.CareInstructions = care; + } + + care.CurrentConditions = request.CurrentConditions; + care.Medications = request.Medications; + care.Allergies = request.Allergies; + care.SpecialInstructions = request.SpecialInstructions; + care.EmergencyContactName = request.EmergencyContactName; + care.EmergencyContactPhone = request.EmergencyContactPhone; + + await unitOfWork.CommitAsync(); + + return OperationResult.SuccessResult(new CareInstructionsDto( + booking.Id, + care.CurrentConditions, + care.Medications, + care.Allergies, + care.SpecialInstructions, + care.EmergencyContactName, + care.EmergencyContactPhone)); + } +} diff --git a/server/src/Core/Baya.Application/Features/Bookings/Commands/SubmitCareInstructions/SubmitCareInstructionsCommand.Validator.cs b/server/src/Core/Baya.Application/Features/Bookings/Commands/SubmitCareInstructions/SubmitCareInstructionsCommand.Validator.cs new file mode 100644 index 0000000..51661f7 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Bookings/Commands/SubmitCareInstructions/SubmitCareInstructionsCommand.Validator.cs @@ -0,0 +1,18 @@ +using FluentValidation; + +namespace Baya.Application.Features.Bookings.Commands.SubmitCareInstructions; + +public sealed class SubmitCareInstructionsCommandValidator : AbstractValidator +{ + public SubmitCareInstructionsCommandValidator() + { + RuleFor(x => x.CurrentConditions).MaximumLength(2000); + RuleFor(x => x.Medications).MaximumLength(2000); + RuleFor(x => x.Allergies).MaximumLength(2000); + RuleFor(x => x.SpecialInstructions).MaximumLength(2000); + RuleFor(x => x.EmergencyContactName).MaximumLength(200); + RuleFor(x => x.EmergencyContactPhone).MaximumLength(30); + + // The booking id is route-supplied, so it is not validated in the body. + } +} diff --git a/server/src/Core/Baya.Application/Features/Bookings/Commands/SubmitCareInstructions/SubmitCareInstructionsCommand.cs b/server/src/Core/Baya.Application/Features/Bookings/Commands/SubmitCareInstructions/SubmitCareInstructionsCommand.cs new file mode 100644 index 0000000..e5341bd --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Bookings/Commands/SubmitCareInstructions/SubmitCareInstructionsCommand.cs @@ -0,0 +1,17 @@ +#nullable enable +using Baya.Application.Models.Booking; +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Bookings.Commands.SubmitCareInstructions; + +/// Writes/updates the 1:1 encrypted booking_care_instructions for a confirmed booking. +/// Customer-authored (or admin). The booking id comes from the route, never the body. +public record SubmitCareInstructionsCommand( + string? CurrentConditions, + string? Medications, + string? Allergies, + string? SpecialInstructions, + string? EmergencyContactName, + string? EmergencyContactPhone, + long BookingId = 0) : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Bookings/Commands/TransitionBookingStatus/TransitionBookingStatusCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Bookings/Commands/TransitionBookingStatus/TransitionBookingStatusCommand.Handler.cs new file mode 100644 index 0000000..3ec85e2 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Bookings/Commands/TransitionBookingStatus/TransitionBookingStatusCommand.Handler.cs @@ -0,0 +1,62 @@ +#nullable enable +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Configuration; +using Baya.Application.Contracts.Persistence; +using Baya.Application.Models.Booking; +using Baya.Application.Models.Common; +using Baya.Domain.Entities.Booking; +using Mediator; + +namespace Baya.Application.Features.Bookings.Commands.TransitionBookingStatus; + +internal sealed class TransitionBookingStatusCommandHandler( + ICurrentUser currentUser, + IUnitOfWork unitOfWork, + IPlatformConfig platformConfig, + IDateTimeProvider dateTimeProvider) + : IRequestHandler> +{ + public async ValueTask> Handle(TransitionBookingStatusCommand request, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } _) + return OperationResult.UnauthorizedResult("Not authenticated."); + + if (currentUser.Roles?.Any(BookingRoles.Admin.Contains) != true) + return OperationResult.ForbiddenResult("Only an admin can drive an explicit booking transition."); + + var target = request.TargetStatus; + if (target == BookingStatus.Cancelled) + return OperationResult.FailureResult("Use the cancel endpoint so the cancellation policy is resolved and snapshotted."); + + var booking = await unitOfWork.BookingRepository.GetTrackedWithSessionsAsync(request.BookingId, cancellationToken); + if (booking is null) + return OperationResult.NotFoundResult("Booking not found."); + + if (!booking.CanTransitionTo(target)) + return OperationResult.ConflictResult($"A booking in '{booking.Status}' cannot transition to '{target}'."); + + // No transition may contradict EVV/session state. + if (target == BookingStatus.InProgress && + !booking.Sessions.Any(s => s.Status == BookingSessionStatus.InProgress)) + return OperationResult.ConflictResult("Cannot move to in_progress with no session checked in."); + + if (target == BookingStatus.Completed && + booking.Sessions.Any(s => s.Status is BookingSessionStatus.Scheduled or BookingSessionStatus.InProgress)) + return OperationResult.ConflictResult("Cannot complete a booking while a session is still scheduled or in progress."); + + var now = dateTimeProvider.UtcNow.UtcDateTime; + booking.TransitionTo(target, now, reason: request.Reason); + + // An admin-driven completion still opens the dispute window (the single payout-eligibility trigger). + if (target == BookingStatus.Completed) + { + var disputeWindowHours = await platformConfig.GetConfig("dispute_window_hours", cancellationToken); + booking.SetDisputeWindow(now.AddHours(disputeWindowHours)); + } + + await unitOfWork.CommitAsync(); + + var detail = await unitOfWork.BookingRepository.GetDetailAsync(booking.Id, cancellationToken); + return OperationResult.SuccessResult(BookingMapper.ToDetailDto(detail!, includeAddress: true)); + } +} diff --git a/server/src/Core/Baya.Application/Features/Bookings/Commands/TransitionBookingStatus/TransitionBookingStatusCommand.Validator.cs b/server/src/Core/Baya.Application/Features/Bookings/Commands/TransitionBookingStatus/TransitionBookingStatusCommand.Validator.cs new file mode 100644 index 0000000..1d34197 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Bookings/Commands/TransitionBookingStatus/TransitionBookingStatusCommand.Validator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace Baya.Application.Features.Bookings.Commands.TransitionBookingStatus; + +public sealed class TransitionBookingStatusCommandValidator : AbstractValidator +{ + public TransitionBookingStatusCommandValidator() + { + RuleFor(x => x.TargetStatus).NotEmpty(); + RuleFor(x => x.Reason).MaximumLength(500); + } +} diff --git a/server/src/Core/Baya.Application/Features/Bookings/Commands/TransitionBookingStatus/TransitionBookingStatusCommand.cs b/server/src/Core/Baya.Application/Features/Bookings/Commands/TransitionBookingStatus/TransitionBookingStatusCommand.cs new file mode 100644 index 0000000..29752db --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Bookings/Commands/TransitionBookingStatus/TransitionBookingStatusCommand.cs @@ -0,0 +1,13 @@ +#nullable enable +using Baya.Application.Models.Booking; +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Bookings.Commands.TransitionBookingStatus; + +/// Applies an admin/dispute status change to a booking — only if allowed by the transition table +/// and consistent with EVV/session state (e.g. you cannot move to in_progress with no session +/// checked in, nor to completed while a session is still live). Cancellation goes through the cancel +/// endpoint (which snapshots the policy), not here. The booking id comes from the route. +public record TransitionBookingStatusCommand(string TargetStatus, string? Reason = null, long BookingId = 0) + : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Bookings/Commands/UpsertCancellationPolicy/UpsertCancellationPolicyCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Bookings/Commands/UpsertCancellationPolicy/UpsertCancellationPolicyCommand.Handler.cs new file mode 100644 index 0000000..e4a1d08 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Bookings/Commands/UpsertCancellationPolicy/UpsertCancellationPolicyCommand.Handler.cs @@ -0,0 +1,37 @@ +#nullable enable +using Baya.Application.Contracts.Persistence; +using Baya.Application.Models.Booking; +using Baya.Application.Models.Common; +using Baya.Domain.Entities.Booking; +using Mediator; + +namespace Baya.Application.Features.Bookings.Commands.UpsertCancellationPolicy; + +// Admin-only access is enforced by the controller policy. +internal sealed class UpsertCancellationPolicyCommandHandler(IUnitOfWork unitOfWork) + : IRequestHandler> +{ + public async ValueTask> Handle(UpsertCancellationPolicyCommand request, CancellationToken cancellationToken) + { + var policy = await unitOfWork.CancellationPolicyRepository.GetTrackedByCodeAsync(request.Code, cancellationToken); + if (policy is null) + { + policy = new CancellationPolicy { Code = request.Code }; + await unitOfWork.CancellationPolicyRepository.AddAsync(policy, cancellationToken); + } + + policy.AppliesTo = request.AppliesTo; + policy.HoursBeforeStartMin = request.HoursBeforeStartMin; + policy.HoursBeforeStartMax = request.HoursBeforeStartMax; + policy.RefundPercentage = request.RefundPercentage; + policy.FeeAmountIrr = request.FeeAmountIrr; + policy.FeeRate = request.FeeRate; + policy.IsActive = request.IsActive; + + await unitOfWork.CommitAsync(); + + return OperationResult.SuccessResult(new CancellationPolicyDto( + policy.Id, policy.Code, policy.AppliesTo, policy.HoursBeforeStartMin, policy.HoursBeforeStartMax, + policy.RefundPercentage, policy.FeeAmountIrr.ToString(), policy.FeeRate, policy.IsActive)); + } +} diff --git a/server/src/Core/Baya.Application/Features/Bookings/Commands/UpsertCancellationPolicy/UpsertCancellationPolicyCommand.Validator.cs b/server/src/Core/Baya.Application/Features/Bookings/Commands/UpsertCancellationPolicy/UpsertCancellationPolicyCommand.Validator.cs new file mode 100644 index 0000000..80a5eb7 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Bookings/Commands/UpsertCancellationPolicy/UpsertCancellationPolicyCommand.Validator.cs @@ -0,0 +1,25 @@ +using Baya.Domain.Entities.Booking; +using FluentValidation; + +namespace Baya.Application.Features.Bookings.Commands.UpsertCancellationPolicy; + +public sealed class UpsertCancellationPolicyCommandValidator : AbstractValidator +{ + public UpsertCancellationPolicyCommandValidator() + { + RuleFor(x => x.Code).NotEmpty().MaximumLength(50); + + RuleFor(x => x.AppliesTo) + .NotEmpty() + .Must(CancellationActor.IsValid) + .WithMessage("applies_to must be one of: customer, nurse, admin."); + + RuleFor(x => x.RefundPercentage).InclusiveBetween(0m, 100m); + RuleFor(x => x.FeeAmountIrr).GreaterThanOrEqualTo(0); + RuleFor(x => x.FeeRate).InclusiveBetween(0m, 1m).When(x => x.FeeRate.HasValue); + + RuleFor(x => x) + .Must(x => x.HoursBeforeStartMin is null || x.HoursBeforeStartMax is null || x.HoursBeforeStartMin < x.HoursBeforeStartMax) + .WithMessage("hours_before_start_min must be less than hours_before_start_max."); + } +} diff --git a/server/src/Core/Baya.Application/Features/Bookings/Commands/UpsertCancellationPolicy/UpsertCancellationPolicyCommand.cs b/server/src/Core/Baya.Application/Features/Bookings/Commands/UpsertCancellationPolicy/UpsertCancellationPolicyCommand.cs new file mode 100644 index 0000000..bbfa815 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Bookings/Commands/UpsertCancellationPolicy/UpsertCancellationPolicyCommand.cs @@ -0,0 +1,19 @@ +#nullable enable +using Baya.Application.Models.Booking; +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Bookings.Commands.UpsertCancellationPolicy; + +/// Admin create/update of a cancellation tier, keyed by unique code. Editing a policy never +/// mutates an already-snapshotted cancellation (past cancellations froze the code + percentage). The fee is +/// a flat IRR amount plus an optional fraction; a tier uses whichever is non-zero. +public record UpsertCancellationPolicyCommand( + string Code, + string AppliesTo, + int? HoursBeforeStartMin, + int? HoursBeforeStartMax, + decimal RefundPercentage, + long FeeAmountIrr, + decimal? FeeRate, + bool IsActive) : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Bookings/Queries/GetBookingDetail/GetBookingDetailQuery.Handler.cs b/server/src/Core/Baya.Application/Features/Bookings/Queries/GetBookingDetail/GetBookingDetailQuery.Handler.cs new file mode 100644 index 0000000..f3002d1 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Bookings/Queries/GetBookingDetail/GetBookingDetailQuery.Handler.cs @@ -0,0 +1,38 @@ +#nullable enable +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Persistence; +using Baya.Application.Models.Booking; +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Bookings.Queries.GetBookingDetail; + +internal sealed class GetBookingDetailQueryHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork) + : IRequestHandler> +{ + public async ValueTask> Handle(GetBookingDetailQuery request, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return OperationResult.UnauthorizedResult("Not authenticated."); + + var detail = await unitOfWork.BookingRepository.GetDetailAsync(request.Id, cancellationToken); + if (detail is null) + return OperationResult.NotFoundResult("Booking not found."); + + var isAdmin = currentUser.Roles?.Any(BookingRoles.Admin.Contains) == true; + + var customerId = await unitOfWork.CustomerProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken); + if (customerId == detail.CustomerId) + return OperationResult.SuccessResult(BookingMapper.ToDetailDto(detail, includeAddress: true)); + + var nurseId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken); + if (nurseId == detail.NurseId) + return OperationResult.SuccessResult(BookingMapper.ToDetailDto(detail, includeAddress: false)); + + if (isAdmin) + return OperationResult.SuccessResult(BookingMapper.ToDetailDto(detail, includeAddress: true)); + + // Neither party nor admin — do not leak that the booking exists. + return OperationResult.NotFoundResult("Booking not found."); + } +} diff --git a/server/src/Core/Baya.Application/Features/Bookings/Queries/GetBookingDetail/GetBookingDetailQuery.cs b/server/src/Core/Baya.Application/Features/Bookings/Queries/GetBookingDetail/GetBookingDetailQuery.cs new file mode 100644 index 0000000..1d57045 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Bookings/Queries/GetBookingDetail/GetBookingDetailQuery.cs @@ -0,0 +1,10 @@ +using Baya.Application.Models.Booking; +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Bookings.Queries.GetBookingDetail; + +/// Booking header + money summary + sessions + status timeline, tenancy-scoped: the customer sees +/// their own bookings, the nurse their assigned bookings, admin all. Never cross-tenant; the nurse view omits +/// the address snapshot and no query ever surfaces care-instruction clinical fields. +public record GetBookingDetailQuery(long Id) : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Bookings/Queries/GetCareInstructions/GetCareInstructionsQuery.Handler.cs b/server/src/Core/Baya.Application/Features/Bookings/Queries/GetCareInstructions/GetCareInstructionsQuery.Handler.cs new file mode 100644 index 0000000..6dafc08 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Bookings/Queries/GetCareInstructions/GetCareInstructionsQuery.Handler.cs @@ -0,0 +1,45 @@ +#nullable enable +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Persistence; +using Baya.Application.Models.Booking; +using Baya.Application.Models.Common; +using Baya.Domain.Entities.Booking; +using Mediator; + +namespace Baya.Application.Features.Bookings.Queries.GetCareInstructions; + +internal sealed class GetCareInstructionsQueryHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork) + : IRequestHandler> +{ + // The clinical fields are visible only once the booking is confirmed and while it is a live/closed + // engagement — never pre-payment, never on a cancelled booking. + private static readonly string[] DisclosableStatuses = + [ + BookingStatus.Confirmed, BookingStatus.InProgress, + BookingStatus.Completed, BookingStatus.Disputed, BookingStatus.Closed + ]; + + public async ValueTask> Handle(GetCareInstructionsQuery request, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return OperationResult.UnauthorizedResult("Not authenticated."); + + var gate = await unitOfWork.BookingRepository.GetCareInstructionsGateAsync(request.BookingId, cancellationToken); + if (gate is null) + return OperationResult.NotFoundResult("Booking not found."); + + var isAdmin = currentUser.Roles?.Any(BookingRoles.Admin.Contains) == true; + var nurseId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken); + var isAssignedNurse = nurseId == gate.NurseId; + + // Two-stage disclosure: only the assigned nurse or an admin, only post-confirmation. Any other caller + // (the customer, an unassigned nurse, or a pre-confirmation booking) must not learn anything. + if ((!isAssignedNurse && !isAdmin) || !DisclosableStatuses.Contains(gate.BookingStatus)) + return OperationResult.NotFoundResult("Care instructions not found."); + + if (gate.Instructions is null) + return OperationResult.NotFoundResult("No care instructions have been added for this booking yet."); + + return OperationResult.SuccessResult(gate.Instructions); + } +} diff --git a/server/src/Core/Baya.Application/Features/Bookings/Queries/GetCareInstructions/GetCareInstructionsQuery.cs b/server/src/Core/Baya.Application/Features/Bookings/Queries/GetCareInstructions/GetCareInstructionsQuery.cs new file mode 100644 index 0000000..b461226 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Bookings/Queries/GetCareInstructions/GetCareInstructionsQuery.cs @@ -0,0 +1,11 @@ +using Baya.Application.Models.Booking; +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Bookings.Queries.GetCareInstructions; + +/// The gated stage-2 clinical read. Decrypts and returns the care instructions only to +/// (a) the assigned nurse of that booking and (b) admin, and only post-confirmation. Any other caller +/// — the customer, an unassigned nurse, or a pre-confirmation booking — gets a clean not-found. This is the +/// two-stage disclosure boundary; it must never leak. +public record GetCareInstructionsQuery(long BookingId) : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Bookings/Queries/GetVisitVerification/GetVisitVerificationQuery.Handler.cs b/server/src/Core/Baya.Application/Features/Bookings/Queries/GetVisitVerification/GetVisitVerificationQuery.Handler.cs new file mode 100644 index 0000000..9dbd5dc --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Bookings/Queries/GetVisitVerification/GetVisitVerificationQuery.Handler.cs @@ -0,0 +1,34 @@ +#nullable enable +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Persistence; +using Baya.Application.Models.Booking; +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Bookings.Queries.GetVisitVerification; + +internal sealed class GetVisitVerificationQueryHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork) + : IRequestHandler> +{ + public async ValueTask> Handle(GetVisitVerificationQuery request, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return OperationResult.UnauthorizedResult("Not authenticated."); + + var gate = await unitOfWork.BookingRepository.GetEvvForSessionAsync(request.SessionId, cancellationToken); + if (gate is null) + return OperationResult.NotFoundResult("Session not found."); + + var isAdmin = currentUser.Roles?.Any(BookingRoles.Admin.Contains) == true; + var nurseId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken); + + // Raw GPS detail is gated to the owning nurse + admin only. + if (nurseId != gate.NurseId && !isAdmin) + return OperationResult.NotFoundResult("Session not found."); + + if (gate.Evv is null) + return OperationResult.NotFoundResult("No EVV record exists for this session yet."); + + return OperationResult.SuccessResult(gate.Evv); + } +} diff --git a/server/src/Core/Baya.Application/Features/Bookings/Queries/GetVisitVerification/GetVisitVerificationQuery.cs b/server/src/Core/Baya.Application/Features/Bookings/Queries/GetVisitVerification/GetVisitVerificationQuery.cs new file mode 100644 index 0000000..45f5cbd --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Bookings/Queries/GetVisitVerification/GetVisitVerificationQuery.cs @@ -0,0 +1,9 @@ +using Baya.Application.Models.Booking; +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Bookings.Queries.GetVisitVerification; + +/// Per-session EVV detail. Raw GPS is gated to the owning nurse + admin only; any other caller gets +/// a clean not-found. +public record GetVisitVerificationQuery(long SessionId) : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Bookings/Queries/ListAdminEvv/ListAdminEvvQuery.Handler.cs b/server/src/Core/Baya.Application/Features/Bookings/Queries/ListAdminEvv/ListAdminEvvQuery.Handler.cs new file mode 100644 index 0000000..b9a0b25 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Bookings/Queries/ListAdminEvv/ListAdminEvvQuery.Handler.cs @@ -0,0 +1,22 @@ +#nullable enable +using Baya.Application.Contracts.Persistence; +using Baya.Application.Models.Booking; +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Bookings.Queries.ListAdminEvv; + +// Admin-only access is enforced by the controller policy; this handler assumes an admin caller. +internal sealed class ListAdminEvvQueryHandler(IUnitOfWork unitOfWork) + : IRequestHandler>> +{ + public async ValueTask>> Handle(ListAdminEvvQuery request, CancellationToken cancellationToken) + { + var page = request.Page < 1 ? 1 : request.Page; + var pageSize = request.PageSize is < 1 or > 100 ? 20 : request.PageSize; + var type = request.Type == "no_show" ? "no_show" : "mismatch"; + + var result = await unitOfWork.BookingRepository.ListAdminEvvAsync(type, page, pageSize, cancellationToken); + return OperationResult>.SuccessResult(result); + } +} diff --git a/server/src/Core/Baya.Application/Features/Bookings/Queries/ListAdminEvv/ListAdminEvvQuery.cs b/server/src/Core/Baya.Application/Features/Bookings/Queries/ListAdminEvv/ListAdminEvvQuery.cs new file mode 100644 index 0000000..709afd0 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Bookings/Queries/ListAdminEvv/ListAdminEvvQuery.cs @@ -0,0 +1,10 @@ +using Baya.Application.Models.Booking; +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Bookings.Queries.ListAdminEvv; + +/// The admin EVV-review queue: type=mismatch (advisory location mismatches) or +/// type=no_show (missed sessions). Projected + paginated; behind the admin policy. +public record ListAdminEvvQuery(string Type = "mismatch", int Page = 1, int PageSize = 20) + : IRequest>>; diff --git a/server/src/Core/Baya.Application/Features/Bookings/Queries/ListBookings/ListBookingsQuery.Handler.cs b/server/src/Core/Baya.Application/Features/Bookings/Queries/ListBookings/ListBookingsQuery.Handler.cs new file mode 100644 index 0000000..f73ba63 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Bookings/Queries/ListBookings/ListBookingsQuery.Handler.cs @@ -0,0 +1,52 @@ +#nullable enable +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Persistence; +using Baya.Application.Models.Booking; +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Bookings.Queries.ListBookings; + +internal sealed class ListBookingsQueryHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork) + : IRequestHandler>> +{ + public async ValueTask>> Handle(ListBookingsQuery request, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return OperationResult>.UnauthorizedResult("Not authenticated."); + + var page = request.Page < 1 ? 1 : request.Page; + var pageSize = request.PageSize is < 1 or > 100 ? 20 : request.PageSize; + var role = request.Role?.ToLowerInvariant(); + + if (role == "all") + { + if (currentUser.Roles?.Any(BookingRoles.Admin.Contains) != true) + return OperationResult>.ForbiddenResult("Only an admin can list all bookings."); + + var all = await unitOfWork.BookingRepository.ListAllAsync(request.Status, page, pageSize, cancellationToken); + return OperationResult>.SuccessResult(all); + } + + if (role == "nurse") + { + var nurseId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken); + if (nurseId is not { } nid) + return OperationResult>.SuccessResult(Empty(page, pageSize)); + + var nurseList = await unitOfWork.BookingRepository.ListForNurseAsync(nid, request.Status, page, pageSize, cancellationToken); + return OperationResult>.SuccessResult(nurseList); + } + + // Default: the customer's own bookings. + var customerId = await unitOfWork.CustomerProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken); + if (customerId is not { } cid) + return OperationResult>.SuccessResult(Empty(page, pageSize)); + + var list = await unitOfWork.BookingRepository.ListForCustomerAsync(cid, request.Status, page, pageSize, cancellationToken); + return OperationResult>.SuccessResult(list); + } + + private static PagedResult Empty(int page, int pageSize) + => new([], 0, page, pageSize); +} diff --git a/server/src/Core/Baya.Application/Features/Bookings/Queries/ListBookings/ListBookingsQuery.cs b/server/src/Core/Baya.Application/Features/Bookings/Queries/ListBookings/ListBookingsQuery.cs new file mode 100644 index 0000000..c7f5717 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Bookings/Queries/ListBookings/ListBookingsQuery.cs @@ -0,0 +1,11 @@ +using Baya.Application.Models.Booking; +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Bookings.Queries.ListBookings; + +/// The role-scoped "My bookings" list. role=customer (default) or role=nurse scopes +/// to the caller; role=all is the admin-only list-everything variant. Status-filterable, projected, +/// paginated. +public record ListBookingsQuery(string? Role = null, string? Status = null, int Page = 1, int PageSize = 20) + : IRequest>>; diff --git a/server/src/Core/Baya.Application/Features/Bookings/Queries/ListCancellationPolicies/ListCancellationPoliciesQuery.Handler.cs b/server/src/Core/Baya.Application/Features/Bookings/Queries/ListCancellationPolicies/ListCancellationPoliciesQuery.Handler.cs new file mode 100644 index 0000000..70cfb78 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Bookings/Queries/ListCancellationPolicies/ListCancellationPoliciesQuery.Handler.cs @@ -0,0 +1,18 @@ +#nullable enable +using Baya.Application.Contracts.Persistence; +using Baya.Application.Models.Booking; +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Bookings.Queries.ListCancellationPolicies; + +// Admin-only access is enforced by the controller policy. +internal sealed class ListCancellationPoliciesQueryHandler(IUnitOfWork unitOfWork) + : IRequestHandler>> +{ + public async ValueTask>> Handle(ListCancellationPoliciesQuery request, CancellationToken cancellationToken) + { + var policies = await unitOfWork.CancellationPolicyRepository.ListAsync(cancellationToken); + return OperationResult>.SuccessResult(policies); + } +} diff --git a/server/src/Core/Baya.Application/Features/Bookings/Queries/ListCancellationPolicies/ListCancellationPoliciesQuery.cs b/server/src/Core/Baya.Application/Features/Bookings/Queries/ListCancellationPolicies/ListCancellationPoliciesQuery.cs new file mode 100644 index 0000000..2875a2f --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Bookings/Queries/ListCancellationPolicies/ListCancellationPoliciesQuery.cs @@ -0,0 +1,8 @@ +using Baya.Application.Models.Booking; +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Bookings.Queries.ListCancellationPolicies; + +/// All cancellation tiers (active + inactive) for the admin management screen. +public record ListCancellationPoliciesQuery : IRequest>>; diff --git a/server/src/Core/Baya.Application/Features/Bookings/Queries/ListSessionsForNurse/ListSessionsForNurseQuery.Handler.cs b/server/src/Core/Baya.Application/Features/Bookings/Queries/ListSessionsForNurse/ListSessionsForNurseQuery.Handler.cs new file mode 100644 index 0000000..fa0fe3c --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Bookings/Queries/ListSessionsForNurse/ListSessionsForNurseQuery.Handler.cs @@ -0,0 +1,32 @@ +#nullable enable +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Persistence; +using Baya.Application.Models.Booking; +using Baya.Application.Models.Common; +using Baya.Domain.Entities.User; +using Mediator; + +namespace Baya.Application.Features.Bookings.Queries.ListSessionsForNurse; + +internal sealed class ListSessionsForNurseQueryHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork) + : IRequestHandler>> +{ + public async ValueTask>> Handle(ListSessionsForNurseQuery request, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return OperationResult>.UnauthorizedResult("Not authenticated."); + + if (currentUser.Roles?.Contains(RoleNames.Nurse) != true) + return OperationResult>.ForbiddenResult("Only a nurse can view their sessions."); + + var page = request.Page < 1 ? 1 : request.Page; + var pageSize = request.PageSize is < 1 or > 100 ? 20 : request.PageSize; + + var nurseId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken); + if (nurseId is not { } nid) + return OperationResult>.SuccessResult(new PagedResult([], 0, page, pageSize)); + + var result = await unitOfWork.BookingRepository.ListSessionsForNurseAsync(nid, request.Date, page, pageSize, cancellationToken); + return OperationResult>.SuccessResult(result); + } +} diff --git a/server/src/Core/Baya.Application/Features/Bookings/Queries/ListSessionsForNurse/ListSessionsForNurseQuery.cs b/server/src/Core/Baya.Application/Features/Bookings/Queries/ListSessionsForNurse/ListSessionsForNurseQuery.cs new file mode 100644 index 0000000..fe48f68 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Bookings/Queries/ListSessionsForNurse/ListSessionsForNurseQuery.cs @@ -0,0 +1,10 @@ +using Baya.Application.Models.Booking; +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Bookings.Queries.ListSessionsForNurse; + +/// The signed-in nurse's sessions for a day (today's visits by default), each with its check-in/out +/// CTA state. Tenancy-scoped to the nurse via ICurrentUser, projected + paginated. +public record ListSessionsForNurseQuery(DateOnly? Date = null, int Page = 1, int PageSize = 20) + : IRequest>>; diff --git a/server/src/Core/Baya.Application/Models/Booking/BookingDtos.cs b/server/src/Core/Baya.Application/Models/Booking/BookingDtos.cs new file mode 100644 index 0000000..380dc55 --- /dev/null +++ b/server/src/Core/Baya.Application/Models/Booking/BookingDtos.cs @@ -0,0 +1,142 @@ +#nullable enable +namespace Baya.Application.Models.Booking; + +/// +/// The full booking view returned by convert/detail/transition/cancel. Money crosses the wire as strings of +/// IRR-Rial digits (integer money, no floats). The nurse view omits the encrypted address snapshot; +/// the customer/admin view includes it. Care-instruction clinical fields are never here — they +/// live behind the gated care-instructions read. +/// +public record BookingDetailDto( + long Id, + long BookingRequestId, + string Status, + long NurseId, + string NurseName, + long PatientId, + string PatientName, + long VariantId, + string VariantSnapshotJson, + long CustomerAddressId, + string? AddressSnapshotJson, + string GrossPriceIrr, + string BalinyaarCommissionIrr, + decimal PlatformFeeRate, + string NursePayoutAmount, + string? PspFeeAmount, + short SessionCount, + DateOnly ScheduledDate, + TimeOnly ScheduledTimeStart, + TimeOnly ScheduledTimeEnd, + DateTime? ConfirmedAt, + DateTime? CompletedAt, + DateTime? CancelledAt, + string? CancelledBy, + string? CancellationReason, + string? CancellationPolicyCode, + decimal? CancellationRefundPercentage, + string? RefundableAmountIrr, + DateTime? DisputeWindowEndsAt, + DateTimeOffset CreatedAt, + IReadOnlyList Sessions); + +/// Per-visit summary embedded in the booking detail — schedule, status, payout, and EVV state. +public record BookingSessionSummaryDto( + long Id, + int SessionIndex, + DateOnly ScheduledDate, + TimeOnly ScheduledTimeStart, + TimeOnly ScheduledTimeEnd, + string Status, + string VisitPayoutAmount, + DateTime? PayoutEligibleAt, + string EvvStatus, + DateTime? CheckInAt, + DateTime? CheckOutAt, + bool? CheckInAddressMatch); + +/// The role-scoped "My bookings" list item — counterparty is the nurse (customer view) or the +/// patient (nurse view); is the gross (customer) or the payout (nurse). +public record BookingListItemDto( + long Id, + string Status, + string CounterpartyName, + DateOnly ScheduledDate, + short SessionCount, + string AmountIrr, + DateTime? DisputeWindowEndsAt, + DateTimeOffset CreatedAt); + +/// The nurse's "today" session-list item, with the per-session check-in/out CTA state. +public record BookingSessionListItemDto( + long SessionId, + long BookingId, + int SessionIndex, + string PatientName, + DateOnly ScheduledDate, + TimeOnly ScheduledTimeStart, + TimeOnly ScheduledTimeEnd, + string Status, + string EvvStatus); + +/// The decrypted stage-2 clinical fields — returned only to the assigned nurse + admin, +/// only post-confirmation. Never projected into a list or logged. +public record CareInstructionsDto( + long BookingId, + string? CurrentConditions, + string? Medications, + string? Allergies, + string? SpecialInstructions, + string? EmergencyContactName, + string? EmergencyContactPhone); + +/// Per-session EVV detail — raw GPS gated to the owning nurse + admin. +public record VisitVerificationDto( + long Id, + long BookingSessionId, + string Status, + DateTime? CheckInAt, + decimal? CheckInLat, + decimal? CheckInLng, + DateTime? CheckOutAt, + decimal? CheckOutLat, + decimal? CheckOutLng, + bool? CheckInAddressMatch, + decimal? CheckInDistanceMeters); + +/// An item in the admin EVV-review queue (location mismatch or no-show). +public record AdminEvvItemDto( + long SessionId, + long BookingId, + long NurseId, + string SessionStatus, + DateOnly ScheduledDate, + TimeOnly ScheduledTimeStart, + DateTime? CheckInAt, + bool? CheckInAddressMatch, + decimal? CheckInDistanceMeters); + +/// Admin-facing cancellation-policy row. +public record CancellationPolicyDto( + long Id, + string Code, + string AppliesTo, + int? HoursBeforeStartMin, + int? HoursBeforeStartMax, + decimal RefundPercentage, + string FeeAmountIrr, + decimal? FeeRate, + bool IsActive); + +/// The outcome of a cancellation — the frozen policy snapshot + computed refundable amount. No +/// refund ledger is posted here (that is b11); this is the figure b11 will consume. +public record CancellationResultDto( + long BookingId, + long? SessionId, + string BookingStatus, + string PolicyCode, + decimal RefundPercentage, + string RefundableAmountIrr); + +/// How many sessions the no-show sweep flagged (idempotent — re-running with none returns zero). +public record NoShowSweepResult(int Missed); diff --git a/server/src/Core/Baya.Application/Models/Booking/BookingProjections.cs b/server/src/Core/Baya.Application/Models/Booking/BookingProjections.cs new file mode 100644 index 0000000..b74168e --- /dev/null +++ b/server/src/Core/Baya.Application/Models/Booking/BookingProjections.cs @@ -0,0 +1,111 @@ +#nullable enable +using Baya.Application.Models.Catalog; + +namespace Baya.Application.Models.Booking; + +/// +/// The role-agnostic booking detail projection. Carries both parties' ids so the query handler can authorize +/// the caller and decide address-snapshot visibility, plus the decrypted address snapshot (masked for the +/// nurse). Money is the raw IRR long; the mapper stringifies it for the wire. +/// +public record BookingDetailProjection( + long Id, + long BookingRequestId, + string Status, + long CustomerId, + long NurseId, + string NurseName, + long PatientId, + string PatientName, + long VariantId, + string VariantSnapshotJson, + long CustomerAddressId, + string AddressSnapshotJson, + long GrossPriceIrr, + long BalinyaarCommissionIrr, + decimal PlatformFeeRate, + long NursePayoutAmount, + long? PspFeeAmount, + short SessionCount, + DateOnly ScheduledDate, + TimeOnly ScheduledTimeStart, + TimeOnly ScheduledTimeEnd, + DateTime? ConfirmedAt, + DateTime? CompletedAt, + DateTime? CancelledAt, + string? CancelledBy, + string? CancellationReason, + string? CancellationPolicyCode, + decimal? CancellationRefundPercentage, + long? RefundableAmountIrr, + DateTime? DisputeWindowEndsAt, + DateTimeOffset CreatedAt, + IReadOnlyList Sessions); + +/// Per-visit projection embedded in . +public record BookingSessionProjection( + long Id, + int SessionIndex, + DateOnly ScheduledDate, + TimeOnly ScheduledTimeStart, + TimeOnly ScheduledTimeEnd, + string Status, + long VisitPayoutAmount, + DateTime? PayoutEligibleAt, + string? EvvStatus, + DateTime? CheckInAt, + DateTime? CheckOutAt, + bool? CheckInAddressMatch); + +/// +/// Everything ConvertRequestToBookingCommand needs to build a booking from an +/// accepted_awaiting_payment request in one read: the ids + participant user ids (for notifications), +/// the engagement schedule, and the source data for the two frozen snapshots. +/// +public record BookingConversionSource( + long RequestId, + string Status, + long CustomerId, + int CustomerUserId, + long NurseId, + int NurseUserId, + long PatientId, + string PatientName, + long VariantId, + long CustomerAddressId, + DateOnly RequestedDate, + TimeOnly RequestedTimeStart, + TimeOnly RequestedTimeEnd, + VariantSnapshot VariantSnapshot, + AddressSnapshot AddressSnapshot); + +/// The immutable address data frozen into address_snapshot_json at booking time — full, +/// decrypted, plus the geocoded coordinates the EVV distance check later reads (so EVV is independent of +/// later address edits). +public record AddressSnapshot( + long AddressId, + string Title, + long CityId, + string CityNameFa, + string CityNameEn, + long? DistrictId, + string? DistrictNameFa, + string? DistrictNameEn, + string AddressLine, + string PostalCode, + string RecipientName, + string RecipientPhone, + decimal? Latitude, + decimal? Longitude); + +/// The two participant user ids for a booking — the recipients of booking notifications. +public record BookingParticipants(int CustomerUserId, int NurseUserId); + +/// The authorization envelope for the gated care-instructions read: the booking's status + both +/// party ids, plus the decrypted instructions (null when none written yet). The handler enforces the +/// two-stage disclosure boundary from these facts before ever surfacing . +public record CareInstructionsGate(string BookingStatus, long NurseId, long CustomerId, CareInstructionsDto? Instructions); + +/// The authorization envelope for the per-session EVV read: both party ids + the EVV detail (null +/// when no verification exists yet). Raw GPS is surfaced only to the owning nurse + admin. +public record EvvGate(long NurseId, long CustomerId, VisitVerificationDto? Evv); diff --git a/server/src/Core/Baya.Domain/Entities/Booking/Booking.cs b/server/src/Core/Baya.Domain/Entities/Booking/Booking.cs new file mode 100644 index 0000000..4c486c3 --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/Booking/Booking.cs @@ -0,0 +1,141 @@ +#nullable enable +using Baya.Domain.Common; + +namespace Baya.Domain.Entities.Booking; + +/// +/// The confirmed engagement — the source of truth for a service event and its money split. A +/// exists only when the nurse accepted and payment was captured; it is +/// created 1:1 from an accepted_awaiting_payment booking request and never from an accept alone. +/// +/// Money is IRR BIGINT only. The three amounts must always reconcile — +/// = + , +/// all ≥ 0 — enforced both by a DB CHECK and by the conversion handler; is +/// derived, never free-entered. , and the +/// encrypted are frozen at conversion: later edits to the source +/// variant/address/config rows must never mutate an existing booking. +/// +/// +/// There is deliberately no "payout done" boolean — paid-ness is derived later from a +/// nurse_payout_booking_links row + the ledger (b13). Payout eligibility is derived from +/// passing with no open dispute, never from alone. +/// +/// +public class Booking : BaseEntity +{ + /// 1:1 with the request that created it (UNIQUE) — the idempotency key for conversion. + public long BookingRequestId { get; set; } + + // Denormalized FKs (copied from the request) for query performance. + public long CustomerId { get; set; } + public long NurseId { get; set; } + public long PatientId { get; set; } + public long VariantId { get; set; } + public long CustomerAddressId { get; set; } + + /// The licensed center / merchant-of-record. partner_centers is DEFERRED to b15, so the + /// FK stays nullable and unset for now. + public long? PartnerCenterId { get; set; } + + /// Variant + option labels frozen at booking time (never re-resolved from the live variant). + public string VariantSnapshotJson { get; set; } = null!; + + /// Full address frozen at booking time — encrypted at rest through the field encryptor. + public string AddressSnapshotJson { get; set; } = null!; + + /// Total charged the customer (IRR). + public long GrossPriceIrr { get; set; } + + /// Balinyaar's own cut (IRR) = round( × ). + public long BalinyaarCommissionIrr { get; set; } + + /// The commission rate snapshot frozen at conversion, for audit. + public decimal PlatformFeeRate { get; set; } + + /// Derived: (IRR). + public long NursePayoutAmount { get; set; } + + /// Gateway cost on this payment (IRR), for true margin. Null until a capture sets it. + public long? PspFeeAmount { get; set; } + + /// 1 = single visit; > 1 = multi-session engagement. Always ≥ 1. + public short SessionCount { get; set; } = 1; + + // Engagement-level schedule; the per-visit schedule lives on booking_sessions. + public DateOnly ScheduledDate { get; set; } + public TimeOnly ScheduledTimeStart { get; set; } + public TimeOnly ScheduledTimeEnd { get; set; } + + /// Guarded — mutated only through so every write goes through the + /// allowed-transition machine. + public string Status { get; private set; } = BookingStatus.PendingPayment; + + public DateTime? ConfirmedAt { get; private set; } + public DateTime? CancelledAt { get; private set; } + public string? CancellationReason { get; private set; } + + /// Who cancelled — a code. + public string? CancelledBy { get; private set; } + + /// The resolved cancellation policy code, frozen at cancel time (a later policy + /// edit must not change it). Absent a b11 refunds/cancellation-event table, the snapshot lives here. + public string? CancellationPolicyCode { get; private set; } + + /// The resolved refund_percentage (0–100), frozen at cancel time. + public decimal? CancellationRefundPercentage { get; private set; } + + /// The computed refundable amount (IRR) for the un-started sessions at cancel time. The refund + /// ledger/execution is b11; this is the frozen figure b11 consumes. + public long? RefundableAmountIrr { get; private set; } + + public DateTime? CompletedAt { get; private set; } + + /// Set on completion = + config(dispute_window_hours, 72). The + /// only thing that makes a payout eligible (b13) — never = completed alone. + public DateTime? DisputeWindowEndsAt { get; private set; } + + public DateTimeOffset? DeletedAt { get; set; } + + public ICollection Sessions { get; set; } = new List(); + public BookingCareInstruction? CareInstructions { get; set; } + + public bool CanTransitionTo(string target) => BookingTransitions.CanTransition(Status, target); + + /// + /// Applies a status change through the allowed-transition guard and stamps the matching lifecycle + /// timestamp. Callers pre-check with and return a clean conflict; reaching + /// an illegal edge here is a programming error, so it fails fast rather than overwriting a terminal state. + /// + public void TransitionTo(string target, DateTime now, string? actor = null, string? reason = null) + { + if (!BookingTransitions.CanTransition(Status, target)) + throw new InvalidOperationException($"Illegal booking transition {Status} → {target}."); + + Status = target; + switch (target) + { + case BookingStatus.Confirmed: + ConfirmedAt = now; + break; + case BookingStatus.Completed: + CompletedAt = now; + break; + case BookingStatus.Cancelled: + CancelledAt = now; + CancelledBy = actor; + CancellationReason = reason; + break; + } + } + + /// Freezes the resolved cancellation policy snapshot + the computed refundable amount. + public void RecordCancellationSnapshot(string policyCode, decimal refundPercentage, long refundableAmountIrr) + { + CancellationPolicyCode = policyCode; + CancellationRefundPercentage = refundPercentage; + RefundableAmountIrr = refundableAmountIrr; + } + + /// Sets the dispute window on completion — the single payout-eligibility trigger for the booking. + public void SetDisputeWindow(DateTime endsAt) => DisputeWindowEndsAt = endsAt; +} diff --git a/server/src/Core/Baya.Domain/Entities/Booking/BookingAmounts.cs b/server/src/Core/Baya.Domain/Entities/Booking/BookingAmounts.cs new file mode 100644 index 0000000..468867a --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/Booking/BookingAmounts.cs @@ -0,0 +1,44 @@ +namespace Baya.Domain.Entities.Booking; + +/// +/// Pure integer-money helpers for the booking split. All money is IRR long — no float survives into +/// storage. Kept here (not in a handler) so the reconciliation rules are unit-testable in isolation. +/// +public static class BookingAmounts +{ + /// + /// Splits into the platform commission and the derived nurse payout using the + /// snapshotted . Commission is integer-rounded (half away from zero) and clamped + /// to [0, gross] so the invariant gross = commission + payout with all ≥ 0 always holds. + /// + public static (long Commission, long Payout) Split(long gross, decimal rate) + { + var commission = (long)decimal.Round(gross * rate, MidpointRounding.AwayFromZero); + if (commission < 0) + commission = 0; + if (commission > gross) + commission = gross; + + return (commission, gross - commission); + } + + /// + /// Distributes across sessions so the parts + /// sum exactly to — equal integer shares with the remainder placed on + /// the last session, so no Rial is created or lost. + /// + public static long[] SplitPayout(long payout, int sessionCount) + { + if (sessionCount < 1) + throw new ArgumentOutOfRangeException(nameof(sessionCount), "A booking always has at least one session."); + + var per = payout / sessionCount; + var amounts = new long[sessionCount]; + for (var i = 0; i < sessionCount; i++) + amounts[i] = per; + + // Remainder of the integer division lands on the last session so Σ == payout exactly. + amounts[sessionCount - 1] += payout - per * sessionCount; + return amounts; + } +} diff --git a/server/src/Core/Baya.Domain/Entities/Booking/BookingCareInstruction.cs b/server/src/Core/Baya.Domain/Entities/Booking/BookingCareInstruction.cs new file mode 100644 index 0000000..7d46e28 --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/Booking/BookingCareInstruction.cs @@ -0,0 +1,36 @@ +using Baya.Domain.Common; + +namespace Baya.Domain.Entities.Booking; + +/// +/// Encrypted clinical/logistical context for a booking — stage 2 of the two-stage disclosure +/// boundary. Kept in its own 1:1 table (not on ) so the financial/scheduling row stays +/// clean and these fields carry stricter access control: readable only post-confirmation and only +/// by the assigned nurse + admin (never the customer, never an unassigned nurse). Every field is encrypted +/// at rest through the field encryptor and is never projected into a list query or logged. +/// +public class BookingCareInstruction : BaseEntity +{ + public long BookingId { get; set; } + public Booking Booking { get; set; } + + /// Encrypted at rest. + public string CurrentConditions { get; set; } + + /// Encrypted at rest. + public string Medications { get; set; } + + /// Encrypted at rest. + public string Allergies { get; set; } + + /// Encrypted at rest. + public string SpecialInstructions { get; set; } + + /// Encrypted at rest. + public string EmergencyContactName { get; set; } + + /// Encrypted at rest. + public string EmergencyContactPhone { get; set; } + + public DateTimeOffset? DeletedAt { get; set; } +} diff --git a/server/src/Core/Baya.Domain/Entities/Booking/BookingSession.cs b/server/src/Core/Baya.Domain/Entities/Booking/BookingSession.cs new file mode 100644 index 0000000..b3ae273 --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/Booking/BookingSession.cs @@ -0,0 +1,55 @@ +#nullable enable +using Baya.Domain.Common; + +namespace Baya.Domain.Entities.Booking; + +/// +/// One visit within a booking — always ≥ 1 per booking, even for a single-visit engagement, so the +/// EVV/payout path is uniform. Each session is independently scheduled, EVV-verified, and payout-accrued. +/// +/// The sum of every session's exactly equals the booking's +/// nurse_payout_amount (integer split, remainder on the last session — no Rial created or lost). +/// is set on completion and is the per-session payout gate consumed by b13. +/// +/// +public class BookingSession : BaseEntity +{ + public long BookingId { get; set; } + public Booking Booking { get; set; } = null!; + + /// 1-based ordinal within the booking. + public int SessionIndex { get; set; } + + public DateOnly ScheduledDate { get; set; } + public TimeOnly ScheduledTimeStart { get; set; } + public TimeOnly ScheduledTimeEnd { get; set; } + + /// This session's portion of the booking's nurse_payout_amount (IRR). + public long VisitPayoutAmount { get; set; } + + /// Guarded — mutated only through . + public string Status { get; private set; } = BookingSessionStatus.Scheduled; + + /// Per-session dispute-window close, set on completion. The per-session payout gate (b13). + public DateTime? PayoutEligibleAt { get; private set; } + + /// Set when this session is cancelled — references the cancellation snapshot. The typed + /// cancellation-event/refund record arrives in b11; nullable and unset here. + public long? CancellationEventId { get; set; } + + public DateTimeOffset? DeletedAt { get; set; } + + public VisitVerification? Verification { get; set; } + + public bool CanTransitionTo(string target) => BookingSessionTransitions.CanTransition(Status, target); + + public void TransitionTo(string target) + { + if (!BookingSessionTransitions.CanTransition(Status, target)) + throw new InvalidOperationException($"Illegal booking-session transition {Status} → {target}."); + + Status = target; + } + + public void SetPayoutEligible(DateTime at) => PayoutEligibleAt = at; +} diff --git a/server/src/Core/Baya.Domain/Entities/Booking/BookingSessionStatus.cs b/server/src/Core/Baya.Domain/Entities/Booking/BookingSessionStatus.cs new file mode 100644 index 0000000..a0f729a --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/Booking/BookingSessionStatus.cs @@ -0,0 +1,23 @@ +namespace Baya.Domain.Entities.Booking; + +/// +/// The closed status vocabulary of a — one visit within a booking. Persisted +/// as stable snake_case codes. Edges live in . +/// +public static class BookingSessionStatus +{ + /// Planned, no EVV check-in yet. The only state a cancellation refunds (un-started). + public const string Scheduled = "scheduled"; + + /// The nurse checked in (EVV open). + public const string InProgress = "in_progress"; + + /// The nurse checked out; the visit happened. Sets the session's payout-eligibility window. + public const string Completed = "completed"; + + /// No check-in by the no-show threshold (set by the no-show sweep). Terminal. + public const string Missed = "missed"; + + /// The session was cancelled before it started. Terminal. + public const string Cancelled = "cancelled"; +} diff --git a/server/src/Core/Baya.Domain/Entities/Booking/BookingSessionTransitions.cs b/server/src/Core/Baya.Domain/Entities/Booking/BookingSessionTransitions.cs new file mode 100644 index 0000000..e323e99 --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/Booking/BookingSessionTransitions.cs @@ -0,0 +1,31 @@ +namespace Baya.Domain.Entities.Booking; + +/// +/// The allowed status machine for . A scheduled visit can start (check-in), +/// be cancelled, or be marked missed (no-show sweep); an in-progress visit can complete (check-out) or be +/// cancelled. Completed/missed/cancelled are terminal. +/// +public static class BookingSessionTransitions +{ + private static readonly IReadOnlyDictionary> Allowed = + new Dictionary> + { + [BookingSessionStatus.Scheduled] = + [ + BookingSessionStatus.InProgress, + BookingSessionStatus.Cancelled, + BookingSessionStatus.Missed + ], + [BookingSessionStatus.InProgress] = + [ + BookingSessionStatus.Completed, + BookingSessionStatus.Cancelled + ], + [BookingSessionStatus.Completed] = [], + [BookingSessionStatus.Missed] = [], + [BookingSessionStatus.Cancelled] = [] + }; + + 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/Booking/BookingStatus.cs b/server/src/Core/Baya.Domain/Entities/Booking/BookingStatus.cs new file mode 100644 index 0000000..285f2b1 --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/Booking/BookingStatus.cs @@ -0,0 +1,31 @@ +namespace Baya.Domain.Entities.Booking; + +/// +/// The closed status vocabulary of a — the post-payment half of the +/// engagement lifecycle. Persisted as these stable snake_case codes (never a C# enum member name). The +/// allowed edges live in and may never contradict EVV/session state. +/// +public static class BookingStatus +{ + /// Transient — a booking is created here and immediately confirmed on captured payment. + public const string PendingPayment = "pending_payment"; + + /// Payment captured, sessions generated, care instructions may now be added. Bookable engagement. + public const string Confirmed = "confirmed"; + + /// At least one session has an open EVV check-in. + public const string InProgress = "in_progress"; + + /// Every session is completed/cancelled/missed; sets the dispute window. + public const string Completed = "completed"; + + /// A dispute was opened inside the dispute window (admin flow). + public const string Disputed = "disputed"; + + /// The dispute window closed / dispute resolved. Terminal — payout eligibility is derived + /// from dispute_window_ends_at + the ledger (b13), never from this status. + public const string Closed = "closed"; + + /// The engagement was cancelled by customer/nurse/admin. Terminal. + public const string Cancelled = "cancelled"; +} diff --git a/server/src/Core/Baya.Domain/Entities/Booking/BookingTransitions.cs b/server/src/Core/Baya.Domain/Entities/Booking/BookingTransitions.cs new file mode 100644 index 0000000..4ae869c --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/Booking/BookingTransitions.cs @@ -0,0 +1,27 @@ +namespace Baya.Domain.Entities.Booking; + +/// +/// The allowed status machine for . Every transition is validated here so an illegal +/// edge is a clean conflict (never a silent overwrite) and terminal states have no outgoing edge. A CHECK +/// constraint backs the terminal states in the DB; this table is the authoritative guard consulted by +/// TransitionBookingStatusCommand and the internal capture/check-in/check-out flows. No transition +/// may contradict EVV — that extra invariant is enforced by the handlers, not encodable here. +/// +public static class BookingTransitions +{ + private static readonly IReadOnlyDictionary> Allowed = + new Dictionary> + { + [BookingStatus.PendingPayment] = [BookingStatus.Confirmed, BookingStatus.Cancelled], + [BookingStatus.Confirmed] = [BookingStatus.InProgress, BookingStatus.Cancelled], + [BookingStatus.InProgress] = [BookingStatus.Completed, BookingStatus.Cancelled], + [BookingStatus.Completed] = [BookingStatus.Disputed, BookingStatus.Closed], + [BookingStatus.Disputed] = [BookingStatus.Closed], + // Terminal states — no outgoing edges. + [BookingStatus.Closed] = [], + [BookingStatus.Cancelled] = [] + }; + + 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/Booking/CancellationActor.cs b/server/src/Core/Baya.Domain/Entities/Booking/CancellationActor.cs new file mode 100644 index 0000000..892f0fd --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/Booking/CancellationActor.cs @@ -0,0 +1,15 @@ +namespace Baya.Domain.Entities.Booking; + +/// +/// The closed code set for who initiated a cancellation — both the cancellation_policies.applies_to +/// dimension and the value frozen into bookings.cancelled_by at cancel time. +/// +public static class CancellationActor +{ + public const string Customer = "customer"; + public const string Nurse = "nurse"; + public const string Admin = "admin"; + + public static bool IsValid(string value) + => value is Customer or Nurse or Admin; +} diff --git a/server/src/Core/Baya.Domain/Entities/Booking/CancellationPolicy.cs b/server/src/Core/Baya.Domain/Entities/Booking/CancellationPolicy.cs new file mode 100644 index 0000000..f8e29ac --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/Booking/CancellationPolicy.cs @@ -0,0 +1,65 @@ +#nullable enable +using Baya.Domain.Common; + +namespace Baya.Domain.Entities.Booking; + +/// +/// A config-driven, snapshot-able cancellation/refund tier keyed by initiating actor + lead-time bucket. +/// The applicable row is resolved at cancel time by (applies_to, hours-before-start) and its +/// + are frozen onto the booking — a later edit to +/// the policy row must never change a past cancellation. +/// +/// The penalty/fee is modelled as a flat IRR amount () plus an optional +/// fraction (): a tier uses whichever is non-zero/non-null. At MVP the customer tiers +/// carry neither; the nurse-no-show penalty is modelled but its posting is deferred to payouts (b13). +/// +/// +public class CancellationPolicy : BaseEntity +{ + /// Stable unique code, e.g. standard_24h, nurse_no_show. + public string Code { get; set; } = null!; + + /// Who the tier applies to — a code. + public string AppliesTo { get; set; } = null!; + + /// Lower bound (inclusive) of the lead-time bucket in hours; null = open lower bound. + public int? HoursBeforeStartMin { get; set; } + + /// Upper bound (exclusive) of the lead-time bucket in hours; null = open upper bound. + public int? HoursBeforeStartMax { get; set; } + + /// Refund fraction of the un-started portion, 0–100. + public decimal RefundPercentage { get; set; } + + /// Flat cancellation fee / nurse penalty (IRR). 0 = none. + public long FeeAmountIrr { get; set; } + + /// Optional cancellation fee / nurse penalty as a fraction. Null = none. + public decimal? FeeRate { get; set; } + + public bool IsActive { get; set; } = true; + + public DateTimeOffset? DeletedAt { get; set; } + + /// True when a lead time of falls in this tier's half-open + /// bucket [min, max) (null bound = open). + public bool Covers(double hoursBeforeStart) + => (HoursBeforeStartMin is null || hoursBeforeStart >= HoursBeforeStartMin) + && (HoursBeforeStartMax is null || hoursBeforeStart < HoursBeforeStartMax); +} + +/// Stable seed codes for the baseline cancellation tiers. +public static class CancellationPolicyCode +{ + /// Customer, ≥ 24h before start → full refund. + public const string Standard24h = "standard_24h"; + + /// Customer, < 24h before start → 50% refund. + public const string StandardInside24h = "standard_inside_24h"; + + /// Nurse no-show / nurse-initiated → full refund (+ nurse penalty, posted in b13). + public const string NurseNoShow = "nurse_no_show"; + + /// Admin-initiated → full refund, no penalty. + public const string AdminCancellation = "admin_cancellation"; +} diff --git a/server/src/Core/Baya.Domain/Entities/Booking/VisitVerification.cs b/server/src/Core/Baya.Domain/Entities/Booking/VisitVerification.cs new file mode 100644 index 0000000..842b16d --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/Booking/VisitVerification.cs @@ -0,0 +1,41 @@ +#nullable enable +using Baya.Domain.Common; + +namespace Baya.Domain.Entities.Booking; + +/// +/// The Electronic Visit Verification (EVV) record — GPS + timestamped check-in/out proving a visit happened; +/// required for payout. The FK is on the (not the booking) so each +/// visit in a multi-session engagement is verified independently. Raw GPS detail is sensitive and gated to +/// the owning nurse + admin only. is advisory: a mismatch flags an +/// admin review alert but never blocks the visit or withholds payout on its own. +/// +public class VisitVerification : BaseEntity +{ + public long BookingSessionId { get; set; } + public BookingSession Session { get; set; } = null!; + + public DateTime? CheckInAt { get; set; } + public decimal? CheckInLat { get; set; } + public decimal? CheckInLng { get; set; } + + public DateTime? CheckOutAt { get; set; } + public decimal? CheckOutLat { get; set; } + public decimal? CheckOutLng { get; set; } + + /// Advisory: did the check-in fall within evv_location_tolerance_meters of the booking + /// address? Null when GPS was unavailable at check-in (still allowed, flagged). + public bool? CheckInAddressMatch { get; set; } + + /// The computed check-in distance to the booking address, for the admin review screen. + public decimal? CheckInDistanceMeters { get; set; } + + /// Guarded — mutated only through the transition helpers. + public string Status { get; private set; } = VisitVerificationStatus.Pending; + + public DateTimeOffset? DeletedAt { get; set; } + + public void MarkCheckedIn() => Status = VisitVerificationStatus.CheckedIn; + + public void MarkCompleted() => Status = VisitVerificationStatus.Completed; +} diff --git a/server/src/Core/Baya.Domain/Entities/Booking/VisitVerificationStatus.cs b/server/src/Core/Baya.Domain/Entities/Booking/VisitVerificationStatus.cs new file mode 100644 index 0000000..1d4255a --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/Booking/VisitVerificationStatus.cs @@ -0,0 +1,18 @@ +namespace Baya.Domain.Entities.Booking; + +/// +/// The closed status vocabulary of a (EVV). It stays consistent with the +/// parent booking/session state via the documented mapping: checked_in ↔ session in_progress +/// ↔ booking in_progress; completed ↔ session completed. +/// +public static class VisitVerificationStatus +{ + /// No check-in recorded yet. + public const string Pending = "pending"; + + /// An open check-in (GPS + timestamp captured), awaiting check-out. + public const string CheckedIn = "checked_in"; + + /// Both check-in and check-out recorded — the proof the visit happened. + public const string Completed = "completed"; +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/MockPaymentCaptureSimulator.cs b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/MockPaymentCaptureSimulator.cs new file mode 100644 index 0000000..986dc28 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/MockPaymentCaptureSimulator.cs @@ -0,0 +1,28 @@ +#nullable enable +using Baya.Application.Contracts.Common; +using Microsoft.Extensions.Options; + +namespace Baya.Infrastructure.CrossCutting.Seams; + +/// +/// Deterministic mock — the temporary conversion trigger until b10's +/// real card capture. It returns a succeeded capture with a stable fake gateway reference and the +/// configured PSP fee, so ConvertRequestToBookingCommand can be exercised end-to-end now. Set +/// to exercise the "capture failed → no booking" path. +/// The real capture replaces this registration in b10 — no mock behaviour is baked into any handler. +/// +public sealed class MockPaymentCaptureSimulator(IOptions options) : IPaymentCaptureSimulator +{ + private readonly PaymentCaptureOptions _options = options.Value.PaymentCapture; + + public ValueTask ConfirmCaptureAsync(long bookingRequestId, CancellationToken cancellationToken = default) + { + if (_options.ForceFailure) + return ValueTask.FromResult(new PaymentCaptureResult(false, string.Empty, null)); + + return ValueTask.FromResult(new PaymentCaptureResult( + Succeeded: true, + GatewayReference: $"mock-capture-{bookingRequestId}", + PspFeeAmount: _options.PspFeeAmount)); + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/SeamOptions.cs b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/SeamOptions.cs index 2fd7526..c4e2452 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/SeamOptions.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/SeamOptions.cs @@ -14,6 +14,21 @@ public sealed class SeamOptions public GeocodingOptions Geocoding { get; set; } = new(); public ShahkarOptions Shahkar { get; set; } = new(); public IdentityKycOptions IdentityKyc { get; set; } = new(); + public PaymentCaptureOptions PaymentCapture { get; set; } = new(); +} + +/// +/// Tunes the mock IPaymentCaptureSimulator (backend-phase-9). By default every capture succeeds with +/// a fake gateway reference and . Set to exercise the +/// "capture failed → no booking" path. The real b10 card capture ignores these. +/// +public sealed class PaymentCaptureOptions +{ + /// When true, every capture returns a failed result so conversion refuses to create a booking. + public bool ForceFailure { get; set; } + + /// The PSP/gateway fee (IRR) the mock reports on a successful capture. + public long? PspFeeAmount { 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 2f32d35..5c05d4c 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/ServiceConfiguration/ServiceCollectionExtension.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/ServiceConfiguration/ServiceCollectionExtension.cs @@ -43,6 +43,11 @@ public static class ServiceCollectionExtension services.AddSingleton(); services.AddSingleton(); + // Payment-capture trigger (backend-phase-9). The mock returns a deterministic succeeded capture so + // ConvertRequestToBooking is testable now; in b10 the real card capture replaces this registration + // and calls ConvertRequestToBooking directly on a real payment_transactions.succeeded. + services.AddSingleton(); + return services; } } diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/ApplicationDbContext.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/ApplicationDbContext.cs index cfd8d89..59da028 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Persistence/ApplicationDbContext.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/ApplicationDbContext.cs @@ -1,6 +1,7 @@ using System.Reflection; using Baya.Application.Contracts.Common; using Baya.Domain.Common; +using Baya.Domain.Entities.Booking; using Baya.Domain.Entities.Identity; using Baya.Domain.Entities.User; using Baya.Domain.Entities.Verification; @@ -139,5 +140,22 @@ public class ApplicationDbContext: IdentityDbContext c.CredentialNumber).HasConversion(encrypted); }); + + // b9 snapshot + clinical PII: the frozen address snapshot and every booking_care_instructions field + // are encrypted at rest through the same seam. The care fields are the stage-2 clinical disclosure — + // decrypted only in the gated care-instructions read (assigned nurse + admin, post-confirmation). + modelBuilder.Entity(builder => + { + builder.Property(b => b.AddressSnapshotJson).HasConversion(encrypted); + }); + modelBuilder.Entity(builder => + { + builder.Property(c => c.CurrentConditions).HasConversion(encrypted); + builder.Property(c => c.Medications).HasConversion(encrypted); + builder.Property(c => c.Allergies).HasConversion(encrypted); + builder.Property(c => c.SpecialInstructions).HasConversion(encrypted); + builder.Property(c => c.EmergencyContactName).HasConversion(encrypted); + builder.Property(c => c.EmergencyContactPhone).HasConversion(encrypted); + }); } } \ No newline at end of file diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/BookingConfig/BookingCareInstructionConfig.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/BookingConfig/BookingCareInstructionConfig.cs new file mode 100644 index 0000000..56ef94f --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/BookingConfig/BookingCareInstructionConfig.cs @@ -0,0 +1,24 @@ +using Baya.Domain.Entities.Booking; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Baya.Infrastructure.Persistence.Configuration.BookingConfig; + +internal sealed class BookingCareInstructionConfig : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("BookingCareInstructions", "booking"); + + // All clinical fields are encrypted at rest (converters wired in ApplicationDbContext) and are + // nvarchar(max); shorter contact fields still ride the same seam, so no length cap here. + + // 1:1 with the booking (UNIQUE index created automatically). + builder.HasOne(c => c.Booking) + .WithOne(b => b.CareInstructions) + .HasForeignKey(c => c.BookingId) + .IsRequired(); + + builder.HasQueryFilter(c => c.DeletedAt == null); + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/BookingConfig/BookingConfig.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/BookingConfig/BookingConfig.cs new file mode 100644 index 0000000..5ed65e2 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/BookingConfig/BookingConfig.cs @@ -0,0 +1,56 @@ +using Baya.Domain.Entities.Booking; +using Baya.Domain.Entities.Catalog; +using Baya.Domain.Entities.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Baya.Infrastructure.Persistence.Configuration.BookingConfig; + +internal sealed class BookingConfig : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + // The three-amount split is a DB CHECK (not handler-only): gross = commission + payout, all ≥ 0. + builder.ToTable("Bookings", "booking", t => t.HasCheckConstraint( + "CK_Bookings_AmountSplit", + "[GrossPriceIrr] = [BalinyaarCommissionIrr] + [NursePayoutAmount] " + + "AND [GrossPriceIrr] >= 0 AND [BalinyaarCommissionIrr] >= 0 AND [NursePayoutAmount] >= 0")); + + // Snapshots freeze history. AddressSnapshotJson is encrypted at rest (converter wired in + // ApplicationDbContext); both are nvarchar(max) so no length cap. + builder.Property(b => b.VariantSnapshotJson).IsRequired(); + builder.Property(b => b.AddressSnapshotJson).IsRequired(); + + builder.Property(b => b.PlatformFeeRate).HasPrecision(5, 4); + builder.Property(b => b.CancellationRefundPercentage).HasPrecision(5, 2); + + builder.Property(b => b.Status).HasMaxLength(30).IsRequired(); + builder.Property(b => b.CancellationReason).HasMaxLength(500); + builder.Property(b => b.CancelledBy).HasMaxLength(20); + builder.Property(b => b.CancellationPolicyCode).HasMaxLength(50); + + builder.HasIndex(b => new { b.CustomerId, b.Status }); + builder.HasIndex(b => new { b.NurseId, b.Status }); + // b13 selects payout-eligible bookings through the dispute-window close. + builder.HasIndex(b => b.DisputeWindowEndsAt); + + // 1:1 with the request that created it (UNIQUE index created automatically). No navigation either + // side — the request is read by id at conversion time. + builder.HasOne() + .WithOne() + .HasForeignKey(b => b.BookingRequestId) + .IsRequired(); + + // Denormalized FKs for query performance (no navigations on Booking). partner_center_id is left + // FK-less and nullable — partner_centers is DEFERRED to b15. + builder.HasOne().WithMany().HasForeignKey(b => b.CustomerId).IsRequired(); + builder.HasOne().WithMany().HasForeignKey(b => b.NurseId).IsRequired(); + builder.HasOne().WithMany().HasForeignKey(b => b.PatientId).IsRequired(); + builder.HasOne().WithMany().HasForeignKey(b => b.VariantId).IsRequired(); + builder.HasOne().WithMany().HasForeignKey(b => b.CustomerAddressId).IsRequired(); + + builder.HasMany(b => b.Sessions).WithOne(s => s.Booking).HasForeignKey(s => s.BookingId); + + builder.HasQueryFilter(b => b.DeletedAt == null); + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/BookingConfig/BookingSessionConfig.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/BookingConfig/BookingSessionConfig.cs new file mode 100644 index 0000000..c37a297 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/BookingConfig/BookingSessionConfig.cs @@ -0,0 +1,26 @@ +using Baya.Domain.Entities.Booking; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Baya.Infrastructure.Persistence.Configuration.BookingConfig; + +internal sealed class BookingSessionConfig : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("BookingSessions", "booking"); + + builder.Property(s => s.Status).HasMaxLength(20).IsRequired(); + + // The nurse's "today" view and the no-show sweep both read (status, scheduled_date). + builder.HasIndex(s => new { s.BookingId, s.SessionIndex }); + builder.HasIndex(s => new { s.Status, s.ScheduledDate }); + + // 1:1 with its EVV record (the FK is on the verification side, on the session). + builder.HasOne(s => s.Verification) + .WithOne(v => v.Session) + .HasForeignKey(v => v.BookingSessionId); + + builder.HasQueryFilter(s => s.DeletedAt == null); + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/BookingConfig/CancellationPolicyConfig.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/BookingConfig/CancellationPolicyConfig.cs new file mode 100644 index 0000000..b4cf362 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/BookingConfig/CancellationPolicyConfig.cs @@ -0,0 +1,59 @@ +using Baya.Domain.Entities.Booking; +using Baya.Infrastructure.Persistence.Configuration; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Baya.Infrastructure.Persistence.Configuration.BookingConfig; + +internal sealed class CancellationPolicyConfig : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("CancellationPolicies", "booking"); + + builder.Property(p => p.Code).HasMaxLength(50).IsRequired(); + builder.Property(p => p.AppliesTo).HasMaxLength(20).IsRequired(); + builder.Property(p => p.RefundPercentage).HasPrecision(5, 2); + builder.Property(p => p.FeeRate).HasPrecision(5, 4); + + builder.HasIndex(p => p.Code).IsUnique(); + builder.HasIndex(p => new { p.AppliesTo, p.IsActive }); + + builder.HasQueryFilter(p => p.DeletedAt == null); + + builder.HasData(Seed()); + } + + // Baseline tiers, seeded via HasData so they land with this phase's migration on a fresh DB. Numbers the + // product doc leaves open (the nurse penalty) default safe and are flagged in the phase report — the + // nurse-no-show penalty is modelled (FeeRate) but posting it is deferred to payouts (b13). + private static object[] Seed() + { + var ts = SeedConstants.Timestamp; + + (long Id, string Code, string AppliesTo, int? Min, int? Max, decimal Refund, long Fee, decimal? Rate)[] rows = + [ + (1, CancellationPolicyCode.Standard24h, CancellationActor.Customer, 24, null, 100m, 0L, null), + // Open lower bound so a cancel < 24h before start (or after it) always resolves a customer tier. + (2, CancellationPolicyCode.StandardInside24h, CancellationActor.Customer, null, 24, 50m, 0L, null), + (3, CancellationPolicyCode.NurseNoShow, CancellationActor.Nurse, null, null, 100m, 0L, 0m), + (4, CancellationPolicyCode.AdminCancellation, CancellationActor.Admin, null, null, 100m, 0L, null), + ]; + + return rows + .Select(r => (object)new + { + r.Id, + r.Code, + r.AppliesTo, + HoursBeforeStartMin = r.Min, + HoursBeforeStartMax = r.Max, + RefundPercentage = r.Refund, + FeeAmountIrr = r.Fee, + FeeRate = r.Rate, + IsActive = true, + CreatedAt = ts + }) + .ToArray(); + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/BookingConfig/VisitVerificationConfig.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/BookingConfig/VisitVerificationConfig.cs new file mode 100644 index 0000000..b8db11f --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/BookingConfig/VisitVerificationConfig.cs @@ -0,0 +1,27 @@ +using Baya.Domain.Entities.Booking; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Baya.Infrastructure.Persistence.Configuration.BookingConfig; + +internal sealed class VisitVerificationConfig : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("VisitVerifications", "booking"); + + builder.Property(v => v.Status).HasMaxLength(20).IsRequired(); + + builder.Property(v => v.CheckInLat).HasPrecision(9, 6); + builder.Property(v => v.CheckInLng).HasPrecision(9, 6); + builder.Property(v => v.CheckOutLat).HasPrecision(9, 6); + builder.Property(v => v.CheckOutLng).HasPrecision(9, 6); + builder.Property(v => v.CheckInDistanceMeters).HasPrecision(10, 2); + + // The FK/1:1 to the session is configured on BookingSessionConfig; the UNIQUE index on + // BookingSessionId is created automatically. The admin mismatch queue reads on the advisory flag. + builder.HasIndex(v => v.CheckInAddressMatch); + + builder.HasQueryFilter(v => v.DeletedAt == null); + } +} 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 e80dd8f..f2b469f 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/ConfigurationConfig/PlatformConfigConfig.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/ConfigurationConfig/PlatformConfigConfig.cs @@ -44,6 +44,8 @@ internal sealed class PlatformConfigConfig : IEntityTypeConfiguration +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("20260706152603_BookingsSessionsEvvCancellation")] + partial class BookingsSessionsEvvCancellation + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Baya.Domain.Entities.Analytics.SystemEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("OccurredAt") + .HasColumnType("datetimeoffset"); + + b.Property("PropsJson") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.HasIndex("OccurredAt"); + + b.HasIndex("UserId"); + + b.ToTable("SystemEvents", "ops"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Audit.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("ActorUserId") + .HasColumnType("int"); + + b.Property("ChangedFieldsJson") + .HasColumnType("nvarchar(max)"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("OccurredAt") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("ActorUserId"); + + b.HasIndex("OccurredAt"); + + b.HasIndex("EntityType", "EntityId"); + + b.ToTable("AuditLogs", "ops"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Booking.Booking", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AddressSnapshotJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("BalinyaarCommissionIrr") + .HasColumnType("bigint"); + + b.Property("BookingRequestId") + .HasColumnType("bigint"); + + b.Property("CancellationPolicyCode") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("CancellationReason") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("CancellationRefundPercentage") + .HasPrecision(5, 2) + .HasColumnType("decimal(5,2)"); + + b.Property("CancelledAt") + .HasColumnType("datetime2"); + + b.Property("CancelledBy") + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("CompletedAt") + .HasColumnType("datetime2"); + + b.Property("ConfirmedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("CustomerAddressId") + .HasColumnType("bigint"); + + b.Property("CustomerId") + .HasColumnType("bigint"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DisputeWindowEndsAt") + .HasColumnType("datetime2"); + + b.Property("GrossPriceIrr") + .HasColumnType("bigint"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseId") + .HasColumnType("bigint"); + + b.Property("NursePayoutAmount") + .HasColumnType("bigint"); + + b.Property("PartnerCenterId") + .HasColumnType("bigint"); + + b.Property("PatientId") + .HasColumnType("bigint"); + + b.Property("PlatformFeeRate") + .HasPrecision(5, 4) + .HasColumnType("decimal(5,4)"); + + b.Property("PspFeeAmount") + .HasColumnType("bigint"); + + b.Property("RefundableAmountIrr") + .HasColumnType("bigint"); + + b.Property("ScheduledDate") + .HasColumnType("date"); + + b.Property("ScheduledTimeEnd") + .HasColumnType("time"); + + b.Property("ScheduledTimeStart") + .HasColumnType("time"); + + b.Property("SessionCount") + .HasColumnType("smallint"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("nvarchar(30)"); + + b.Property("VariantId") + .HasColumnType("bigint"); + + b.Property("VariantSnapshotJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("BookingRequestId") + .IsUnique(); + + b.HasIndex("CustomerAddressId"); + + b.HasIndex("DisputeWindowEndsAt"); + + b.HasIndex("PatientId"); + + b.HasIndex("VariantId"); + + b.HasIndex("CustomerId", "Status"); + + b.HasIndex("NurseId", "Status"); + + b.ToTable("Bookings", "booking", t => + { + t.HasCheckConstraint("CK_Bookings_AmountSplit", "[GrossPriceIrr] = [BalinyaarCommissionIrr] + [NursePayoutAmount] AND [GrossPriceIrr] >= 0 AND [BalinyaarCommissionIrr] >= 0 AND [NursePayoutAmount] >= 0"); + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Booking.BookingCareInstruction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Allergies") + .HasColumnType("nvarchar(max)"); + + b.Property("BookingId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("CurrentConditions") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("EmergencyContactName") + .HasColumnType("nvarchar(max)"); + + b.Property("EmergencyContactPhone") + .HasColumnType("nvarchar(max)"); + + b.Property("Medications") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("SpecialInstructions") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("BookingId") + .IsUnique(); + + b.ToTable("BookingCareInstructions", "booking"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Booking.BookingRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("CustomerAddressId") + .HasColumnType("bigint"); + + b.Property("CustomerId") + .HasColumnType("bigint"); + + b.Property("CustomerNotes") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseId") + .HasColumnType("bigint"); + + b.Property("NurseRejectionReason") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("NurseResponseDeadlineAt") + .HasColumnType("datetime2"); + + b.Property("PatientId") + .HasColumnType("bigint"); + + b.Property("PaymentDeadlineAt") + .HasColumnType("datetime2"); + + b.Property("RequestedDate") + .HasColumnType("date"); + + b.Property("RequestedTimeEnd") + .HasColumnType("time"); + + b.Property("RequestedTimeStart") + .HasColumnType("time"); + + b.Property("RequiredCaregiverGender") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("VariantId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("CustomerAddressId"); + + b.HasIndex("PatientId"); + + b.HasIndex("VariantId"); + + b.HasIndex("CustomerId", "Status"); + + b.HasIndex("NurseId", "Status"); + + b.HasIndex("Status", "NurseResponseDeadlineAt"); + + b.HasIndex("Status", "PaymentDeadlineAt"); + + b.ToTable("BookingRequests", "booking"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Booking.BookingSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BookingId") + .HasColumnType("bigint"); + + b.Property("CancellationEventId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("PayoutEligibleAt") + .HasColumnType("datetime2"); + + b.Property("ScheduledDate") + .HasColumnType("date"); + + b.Property("ScheduledTimeEnd") + .HasColumnType("time"); + + b.Property("ScheduledTimeStart") + .HasColumnType("time"); + + b.Property("SessionIndex") + .HasColumnType("int"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("VisitPayoutAmount") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("BookingId", "SessionIndex"); + + b.HasIndex("Status", "ScheduledDate"); + + b.ToTable("BookingSessions", "booking"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Booking.CancellationPolicy", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AppliesTo") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("FeeAmountIrr") + .HasColumnType("bigint"); + + b.Property("FeeRate") + .HasPrecision(5, 4) + .HasColumnType("decimal(5,4)"); + + b.Property("HoursBeforeStartMax") + .HasColumnType("int"); + + b.Property("HoursBeforeStartMin") + .HasColumnType("int"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("RefundPercentage") + .HasPrecision(5, 2) + .HasColumnType("decimal(5,2)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("AppliesTo", "IsActive"); + + b.ToTable("CancellationPolicies", "booking"); + + b.HasData( + new + { + Id = 1L, + AppliesTo = "customer", + Code = "standard_24h", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + FeeAmountIrr = 0L, + HoursBeforeStartMin = 24, + IsActive = true, + RefundPercentage = 100m + }, + new + { + Id = 2L, + AppliesTo = "customer", + Code = "standard_inside_24h", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + FeeAmountIrr = 0L, + HoursBeforeStartMax = 24, + IsActive = true, + RefundPercentage = 50m + }, + new + { + Id = 3L, + AppliesTo = "nurse", + Code = "nurse_no_show", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + FeeAmountIrr = 0L, + FeeRate = 0m, + IsActive = true, + RefundPercentage = 100m + }, + new + { + Id = 4L, + AppliesTo = "admin", + Code = "admin_cancellation", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + FeeAmountIrr = 0L, + IsActive = true, + RefundPercentage = 100m + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Booking.VisitVerification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BookingSessionId") + .HasColumnType("bigint"); + + b.Property("CheckInAddressMatch") + .HasColumnType("bit"); + + b.Property("CheckInAt") + .HasColumnType("datetime2"); + + b.Property("CheckInDistanceMeters") + .HasPrecision(10, 2) + .HasColumnType("decimal(10,2)"); + + b.Property("CheckInLat") + .HasPrecision(9, 6) + .HasColumnType("decimal(9,6)"); + + b.Property("CheckInLng") + .HasPrecision(9, 6) + .HasColumnType("decimal(9,6)"); + + b.Property("CheckOutAt") + .HasColumnType("datetime2"); + + b.Property("CheckOutLat") + .HasPrecision(9, 6) + .HasColumnType("decimal(9,6)"); + + b.Property("CheckOutLng") + .HasPrecision(9, 6) + .HasColumnType("decimal(9,6)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("BookingSessionId") + .IsUnique(); + + b.HasIndex("CheckInAddressMatch"); + + b.ToTable("VisitVerifications", "booking"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.NurseServiceVariant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseId") + .HasColumnType("bigint"); + + b.Property("OptionSetHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Price") + .HasColumnType("bigint"); + + b.Property("PriceUnit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("ServiceCategoryId") + .HasColumnType("bigint"); + + b.Property("SessionCount") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ServiceCategoryId"); + + b.HasIndex("NurseId", "IsActive"); + + b.HasIndex("NurseId", "ServiceCategoryId", "OptionSetHash") + .IsUnique() + .HasDatabaseName("UX_NurseServiceVariants_Nurse_Category_OptionSet") + .HasFilter("[DeletedAt] IS NULL"); + + b.ToTable("NurseServiceVariants", "catalog"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.NurseServiceVariantOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("OptionGroupId") + .HasColumnType("bigint"); + + b.Property("OptionValueId") + .HasColumnType("bigint"); + + b.Property("VariantId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("OptionGroupId"); + + b.HasIndex("OptionValueId"); + + b.HasIndex("VariantId", "OptionGroupId") + .IsUnique() + .HasDatabaseName("UX_NurseServiceVariantOptions_Variant_Group"); + + b.ToTable("NurseServiceVariantOptions", "catalog"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DescriptionEn") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("DescriptionFa") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("IconKey") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("NameFa") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("IsActive", "SortOrder"); + + b.ToTable("ServiceCategories", "catalog"); + + b.HasData( + new + { + Id = 1L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Elderly Care", + NameFa = "مراقبت از سالمند", + SortOrder = 1 + }, + new + { + Id = 2L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Post-Surgery Recovery", + NameFa = "مراقبت پس از جراحی", + SortOrder = 2 + }, + new + { + Id = 3L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Infant Care", + NameFa = "مراقبت از نوزاد", + SortOrder = 3 + }, + new + { + Id = 4L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Chronic Illness Management", + NameFa = "مدیریت بیماری مزمن", + SortOrder = 4 + }, + new + { + Id = 5L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Companionship", + NameFa = "همراهی و مراقبت روزمره", + SortOrder = 5 + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceOptionGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("IsRequired") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("NameFa") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("ServiceCategoryId") + .HasColumnType("bigint"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("ServiceCategoryId", "SortOrder"); + + b.ToTable("ServiceOptionGroups", "catalog"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceOptionValue", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("NameFa") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("OptionGroupId") + .HasColumnType("bigint"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("OptionGroupId", "SortOrder"); + + b.ToTable("ServiceOptionValues", "catalog"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Configuration.PlatformConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DataType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("Value") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("PlatformConfigs", "ops"); + + b.HasData( + new + { + Id = 1L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "decimal", + Description = "Balinyaar commission rate on the booking gross (fraction).", + Key = "platform_fee_rate", + Value = "0.15" + }, + new + { + Id = 2L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "decimal", + Description = "VAT rate applied to the commission line only (fraction).", + Key = "vat_rate", + Value = "0.10" + }, + new + { + Id = 3L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Hours after check-out a booking can be disputed.", + Key = "dispute_window_hours", + Value = "72" + }, + new + { + Id = 4L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Minutes a family has to pay before a pending booking expires.", + Key = "booking_payment_deadline_minutes", + Value = "30" + }, + new + { + Id = 5L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Hours a nurse has to accept/decline a booking request.", + Key = "nurse_response_deadline_hours", + Value = "24" + }, + new + { + Id = 6L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Weekly payout cadence in days.", + Key = "nurse_payout_interval_days", + Value = "7" + }, + new + { + Id = 7L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Allowed EVV check-in distance from the care address.", + Key = "evv_location_tolerance_meters", + Value = "200" + }, + new + { + Id = 8L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "decimal", + Description = "A review at or below this rating raises a support alert.", + Key = "min_rating_for_support_alert", + Value = "2" + }, + new + { + Id = 9L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "string", + Description = "Who is merchant of record for BNPL orders (platform|nurse).", + Key = "bnpl_merchant_of_record", + Value = "platform" + }, + new + { + Id = 10L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "decimal", + Description = "BNPL provider commission rate (fraction).", + Key = "bnpl_provider_commission_rate", + Value = "0.07" + }, + new + { + Id = 11L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "string", + Description = "When BNPL settles funds to the platform (immediate|deferred).", + Key = "bnpl_settlement_timing", + Value = "immediate" + }, + new + { + Id = 12L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "json", + Description = "Tiered cancellation refund policy: refund_percent by hours before the visit.", + Key = "cancellation_tiers", + Value = "[{\"min_hours_before\":48,\"refund_percent\":100},{\"min_hours_before\":24,\"refund_percent\":50},{\"min_hours_before\":0,\"refund_percent\":0}]" + }, + new + { + Id = 13L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Seconds a phone must wait before another OTP can be requested.", + Key = "auth_otp_resend_seconds", + Value = "120" + }, + new + { + Id = 14L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Wrong-code attempts allowed before OTP verification is refused until a fresh code.", + Key = "auth_otp_max_attempts", + Value = "5" + }, + new + { + Id = 15L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Refresh-token session lifetime in days.", + Key = "auth_session_ttl_days", + Value = "30" + }, + new + { + Id = 16L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Hours between credential-expiry scans (the scheduled cron is deferred; the scan is admin-triggered today).", + Key = "verification_expiry_scan_cadence_hours", + Value = "24" + }, + new + { + Id = 17L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Minutes after a session's scheduled start with no EVV check-in before it is flagged a no-show.", + Key = "no_show_threshold_minutes", + Value = "60" + }, + new + { + Id = 18L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Hours between no-show sweeps (the scheduled cron is deferred; the sweep is admin-triggered today).", + Key = "no_show_scan_cadence_hours", + Value = "1" + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.City", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("NameFa") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("ProvinceId") + .HasColumnType("bigint"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("ProvinceId", "SortOrder"); + + b.ToTable("Cities", "geo"); + + b.HasData( + new + { + Id = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Tehran", + NameFa = "تهران", + ProvinceId = 1L, + SortOrder = 1 + }, + new + { + Id = 102L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Karaj", + NameFa = "کرج", + ProvinceId = 2L, + SortOrder = 1 + }, + new + { + Id = 103L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Isfahan", + NameFa = "اصفهان", + ProvinceId = 3L, + SortOrder = 1 + }, + new + { + Id = 104L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Shiraz", + NameFa = "شیراز", + ProvinceId = 4L, + SortOrder = 1 + }, + new + { + Id = 105L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Mashhad", + NameFa = "مشهد", + ProvinceId = 5L, + SortOrder = 1 + }, + new + { + Id = 106L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Tabriz", + NameFa = "تبریز", + ProvinceId = 6L, + SortOrder = 1 + }, + new + { + Id = 107L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Urmia", + NameFa = "ارومیه", + ProvinceId = 7L, + SortOrder = 1 + }, + new + { + Id = 108L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Ahvaz", + NameFa = "اهواز", + ProvinceId = 8L, + SortOrder = 1 + }, + new + { + Id = 109L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Qom", + NameFa = "قم", + ProvinceId = 9L, + SortOrder = 1 + }, + new + { + Id = 110L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Kerman", + NameFa = "کرمان", + ProvinceId = 10L, + SortOrder = 1 + }, + new + { + Id = 111L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Rasht", + NameFa = "رشت", + ProvinceId = 11L, + SortOrder = 1 + }, + new + { + Id = 112L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Sari", + NameFa = "ساری", + ProvinceId = 12L, + SortOrder = 1 + }, + new + { + Id = 113L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Arak", + NameFa = "اراک", + ProvinceId = 13L, + SortOrder = 1 + }, + new + { + Id = 114L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Ardabil", + NameFa = "اردبیل", + ProvinceId = 14L, + SortOrder = 1 + }, + new + { + Id = 115L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Qazvin", + NameFa = "قزوین", + ProvinceId = 15L, + SortOrder = 1 + }, + new + { + Id = 116L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Kermanshah", + NameFa = "کرمانشاه", + ProvinceId = 16L, + SortOrder = 1 + }, + new + { + Id = 117L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Bojnord", + NameFa = "بجنورد", + ProvinceId = 17L, + SortOrder = 1 + }, + new + { + Id = 118L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Birjand", + NameFa = "بیرجند", + ProvinceId = 18L, + SortOrder = 1 + }, + new + { + Id = 119L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Hamadan", + NameFa = "همدان", + ProvinceId = 19L, + SortOrder = 1 + }, + new + { + Id = 120L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Sanandaj", + NameFa = "سنندج", + ProvinceId = 20L, + SortOrder = 1 + }, + new + { + Id = 121L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Khorramabad", + NameFa = "خرم‌آباد", + ProvinceId = 21L, + SortOrder = 1 + }, + new + { + Id = 122L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Gorgan", + NameFa = "گرگان", + ProvinceId = 22L, + SortOrder = 1 + }, + new + { + Id = 123L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Bandar Abbas", + NameFa = "بندرعباس", + ProvinceId = 23L, + SortOrder = 1 + }, + new + { + Id = 124L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Bushehr", + NameFa = "بوشهر", + ProvinceId = 24L, + SortOrder = 1 + }, + new + { + Id = 125L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Zanjan", + NameFa = "زنجان", + ProvinceId = 25L, + SortOrder = 1 + }, + new + { + Id = 126L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Semnan", + NameFa = "سمنان", + ProvinceId = 26L, + SortOrder = 1 + }, + new + { + Id = 127L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Yazd", + NameFa = "یزد", + ProvinceId = 27L, + SortOrder = 1 + }, + new + { + Id = 128L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Zahedan", + NameFa = "زاهدان", + ProvinceId = 28L, + SortOrder = 1 + }, + new + { + Id = 129L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Shahrekord", + NameFa = "شهرکرد", + ProvinceId = 29L, + SortOrder = 1 + }, + new + { + Id = 130L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Yasuj", + NameFa = "یاسوج", + ProvinceId = 30L, + SortOrder = 1 + }, + new + { + Id = 131L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Ilam", + NameFa = "ایلام", + ProvinceId = 31L, + SortOrder = 1 + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.District", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CityId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("NameFa") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("CityId", "SortOrder"); + + b.ToTable("Districts", "geo"); + + b.HasData( + new + { + Id = 1001L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 1", + NameFa = "منطقه ۱", + SortOrder = 1 + }, + new + { + Id = 1002L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 2", + NameFa = "منطقه ۲", + SortOrder = 2 + }, + new + { + Id = 1003L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 3", + NameFa = "منطقه ۳", + SortOrder = 3 + }, + new + { + Id = 1004L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 4", + NameFa = "منطقه ۴", + SortOrder = 4 + }, + new + { + Id = 1005L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 5", + NameFa = "منطقه ۵", + SortOrder = 5 + }, + new + { + Id = 1006L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 6", + NameFa = "منطقه ۶", + SortOrder = 6 + }, + new + { + Id = 1007L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 7", + NameFa = "منطقه ۷", + SortOrder = 7 + }, + new + { + Id = 1008L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 8", + NameFa = "منطقه ۸", + SortOrder = 8 + }, + new + { + Id = 1009L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 9", + NameFa = "منطقه ۹", + SortOrder = 9 + }, + new + { + Id = 1010L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 10", + NameFa = "منطقه ۱۰", + SortOrder = 10 + }, + new + { + Id = 1011L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 11", + NameFa = "منطقه ۱۱", + SortOrder = 11 + }, + new + { + Id = 1012L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 12", + NameFa = "منطقه ۱۲", + SortOrder = 12 + }, + new + { + Id = 1013L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 13", + NameFa = "منطقه ۱۳", + SortOrder = 13 + }, + new + { + Id = 1014L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 14", + NameFa = "منطقه ۱۴", + SortOrder = 14 + }, + new + { + Id = 1015L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 15", + NameFa = "منطقه ۱۵", + SortOrder = 15 + }, + new + { + Id = 1016L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 16", + NameFa = "منطقه ۱۶", + SortOrder = 16 + }, + new + { + Id = 1017L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 17", + NameFa = "منطقه ۱۷", + SortOrder = 17 + }, + new + { + Id = 1018L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 18", + NameFa = "منطقه ۱۸", + SortOrder = 18 + }, + new + { + Id = 1019L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 19", + NameFa = "منطقه ۱۹", + SortOrder = 19 + }, + new + { + Id = 1020L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 20", + NameFa = "منطقه ۲۰", + SortOrder = 20 + }, + new + { + Id = 1021L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 21", + NameFa = "منطقه ۲۱", + SortOrder = 21 + }, + new + { + Id = 1022L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 22", + NameFa = "منطقه ۲۲", + SortOrder = 22 + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.NurseServiceArea", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CityId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DistrictId") + .HasColumnType("bigint"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("CityId"); + + b.HasIndex("DistrictId"); + + b.HasIndex("NurseId", "CityId") + .IsUnique() + .HasDatabaseName("UX_NurseServiceAreas_Nurse_City_WholeCity") + .HasFilter("[DistrictId] IS NULL AND [DeletedAt] IS NULL"); + + b.HasIndex("NurseId", "CityId", "DistrictId") + .IsUnique() + .HasDatabaseName("UX_NurseServiceAreas_Nurse_City_District") + .HasFilter("[DistrictId] IS NOT NULL AND [DeletedAt] IS NULL"); + + b.ToTable("NurseServiceAreas", "geo"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.Province", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("NameFa") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("SortOrder"); + + b.ToTable("Provinces", "geo"); + + b.HasData( + new + { + Id = 1L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Tehran", + NameFa = "تهران", + SortOrder = 1 + }, + new + { + Id = 2L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Alborz", + NameFa = "البرز", + SortOrder = 2 + }, + new + { + Id = 3L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Isfahan", + NameFa = "اصفهان", + SortOrder = 3 + }, + new + { + Id = 4L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Fars", + NameFa = "فارس", + SortOrder = 4 + }, + new + { + Id = 5L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Razavi Khorasan", + NameFa = "خراسان رضوی", + SortOrder = 5 + }, + new + { + Id = 6L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "East Azerbaijan", + NameFa = "آذربایجان شرقی", + SortOrder = 6 + }, + new + { + Id = 7L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "West Azerbaijan", + NameFa = "آذربایجان غربی", + SortOrder = 7 + }, + new + { + Id = 8L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Khuzestan", + NameFa = "خوزستان", + SortOrder = 8 + }, + new + { + Id = 9L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Qom", + NameFa = "قم", + SortOrder = 9 + }, + new + { + Id = 10L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Kerman", + NameFa = "کرمان", + SortOrder = 10 + }, + new + { + Id = 11L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Gilan", + NameFa = "گیلان", + SortOrder = 11 + }, + new + { + Id = 12L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Mazandaran", + NameFa = "مازندران", + SortOrder = 12 + }, + new + { + Id = 13L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Markazi", + NameFa = "مرکزی", + SortOrder = 13 + }, + new + { + Id = 14L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Ardabil", + NameFa = "اردبیل", + SortOrder = 14 + }, + new + { + Id = 15L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Qazvin", + NameFa = "قزوین", + SortOrder = 15 + }, + new + { + Id = 16L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Kermanshah", + NameFa = "کرمانشاه", + SortOrder = 16 + }, + new + { + Id = 17L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "North Khorasan", + NameFa = "خراسان شمالی", + SortOrder = 17 + }, + new + { + Id = 18L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "South Khorasan", + NameFa = "خراسان جنوبی", + SortOrder = 18 + }, + new + { + Id = 19L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Hamadan", + NameFa = "همدان", + SortOrder = 19 + }, + new + { + Id = 20L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Kurdistan", + NameFa = "کردستان", + SortOrder = 20 + }, + new + { + Id = 21L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Lorestan", + NameFa = "لرستان", + SortOrder = 21 + }, + new + { + Id = 22L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Golestan", + NameFa = "گلستان", + SortOrder = 22 + }, + new + { + Id = 23L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Hormozgan", + NameFa = "هرمزگان", + SortOrder = 23 + }, + new + { + Id = 24L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Bushehr", + NameFa = "بوشهر", + SortOrder = 24 + }, + new + { + Id = 25L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Zanjan", + NameFa = "زنجان", + SortOrder = 25 + }, + new + { + Id = 26L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Semnan", + NameFa = "سمنان", + SortOrder = 26 + }, + new + { + Id = 27L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Yazd", + NameFa = "یزد", + SortOrder = 27 + }, + new + { + Id = 28L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Sistan and Baluchestan", + NameFa = "سیستان و بلوچستان", + SortOrder = 28 + }, + new + { + Id = 29L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Chaharmahal and Bakhtiari", + NameFa = "چهارمحال و بختیاری", + SortOrder = 29 + }, + new + { + Id = 30L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Kohgiluyeh and Boyer-Ahmad", + NameFa = "کهگیلویه و بویراحمد", + SortOrder = 30 + }, + new + { + Id = 31L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Ilam", + NameFa = "ایلام", + SortOrder = 31 + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Holidays.IranianHoliday", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("HolidayDate") + .HasColumnType("date"); + + b.Property("IsBankClosed") + .HasColumnType("bit"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NameFa") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("HolidayDate") + .IsUnique(); + + b.ToTable("IranianHolidays", "ops"); + + b.HasData( + new + { + Id = 1L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HolidayDate = new DateOnly(2026, 2, 11), + IsBankClosed = true, + NameFa = "پیروزی انقلاب اسلامی", + Type = "national" + }, + new + { + Id = 2L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HolidayDate = new DateOnly(2026, 3, 21), + IsBankClosed = true, + NameFa = "نوروز", + Type = "national" + }, + new + { + Id = 3L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HolidayDate = new DateOnly(2026, 3, 22), + IsBankClosed = true, + NameFa = "نوروز", + Type = "national" + }, + new + { + Id = 4L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HolidayDate = new DateOnly(2026, 3, 23), + IsBankClosed = true, + NameFa = "نوروز", + Type = "national" + }, + new + { + Id = 5L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HolidayDate = new DateOnly(2026, 3, 24), + IsBankClosed = true, + NameFa = "نوروز", + Type = "national" + }, + new + { + Id = 6L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HolidayDate = new DateOnly(2026, 4, 1), + IsBankClosed = true, + NameFa = "روز طبیعت (سیزده‌به‌در)", + Type = "official" + }, + new + { + Id = 7L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HolidayDate = new DateOnly(2026, 6, 26), + IsBankClosed = true, + NameFa = "عید سعید قربان", + Type = "religious" + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.CustomerAddress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AddressLine") + .HasColumnType("nvarchar(max)"); + + b.Property("CityId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("CustomerId") + .HasColumnType("bigint"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DistrictId") + .HasColumnType("bigint"); + + b.Property("IsPrimary") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("Latitude") + .HasPrecision(9, 6) + .HasColumnType("decimal(9,6)"); + + b.Property("Longitude") + .HasPrecision(9, 6) + .HasColumnType("decimal(9,6)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("PostalCode") + .HasColumnType("nvarchar(max)"); + + b.Property("RecipientName") + .HasColumnType("nvarchar(max)"); + + b.Property("RecipientPhone") + .HasColumnType("nvarchar(max)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("CityId"); + + b.HasIndex("CustomerId") + .IsUnique() + .HasDatabaseName("UX_CustomerAddresses_Customer_Primary") + .HasFilter("[IsPrimary] = 1 AND [DeletedAt] IS NULL"); + + b.HasIndex("DistrictId"); + + b.ToTable("CustomerAddresses", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.CustomerProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DefaultEmergencyContactName") + .HasColumnType("nvarchar(max)"); + + b.Property("DefaultEmergencyContactPhone") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("CustomerProfiles", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.NurseBankAccount", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountHolderFromBank") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("AccountHolderName") + .HasColumnType("nvarchar(max)"); + + b.Property("BankName") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("Iban") + .HasColumnType("nvarchar(max)"); + + b.Property("IbanHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("IsPrimary") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("IsVerified") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("MatchedNationalId") + .HasColumnType("bit"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseId") + .HasColumnType("bigint"); + + b.Property("OwnershipVendorRef") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("VerifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("VerifiedByAdminId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("IbanHash") + .IsUnique(); + + b.HasIndex("NurseId") + .IsUnique() + .HasDatabaseName("UX_NurseBankAccounts_NurseId_Primary") + .HasFilter("[IsPrimary] = 1"); + + b.HasIndex("VerifiedByAdminId"); + + b.ToTable("NurseBankAccounts", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.NurseProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AverageRating") + .ValueGeneratedOnAdd() + .HasPrecision(3, 2) + .HasColumnType("decimal(3,2)") + .HasDefaultValue(0m); + + b.Property("Bio") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("EducationField") + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("EducationLevel") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("IsAcceptingBookings") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("IsVerified") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("PartnerCenterId") + .HasColumnType("bigint"); + + b.Property("SpecializationsJson") + .HasColumnType("nvarchar(max)"); + + b.Property("TotalCompletedBookings") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.Property("TotalReviews") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.Property("UserId") + .HasColumnType("int"); + + b.Property("YearsOfExperience") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("NurseProfiles", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.Patient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BirthDate") + .HasColumnType("date"); + + b.Property("BloodType") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("CustomerId") + .HasColumnType("bigint"); + + b.Property("DisplayName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("FirstName") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Gender") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("InitialMedicalNotes") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("LastName") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId"); + + b.ToTable("Patients", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Notifications.Notification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Body") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DataJson") + .HasColumnType("nvarchar(max)"); + + b.Property("IsRead") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("ReadAt") + .HasColumnType("datetimeoffset"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "IsRead", "CreatedAt"); + + b.ToTable("Notifications", "ops"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Search.NurseSearchIndex", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AverageRating") + .HasPrecision(3, 2) + .HasColumnType("decimal(3,2)"); + + b.Property("CityId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DistrictId") + .HasColumnType("bigint"); + + b.Property("IsSearchable") + .HasColumnType("bit"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseGender") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("NurseId") + .HasColumnType("bigint"); + + b.Property("Price") + .HasColumnType("bigint"); + + b.Property("PriceUnit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("ServiceCategoryId") + .HasColumnType("bigint"); + + b.Property("TotalCompletedBookings") + .HasColumnType("int"); + + b.Property("TotalReviews") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("VariantId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("NurseId"); + + b.HasIndex("VariantId", "CityId") + .IsUnique() + .HasDatabaseName("UX_NurseSearchIndex_Variant_City_WholeCity") + .HasFilter("[DistrictId] IS NULL AND [DeletedAt] IS NULL"); + + b.HasIndex("VariantId", "CityId", "DistrictId") + .IsUnique() + .HasDatabaseName("UX_NurseSearchIndex_Variant_City_District") + .HasFilter("[DistrictId] IS NOT NULL AND [DeletedAt] IS NULL"); + + b.HasIndex("IsSearchable", "ServiceCategoryId", "CityId", "DistrictId") + .HasDatabaseName("IX_NurseSearchIndex_Search"); + + SqlServerIndexBuilderExtensions.IncludeProperties(b.HasIndex("IsSearchable", "ServiceCategoryId", "CityId", "DistrictId"), new[] { "Price", "NurseGender", "AverageRating", "TotalReviews", "NurseId", "VariantId" }); + + b.ToTable("NurseSearchIndices", "search"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.SupportAlerts.SupportAlert", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BookingId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("OwnerUserId") + .HasColumnType("int"); + + b.Property("ResolutionNote") + .HasColumnType("nvarchar(max)"); + + b.Property("ResolvedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ReviewId") + .HasColumnType("bigint"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.HasIndex("Status"); + + b.HasIndex("Type"); + + b.ToTable("SupportAlerts", "ops"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("Roles", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.RoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedClaim") + .HasColumnType("datetime2"); + + b.Property("RoleId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("RoleClaims", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("UserId"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("FamilyName") + .HasColumnType("nvarchar(max)"); + + b.Property("Gender") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("GeneratedCode") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("NationalId") + .HasColumnType("nvarchar(max)"); + + b.Property("NationalIdVerifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneHash") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("PhoneVerifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("ShahkarVerifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.HasIndex("PhoneHash") + .IsUnique() + .HasFilter("[PhoneHash] IS NOT NULL"); + + b.ToTable("Users", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserClaims", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("LoggedOn") + .HasColumnType("datetime2"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("UserLogins", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserRefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("IsValid") + .HasColumnType("bit"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserRefreshTokens", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserRole", b => + { + b.Property("UserId") + .HasColumnType("int"); + + b.Property("RoleId") + .HasColumnType("int"); + + b.Property("CreatedUserRoleDate") + .HasColumnType("datetime2"); + + b.Property("GrantedAt") + .HasColumnType("datetimeoffset"); + + b.Property("GrantedById") + .HasColumnType("int"); + + b.Property("RevokedAt") + .HasColumnType("datetimeoffset"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("GrantedById"); + + b.HasIndex("RoleId"); + + b.ToTable("UserRoles", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeviceInfo") + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("ExpiresAt") + .HasColumnType("datetimeoffset"); + + b.Property("IpAddress") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("IsRevoked") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("RefreshTokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("RevokedAt") + .HasColumnType("datetimeoffset"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("RefreshTokenHash") + .IsUnique(); + + b.HasIndex("UserId", "IsRevoked"); + + b.ToTable("UserSessions", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserToken", b => + { + b.Property("UserId") + .HasColumnType("int"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("GeneratedTime") + .HasColumnType("datetime2"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("UserTokens", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.NurseCredential", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("CredentialNumber") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("CredentialType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ExpiresAt") + .HasColumnType("date"); + + b.Property("HolderNameSnapshot") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("IssuedAt") + .HasColumnType("date"); + + b.Property("IssuingAuthority") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseId") + .HasColumnType("bigint"); + + b.Property("VerificationMethod") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("VerificationSource") + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("VerifiedByAdminId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("VerifiedByAdminId"); + + b.HasIndex("NurseId", "CredentialType"); + + b.ToTable("NurseCredentials", "verif"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.NurseVerification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ApprovedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("InternalNotes") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseId") + .HasColumnType("bigint"); + + b.Property("RejectedAt") + .HasColumnType("datetimeoffset"); + + b.Property("RejectionReason") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("ReviewedByAdminId") + .HasColumnType("int"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("SubmittedAt") + .HasColumnType("datetimeoffset"); + + b.Property("SuspendedAt") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("NurseId") + .IsUnique(); + + b.HasIndex("ReviewedByAdminId"); + + b.ToTable("NurseVerifications", "verif"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.VerificationDocument", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("FileSizeBytes") + .HasColumnType("bigint"); + + b.Property("IntegrityHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("ObjectStorageKey") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("OriginalFileName") + .HasMaxLength(260) + .HasColumnType("nvarchar(260)"); + + b.Property("StepId") + .HasColumnType("bigint"); + + b.Property("UploadedByUserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("StepId"); + + b.HasIndex("UploadedByUserId"); + + b.ToTable("VerificationDocuments", "verif"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.VerificationStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("ExpiresAt") + .HasColumnType("datetimeoffset"); + + b.Property("ExternalResponseJson") + .HasColumnType("nvarchar(max)"); + + b.Property("FailureReason") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("IsAutomated") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseVerificationId") + .HasColumnType("bigint"); + + b.Property("StartedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("StepTypeId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("StepTypeId"); + + b.HasIndex("NurseVerificationId", "StepTypeId") + .IsUnique() + .HasDatabaseName("UX_VerificationSteps_Verification_StepType"); + + b.ToTable("VerificationSteps", "verif"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.VerificationStepType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AutomationProvider") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("IsAutomated") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("IsRequired") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsActive", "SortOrder"); + + b.ToTable("VerificationStepTypes", "verif"); + + b.HasData( + new + { + Id = 1L, + AutomationProvider = "identity_kyc_vendor", + Code = "identity_kyc", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + Description = "National-ID validity, name match and photo/video liveness via an Iranian e-KYC vendor.", + DisplayName = "Identity Verification (KYC)", + IsActive = true, + IsAutomated = true, + IsRequired = true, + SortOrder = 1 + }, + new + { + Id = 2L, + AutomationProvider = "shahkar", + Code = "shahkar_match", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + Description = "Confirms the login SIM is registered to the nurse's own national ID (شاهکار).", + DisplayName = "Shahkar Phone Binding", + IsActive = true, + IsAutomated = true, + IsRequired = true, + SortOrder = 2 + }, + new + { + Id = 3L, + Code = "moh_competency_license", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + Description = "پروانه صلاحیت حرفه‌ای — the MoH-mandated in-home nursing licence (bundles the criminal-record screen). Manual today.", + DisplayName = "MoH Professional Competency License", + IsActive = true, + IsAutomated = false, + IsRequired = true, + SortOrder = 3 + }, + new + { + Id = 4L, + Code = "ino_membership", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + Description = "نظام پرستاری membership cross-check (ino.ir). Manual today.", + DisplayName = "Nursing Organization (INO) Membership", + IsActive = true, + IsAutomated = false, + IsRequired = true, + SortOrder = 4 + }, + new + { + Id = 5L, + Code = "criminal_record", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + Description = "عدم سوء پیشینه — consent-gated, nurse-uploaded, time-limited (reverts on expiry).", + DisplayName = "Criminal Record Certificate", + IsActive = true, + IsAutomated = false, + IsRequired = true, + SortOrder = 5 + }, + new + { + Id = 6L, + AutomationProvider = "sheba", + Code = "bank_account_verification", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + Description = "استعلام شبا — the payout IBAN owner's national ID must equal the verified nurse national ID.", + DisplayName = "Bank Account (IBAN) Ownership", + IsActive = true, + IsAutomated = true, + IsRequired = true, + SortOrder = 6 + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Analytics.SystemEvent", b => + { + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("UserId"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Audit.AuditLog", b => + { + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("ActorUserId"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Booking.Booking", b => + { + b.HasOne("Baya.Domain.Entities.Booking.BookingRequest", null) + .WithOne() + .HasForeignKey("Baya.Domain.Entities.Booking.Booking", "BookingRequestId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Identity.CustomerAddress", null) + .WithMany() + .HasForeignKey("CustomerAddressId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Identity.CustomerProfile", null) + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", null) + .WithMany() + .HasForeignKey("NurseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Identity.Patient", null) + .WithMany() + .HasForeignKey("PatientId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Catalog.NurseServiceVariant", null) + .WithMany() + .HasForeignKey("VariantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Booking.BookingCareInstruction", b => + { + b.HasOne("Baya.Domain.Entities.Booking.Booking", "Booking") + .WithOne("CareInstructions") + .HasForeignKey("Baya.Domain.Entities.Booking.BookingCareInstruction", "BookingId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Booking"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Booking.BookingRequest", b => + { + b.HasOne("Baya.Domain.Entities.Identity.CustomerAddress", "CustomerAddress") + .WithMany() + .HasForeignKey("CustomerAddressId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Identity.CustomerProfile", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", "Nurse") + .WithMany() + .HasForeignKey("NurseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Identity.Patient", "Patient") + .WithMany() + .HasForeignKey("PatientId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Catalog.NurseServiceVariant", "Variant") + .WithMany() + .HasForeignKey("VariantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Customer"); + + b.Navigation("CustomerAddress"); + + b.Navigation("Nurse"); + + b.Navigation("Patient"); + + b.Navigation("Variant"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Booking.BookingSession", b => + { + b.HasOne("Baya.Domain.Entities.Booking.Booking", "Booking") + .WithMany("Sessions") + .HasForeignKey("BookingId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Booking"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Booking.VisitVerification", b => + { + b.HasOne("Baya.Domain.Entities.Booking.BookingSession", "Session") + .WithOne("Verification") + .HasForeignKey("Baya.Domain.Entities.Booking.VisitVerification", "BookingSessionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Session"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.NurseServiceVariant", b => + { + b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", "Nurse") + .WithMany() + .HasForeignKey("NurseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Catalog.ServiceCategory", "ServiceCategory") + .WithMany("Variants") + .HasForeignKey("ServiceCategoryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Nurse"); + + b.Navigation("ServiceCategory"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.NurseServiceVariantOption", b => + { + b.HasOne("Baya.Domain.Entities.Catalog.ServiceOptionGroup", "OptionGroup") + .WithMany() + .HasForeignKey("OptionGroupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Catalog.ServiceOptionValue", "OptionValue") + .WithMany() + .HasForeignKey("OptionValueId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Catalog.NurseServiceVariant", "Variant") + .WithMany("Options") + .HasForeignKey("VariantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("OptionGroup"); + + b.Navigation("OptionValue"); + + b.Navigation("Variant"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceOptionGroup", b => + { + b.HasOne("Baya.Domain.Entities.Catalog.ServiceCategory", "ServiceCategory") + .WithMany("OptionGroups") + .HasForeignKey("ServiceCategoryId"); + + b.Navigation("ServiceCategory"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceOptionValue", b => + { + b.HasOne("Baya.Domain.Entities.Catalog.ServiceOptionGroup", "OptionGroup") + .WithMany("Values") + .HasForeignKey("OptionGroupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("OptionGroup"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.City", b => + { + b.HasOne("Baya.Domain.Entities.Geography.Province", "Province") + .WithMany("Cities") + .HasForeignKey("ProvinceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Province"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.District", b => + { + b.HasOne("Baya.Domain.Entities.Geography.City", "City") + .WithMany("Districts") + .HasForeignKey("CityId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("City"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.NurseServiceArea", b => + { + b.HasOne("Baya.Domain.Entities.Geography.City", "City") + .WithMany() + .HasForeignKey("CityId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Geography.District", "District") + .WithMany() + .HasForeignKey("DistrictId"); + + b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", null) + .WithMany() + .HasForeignKey("NurseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("City"); + + b.Navigation("District"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.CustomerAddress", b => + { + b.HasOne("Baya.Domain.Entities.Geography.City", "City") + .WithMany() + .HasForeignKey("CityId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Identity.CustomerProfile", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Geography.District", "District") + .WithMany() + .HasForeignKey("DistrictId"); + + b.Navigation("City"); + + b.Navigation("Customer"); + + b.Navigation("District"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.CustomerProfile", b => + { + b.HasOne("Baya.Domain.Entities.User.User", "User") + .WithOne() + .HasForeignKey("Baya.Domain.Entities.Identity.CustomerProfile", "UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.NurseBankAccount", b => + { + b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", "Nurse") + .WithMany("BankAccounts") + .HasForeignKey("NurseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("VerifiedByAdminId"); + + b.Navigation("Nurse"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.NurseProfile", b => + { + b.HasOne("Baya.Domain.Entities.User.User", "User") + .WithOne() + .HasForeignKey("Baya.Domain.Entities.Identity.NurseProfile", "UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.Patient", b => + { + b.HasOne("Baya.Domain.Entities.Identity.CustomerProfile", "Customer") + .WithMany("Patients") + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Customer"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Notifications.Notification", b => + { + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Baya.Domain.Entities.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/20260706152603_BookingsSessionsEvvCancellation.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260706152603_BookingsSessionsEvvCancellation.cs new file mode 100644 index 0000000..414ce24 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260706152603_BookingsSessionsEvvCancellation.cs @@ -0,0 +1,379 @@ +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 BookingsSessionsEvvCancellation : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Bookings", + schema: "booking", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + BookingRequestId = table.Column(type: "bigint", nullable: false), + CustomerId = table.Column(type: "bigint", nullable: false), + NurseId = table.Column(type: "bigint", nullable: false), + PatientId = table.Column(type: "bigint", nullable: false), + VariantId = table.Column(type: "bigint", nullable: false), + CustomerAddressId = table.Column(type: "bigint", nullable: false), + PartnerCenterId = table.Column(type: "bigint", nullable: true), + VariantSnapshotJson = table.Column(type: "nvarchar(max)", nullable: false), + AddressSnapshotJson = table.Column(type: "nvarchar(max)", nullable: false), + GrossPriceIrr = table.Column(type: "bigint", nullable: false), + BalinyaarCommissionIrr = table.Column(type: "bigint", nullable: false), + PlatformFeeRate = table.Column(type: "decimal(5,4)", precision: 5, scale: 4, nullable: false), + NursePayoutAmount = table.Column(type: "bigint", nullable: false), + PspFeeAmount = table.Column(type: "bigint", nullable: true), + SessionCount = table.Column(type: "smallint", nullable: false), + ScheduledDate = table.Column(type: "date", nullable: false), + ScheduledTimeStart = table.Column(type: "time", nullable: false), + ScheduledTimeEnd = table.Column(type: "time", nullable: false), + Status = table.Column(type: "nvarchar(30)", maxLength: 30, nullable: false), + ConfirmedAt = table.Column(type: "datetime2", nullable: true), + CancelledAt = table.Column(type: "datetime2", nullable: true), + CancellationReason = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: true), + CancelledBy = table.Column(type: "nvarchar(20)", maxLength: 20, nullable: true), + CancellationPolicyCode = table.Column(type: "nvarchar(50)", maxLength: 50, nullable: true), + CancellationRefundPercentage = table.Column(type: "decimal(5,2)", precision: 5, scale: 2, nullable: true), + RefundableAmountIrr = table.Column(type: "bigint", nullable: true), + CompletedAt = table.Column(type: "datetime2", nullable: true), + DisputeWindowEndsAt = 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_Bookings", x => x.Id); + table.CheckConstraint("CK_Bookings_AmountSplit", "[GrossPriceIrr] = [BalinyaarCommissionIrr] + [NursePayoutAmount] AND [GrossPriceIrr] >= 0 AND [BalinyaarCommissionIrr] >= 0 AND [NursePayoutAmount] >= 0"); + table.ForeignKey( + name: "FK_Bookings_BookingRequests_BookingRequestId", + column: x => x.BookingRequestId, + principalSchema: "booking", + principalTable: "BookingRequests", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_Bookings_CustomerAddresses_CustomerAddressId", + column: x => x.CustomerAddressId, + principalSchema: "usr", + principalTable: "CustomerAddresses", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_Bookings_CustomerProfiles_CustomerId", + column: x => x.CustomerId, + principalSchema: "usr", + principalTable: "CustomerProfiles", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_Bookings_NurseProfiles_NurseId", + column: x => x.NurseId, + principalSchema: "usr", + principalTable: "NurseProfiles", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_Bookings_NurseServiceVariants_VariantId", + column: x => x.VariantId, + principalSchema: "catalog", + principalTable: "NurseServiceVariants", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_Bookings_Patients_PatientId", + column: x => x.PatientId, + principalSchema: "usr", + principalTable: "Patients", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "CancellationPolicies", + schema: "booking", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Code = table.Column(type: "nvarchar(50)", maxLength: 50, nullable: false), + AppliesTo = table.Column(type: "nvarchar(20)", maxLength: 20, nullable: false), + HoursBeforeStartMin = table.Column(type: "int", nullable: true), + HoursBeforeStartMax = table.Column(type: "int", nullable: true), + RefundPercentage = table.Column(type: "decimal(5,2)", precision: 5, scale: 2, nullable: false), + FeeAmountIrr = table.Column(type: "bigint", nullable: false), + FeeRate = table.Column(type: "decimal(5,4)", precision: 5, scale: 4, nullable: true), + IsActive = table.Column(type: "bit", 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_CancellationPolicies", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "BookingCareInstructions", + schema: "booking", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + BookingId = table.Column(type: "bigint", nullable: false), + CurrentConditions = table.Column(type: "nvarchar(max)", nullable: true), + Medications = table.Column(type: "nvarchar(max)", nullable: true), + Allergies = table.Column(type: "nvarchar(max)", nullable: true), + SpecialInstructions = table.Column(type: "nvarchar(max)", nullable: true), + EmergencyContactName = table.Column(type: "nvarchar(max)", nullable: true), + EmergencyContactPhone = table.Column(type: "nvarchar(max)", 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_BookingCareInstructions", x => x.Id); + table.ForeignKey( + name: "FK_BookingCareInstructions_Bookings_BookingId", + column: x => x.BookingId, + principalSchema: "booking", + principalTable: "Bookings", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "BookingSessions", + schema: "booking", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + BookingId = table.Column(type: "bigint", nullable: false), + SessionIndex = table.Column(type: "int", nullable: false), + ScheduledDate = table.Column(type: "date", nullable: false), + ScheduledTimeStart = table.Column(type: "time", nullable: false), + ScheduledTimeEnd = table.Column(type: "time", nullable: false), + VisitPayoutAmount = table.Column(type: "bigint", nullable: false), + Status = table.Column(type: "nvarchar(20)", maxLength: 20, nullable: false), + PayoutEligibleAt = table.Column(type: "datetime2", nullable: true), + CancellationEventId = table.Column(type: "bigint", 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_BookingSessions", x => x.Id); + table.ForeignKey( + name: "FK_BookingSessions_Bookings_BookingId", + column: x => x.BookingId, + principalSchema: "booking", + principalTable: "Bookings", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "VisitVerifications", + schema: "booking", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + BookingSessionId = table.Column(type: "bigint", nullable: false), + CheckInAt = table.Column(type: "datetime2", nullable: true), + CheckInLat = table.Column(type: "decimal(9,6)", precision: 9, scale: 6, nullable: true), + CheckInLng = table.Column(type: "decimal(9,6)", precision: 9, scale: 6, nullable: true), + CheckOutAt = table.Column(type: "datetime2", nullable: true), + CheckOutLat = table.Column(type: "decimal(9,6)", precision: 9, scale: 6, nullable: true), + CheckOutLng = table.Column(type: "decimal(9,6)", precision: 9, scale: 6, nullable: true), + CheckInAddressMatch = table.Column(type: "bit", nullable: true), + CheckInDistanceMeters = table.Column(type: "decimal(10,2)", precision: 10, scale: 2, nullable: true), + Status = table.Column(type: "nvarchar(20)", maxLength: 20, 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_VisitVerifications", x => x.Id); + table.ForeignKey( + name: "FK_VisitVerifications_BookingSessions_BookingSessionId", + column: x => x.BookingSessionId, + principalSchema: "booking", + principalTable: "BookingSessions", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.InsertData( + schema: "booking", + table: "CancellationPolicies", + columns: new[] { "Id", "AppliesTo", "Code", "CreatedAt", "CreatedById", "DeletedAt", "FeeAmountIrr", "FeeRate", "HoursBeforeStartMax", "HoursBeforeStartMin", "IsActive", "ModifiedAt", "ModifiedById", "RefundPercentage" }, + values: new object[,] + { + { 1L, "customer", "standard_24h", new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, 0L, null, null, 24, true, null, null, 100m }, + { 2L, "customer", "standard_inside_24h", new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, 0L, null, 24, null, true, null, null, 50m }, + { 3L, "nurse", "nurse_no_show", new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, 0L, 0m, null, null, true, null, null, 100m }, + { 4L, "admin", "admin_cancellation", new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, 0L, null, null, null, true, null, null, 100m } + }); + + migrationBuilder.InsertData( + schema: "ops", + table: "PlatformConfigs", + columns: new[] { "Id", "CreatedAt", "CreatedById", "DataType", "Description", "Key", "ModifiedAt", "ModifiedById", "Value" }, + values: new object[,] + { + { 17L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, "int", "Minutes after a session's scheduled start with no EVV check-in before it is flagged a no-show.", "no_show_threshold_minutes", null, null, "60" }, + { 18L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, "int", "Hours between no-show sweeps (the scheduled cron is deferred; the sweep is admin-triggered today).", "no_show_scan_cadence_hours", null, null, "1" } + }); + + migrationBuilder.CreateIndex( + name: "IX_BookingCareInstructions_BookingId", + schema: "booking", + table: "BookingCareInstructions", + column: "BookingId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Bookings_BookingRequestId", + schema: "booking", + table: "Bookings", + column: "BookingRequestId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Bookings_CustomerAddressId", + schema: "booking", + table: "Bookings", + column: "CustomerAddressId"); + + migrationBuilder.CreateIndex( + name: "IX_Bookings_CustomerId_Status", + schema: "booking", + table: "Bookings", + columns: new[] { "CustomerId", "Status" }); + + migrationBuilder.CreateIndex( + name: "IX_Bookings_DisputeWindowEndsAt", + schema: "booking", + table: "Bookings", + column: "DisputeWindowEndsAt"); + + migrationBuilder.CreateIndex( + name: "IX_Bookings_NurseId_Status", + schema: "booking", + table: "Bookings", + columns: new[] { "NurseId", "Status" }); + + migrationBuilder.CreateIndex( + name: "IX_Bookings_PatientId", + schema: "booking", + table: "Bookings", + column: "PatientId"); + + migrationBuilder.CreateIndex( + name: "IX_Bookings_VariantId", + schema: "booking", + table: "Bookings", + column: "VariantId"); + + migrationBuilder.CreateIndex( + name: "IX_BookingSessions_BookingId_SessionIndex", + schema: "booking", + table: "BookingSessions", + columns: new[] { "BookingId", "SessionIndex" }); + + migrationBuilder.CreateIndex( + name: "IX_BookingSessions_Status_ScheduledDate", + schema: "booking", + table: "BookingSessions", + columns: new[] { "Status", "ScheduledDate" }); + + migrationBuilder.CreateIndex( + name: "IX_CancellationPolicies_AppliesTo_IsActive", + schema: "booking", + table: "CancellationPolicies", + columns: new[] { "AppliesTo", "IsActive" }); + + migrationBuilder.CreateIndex( + name: "IX_CancellationPolicies_Code", + schema: "booking", + table: "CancellationPolicies", + column: "Code", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_VisitVerifications_BookingSessionId", + schema: "booking", + table: "VisitVerifications", + column: "BookingSessionId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_VisitVerifications_CheckInAddressMatch", + schema: "booking", + table: "VisitVerifications", + column: "CheckInAddressMatch"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "BookingCareInstructions", + schema: "booking"); + + migrationBuilder.DropTable( + name: "CancellationPolicies", + schema: "booking"); + + migrationBuilder.DropTable( + name: "VisitVerifications", + schema: "booking"); + + migrationBuilder.DropTable( + name: "BookingSessions", + schema: "booking"); + + migrationBuilder.DropTable( + name: "Bookings", + schema: "booking"); + + migrationBuilder.DeleteData( + schema: "ops", + table: "PlatformConfigs", + keyColumn: "Id", + keyValue: 17L); + + migrationBuilder.DeleteData( + schema: "ops", + table: "PlatformConfigs", + keyColumn: "Id", + keyValue: 18L); + } + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/ApplicationDbContextModelSnapshot.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/ApplicationDbContextModelSnapshot.cs index e3ea472..349c462 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/ApplicationDbContextModelSnapshot.cs @@ -98,6 +98,197 @@ namespace Baya.Infrastructure.Persistence.Migrations 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") @@ -187,6 +378,245 @@ namespace Baya.Infrastructure.Persistence.Migrations 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") @@ -707,6 +1137,24 @@ namespace Baya.Infrastructure.Persistence.Migrations 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" }); }); @@ -3165,6 +3613,56 @@ namespace Baya.Infrastructure.Persistence.Migrations .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") @@ -3208,6 +3706,28 @@ namespace Baya.Infrastructure.Persistence.Migrations 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") @@ -3578,6 +4098,18 @@ namespace Baya.Infrastructure.Persistence.Migrations 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"); diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/BookingRepository.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/BookingRepository.cs new file mode 100644 index 0000000..ff69455 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/BookingRepository.cs @@ -0,0 +1,272 @@ +#nullable enable +using Baya.Application.Contracts.Persistence; +using Baya.Application.Models.Booking; +using Baya.Application.Models.Common; +using Baya.Domain.Entities.Booking; +using Baya.Domain.Entities.Identity; +using Baya.Infrastructure.Persistence.Repositories.Common; +using Microsoft.EntityFrameworkCore; + +namespace Baya.Infrastructure.Persistence.Repositories; + +internal sealed class BookingRepository : BaseAsyncRepository, IBookingRepository +{ + public BookingRepository(ApplicationDbContext dbContext) : base(dbContext) + { + } + + public Task AddAsync(Booking booking, CancellationToken cancellationToken) + => base.AddAsync(booking); + + public Task GetBookingIdByRequestIdAsync(long bookingRequestId, CancellationToken cancellationToken) + => TableNoTracking + .Where(b => b.BookingRequestId == bookingRequestId) + .Select(b => (long?)b.Id) + .FirstOrDefaultAsync(cancellationToken); + + public async Task GetDetailAsync(long id, CancellationToken cancellationToken) + { + var row = await ( + from b in TableNoTracking + where b.Id == id + join n in DbContext.Set() on b.NurseId equals n.Id + join p in DbContext.Set() on b.PatientId equals p.Id + select new + { + Booking = b, + NurseName = n.User.Name, + NurseFamily = n.User.FamilyName, + PatientName = p.DisplayName, + Sessions = b.Sessions + .OrderBy(s => s.SessionIndex) + .Select(s => new BookingSessionProjection( + s.Id, + s.SessionIndex, + s.ScheduledDate, + s.ScheduledTimeStart, + s.ScheduledTimeEnd, + s.Status, + s.VisitPayoutAmount, + s.PayoutEligibleAt, + s.Verification == null ? null : s.Verification.Status, + s.Verification == null ? null : s.Verification.CheckInAt, + s.Verification == null ? null : s.Verification.CheckOutAt, + s.Verification == null ? null : s.Verification.CheckInAddressMatch)) + .ToList() + }) + .FirstOrDefaultAsync(cancellationToken); + + if (row is null) + return null; + + var bk = row.Booking; + return new BookingDetailProjection( + bk.Id, bk.BookingRequestId, bk.Status, bk.CustomerId, bk.NurseId, + ComposeName(row.NurseName, row.NurseFamily), bk.PatientId, row.PatientName, + bk.VariantId, bk.VariantSnapshotJson, bk.CustomerAddressId, bk.AddressSnapshotJson, + bk.GrossPriceIrr, bk.BalinyaarCommissionIrr, bk.PlatformFeeRate, bk.NursePayoutAmount, bk.PspFeeAmount, + bk.SessionCount, bk.ScheduledDate, bk.ScheduledTimeStart, bk.ScheduledTimeEnd, + bk.ConfirmedAt, bk.CompletedAt, bk.CancelledAt, bk.CancelledBy, bk.CancellationReason, + bk.CancellationPolicyCode, bk.CancellationRefundPercentage, bk.RefundableAmountIrr, + bk.DisputeWindowEndsAt, bk.CreatedAt, row.Sessions); + } + + public Task> ListForCustomerAsync(long customerId, string? status, int page, int pageSize, CancellationToken cancellationToken) + => ListAsync(TableNoTracking.Where(b => b.CustomerId == customerId), status, forNurse: false, page, pageSize, cancellationToken); + + public Task> ListForNurseAsync(long nurseId, string? status, int page, int pageSize, CancellationToken cancellationToken) + => ListAsync(TableNoTracking.Where(b => b.NurseId == nurseId), status, forNurse: true, page, pageSize, cancellationToken); + + public Task> ListAllAsync(string? status, int page, int pageSize, CancellationToken cancellationToken) + => ListAsync(TableNoTracking, status, forNurse: false, page, pageSize, cancellationToken); + + private async Task> ListAsync( + IQueryable source, string? status, bool forNurse, int page, int pageSize, CancellationToken cancellationToken) + { + if (!string.IsNullOrWhiteSpace(status)) + source = source.Where(b => b.Status == status); + + var total = await source.CountAsync(cancellationToken); + + var rows = await ( + from b in source + join n in DbContext.Set() on b.NurseId equals n.Id + join p in DbContext.Set() on b.PatientId equals p.Id + orderby b.Id descending + select new + { + b.Id, + b.Status, + NurseName = n.User.Name, + NurseFamily = n.User.FamilyName, + PatientName = p.DisplayName, + b.ScheduledDate, + b.SessionCount, + b.GrossPriceIrr, + b.NursePayoutAmount, + b.DisputeWindowEndsAt, + b.CreatedAt + }) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(cancellationToken); + + var items = rows + .Select(r => new BookingListItemDto( + r.Id, + r.Status, + forNurse ? r.PatientName : ComposeName(r.NurseName, r.NurseFamily), + r.ScheduledDate, + r.SessionCount, + (forNurse ? r.NursePayoutAmount : r.GrossPriceIrr).ToString(), + r.DisputeWindowEndsAt, + r.CreatedAt)) + .ToList(); + + return new PagedResult(items, total, page, pageSize); + } + + public Task GetParticipantsAsync(long bookingId, CancellationToken cancellationToken) + => ( + from b in TableNoTracking + where b.Id == bookingId + join c in DbContext.Set() on b.CustomerId equals c.Id + join n in DbContext.Set() on b.NurseId equals n.Id + select new BookingParticipants(c.UserId, n.UserId)) + .FirstOrDefaultAsync(cancellationToken); + + public Task GetTrackedWithSessionsAsync(long id, CancellationToken cancellationToken) + => Table.Include(b => b.Sessions).FirstOrDefaultAsync(b => b.Id == id, cancellationToken); + + public Task GetTrackedWithCareAsync(long id, CancellationToken cancellationToken) + => Table.Include(b => b.CareInstructions).FirstOrDefaultAsync(b => b.Id == id, cancellationToken); + + public async Task GetTrackedBookingBySessionAsync(long sessionId, CancellationToken cancellationToken) + { + var bookingId = await DbContext.Set() + .Where(s => s.Id == sessionId) + .Select(s => (long?)s.BookingId) + .FirstOrDefaultAsync(cancellationToken); + + if (bookingId is not { } bid) + return null; + + return await Table + .Include(b => b.Sessions).ThenInclude(s => s.Verification) + .FirstOrDefaultAsync(b => b.Id == bid, cancellationToken); + } + + public Task GetCareInstructionsGateAsync(long bookingId, CancellationToken cancellationToken) + => TableNoTracking + .Where(b => b.Id == bookingId) + .Select(b => new CareInstructionsGate( + b.Status, + b.NurseId, + b.CustomerId, + b.CareInstructions == null + ? null + : new CareInstructionsDto( + b.Id, + b.CareInstructions.CurrentConditions, + b.CareInstructions.Medications, + b.CareInstructions.Allergies, + b.CareInstructions.SpecialInstructions, + b.CareInstructions.EmergencyContactName, + b.CareInstructions.EmergencyContactPhone))) + .FirstOrDefaultAsync(cancellationToken); + + public async Task> ListSessionsForNurseAsync( + long nurseId, DateOnly? date, int page, int pageSize, CancellationToken cancellationToken) + { + var query = DbContext.Set().AsNoTracking() + .Where(s => s.Booking.NurseId == nurseId); + + if (date is { } d) + query = query.Where(s => s.ScheduledDate == d); + + var total = await query.CountAsync(cancellationToken); + + var items = await ( + from s in query + join p in DbContext.Set() on s.Booking.PatientId equals p.Id + orderby s.ScheduledDate, s.ScheduledTimeStart + select new BookingSessionListItemDto( + s.Id, + s.BookingId, + s.SessionIndex, + p.DisplayName, + s.ScheduledDate, + s.ScheduledTimeStart, + s.ScheduledTimeEnd, + s.Status, + s.Verification == null ? VisitVerificationStatus.Pending : s.Verification.Status)) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(cancellationToken); + + return new PagedResult(items, total, page, pageSize); + } + + public Task GetEvvForSessionAsync(long sessionId, CancellationToken cancellationToken) + => DbContext.Set().AsNoTracking() + .Where(s => s.Id == sessionId) + .Select(s => new EvvGate( + s.Booking.NurseId, + s.Booking.CustomerId, + s.Verification == null + ? null + : new VisitVerificationDto( + s.Verification.Id, + s.Id, + s.Verification.Status, + s.Verification.CheckInAt, + s.Verification.CheckInLat, + s.Verification.CheckInLng, + s.Verification.CheckOutAt, + s.Verification.CheckOutLat, + s.Verification.CheckOutLng, + s.Verification.CheckInAddressMatch, + s.Verification.CheckInDistanceMeters))) + .FirstOrDefaultAsync(cancellationToken); + + public async Task> ListAdminEvvAsync(string type, int page, int pageSize, CancellationToken cancellationToken) + { + var query = DbContext.Set().AsNoTracking().AsQueryable(); + + // The two admin review lanes: an advisory location mismatch, or a no-show (missed) session. + query = type == "no_show" + ? query.Where(s => s.Status == BookingSessionStatus.Missed) + : query.Where(s => s.Verification != null && s.Verification.CheckInAddressMatch == false); + + var total = await query.CountAsync(cancellationToken); + + var items = await query + .OrderByDescending(s => s.Id) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .Select(s => new AdminEvvItemDto( + s.Id, + s.BookingId, + s.Booking.NurseId, + s.Status, + s.ScheduledDate, + s.ScheduledTimeStart, + s.Verification == null ? null : s.Verification.CheckInAt, + s.Verification == null ? null : s.Verification.CheckInAddressMatch, + s.Verification == null ? null : s.Verification.CheckInDistanceMeters)) + .ToListAsync(cancellationToken); + + return new PagedResult(items, total, page, pageSize); + } + + public async Task> GetNoShowCandidatesAsync(DateOnly today, int batchSize, CancellationToken cancellationToken) + => await DbContext.Set() + .Include(s => s.Booking) + .Where(s => s.Status == BookingSessionStatus.Scheduled && s.ScheduledDate <= today) + .OrderBy(s => s.Id) + .Take(batchSize) + .ToListAsync(cancellationToken); + + private static string ComposeName(string? name, string? familyName) + => string.Join(' ', new[] { name, familyName }.Where(s => !string.IsNullOrWhiteSpace(s))).Trim(); +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/BookingRequestRepository.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/BookingRequestRepository.cs index ccc2caf..30ca875 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/BookingRequestRepository.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/BookingRequestRepository.cs @@ -1,6 +1,7 @@ #nullable enable using Baya.Application.Contracts.Persistence; using Baya.Application.Models.Booking; +using Baya.Application.Models.Catalog; using Baya.Application.Models.Common; using Baya.Domain.Entities.Booking; using Baya.Infrastructure.Persistence.Repositories.Common; @@ -166,6 +167,61 @@ internal sealed class BookingRequestRepository : BaseAsyncRepository GetTrackedByIdAsync(long id, CancellationToken cancellationToken) + => Table.FirstOrDefaultAsync(r => r.Id == id, cancellationToken); + + public Task GetConversionSourceAsync(long id, CancellationToken cancellationToken) + => TableNoTracking + .Where(r => r.Id == id) + .Select(r => new BookingConversionSource( + r.Id, + r.Status, + r.CustomerId, + r.Customer.UserId, + r.NurseId, + r.Nurse.UserId, + r.PatientId, + r.Patient.DisplayName, + r.VariantId, + r.CustomerAddressId, + r.RequestedDate, + r.RequestedTimeStart, + r.RequestedTimeEnd, + new VariantSnapshot( + r.Variant.Id, + r.Variant.ServiceCategoryId, + r.Variant.ServiceCategory.NameFa, + r.Variant.ServiceCategory.NameEn, + r.Variant.Price, + r.Variant.PriceUnit, + r.Variant.SessionCount, + r.Variant.DisplayName, + r.Variant.Options + .Select(o => new VariantOptionDto( + o.OptionGroupId, + o.OptionGroup.NameFa, + o.OptionGroup.NameEn, + o.OptionValueId, + o.OptionValue.NameFa, + o.OptionValue.NameEn)) + .ToList()), + new AddressSnapshot( + r.CustomerAddress.Id, + r.CustomerAddress.Title, + r.CustomerAddress.CityId, + r.CustomerAddress.City.NameFa, + r.CustomerAddress.City.NameEn, + r.CustomerAddress.DistrictId, + r.CustomerAddress.DistrictId == null ? null : r.CustomerAddress.District.NameFa, + r.CustomerAddress.DistrictId == null ? null : r.CustomerAddress.District.NameEn, + r.CustomerAddress.AddressLine, + r.CustomerAddress.PostalCode, + r.CustomerAddress.RecipientName, + r.CustomerAddress.RecipientPhone, + r.CustomerAddress.Latitude, + r.CustomerAddress.Longitude))) + .FirstOrDefaultAsync(cancellationToken); + // Actionable (awaiting a party's action) rows float above terminal ones, then most-recent first. Recency // (id) rather than the deadline is the secondary key: for a given status the deadline tracks creation // time anyway, and DateTimeOffset is not sortable on the SQLite test provider. diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/CancellationPolicyRepository.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/CancellationPolicyRepository.cs new file mode 100644 index 0000000..f4518d0 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/CancellationPolicyRepository.cs @@ -0,0 +1,36 @@ +#nullable enable +using Baya.Application.Contracts.Persistence; +using Baya.Application.Models.Booking; +using Baya.Domain.Entities.Booking; +using Baya.Infrastructure.Persistence.Repositories.Common; +using Microsoft.EntityFrameworkCore; + +namespace Baya.Infrastructure.Persistence.Repositories; + +internal sealed class CancellationPolicyRepository : BaseAsyncRepository, ICancellationPolicyRepository +{ + public CancellationPolicyRepository(ApplicationDbContext dbContext) : base(dbContext) + { + } + + public async Task> GetActiveForActorAsync(string appliesTo, CancellationToken cancellationToken) + => await TableNoTracking + .Where(p => p.AppliesTo == appliesTo && p.IsActive) + // Tighter (bounded) buckets first so the resolver prefers the most specific covering tier. + .OrderBy(p => p.HoursBeforeStartMin ?? int.MinValue) + .ToListAsync(cancellationToken); + + public async Task> ListAsync(CancellationToken cancellationToken) + => await TableNoTracking + .OrderBy(p => p.AppliesTo).ThenBy(p => p.Code) + .Select(p => new CancellationPolicyDto( + p.Id, p.Code, p.AppliesTo, p.HoursBeforeStartMin, p.HoursBeforeStartMax, + p.RefundPercentage, p.FeeAmountIrr.ToString(), p.FeeRate, p.IsActive)) + .ToListAsync(cancellationToken); + + public Task GetTrackedByCodeAsync(string code, CancellationToken cancellationToken) + => Table.FirstOrDefaultAsync(p => p.Code == code, cancellationToken); + + public Task AddAsync(CancellationPolicy policy, CancellationToken cancellationToken) + => base.AddAsync(policy); +} 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 d0c3e4b..5b35dbd 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/Common/UnitOfWork.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/Common/UnitOfWork.cs @@ -20,6 +20,8 @@ public class UnitOfWork : IUnitOfWork public INurseServiceVariantRepository NurseServiceVariantRepository { get; } public IVerificationRepository VerificationRepository { get; } public IBookingRequestRepository BookingRequestRepository { get; } + public IBookingRepository BookingRepository { get; } + public ICancellationPolicyRepository CancellationPolicyRepository { get; } public UnitOfWork(ApplicationDbContext db) { @@ -38,6 +40,8 @@ public class UnitOfWork : IUnitOfWork NurseServiceVariantRepository = new NurseServiceVariantRepository(_db); VerificationRepository = new VerificationRepository(_db); BookingRequestRepository = new BookingRequestRepository(_db); + BookingRepository = new BookingRepository(_db); + CancellationPolicyRepository = new CancellationPolicyRepository(_db); } public Task CommitAsync() diff --git a/server/src/Tests/Baya.Test.Api/AdminCancellationPoliciesApiTests.cs b/server/src/Tests/Baya.Test.Api/AdminCancellationPoliciesApiTests.cs new file mode 100644 index 0000000..eda2c7c --- /dev/null +++ b/server/src/Tests/Baya.Test.Api/AdminCancellationPoliciesApiTests.cs @@ -0,0 +1,69 @@ +using System.Net; +using System.Net.Http.Json; + +namespace Baya.Test.Api; + +public class AdminCancellationPoliciesApiTests(BayaApiFactory factory) : IClassFixture +{ + [Fact] + public async Task List_Unauthenticated_Returns401() + { + var client = factory.CreateClient(); + var response = await client.GetAsync("/api/v1/admin_cancellation_policies/list"); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task List_AsAdmin_ReturnsSeededTiers() + { + var admin = factory.CreateClient(); + await AdminTestClient.AuthenticateAsync(factory, admin, "09131900401"); + + var response = await admin.GetAsync("/api/v1/admin_cancellation_policies/list"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var data = await AuthTestClient.ReadDataAsync(response); + // The four baseline tiers are seeded via HasData. + Assert.True(data.GetArrayLength() >= 4); + } + + [Fact] + public async Task Upsert_InvalidPercentage_Returns400() + { + var admin = factory.CreateClient(); + await AdminTestClient.AuthenticateAsync(factory, admin, "09131900402"); + + var response = await admin.PostAsJsonAsync("/api/v1/admin_cancellation_policies/upsert", new + { + code = "custom_tier", + appliesTo = "customer", + refundPercentage = 150, + feeAmountIrr = 0, + isActive = true + }); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task Upsert_AsAdmin_CreatesTier() + { + var admin = factory.CreateClient(); + await AdminTestClient.AuthenticateAsync(factory, admin, "09131900403"); + + var response = await admin.PostAsJsonAsync("/api/v1/admin_cancellation_policies/upsert", new + { + code = "late_customer", + appliesTo = "customer", + hoursBeforeStartMin = 0, + hoursBeforeStartMax = 6, + refundPercentage = 25, + feeAmountIrr = 0, + isActive = true + }); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var data = await AuthTestClient.ReadDataAsync(response); + Assert.Equal("late_customer", data.GetProperty("code").GetString()); + } +} diff --git a/server/src/Tests/Baya.Test.Api/AdminEvvApiTests.cs b/server/src/Tests/Baya.Test.Api/AdminEvvApiTests.cs new file mode 100644 index 0000000..2f706fe --- /dev/null +++ b/server/src/Tests/Baya.Test.Api/AdminEvvApiTests.cs @@ -0,0 +1,40 @@ +using System.Net; + +namespace Baya.Test.Api; + +public class AdminEvvApiTests(BayaApiFactory factory) : IClassFixture +{ + [Fact] + public async Task List_Unauthenticated_Returns401() + { + var client = factory.CreateClient(); + var response = await client.GetAsync("/api/v1/admin_evv/list?type=mismatch"); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task List_AsAdmin_ReturnsEmptyPagedEnvelope() + { + var admin = factory.CreateClient(); + await AdminTestClient.AuthenticateAsync(factory, admin, "09131900301"); + + var response = await admin.GetAsync("/api/v1/admin_evv/list?type=mismatch"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var data = await AuthTestClient.ReadDataAsync(response); + Assert.Equal(0, data.GetProperty("total").GetInt32()); + } + + [Fact] + public async Task DetectNoShows_AsAdmin_ReturnsZeroOnEmptySet() + { + var admin = factory.CreateClient(); + await AdminTestClient.AuthenticateAsync(factory, admin, "09131900302"); + + var response = await admin.PostAsync("/api/v1/admin_evv/detect_no_shows", null); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var data = await AuthTestClient.ReadDataAsync(response); + Assert.Equal(0, data.GetProperty("missed").GetInt32()); + } +} diff --git a/server/src/Tests/Baya.Test.Api/BookingSessionsApiTests.cs b/server/src/Tests/Baya.Test.Api/BookingSessionsApiTests.cs new file mode 100644 index 0000000..34576d3 --- /dev/null +++ b/server/src/Tests/Baya.Test.Api/BookingSessionsApiTests.cs @@ -0,0 +1,40 @@ +using System.Net; +using System.Net.Http.Json; + +namespace Baya.Test.Api; + +public class BookingSessionsApiTests(BayaApiFactory factory) : IClassFixture +{ + [Fact] + public async Task CheckIn_Unauthenticated_Returns401() + { + var client = factory.CreateClient(); + var response = await client.PostAsJsonAsync("/api/v1/booking_sessions/check_in/1", new { latitude = 35.6m, longitude = 51.3m }); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task Today_AsNurse_ReturnsEmptyPagedEnvelope() + { + var client = factory.CreateClient(); + await ProfileTestClient.AuthenticateAsync(factory, client, "09131900201", "nurse"); + + var response = await client.GetAsync("/api/v1/booking_sessions/today"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var data = await AuthTestClient.ReadDataAsync(response); + Assert.Equal(0, data.GetProperty("total").GetInt32()); + } + + [Fact] + public async Task CheckIn_UnknownSession_ReturnsNotFound() + { + var client = factory.CreateClient(); + await ProfileTestClient.AuthenticateAsync(factory, client, "09131900202", "nurse"); + + // The role-selected nurse has no nurse profile yet (403) and the session is absent (404) — either + // way the check-in never succeeds and never leaks another booking's session. + var response = await client.PostAsJsonAsync("/api/v1/booking_sessions/check_in/999999", new { latitude = 35.6m, longitude = 51.3m }); + Assert.True(response.StatusCode is HttpStatusCode.NotFound or HttpStatusCode.Forbidden); + } +} diff --git a/server/src/Tests/Baya.Test.Api/BookingsApiTests.cs b/server/src/Tests/Baya.Test.Api/BookingsApiTests.cs new file mode 100644 index 0000000..3cf320a --- /dev/null +++ b/server/src/Tests/Baya.Test.Api/BookingsApiTests.cs @@ -0,0 +1,50 @@ +using System.Net; +using System.Net.Http.Json; + +namespace Baya.Test.Api; + +public class BookingsApiTests(BayaApiFactory factory) : IClassFixture +{ + [Fact] + public async Task Convert_Unauthenticated_Returns401() + { + var client = factory.CreateClient(); + var response = await client.PostAsJsonAsync("/api/v1/bookings/convert", new { bookingRequestId = 1 }); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task Convert_InvalidId_Returns400() + { + var client = factory.CreateClient(); + await ProfileTestClient.AuthenticateAsync(factory, client, "09131900101", "customer"); + + var response = await client.PostAsJsonAsync("/api/v1/bookings/convert", new { bookingRequestId = 0 }); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task List_Authenticated_ReturnsEmptyPagedEnvelope() + { + var client = factory.CreateClient(); + await ProfileTestClient.AuthenticateAsync(factory, client, "09131900102", "customer"); + + var response = await client.GetAsync("/api/v1/bookings/list"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var data = await AuthTestClient.ReadDataAsync(response); + Assert.Equal(0, data.GetProperty("total").GetInt32()); + } + + [Fact] + public async Task CareInstructions_UnauthorizedCaller_DoesNotLeak() + { + var client = factory.CreateClient(); + await ProfileTestClient.AuthenticateAsync(factory, client, "09131900103", "customer"); + + // The two-stage disclosure boundary: a caller who is not the assigned nurse/admin gets a clean + // not-found for a booking they cannot read (here, a non-existent one) — never the clinical fields. + var response = await client.GetAsync("/api/v1/bookings/care_instructions/999999"); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } +} diff --git a/server/src/Tests/Baya.Test.Foundation/Bookings/BookingAmountsTests.cs b/server/src/Tests/Baya.Test.Foundation/Bookings/BookingAmountsTests.cs new file mode 100644 index 0000000..63b96b6 --- /dev/null +++ b/server/src/Tests/Baya.Test.Foundation/Bookings/BookingAmountsTests.cs @@ -0,0 +1,50 @@ +using Baya.Domain.Entities.Booking; + +namespace Baya.Test.Foundation.Bookings; + +public class BookingAmountsTests +{ + [Theory] + [InlineData(10_000_000, 0.15, 1_500_000, 8_500_000)] + [InlineData(5_000_000, 0.15, 750_000, 4_250_000)] + // Rounding half away from zero: 3 × 0.15 = 0.45 → 0 (commission), payout 3. + [InlineData(3, 0.15, 0, 3)] + public void Split_reconciles_gross_equals_commission_plus_payout(long gross, decimal rate, long expectedCommission, long expectedPayout) + { + var (commission, payout) = BookingAmounts.Split(gross, rate); + + Assert.Equal(expectedCommission, commission); + Assert.Equal(expectedPayout, payout); + Assert.Equal(gross, commission + payout); + Assert.True(commission >= 0 && payout >= 0); + } + + [Theory] + [InlineData(8_500_000, 1)] + [InlineData(8_500_000, 3)] + [InlineData(8_500_001, 3)] + [InlineData(10, 3)] + [InlineData(2, 3)] + public void SplitPayout_sums_exactly_to_payout_with_remainder_on_last(long payout, int sessions) + { + var parts = BookingAmounts.SplitPayout(payout, sessions); + + Assert.Equal(sessions, parts.Length); + Assert.Equal(payout, parts.Sum()); + Assert.All(parts, p => Assert.True(p >= 0)); + + // Equal shares except the last, which carries the remainder. + var per = payout / sessions; + for (var i = 0; i < sessions - 1; i++) + Assert.Equal(per, parts[i]); + Assert.Equal(payout - per * (sessions - 1), parts[^1]); + } + + [Fact] + public void SplitPayout_single_visit_returns_whole_amount() + { + var parts = BookingAmounts.SplitPayout(4_250_000, 1); + Assert.Single(parts); + Assert.Equal(4_250_000, parts[0]); + } +} diff --git a/server/src/Tests/Baya.Test.Foundation/Bookings/BookingConversionTests.cs b/server/src/Tests/Baya.Test.Foundation/Bookings/BookingConversionTests.cs new file mode 100644 index 0000000..0d44298 --- /dev/null +++ b/server/src/Tests/Baya.Test.Foundation/Bookings/BookingConversionTests.cs @@ -0,0 +1,97 @@ +using System.Globalization; +using Baya.Application.Common; +using Baya.Application.Contracts.Common; +using Baya.Application.Features.Bookings.Commands.ConvertRequestToBooking; +using Baya.Domain.Entities.Booking; +using Microsoft.EntityFrameworkCore; +using NSubstitute; +using BookingEntity = Baya.Domain.Entities.Booking.Booking; + +namespace Baya.Test.Foundation.Bookings; + +public class BookingConversionTests +{ + private static readonly DateTimeOffset Now = new(2026, 7, 6, 10, 0, 0, TimeSpan.Zero); + + private static ConvertRequestToBookingCommandHandler Handler(BookingsTestHost host, IPaymentCaptureSimulator capture) + => new(host.AsCustomer(), host.UnitOfWork, host.Config(), host.Clock(Now), capture, new VariantSnapshotSerializer(), + Substitute.For()); + + [Fact] + public async Task Convert_creates_confirmed_booking_with_reconciling_amounts_and_sessions() + { + using var host = new BookingsTestHost(); + var variantId = host.AddVariant(sessionCount: 3, price: 5_000_000); // gross = 15_000_000 + var requestId = host.AddAcceptedRequest(variantId); + + var result = await Handler(host, host.Capture()).Handle(new ConvertRequestToBookingCommand(requestId), CancellationToken.None); + + Assert.True(result.IsSuccess); + var dto = result.Result; + Assert.Equal(BookingStatus.Confirmed, dto.Status); + + var gross = long.Parse(dto.GrossPriceIrr, CultureInfo.InvariantCulture); + var commission = long.Parse(dto.BalinyaarCommissionIrr, CultureInfo.InvariantCulture); + var payout = long.Parse(dto.NursePayoutAmount, CultureInfo.InvariantCulture); + Assert.Equal(15_000_000, gross); + Assert.Equal(2_250_000, commission); // 15M × 0.15 + Assert.Equal(gross, commission + payout); + Assert.Equal(0.15m, dto.PlatformFeeRate); + Assert.NotNull(dto.AddressSnapshotJson); + Assert.Contains("خیابان اول", dto.AddressSnapshotJson!); // snapshot decrypted for the customer view + + Assert.Equal((short)3, dto.SessionCount); + Assert.Equal(3, dto.Sessions.Count); + var sessionPayoutSum = dto.Sessions.Sum(s => long.Parse(s.VisitPayoutAmount, CultureInfo.InvariantCulture)); + Assert.Equal(payout, sessionPayoutSum); + + // Request flipped to converted. + var reqStatus = host.Db.Set().AsNoTracking().Single(r => r.Id == requestId).Status; + Assert.Equal(BookingRequestStatus.Converted, reqStatus); + } + + [Fact] + public async Task Convert_single_visit_creates_exactly_one_session() + { + using var host = new BookingsTestHost(); + var variantId = host.AddVariant(sessionCount: null); // single visit + var requestId = host.AddAcceptedRequest(variantId); + + var result = await Handler(host, host.Capture()).Handle(new ConvertRequestToBookingCommand(requestId), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal((short)1, result.Result.SessionCount); + Assert.Single(result.Result.Sessions); + } + + [Fact] + public async Task Convert_is_idempotent_returns_same_booking_on_replay() + { + using var host = new BookingsTestHost(); + var variantId = host.AddVariant(sessionCount: 2); + var requestId = host.AddAcceptedRequest(variantId); + + var first = await Handler(host, host.Capture()).Handle(new ConvertRequestToBookingCommand(requestId), CancellationToken.None); + var second = await Handler(host, host.Capture()).Handle(new ConvertRequestToBookingCommand(requestId), CancellationToken.None); + + Assert.True(first.IsSuccess); + Assert.True(second.IsSuccess); + Assert.Equal(first.Result.Id, second.Result.Id); + Assert.Equal(1, host.Db.Set().Count()); + } + + [Fact] + public async Task Convert_with_failed_capture_creates_no_booking_and_leaves_request_accepted() + { + using var host = new BookingsTestHost(); + var variantId = host.AddVariant(sessionCount: 1); + var requestId = host.AddAcceptedRequest(variantId); + + var result = await Handler(host, host.Capture(succeeds: false)).Handle(new ConvertRequestToBookingCommand(requestId), CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Equal(0, host.Db.Set().Count()); + var reqStatus = host.Db.Set().AsNoTracking().Single(r => r.Id == requestId).Status; + Assert.Equal(BookingRequestStatus.AcceptedAwaitingPayment, reqStatus); + } +} diff --git a/server/src/Tests/Baya.Test.Foundation/Bookings/BookingEvvTests.cs b/server/src/Tests/Baya.Test.Foundation/Bookings/BookingEvvTests.cs new file mode 100644 index 0000000..ed7f92a --- /dev/null +++ b/server/src/Tests/Baya.Test.Foundation/Bookings/BookingEvvTests.cs @@ -0,0 +1,100 @@ +using Baya.Application.Common; +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.SupportAlerts; +using Baya.Application.Features.Bookings.Commands.CheckInVisit; +using Baya.Application.Features.Bookings.Commands.CheckOutVisit; +using Baya.Application.Features.Bookings.Commands.ConvertRequestToBooking; +using Baya.Domain.Entities.Booking; +using Baya.Domain.Entities.SupportAlerts; +using Microsoft.EntityFrameworkCore; +using NSubstitute; +using BookingEntity = Baya.Domain.Entities.Booking.Booking; + +namespace Baya.Test.Foundation.Bookings; + +public class BookingEvvTests +{ + private static readonly DateTimeOffset Now = new(2026, 8, 1, 9, 5, 0, TimeSpan.Zero); + + private static async Task<(long BookingId, long SessionId)> ConvertSingleAsync(BookingsTestHost host) + { + var variantId = host.AddVariant(sessionCount: 1); + var requestId = host.AddAcceptedRequest(variantId); + var handler = new ConvertRequestToBookingCommandHandler( + host.AsCustomer(), host.UnitOfWork, host.Config(), host.Clock(Now), host.Capture(), + new VariantSnapshotSerializer(), Substitute.For()); + var result = await handler.Handle(new ConvertRequestToBookingCommand(requestId), CancellationToken.None); + return (result.Result.Id, result.Result.Sessions[0].Id); + } + + private IGeocoder GeocoderAt(BookingsTestHost host) + { + var g = Substitute.For(); + g.GeocodeAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new GeocodeResult(host.AddressLat, host.AddressLng, "formatted", 0.9)); + return g; + } + + [Fact] + public async Task CheckIn_in_range_then_checkout_completes_booking_and_sets_dispute_window() + { + using var host = new BookingsTestHost(); + var (bookingId, sessionId) = await ConvertSingleAsync(host); + var alerts = Substitute.For(); + + var checkIn = new CheckInVisitCommandHandler(host.AsNurse(), host.UnitOfWork, host.Config(), host.Clock(Now), + GeocoderAt(host), alerts, Substitute.For()); + var inResult = await checkIn.Handle(new CheckInVisitCommand(host.AddressLat, host.AddressLng, sessionId), CancellationToken.None); + + Assert.True(inResult.IsSuccess); + Assert.True(inResult.Result.CheckInAddressMatch); // in range + Assert.Equal(BookingStatus.InProgress, host.StatusOf(bookingId)); + await alerts.DidNotReceive().RaiseAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any()); + + var checkOutTime = new DateTimeOffset(2026, 8, 1, 13, 0, 0, TimeSpan.Zero); + var checkOut = new CheckOutVisitCommandHandler(host.AsNurse(), host.UnitOfWork, host.Config(), host.Clock(checkOutTime)); + var outResult = await checkOut.Handle(new CheckOutVisitCommand(host.AddressLat, host.AddressLng, sessionId), CancellationToken.None); + + Assert.True(outResult.IsSuccess); + Assert.Equal(VisitVerificationStatus.Completed, outResult.Result.Status); + Assert.Equal(BookingStatus.Completed, host.StatusOf(bookingId)); + + var booking = host.Db.Set().Include(b => b.Sessions).AsNoTracking().Single(b => b.Id == bookingId); + Assert.Equal(checkOutTime.UtcDateTime.AddHours(72), booking.DisputeWindowEndsAt); + Assert.Equal(checkOutTime.UtcDateTime.AddHours(72), booking.Sessions.Single().PayoutEligibleAt); + } + + [Fact] + public async Task CheckIn_out_of_range_raises_alert_without_blocking() + { + using var host = new BookingsTestHost(); + var (bookingId, sessionId) = await ConvertSingleAsync(host); + var alerts = Substitute.For(); + + var checkIn = new CheckInVisitCommandHandler(host.AsNurse(), host.UnitOfWork, host.Config(), host.Clock(Now), + GeocoderAt(host), alerts, Substitute.For()); + + // Far-away GPS (Gulf of Guinea) → well beyond tolerance. + var result = await checkIn.Handle(new CheckInVisitCommand(0m, 0m, sessionId), CancellationToken.None); + + Assert.True(result.IsSuccess); // never blocked + Assert.False(result.Result.CheckInAddressMatch); // flagged + Assert.Equal(BookingStatus.InProgress, host.StatusOf(bookingId)); // visit proceeds + await alerts.Received(1).RaiseAsync( + SupportAlertType.EvvLocationMismatch, "booking_session", sessionId.ToString(), + SupportAlertSeverity.Medium, bookingId, Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task CheckOut_without_open_checkin_fails() + { + using var host = new BookingsTestHost(); + var (_, sessionId) = await ConvertSingleAsync(host); + + var checkOut = new CheckOutVisitCommandHandler(host.AsNurse(), host.UnitOfWork, host.Config(), host.Clock(Now)); + var result = await checkOut.Handle(new CheckOutVisitCommand(host.AddressLat, host.AddressLng, sessionId), CancellationToken.None); + + Assert.False(result.IsSuccess); + } +} diff --git a/server/src/Tests/Baya.Test.Foundation/Bookings/BookingTransitionsTests.cs b/server/src/Tests/Baya.Test.Foundation/Bookings/BookingTransitionsTests.cs new file mode 100644 index 0000000..9ce6c0b --- /dev/null +++ b/server/src/Tests/Baya.Test.Foundation/Bookings/BookingTransitionsTests.cs @@ -0,0 +1,63 @@ +using Baya.Domain.Entities.Booking; +using BookingEntity = Baya.Domain.Entities.Booking.Booking; + +namespace Baya.Test.Foundation.Bookings; + +public class BookingTransitionsTests +{ + [Theory] + [InlineData(BookingStatus.PendingPayment, BookingStatus.Confirmed, true)] + [InlineData(BookingStatus.PendingPayment, BookingStatus.Cancelled, true)] + [InlineData(BookingStatus.Confirmed, BookingStatus.InProgress, true)] + [InlineData(BookingStatus.InProgress, BookingStatus.Completed, true)] + [InlineData(BookingStatus.Completed, BookingStatus.Disputed, true)] + [InlineData(BookingStatus.Completed, BookingStatus.Closed, true)] + [InlineData(BookingStatus.Disputed, BookingStatus.Closed, true)] + // Illegal edges. + [InlineData(BookingStatus.Confirmed, BookingStatus.Completed, false)] + [InlineData(BookingStatus.PendingPayment, BookingStatus.InProgress, false)] + [InlineData(BookingStatus.Closed, BookingStatus.InProgress, false)] + [InlineData(BookingStatus.Cancelled, BookingStatus.Confirmed, false)] + public void Booking_transition_guard(string from, string to, bool allowed) + => Assert.Equal(allowed, BookingTransitions.CanTransition(from, to)); + + [Theory] + [InlineData(BookingSessionStatus.Scheduled, BookingSessionStatus.InProgress, true)] + [InlineData(BookingSessionStatus.Scheduled, BookingSessionStatus.Cancelled, true)] + [InlineData(BookingSessionStatus.Scheduled, BookingSessionStatus.Missed, true)] + [InlineData(BookingSessionStatus.InProgress, BookingSessionStatus.Completed, true)] + [InlineData(BookingSessionStatus.Completed, BookingSessionStatus.InProgress, false)] + [InlineData(BookingSessionStatus.Missed, BookingSessionStatus.Completed, false)] + public void Session_transition_guard(string from, string to, bool allowed) + => Assert.Equal(allowed, BookingSessionTransitions.CanTransition(from, to)); + + [Fact] + public void Booking_TransitionTo_stamps_timestamps_and_rejects_illegal_edge() + { + var booking = new BookingEntity(); + var now = new DateTime(2026, 7, 6, 10, 0, 0, DateTimeKind.Utc); + + booking.TransitionTo(BookingStatus.Confirmed, now); + Assert.Equal(BookingStatus.Confirmed, booking.Status); + Assert.Equal(now, booking.ConfirmedAt); + + Assert.Throws(() => { booking.TransitionTo(BookingStatus.Completed, now); }); + + booking.TransitionTo(BookingStatus.InProgress, now); + booking.TransitionTo(BookingStatus.Completed, now); + Assert.Equal(now, booking.CompletedAt); + } + + [Theory] + [InlineData(48, true, false)] // ≥ 24h → standard_24h + [InlineData(10, false, true)] // < 24h → standard_inside_24h + [InlineData(-3, false, true)] // already started → still inside-24h bucket (open lower bound) + public void Customer_policy_buckets_are_non_overlapping_and_total(double hoursBefore, bool covers24h, bool coversInside) + { + var full = new CancellationPolicy { HoursBeforeStartMin = 24, HoursBeforeStartMax = null }; + var inside = new CancellationPolicy { HoursBeforeStartMin = null, HoursBeforeStartMax = 24 }; + + Assert.Equal(covers24h, full.Covers(hoursBefore)); + Assert.Equal(coversInside, inside.Covers(hoursBefore)); + } +} diff --git a/server/src/Tests/Baya.Test.Foundation/Bookings/BookingsTestHost.cs b/server/src/Tests/Baya.Test.Foundation/Bookings/BookingsTestHost.cs new file mode 100644 index 0000000..eca27c0 --- /dev/null +++ b/server/src/Tests/Baya.Test.Foundation/Bookings/BookingsTestHost.cs @@ -0,0 +1,194 @@ +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Configuration; +using Baya.Domain.Entities.Booking; +using Baya.Domain.Entities.Catalog; +using Baya.Domain.Entities.Geography; +using Baya.Domain.Entities.Identity; +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.Bookings; + +/// +/// A self-contained SQLite host exercising the real EF model (schema, filtered indexes, CHECK, query +/// filters, HasData seeds incl. the cancellation-policy tiers) for the b9 booking engine. Seeds one bookable +/// nurse and one customer (with a geocoded address), and lets a test create an accepted request, convert it, +/// and drive the real against real handlers with substituted seams. +/// +public sealed class BookingsTestHost : IDisposable +{ + private readonly SqliteConnection _connection; + public ApplicationDbContext Db { get; } + public UnitOfWork UnitOfWork { get; } + + public long CustomerId { get; } + public int CustomerUserId { get; } + public long NurseId { get; } + public int NurseUserId { get; } + public long PatientId { get; } + public long AddressId { get; } + + // The seeded address coordinates — the EVV distance check resolves the booking address here. + public decimal AddressLat => 35.6892m; + public decimal AddressLng => 51.3890m; + + private readonly long _cityId; + + public BookingsTestHost() + { + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + + var options = new DbContextOptionsBuilder().UseSqlite(_connection).Options; + Db = new ApplicationDbContext(options, TestFieldEncryptor.Instance); + Db.Database.EnsureCreated(); + UnitOfWork = new UnitOfWork(Db); + + var province = new Province { NameFa = "تهران", NameEn = "Tehran", SortOrder = 1, IsActive = true }; + Db.Set().Add(province); + Db.SaveChanges(); + + var city = new City { ProvinceId = province.Id, NameFa = "تهران", NameEn = "Tehran", SortOrder = 1, IsActive = true }; + Db.Set().Add(city); + Db.SaveChanges(); + _cityId = city.Id; + + var category = new ServiceCategory { NameFa = "سالمند", NameEn = "Elderly", SortOrder = 1, IsActive = true }; + Db.Set().Add(category); + Db.SaveChanges(); + CategoryId = category.Id; + + var customerUser = new User { UserName = "cust1", PhoneNumber = "09120000001", Gender = "male", Name = "علی", FamilyName = "رضایی", IsActive = true }; + Db.Users.Add(customerUser); + Db.SaveChanges(); + CustomerUserId = customerUser.Id; + + var customer = new CustomerProfile { UserId = customerUser.Id }; + Db.Set().Add(customer); + Db.SaveChanges(); + CustomerId = customer.Id; + + var patient = new Patient { CustomerId = customer.Id, DisplayName = "پدر", FirstName = "حسن", LastName = "رضایی", Gender = "male", IsActive = true }; + Db.Set().Add(patient); + Db.SaveChanges(); + PatientId = patient.Id; + + var address = new CustomerAddress + { + CustomerId = customer.Id, CityId = city.Id, Title = "خانه", + AddressLine = "خیابان اول", PostalCode = "1111111111", RecipientName = "علی", RecipientPhone = "09120000001", + Latitude = AddressLat, Longitude = AddressLng, IsPrimary = true + }; + Db.Set().Add(address); + Db.SaveChanges(); + AddressId = address.Id; + + var nurseUser = new User { UserName = "nurse1", PhoneNumber = "09120000002", Gender = "female", Name = "زهرا", FamilyName = "احمدی", IsActive = true }; + Db.Users.Add(nurseUser); + Db.SaveChanges(); + NurseUserId = nurseUser.Id; + + var nurse = new NurseProfile { UserId = nurseUser.Id }; + nurse.MarkVerified(); + nurse.SetAcceptingBookings(true); + Db.Set().Add(nurse); + Db.SaveChanges(); + NurseId = nurse.Id; + } + + public long CategoryId { get; } + + /// Adds a bookable variant; null = single visit. + public long AddVariant(int? sessionCount, long price = 5_000_000) + { + var variant = new NurseServiceVariant + { + NurseId = NurseId, ServiceCategoryId = CategoryId, Price = price, PriceUnit = "per_day", + SessionCount = sessionCount, DisplayName = "مراقبت روزانه", OptionSetHash = $"hash-{Guid.NewGuid():N}", IsActive = true + }; + Db.Set().Add(variant); + Db.SaveChanges(); + return variant.Id; + } + + /// Creates an accepted_awaiting_payment request against the given variant. + public long AddAcceptedRequest(long variantId) + { + var request = new BookingRequest + { + CustomerId = CustomerId, NurseId = NurseId, PatientId = PatientId, VariantId = variantId, + CustomerAddressId = AddressId, RequiredCaregiverGender = CaregiverGender.Any, + RequestedDate = new DateOnly(2026, 8, 1), RequestedTimeStart = new TimeOnly(9, 0), RequestedTimeEnd = new TimeOnly(13, 0), + CustomerNotes = "note", NurseResponseDeadlineAt = new DateTime(2030, 1, 1, 0, 0, 0, DateTimeKind.Utc) + }; + Db.Set().Add(request); + Db.SaveChanges(); + request.Accept(new DateTime(2030, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + Db.SaveChanges(); + return request.Id; + } + + public ICurrentUser AsCustomer() + { + var u = Substitute.For(); + u.UserId.Returns(CustomerUserId); + u.Roles.Returns(new[] { RoleNames.Customer }); + return u; + } + + public ICurrentUser AsNurse(int? userId = null) + { + var u = Substitute.For(); + u.UserId.Returns(userId ?? NurseUserId); + u.Roles.Returns(new[] { RoleNames.Nurse }); + return u; + } + + 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 feeRate = 0.15m, int disputeWindowHours = 72, int evvToleranceMeters = 200, int noShowThresholdMinutes = 60) + { + var cfg = Substitute.For(); + cfg.GetConfig("platform_fee_rate", Arg.Any()).Returns(feeRate); + cfg.GetConfig("dispute_window_hours", Arg.Any()).Returns(disputeWindowHours); + cfg.GetConfig("evv_location_tolerance_meters", Arg.Any()).Returns(evvToleranceMeters); + cfg.GetConfig("no_show_threshold_minutes", Arg.Any()).Returns(noShowThresholdMinutes); + return cfg; + } + + public IPaymentCaptureSimulator Capture(bool succeeds = true, long? pspFee = 12_000) + { + var c = Substitute.For(); + c.ConfirmCaptureAsync(Arg.Any(), Arg.Any()) + .Returns(new PaymentCaptureResult(succeeds, succeeds ? "ref" : string.Empty, succeeds ? pspFee : null)); + return c; + } + + public string StatusOf(long bookingId) + => Db.Set().AsNoTracking().Where(b => b.Id == bookingId).Select(b => b.Status).Single(); + + public void Dispose() + { + Db.Dispose(); + _connection.Dispose(); + } +} diff --git a/server/src/Tests/Baya.Test.Foundation/Bookings/CancellationTests.cs b/server/src/Tests/Baya.Test.Foundation/Bookings/CancellationTests.cs new file mode 100644 index 0000000..a9c296c --- /dev/null +++ b/server/src/Tests/Baya.Test.Foundation/Bookings/CancellationTests.cs @@ -0,0 +1,66 @@ +using System.Globalization; +using Baya.Application.Common; +using Baya.Application.Contracts.Common; +using Baya.Application.Features.Bookings.Commands.CancelBooking; +using Baya.Application.Features.Bookings.Commands.ConvertRequestToBooking; +using Baya.Domain.Entities.Booking; +using Microsoft.EntityFrameworkCore; +using NSubstitute; +using BookingEntity = Baya.Domain.Entities.Booking.Booking; + +namespace Baya.Test.Foundation.Bookings; + +public class CancellationTests +{ + // Well before the 2026-08-01 engagement start → the ≥24h customer tier (100% refund). + private static readonly DateTimeOffset Now = new(2026, 7, 6, 10, 0, 0, TimeSpan.Zero); + + private static async Task ConvertAsync(BookingsTestHost host, int sessions) + { + var variantId = host.AddVariant(sessionCount: sessions, price: 5_000_000); + var requestId = host.AddAcceptedRequest(variantId); + var handler = new ConvertRequestToBookingCommandHandler( + host.AsCustomer(), host.UnitOfWork, host.Config(), host.Clock(Now), host.Capture(), + new VariantSnapshotSerializer(), Substitute.For()); + var result = await handler.Handle(new ConvertRequestToBookingCommand(requestId), CancellationToken.None); + return result.Result.Id; + } + + [Fact] + public async Task Cancel_resolves_snapshots_policy_and_refunds_unstarted_sessions() + { + using var host = new BookingsTestHost(); + var bookingId = await ConvertAsync(host, sessions: 3); // gross 15M, all un-started + + var handler = new CancelBookingCommandHandler(host.AsCustomer(), host.UnitOfWork, host.Clock(Now)); + var result = await handler.Handle(new CancelBookingCommand("changed plans", bookingId), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal(CancellationPolicyCode.Standard24h, result.Result.PolicyCode); + Assert.Equal(100m, result.Result.RefundPercentage); + Assert.Equal(15_000_000, long.Parse(result.Result.RefundableAmountIrr, CultureInfo.InvariantCulture)); + Assert.Equal(BookingStatus.Cancelled, host.StatusOf(bookingId)); + + var sessions = host.Db.Set().AsNoTracking().Where(s => s.BookingId == bookingId).ToList(); + Assert.All(sessions, s => Assert.Equal(BookingSessionStatus.Cancelled, s.Status)); + } + + [Fact] + public async Task Cancellation_snapshot_is_immune_to_a_later_policy_edit() + { + using var host = new BookingsTestHost(); + var bookingId = await ConvertAsync(host, sessions: 1); + + var handler = new CancelBookingCommandHandler(host.AsCustomer(), host.UnitOfWork, host.Clock(Now)); + await handler.Handle(new CancelBookingCommand("changed plans", bookingId), CancellationToken.None); + + // Editing the underlying policy afterward must not change the frozen snapshot. + var policy = host.Db.Set().Single(p => p.Code == CancellationPolicyCode.Standard24h); + policy.RefundPercentage = 10m; + host.Db.SaveChanges(); + + var booking = host.Db.Set().AsNoTracking().Single(b => b.Id == bookingId); + Assert.Equal(100m, booking.CancellationRefundPercentage); + Assert.Equal(CancellationPolicyCode.Standard24h, booking.CancellationPolicyCode); + } +} diff --git a/server/src/Tests/Baya.Test.Foundation/Bookings/CareInstructionsDisclosureTests.cs b/server/src/Tests/Baya.Test.Foundation/Bookings/CareInstructionsDisclosureTests.cs new file mode 100644 index 0000000..146a77b --- /dev/null +++ b/server/src/Tests/Baya.Test.Foundation/Bookings/CareInstructionsDisclosureTests.cs @@ -0,0 +1,59 @@ +using Baya.Application.Common; +using Baya.Application.Contracts.Common; +using Baya.Application.Features.Bookings.Commands.ConvertRequestToBooking; +using Baya.Application.Features.Bookings.Commands.SubmitCareInstructions; +using Baya.Application.Features.Bookings.Queries.GetCareInstructions; +using NSubstitute; + +namespace Baya.Test.Foundation.Bookings; + +public class CareInstructionsDisclosureTests +{ + private static readonly DateTimeOffset Now = new(2026, 7, 6, 10, 0, 0, TimeSpan.Zero); + + private static async Task ConvertAsync(BookingsTestHost host) + { + var variantId = host.AddVariant(sessionCount: 1); + var requestId = host.AddAcceptedRequest(variantId); + var handler = new ConvertRequestToBookingCommandHandler( + host.AsCustomer(), host.UnitOfWork, host.Config(), host.Clock(Now), host.Capture(), + new VariantSnapshotSerializer(), Substitute.For()); + var result = await handler.Handle(new ConvertRequestToBookingCommand(requestId), CancellationToken.None); + return result.Result.Id; + } + + [Fact] + public async Task Care_instructions_are_gated_to_assigned_nurse_and_admin_only() + { + using var host = new BookingsTestHost(); + var bookingId = await ConvertAsync(host); + + // Customer authors the encrypted stage-2 instructions. + var submit = new SubmitCareInstructionsCommandHandler(host.AsCustomer(), host.UnitOfWork); + var submitResult = await submit.Handle( + new SubmitCareInstructionsCommand("diabetes", "insulin", "penicillin", "ring twice", "Sara", "0912", bookingId), + CancellationToken.None); + Assert.True(submitResult.IsSuccess); + + // Assigned nurse → decrypted fields. + var asNurse = new GetCareInstructionsQueryHandler(host.AsNurse(), host.UnitOfWork); + var nurseRead = await asNurse.Handle(new GetCareInstructionsQuery(bookingId), CancellationToken.None); + Assert.True(nurseRead.IsSuccess); + Assert.Equal("insulin", nurseRead.Result.Medications); + + // Admin → decrypted fields. + var asAdmin = new GetCareInstructionsQueryHandler(host.AsAdmin(), host.UnitOfWork); + var adminRead = await asAdmin.Handle(new GetCareInstructionsQuery(bookingId), CancellationToken.None); + Assert.True(adminRead.IsSuccess); + + // The customer (author) cannot read them back — the disclosure is the nurse's. + var asCustomer = new GetCareInstructionsQueryHandler(host.AsCustomer(), host.UnitOfWork); + var customerRead = await asCustomer.Handle(new GetCareInstructionsQuery(bookingId), CancellationToken.None); + Assert.True(customerRead.IsNotFound); + + // An unassigned nurse (no matching profile) cannot read them. + var asOtherNurse = new GetCareInstructionsQueryHandler(host.AsNurse(userId: 987654), host.UnitOfWork); + var otherRead = await asOtherNurse.Handle(new GetCareInstructionsQuery(bookingId), CancellationToken.None); + Assert.True(otherRead.IsNotFound); + } +}