# Backend Phase 9 — Bookings, sessions, care instructions & EVV > **Mission:** turn an accepted, paid request into a real **engagement**. On payment capture, convert a > `booking_requests` row into a `bookings` row with the **three-amount money split** > (`gross_price_irr = balinyaar_commission_irr + nurse_payout_amount`), freeze the service/address/policy > as **snapshots**, and fan out **N `booking_sessions`** (always ≥ 1, even for a single visit) so every > visit has its own schedule, its own **EVV** check-in/out, and its own payout accrual. Build the > **two-stage clinical disclosure** boundary (`booking_care_instructions`, encrypted, readable only > post-confirmation by the assigned nurse + admin), the **EVV** records (`visit_verifications` — GPS + > timestamps, an *advisory* address match that flags review but never blocks), and the **dispute-window** > gate that — and only that — makes a session payout-eligible. This phase is the spine the payments > capture (b10), refunds (b11), payouts (b13), and reviews (b14) all hang off. > > **Track:** backend · **Depends on:** [b8](./backend-phase-8.md) (`booking_requests` lifecycle), [b1](./backend-phase-1.md) (`platform_configs`, `support_alerts`, `INotificationDispatcher`), [b0](./backend-phase-0.md) (`IFieldEncryptor`, `ICurrentUser`, audit interceptor, REST/`OperationResult`), [b4](./backend-phase-4.md) (`IGeocoder`, `customer_addresses` coordinates) · **Unlocks:** payments capture **b10**, reviews **b14**, payouts **b13**; frontend **f8-b9** > **Before you start, read [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md).** It is not optional. --- ## 1. Context — where this sits This is **backend phase b9**, the hinge between the **request** arc (b8) and the **money** arc (b10–b13). Balinyaar splits the engagement lifecycle into two tables on purpose: a money-free **request phase** (`booking_requests`, built in b8) and a payment-backed **booking phase** (`bookings`, built **here**). A `bookings` row exists **only** when the nurse accepted **and** payment was captured — never on accept alone. This phase builds the booking, its sessions, the encrypted care instructions, the EVV proof of service, and the dispute-window gate; the actual card capture that *triggers* the conversion lands in **b10**, so this phase ships a **mock-confirm path** (a DI seam) to make conversion testable now. The product framing: home nursing in Iran is dominantly **multi-visit / شبانه‌روزی live-in** care, so a booking carries a `session_count` and owns **N `booking_sessions`**, each independently scheduled, verified (EVV), and paid out per completed session — money releases per session, not as one whole-month escrow ([`product/business/05-booking-and-scheduling.md`](../../../product/business/05-booking-and-scheduling.md)). EVV ([`product/business/06-evv-and-service-delivery.md`](../../../product/business/06-evv-and-service-delivery.md)) is the authoritative GPS-and-timestamp proof that a visit happened, and is the gate that — together with a closed dispute window — releases escrow. A single-visit booking still creates **exactly one** session so the EVV/payout path is uniform. **What already exists (do not rebuild) — built by prior phases:** - **`booking_requests` + its lifecycle** — [b8](./backend-phase-8.md) built `booking_requests` (`customer_id`, `nurse_id`, `patient_id`, `variant_id`, `customer_address_id`, `required_caregiver_gender`, `requested_date`/`requested_time_start`/`requested_time_end`, unencrypted request-stage `customer_notes`, frozen `nurse_response_deadline_at` + `payment_deadline_at`, `nurse_rejection_reason`, and the `status` machine `pending_nurse_response → accepted_awaiting_payment → converted | rejected_by_nurse | expired_no_response | payment_deadline_expired | cancelled_by_customer`), the create/accept/reject commands, the expiry job, and the **same-gender + tenancy validation** at request time. **This phase reads an `accepted_awaiting_payment` request and converts it; it does not re-validate gender/tenancy from scratch — those were enforced at request creation and are frozen.** The conversion flips the request to `converted`. - **`platform_configs` typed cached accessor + `support_alerts` + notifications** — [b1](./backend-phase-1.md) built the typed, cached config reader (read `dispute_window_hours` default `72`, `evv_location_tolerance_meters`, and the no-show late threshold through it — **never hardcode**), the `support_alerts` table + raise API (this phase raises `location_mismatch` and `no_show` alerts), and the real in-app `notifications` write behind **`INotificationDispatcher`**. - **`IGeocoder` + address coordinates** — [b4](./backend-phase-4.md) built `customer_addresses` (with lat/lng) and the **`IGeocoder`** seam. This phase **reuses `IGeocoder`** for the EVV address-match distance computation; it does not introduce a new geo seam. - **`IFieldEncryptor`, `ICurrentUser` + audit interceptor, the REST surface** — [b0](./backend-phase-0.md) built `IFieldEncryptor` (encrypts `address_snapshot_json` and the `booking_care_instructions` columns; never logs plaintext), `ICurrentUser` + the audit-field SaveChanges interceptor, the rate limiter, the `BaseController` + `OperationResult` envelope, CQRS via **`martinothamar/Mediator`**, and `IDateTimeProvider`. - **`nurse_service_variants`, `patients`, `customer_addresses`** — the priced variant, the patient, and the service address the request points at, built in catalog (b5) / identity (b3) / geo (b4). This phase reads them only to **snapshot** them — it never mutates them. **What this phase introduces:** the five booking-domain tables (`bookings`, `booking_sessions`, `booking_care_instructions`, `visit_verifications`, `cancellation_policies`), the conversion / session / EVV / dispute-window / cancellation capabilities, and **one new seam — `IPaymentCaptureSimulator`** (the mock-confirm path that stands in for b10's real card capture so conversion is testable now). The actual card gateway, ledger postings, and refund execution are **DEFERRED** to b10/b11 (pointers in §3.6). ## 2. Required reading (do this first) - [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md) and [`../_shared/backend-conventions-checklist.md`](../_shared/backend-conventions-checklist.md) — especially the *Performance, caching, money, idempotency* block (IRR `BIGINT`, the three-amount split, encrypted PII columns through the field-encryptor seam, projected + paginated reads). - [`product/business/05-booking-and-scheduling.md`](../../../product/business/05-booking-and-scheduling.md) — **the business rules**: the two-phase split (no money on a request; a booking implies captured payment), single-visit *and* multi-session engagements, the booking status machine, snapshots, and MVP vs DEFERRED (recurring schedules modeled-but-inactive). - [`product/business/06-evv-and-service-delivery.md`](../../../product/business/06-evv-and-service-delivery.md) — **the EVV rules**: per-session GPS check-in/out, the *advisory* address-match tolerance (`evv_location_tolerance_meters`) that flags review but never auto-cancels, no-show alerting, and that **payout is gated on EVV completion + a closed dispute window** (never on `completed` alone). - [`product/data-model/05-booking-and-scheduling.md`](../../../product/data-model/05-booking-and-scheduling.md) — **the canonical schema** for `bookings` (the three amounts + `platform_fee_rate` + `session_count` + `dispute_window_ends_at` + snapshots + the guarded status), `booking_sessions`, `booking_care_instructions`, `visit_verifications` (FK now on `booking_session_id`), and `cancellation_policies`. **Mirror these field names and the CHECK exactly.** - [`product/overview/platform-summary.md`](../../../product/overview/platform-summary.md) — the four ground truths (no cash custody → escrow is a ledger state, the weekly-payout / hold-then-pay model that *requires* EVV proof) and the **IRR-Rials-always** money rule. - **Code to mirror:** b8's `Features/Bookings/**` (or `Features/BookingRequests/**`) command/query structure, validators, and the `booking_requests` config; b4's `customer_addresses` config + `IGeocoder` usage; b1's typed config accessor and `support_alerts` raise API + `INotificationDispatcher`; b0's `IFieldEncryptor` usage on encrypted columns and the `BaseController`/`OperationResult` pattern. - **Contract conventions:** [`../../contracts/conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md) and [`money-and-types.md`](../../contracts/conventions/money-and-types.md) (IRR `BIGINT`, the envelope, enum casing). - **Prior handoffs:** `dev/shared-working-context/backend/handoff/after-backend-phase-8.md`, `…-4.md`, `…-1.md`, `…-0.md`, and `reports/mocks-registry.md` (the seam rows you reuse — `IGeocoder`, `IFieldEncryptor`, `INotificationDispatcher` — plus the one you add). ## 3. Scope — build this All money is IRR `long` / `BIGINT`. Features live under `Baya.Application/Features/Bookings/{Commands|Queries}//`; entities in `Baya.Domain/Entities/Bookings/`; one `IEntityTypeConfiguration` per entity in `Persistence/Configuration/BookingsConfig/`; one EF migration for the five tables. Encrypted columns (`address_snapshot_json`, the `booking_care_instructions` clinical fields, EVV GPS detail) go through `IFieldEncryptor` — never stored or logged in plaintext. ### 3.1 Entities + migration **`bookings`** [CORE] — the confirmed engagement; source of truth for the service event + its money split. - Fields: `id` (BIGINT PK), `booking_request_id` (BIGINT FK → `booking_requests`, **UNIQUE** — 1:1), `customer_id`, `nurse_id`, `patient_id`, `variant_id`, `customer_address_id` (denormalized FKs for query performance), `partner_center_id` (BIGINT FK → `partner_centers`, **nullable** — the licensed center / merchant-of-record; `partner_centers` is DEFERRED to b15, so leave the FK nullable and unset for now), `variant_snapshot_json` (NVARCHAR(MAX) — variant + option labels at booking time), `address_snapshot_json` (NVARCHAR(MAX), **encrypted** — full address at booking time), `gross_price_irr` (BIGINT — total charged the customer), `balinyaar_commission_irr` (BIGINT — platform's cut), `platform_fee_rate` (DECIMAL(5,4) — **rate snapshot for audit**, frozen at conversion), `nurse_payout_amount` (BIGINT — `= gross_price_irr − balinyaar_commission_irr`, **derived, not free-entered**), `psp_fee_amount` (BIGINT, **nullable** — gateway cost for true margin; the mock-confirm path may set it, real capture sets it in b10), `session_count` (SMALLINT NOT NULL DEFAULT 1), `scheduled_date` / `scheduled_time_start` / `scheduled_time_end` (engagement-level; per-visit lives in `booking_sessions`), `status` (NVARCHAR(30) — the guarded machine below), `confirmed_at`, `cancelled_at`, `cancellation_reason`, `cancelled_by`, `completed_at`, `dispute_window_ends_at` (DATETIME2, **nullable** — set on completion = `completed_at + config(dispute_window_hours, 72)`), audit + soft-delete fields. - **CHECK (DB constraint, not handler-only):** `gross_price_irr = balinyaar_commission_irr + nurse_payout_amount`; **all three ≥ 0**. - **`payout_released` was CUT — do NOT add any boolean "paid" flag.** Paid-ness is derived later from a `nurse_payout_booking_links` row + the ledger (b13). - Relations: 1:1 ← `booking_requests`; 1:N → `booking_sessions`; 1:1 → `booking_care_instructions`; referenced later by `payment_transactions`/`ledger_entries`/`reviews`/`invoices`/`refunds`/`nurse_clawbacks`/ `nurse_payout_booking_links` (those tables land in later phases — do not create them here). **`booking_sessions`** [MVP] — one row per **visit**; always ≥ 1, even for a single-visit booking. - Fields: `id` (BIGINT PK), `booking_id` (BIGINT FK → `bookings`), `session_index` (INT — 1-based ordinal), `scheduled_date` / `scheduled_time_start` / `scheduled_time_end` (per-visit), `visit_payout_amount` (BIGINT — this session's portion of `nurse_payout_amount`), `status` (NVARCHAR(20) — `scheduled` | `in_progress` | `completed` | `missed` | `cancelled`), `payout_eligible_at` (DATETIME2, **nullable** — per-session dispute-window close, set on completion), `cancellation_event_id` (BIGINT, **nullable** — set when this session is cancelled, references the cancellation snapshot recorded on the booking/session), audit + soft-delete fields. - **Invariant (handler-enforced):** `Σ(booking_sessions.visit_payout_amount) = bookings.nurse_payout_amount` for the booking — the split must reconcile exactly (distribute the remainder of integer division onto the last session so no Rial is lost or created). All `visit_payout_amount ≥ 0`. - Relations: N:1 → `bookings`; 1:1 → `visit_verifications`. **`booking_care_instructions`** [CORE] — encrypted clinical/logistical context; **post-confirmation + assigned-nurse/admin only**. - Fields: `id` (BIGINT PK), `booking_id` (BIGINT FK → `bookings`, **UNIQUE** — 1:1), and the encrypted fields (all NVARCHAR(MAX) **enc**): `current_conditions`, `medications`, `allergies`, `special_instructions`, `emergency_contact_name`, `emergency_contact_phone`, audit + soft-delete fields. - **Why separate + encrypted:** keeps the financial/scheduling table clean and enforces the two-stage disclosure boundary with stricter access control. **Never** project these fields into a list query or log them; decrypt only in the gated `GetCareInstructionsQuery` (§3.2). - Relations: 1:1 → `bookings`. **`visit_verifications`** [CORE] — the EVV record; **required for payout**. - Fields: `id` (BIGINT PK), `booking_session_id` (BIGINT FK → `booking_sessions`, **UNIQUE** — 1:1; the FK is on the *session*, not the booking, so each visit is verified independently), `check_in_at` (DATETIME2, nullable), `check_in_lat` / `check_in_lng` (decimal, nullable), `check_out_at` (DATETIME2, nullable), `check_out_lat` / `check_out_lng` (decimal, nullable), `check_in_address_match` (BIT/bool, **nullable** — *advisory*: did check-in fall within `evv_location_tolerance_meters` of the booking address?), `check_in_distance_meters` (decimal, nullable — the computed distance, for the admin review screen), `status` (NVARCHAR(20) — `pending` | `checked_in` | `completed`), audit + soft-delete fields. - **GPS detail is sensitive** — treat with the same access discipline as PII; only the owning nurse + admin read raw EVV detail. `visit_verifications.status` and the parent `bookings.status` must stay consistent via the documented mapping (§5). - Relations: 1:1 → `booking_sessions`. **`cancellation_policies`** [MVP] — config-driven, snapshot-able refund/penalty tiers by lead time + actor. - Fields: `id` (BIGINT PK), `code` (NVARCHAR(50) **UNIQUE** — e.g. `standard_24h`, `nurse_no_show`), `applies_to` (NVARCHAR(20) — `customer` | `nurse` | `admin`), `hours_before_start_min` / `hours_before_start_max` (INT, **nullable** — tier bounds, half-open ranges), `refund_percentage` (DECIMAL(5,2) — 0–100), `fee_amount_or_rate` (cancellation fee / nurse penalty — store as a BIGINT IRR fee plus an optional DECIMAL rate, or a discriminator + value; pick one and document it), `is_active` (bool), audit + soft-delete fields. - **Seed** the baseline tiers (admin CRUD is below): e.g. `standard_24h` (customer, ≥ 24h before start → 100% refund), `standard_inside_24h` (customer, < 24h → 50% refund), `nurse_no_show` (nurse → 100% refund + nurse penalty). Confirm exact tiers against the product doc; if the doc leaves a number open, pick the safe default, make it config-seeded, and flag it in the report. - **Resolution + snapshot:** the applicable row is resolved by `(applies_to, lead-time bucket)` at cancel time and its **`code` + `refund_percentage`** are **frozen onto the cancellation** — a later edit to the policy row must not change a past cancellation. **Status enums** (define as proper enums; persist as string per project convention so the contract is readable): - `BookingStatus`: `pending_payment` | `confirmed` | `in_progress` | `completed` | `disputed` | `closed` | `cancelled`. - `BookingSessionStatus`: `scheduled` | `in_progress` | `completed` | `missed` | `cancelled`. - `VisitVerificationStatus`: `pending` | `checked_in` | `completed`. - `CancellationActor` (for `applies_to`): `customer` | `nurse` | `admin`. **Allowed booking transitions** (encode as a transition table consulted by `TransitionBookingStatusCommand` — a CHECK constraint can back the terminal states; the table is the authoritative guard): `pending_payment → confirmed | cancelled`; `confirmed → in_progress | cancelled`; `in_progress → completed | cancelled`; `completed → disputed | closed`; `disputed → closed`. `closed`, `cancelled` are terminal. **No transition may contradict EVV** (e.g. you cannot move a booking to `in_progress` with no session checked-in; you cannot move to `completed` while a session is still `in_progress`). ### 3.2 Commands & queries (CQRS, `OperationResult`, never throw for expected failures) | Capability | Type | Route | What it does | | --- | --- | --- | --- | | **`ConvertRequestToBookingCommand`** | Command (internal step + mock-confirm trigger) | `POST api/v1/bookings/convert` (mock/test path — see §4) | The conversion engine, **invoked by payment capture in b10**. Loads an `accepted_awaiting_payment` `booking_requests` row, verifies capture succeeded (via `IPaymentCaptureSimulator` now / real `payment_transactions.succeeded` in b10). Creates a `bookings` row 1:1 (`pending_payment → confirmed`), writes `variant_snapshot_json` + **encrypted** `address_snapshot_json` from the current variant/address, **computes the three amounts** (`gross_price_irr` from the variant price × sessions/units; `balinyaar_commission_irr = round(gross × platform_fee_rate)` from the *config rate snapshotted into* `platform_fee_rate`; `nurse_payout_amount = gross − commission`, asserting `gross = commission + payout`), sets `session_count`, flips the request → `converted`, and orchestrates **`GenerateBookingSessions`** in the same unit of work. **Idempotent:** the `booking_request_id` UNIQUE means a replay can't create a second booking — detect the existing booking and return it. | | **`GenerateBookingSessions`** | Command (internal step) | — | Creates `session_count` `booking_sessions` (`session_index` 1…N, status `scheduled`), splitting `nurse_payout_amount` into `visit_payout_amount` so **Σ exactly equals `nurse_payout_amount`** (integer split + remainder on the last session). **Always creates ≥ 1 session**, even for a single visit, so the EVV/payout path is uniform. Per-visit schedule defaults from the engagement schedule; multi-session schedules can be filled later. | | **`GetBookingDetailQuery`** | Query | `GET api/v1/bookings/{id}` | Booking header + money summary (the three amounts, `platform_fee_rate`, `psp_fee_amount`) + sessions (schedule, status, EVV state) + status timeline. **Tenancy-scoped:** customer sees own bookings, nurse sees assigned bookings, admin sees all — never cross-tenant. Projected (AsNoTracking + `.Select`). **Never** includes care-instruction clinical fields. | | **`ListBookingsQuery`** | Query | `GET api/v1/bookings?role=customer\|nurse&status=&page=&page_size=` | The role-scoped "My bookings" list (customer / nurse), status-filterable, **projected + paginated**. Admin variant lists all. | | **`ListSessionsForNurseQuery`** | Query | `GET api/v1/booking_sessions/today?date=` | The nurse's sessions for a day (today's visits), with per-session check-in/out CTA state. Tenancy-scoped to the nurse via `ICurrentUser`, projected + paginated. | | **`TransitionBookingStatusCommand`** | Command | `POST api/v1/bookings/{id}/transition` | Applies a status change **only if allowed** by the transition table (§3.1) **and** consistent with EVV/session state; otherwise `OperationResult.FailureResult` (no throw). Records `confirmed_at`/`cancelled_at`/`completed_at` as appropriate. Most transitions are driven internally (capture → `confirmed`, first check-in → `in_progress`, last check-out → `completed`); the explicit endpoint covers admin/dispute moves. | | **`SubmitCareInstructionsCommand`** | Command | `POST api/v1/bookings/{id}/care_instructions` | Writes/updates the 1:1 `booking_care_instructions` (**encrypted**) for a **confirmed** booking. Customer-authored (or admin). Validates the booking is `confirmed`+ (not `pending_payment`/`cancelled`). | | **`GetCareInstructionsQuery`** | Query | `GET api/v1/bookings/{id}/care_instructions` | **Decrypts and returns** the clinical fields **only** to (a) the **assigned nurse** of that booking and (b) **admin**, and **only post-confirmation**. Any other caller (the customer, an unassigned nurse, pre-confirmation) → `403`/`NotFoundResult` — **the two-stage disclosure boundary; do not leak.** | | **`CheckInVisitCommand`** | Command | `POST api/v1/booking_sessions/{id}/check_in` | The assigned nurse clocks in: captures GPS + timestamp into `visit_verifications`, computes `check_in_distance_meters` to the booking address via **`IGeocoder`** and sets `check_in_address_match` against `config(evv_location_tolerance_meters)`. **On mismatch:** raise a `support_alerts` (`location_mismatch`) for admin review and notify via `INotificationDispatcher` — **never block, never cancel.** Sets the session → `in_progress` and the booking → `in_progress` (first relevant check-in). Tenancy: only the assigned nurse. | | **`CheckOutVisitCommand`** | Command | `POST api/v1/booking_sessions/{id}/check_out` | Must follow an open check-in (else `FailureResult`). Captures GPS + timestamp, sets `visit_verifications.status = completed`, the session → `completed`, and — when **all** of the booking's sessions are `completed`/`cancelled`/`missed` — the booking → `completed`, which fires **`SetDisputeWindow`** (below). | | **`SetDisputeWindow`** | Command (internal step on completion) | — | On booking completion sets `dispute_window_ends_at = completed_at + config(dispute_window_hours, 72)`; on **each** session completion sets that session's `payout_eligible_at` from the same/per-session window. This is the **only** thing that makes a session payout-eligible — `completed` alone never is. | | **`GetVisitVerificationQuery`** / **`ListSessionEvvQuery`** | Query | `GET api/v1/booking_sessions/{id}/evv`, `GET api/v1/admin_evv?type=mismatch\|no_show&page=&page_size=` | Per-session EVV detail (owning nurse + admin only) and the **admin EVV-review queue** (mismatch / no-show, joined to `support_alerts`). Projected + paginated; raw GPS gated to nurse(own)+admin. | | **`CancelBookingCommand`** / **`CancelSessionCommand`** | Command | `POST api/v1/bookings/{id}/cancel`, `POST api/v1/booking_sessions/{id}/cancel` | Resolve the applicable `cancellation_policies` row by **lead time + actor**, **snapshot its `code` + `refund_percentage`** onto the cancellation (record `cancellation_event_id` on the session/booking, `cancelled_by`, `cancellation_reason`), set the session(s) → `cancelled` and (if whole booking) the booking → `cancelled`. **Refund only un-started sessions** (those still `scheduled` with no EVV check-in); a session already `in_progress`/`completed` is not refunded. **Refund *execution* is b11** — this phase records the cancellation + computes/snapshots the refundable amount and policy; it does **not** post the refund ledger or call a refund channel. | | **`ManageCancellationPoliciesCommand`** (CRUD) | Command | `POST/PUT api/v1/admin_cancellation_policies` | Admin CRUD + the seed for the baseline tiers. Editing a policy **never** mutates an already-snapshotted cancellation. | - **Controllers:** `BookingsController` (customer/nurse/admin, tenancy-scoped), `BookingSessionsController` (nurse EVV + session views), `AdminEvvController` (admin review queue), and `AdminCancellationPoliciesController` (admin). All `sealed : BaseController`, inject `ISender`, return `base.OperationResult(...)`, snake_case `[controller]`/`[action]` routes, `CancellationToken` threaded. Cancellation and EVV endpoints carry the **admin/nurse** narrowest-fitting policy; the cancel/convert endpoints are **rate-limited**. - **Validators:** FluentValidation on the input-bearing commands (`ConvertRequestToBookingCommand` — request id present + in `accepted_awaiting_payment`; `SubmitCareInstructionsCommand` — booking confirmed, field lengths; `CheckIn/CheckOutVisitCommand` — GPS present, session belongs to the nurse; `CancelBooking/CancelSessionCommand` — reason present; `ManageCancellationPoliciesCommand` — percentage 0–100, non-overlapping tiers per actor). ### 3.3 No-show / late detection (job) A scheduled sweep: if a session has no check-in by `scheduled_time_start + config(no_show_threshold)`, create a `no_show` `support_alerts` row and notify the family via `INotificationDispatcher`, and mark the session `missed` (per the EVV doc). **The recurring scheduler itself is DEFERRED** — build the `DetectNoShowSessions` command (the unit of work the cron will call) and a config key for the cadence; trigger it from an admin/test endpoint now and note it in the report. (Roadmap: a hosted scheduler — same pattern as b8's `ExpireBookingRequests` and b13's `SchedulePayoutJob`.) ### 3.4 DEFERRED (build the seam/flag, not the feature) - **`recurring_booking_schedules`** — open-ended recurring engagements: **modeled-but-inactive** per the product doc. Do **not** create the table or any activation logic/UI this phase; launch is all finite engagements. Note the deferral in the report. - **Hard availability-based booking blocks** — availability slots/exceptions remain **soft** (search guidance only, owned by the nurse domain); the nurse always individually accepts/rejects. Do not add a hard block here. - **Continuous geofencing during a live-in shift, supervisory tele-check-ins, family-visible live care logs, consented in-home cameras** — DEFERRED per [`product/business/06-evv-and-service-delivery.md`](../../../product/business/06-evv-and-service-delivery.md) §(c). Build only the per-session check-in/out EVV. ### 3.5 What this phase does NOT do (handed to later phases) - **Real card capture, `payment_transactions`, `payment_webhook_events`, `ledger_entries`** — b10. This phase only **consumes a capture signal** via the `IPaymentCaptureSimulator` seam to drive conversion. - **Refund execution / refund ledger / `refunds`** — b11. This phase records the cancellation + snapshots the policy + computes the refundable un-started-session amount; it posts no refund. - **Payout batching / `nurse_payout_booking_links` / `dispute_window`-gated eligibility selection** — b13. This phase only **sets** `dispute_window_ends_at` / `payout_eligible_at`; b13 consumes them. - **Reviews on a completed booking** — b14. ## 4. Mocks & seams in this phase | Seam | Owner | Mock behaviour | Registry | | --- | --- | --- | --- | | **`IPaymentCaptureSimulator`** | **introduced here** | `ConfirmCaptureAsync(bookingRequestId, ct)` returns a deterministic *succeeded* capture result (a fake `gateway_reference`, an optional `psp_fee_amount`) so `ConvertRequestToBookingCommand` is testable now. **In b10 the real card capture replaces this** by calling `ConvertRequestToBooking` directly after a real `payment_transactions.succeeded`; this seam is the temporary trigger, not a parallel money path. A config switch can force a *failed* capture so the "capture failed → no booking" path is testable. | **add a new row** (🟡) | | `IGeocoder` | reuse from **b4** | mock returns fixed/deterministic coordinates + a haversine distance; used for the EVV `check_in_address_match` advisory and `check_in_distance_meters`. | reuse row | | `IFieldEncryptor` | reuse from **b0** | local symmetric key; encrypts `address_snapshot_json` + the `booking_care_instructions` clinical fields; **never logs plaintext**. | reuse row | | `INotificationDispatcher` | reuse from **b1** | real in-app `notifications` write; used for no-show/late + location-mismatch alerts to family/admin. | reuse row | | `ICacheService` | reuse from **b0/b1** | in-memory; behind the typed config accessor (`dispute_window_hours`, `evv_location_tolerance_meters`, no-show threshold). | reuse row | The `IPaymentCaptureSimulator` mock lives behind a **DI-registered interface** in Infrastructure (selected by config; **no `if (mock)` branch in a handler**), so b10 swaps in the real capture trigger cleanly. Append its row to [`../../shared-working-context/reports/mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md) (seam, file, what's faked, config keys, **step-by-step how to make it real** — in b10 this seam is removed and `ConfirmPaymentAndPostLedger` calls `ConvertRequestToBooking` directly on a real `succeeded` transaction). ## 5. Critical rules you must not get wrong **Money correctness is sacred — the following must hold verbatim:** - **Money is IRR `BIGINT`, no floats, ever.** `gross_price_irr`, `balinyaar_commission_irr`, `nurse_payout_amount`, `psp_fee_amount`, `visit_payout_amount` are all `long`/`BIGINT`. Commission is computed by integer-rounding `gross × platform_fee_rate` at conversion and the rate is **snapshotted** into `platform_fee_rate`; no float survives into storage. - **`gross_price_irr = balinyaar_commission_irr + nurse_payout_amount`, all amounts ≥ 0** — a DB CHECK on `bookings`, and `nurse_payout_amount` is **derived** (`gross − commission`), never free-entered. Never store a split that doesn't sum. - **A booking exists ONLY when the nurse accepted AND payment was captured.** Never create a `bookings` row from an unpaid or unaccepted request; conversion runs **only** from an `accepted_awaiting_payment` request with a successful capture (the `IPaymentCaptureSimulator` now / real `succeeded` transaction in b10). On capture, flip the request → `converted`. - **Append-only / snapshot discipline — snapshots freeze history.** `variant_snapshot_json`, `address_snapshot_json`, `platform_fee_rate`, and the resolved cancellation `code` + `refund_percentage` are frozen at their moment; **later edits to the variant/address/policy rows must not mutate an existing booking.** Read snapshots from the booking, never re-resolve from live source rows. - **The `payout_released` boolean was CUT — never reintroduce it.** Do not add any boolean "paid"/ "payout done" flag to `bookings` or `booking_sessions`. Paid-ness is derived later from a `nurse_payout_booking_links` row + the ledger (b13). - **Payout is eligible ONLY after `dispute_window_ends_at` passes with no open dispute — never on `completed` alone.** `SetDisputeWindow` sets `dispute_window_ends_at = completed_at + config(dispute_window_hours, 72)` (and per-session `payout_eligible_at`); b13 gates the payout on that, not on the `completed` status. EVV check-out is necessary but not sufficient. - **`Σ(visit_payout_amount) = nurse_payout_amount`** across the booking's sessions — reconcile exactly, remainder on the last session; no Rial created or lost in the split. **Domain invariants you must not get wrong:** - **Two-stage clinical disclosure.** Pre-accept, the nurse sees **only** the unencrypted request-stage `customer_notes` (b8). The full **encrypted** `booking_care_instructions` are readable **only post-confirmation** and **only** by the **assigned nurse** + **admin** — never the customer, never an unassigned nurse, never pre-confirmation. `GetCareInstructionsQuery` enforces this; the fields are never projected into a list or logged. - **A single-visit booking still creates exactly one `booking_session`** so EVV and payout follow one uniform path. `GenerateBookingSessions` always produces ≥ 1. - **EVV address mismatch is *advisory only*.** On a check-in outside `evv_location_tolerance_meters`, raise a `location_mismatch` `support_alerts` + notify for admin review — **never auto-cancel, never block the visit, never withhold based on mismatch alone.** GPS-permission-denied/unavailable still allows check-in (flagged). Tolerance radius + no-show threshold come from `platform_configs`, not hardcoded constants. - **EVV is per session, not per booking.** The FK is `booking_session_id`; a multi-day engagement accrues payout per completed session; one EVV cannot represent a multi-day engagement. - **Booking and EVV state machines must not diverge.** Transitions go through `TransitionBookingStatusCommand`'s allowed-transition guard; `visit_verifications.status` and `bookings.status` stay consistent via the documented mapping (`checked_in` ↔ session `in_progress` ↔ booking `in_progress`; all sessions `completed` ↔ booking `completed`). No transition may contradict EVV. - **Cancellation refunds only un-started sessions.** Mid-engagement cancel refunds only sessions still `scheduled` with no check-in; `in_progress`/`completed` sessions are not refunded. The applicable policy is resolved by lead time + actor and **snapshotted** at cancel time. - **Tenancy + access discipline.** `GetBookingDetail`/`ListBookings`/`ListSessionsForNurse` are scoped to the authenticated customer or nurse via `ICurrentUser` — a customer/nurse can never read another's bookings or sessions; admin endpoints sit behind the admin policy. Raw EVV GPS detail and care instructions are gated to the owning nurse + admin only. ## 6. Definition of Done The shared [definition-of-done.md](../_shared/definition-of-done.md), plus: - [ ] The five tables (`bookings`, `booking_sessions`, `booking_care_instructions`, `visit_verifications`, `cancellation_policies`) exist via one migration, each with its `IEntityTypeConfiguration`: the `gross = commission + payout` (all ≥ 0) DB CHECK on `bookings`, the `booking_request_id` / `booking_id` (care) / `booking_session_id` (EVV) UNIQUE 1:1 indexes, the encrypted `address_snapshot_json` + care-instruction columns, the `cancellation_policies.code` UNIQUE + seeded tiers, and soft-delete/audit wiring per conventions. - [ ] All §3.2 commands/queries implemented (CQRS, `OperationResult`, projected + paginated reads, validators), with `BookingsController`, `BookingSessionsController`, `AdminEvvController`, `AdminCancellationPoliciesController`. - [ ] **`IPaymentCaptureSimulator`** introduced (Application interface, Infrastructure mock, DI via a `ServiceConfiguration/` extension, config-selected). No `if (mock)` in handlers. - [ ] Conversion computes the three amounts correctly (`gross = commission + payout`), writes both snapshots (address encrypted), sets `session_count`, generates ≥ 1 session with reconciling `visit_payout_amount`, and flips the request → `converted` — idempotently (replay returns the existing booking). - [ ] Care instructions are hidden pre-confirmation and from the customer/unassigned nurse, and decrypt only for the assigned nurse + admin post-confirmation. EVV check-in/out marks a session completed; a GPS mismatch raises a `location_mismatch` alert **without blocking**; the last check-out completes the booking and `SetDisputeWindow` sets `dispute_window_ends_at` (+ per-session `payout_eligible_at`). - [ ] Cancellation resolves + snapshots the policy code/percentage and refunds only un-started sessions (no refund ledger posted — that's b11). The no-show `DetectNoShowSessions` command works (admin/test-triggered; cron DEFERRED). - [ ] Handler unit tests (NSubstitute) for: the three-amount split + session reconciliation; the two-stage disclosure gate; the EVV mismatch-raises-alert-without-blocking path; `SetDisputeWindow` on completion; the cancellation policy resolution/snapshot + un-started-only refund computation; the transition guard. ≥ 1 `WebApplicationFactory` integration test per controller (happy path, 401, validation 400, and a 403 for the disclosure boundary). `dotnet build Baya.sln` zero new warnings; `dotnet test Baya.sln` green. - [ ] The `Baya.Application/Features/Bookings/**` area is reflected in the **Project map** in `server/CLAUDE.md`; the `IPaymentCaptureSimulator` seam noted where seams are documented. - [ ] The contract `dev/contracts/domains/bookings-evv.md` written and the `swagger.json` snapshot republished. ## 7. How to test (what a human can verify after this phase) Seed (or reuse from b8) an `accepted_awaiting_payment` `booking_requests` row pointing at a real variant, patient, and address; have the nurse identity and a customer identity available. Keep the `IPaymentCaptureSimulator` mock in *succeeded* mode unless a step says otherwise. 1. **Convert a request → booking (mock capture)** — `POST api/v1/bookings/convert` for the accepted request → a `bookings` row appears (`confirmed`), the request flips to `converted`, the **three amounts sum** (`gross_price_irr = balinyaar_commission_irr + nurse_payout_amount`, all ≥ 0), `platform_fee_rate` and the snapshots are populated (`address_snapshot_json` encrypted), `session_count` is set, and **N `booking_sessions` are generated** with `Σ visit_payout_amount = nurse_payout_amount`. Re-`convert` the same request → the **same** booking is returned (no second booking — idempotent). 2. **Single-visit uniformity** — convert a `session_count = 1` request → **exactly one** `booking_sessions` row is created. 3. **Care instructions — disclosure boundary** — `POST .../care_instructions` on the confirmed booking, then `GET .../care_instructions`: **as the customer or an unassigned nurse → `403`/not-found** (hidden); **as the assigned nurse (post-confirmation) → the decrypted fields** are returned; **as admin →** returned. Confirm the clinical fields never appear in `GET api/v1/bookings/{id}` or any list. 4. **EVV check-in/out marks a session completed** — as the assigned nurse, `POST .../booking_sessions/{id}/check_in` with in-range GPS → the session → `in_progress`, the booking → `in_progress`; `POST .../check_out` → `visit_verifications.status = completed`, the session → `completed`. 5. **GPS mismatch raises an alert without blocking** — check in with out-of-range GPS (force the `IGeocoder` mock distance past `evv_location_tolerance_meters`) → the check-in **still succeeds**, the session goes `in_progress`, `check_in_address_match = false`, and a `location_mismatch` `support_alerts` row + a notification are created. Confirm it appears in `GET api/v1/admin_evv?type=mismatch`. 6. **Completion sets the dispute window** — check out the **last** remaining session → the booking → `completed`, `dispute_window_ends_at = completed_at + 72h` (from config), and each completed session's `payout_eligible_at` is set. Confirm the booking is **not** payout-eligible before that timestamp passes. 7. **Cancellation** — `POST api/v1/bookings/{id}/cancel` (or a session) → the applicable `cancellation_policies` tier is resolved by lead time + actor, its `code` + `refund_percentage` are **snapshotted** onto the cancellation, only **un-started** sessions are marked refundable, and the booking/session → `cancelled`. Edit the underlying policy row afterward → the snapshot on the past cancellation is **unchanged**. (No refund ledger is posted — that's b11.) 8. **Transition guard** — attempt an illegal transition (e.g. `confirmed → completed` skipping `in_progress`, or `completed` while a session is still `in_progress`) → `OperationResult` failure, no state change. 9. **No-show** — trigger `DetectNoShowSessions` (admin/test endpoint) for a session past `scheduled_time_start + threshold` with no check-in → a `no_show` `support_alerts` + family notification; the session → `missed`. ## 8. Hand off & document (close the phase) - **Docs to update:** the **Project map** in [`server/CLAUDE.md`](../../../server/CLAUDE.md) (add the `Features/Bookings/**` area + the `IPaymentCaptureSimulator` seam). If you discover/confirm a rule the product docs don't capture (e.g. the exact `visit_payout_amount` remainder-on-last-session split, the EVV-state ↔ booking-state mapping table, the seeded cancellation tiers, or a `no_show` threshold default), record it in [`product/business/05-booking-and-scheduling.md`](../../../product/business/05-booking-and-scheduling.md) / [`product/business/06-evv-and-service-delivery.md`](../../../product/business/06-evv-and-service-delivery.md) (and regenerate the HTML view per `product/CLAUDE.md`) — **don't invent rules**, record decisions. - **Contract to write:** **`dev/contracts/domains/bookings-evv.md`** (per [`../../contracts/domains/_TEMPLATE.md`](../../contracts/domains/_TEMPLATE.md)) — the booking endpoints (convert/detail/list, transition), the care-instruction submit/read (with the **two-stage disclosure** note: assigned-nurse/admin only, post-confirmation), the session/EVV endpoints (check-in/out, today's sessions, EVV detail, admin EVV-review queue), the cancellation endpoints + admin policy CRUD; the `BookingStatus` / `BookingSessionStatus` / `VisitVerificationStatus` / `CancellationActor` enums; the booking/session/EVV/care-instruction DTO shapes (IRR `BIGINT`; the three amounts; **address snapshot masked/omitted** in list views; **raw GPS gated**); auth/tenancy/rate-limit notes; and the side-effects (dispute-window set on completion, `support_alerts` on mismatch/no-show, snapshot freezing). Republish the `swagger.json` snapshot per [`../../contracts/openapi/README.md`](../../contracts/openapi/README.md). This is what **f8-b9** consumes. - **Handoff & report:** write `dev/shared-working-context/backend/handoff/after-backend-phase-9.md` (the booking engine is live; what f8 can now build — booking detail & sessions, nurse EVV check-in/out, the post-confirmation care-instructions form, the status timeline; which endpoints/contracts are live; that capture is mocked behind `IPaymentCaptureSimulator` and the real conversion trigger arrives with b10 payments). Append to `backend/STATUS.md`. Write `dev/shared-working-context/reports/backend-phase-9-report.md` (what was built, **what is now testable and exactly how** per §7, what is mocked + how to make it real, contracts produced/consumed, follow-ups: the no-show cron, refund execution in b11, payout eligibility consumption in b13, `partner_centers` wiring in b15). Update `dev/shared-working-context/reports/mocks-registry.md` (the `IPaymentCaptureSimulator` row → 🟡). - **Memory:** save a `project` memory note for the non-obvious decisions this phase fixes — the conversion-only-on-accept+capture rule, the three-amount split + `Σ visit_payout_amount = nurse_payout_amount` reconciliation, the snapshot-freezes-history discipline, the **two-stage clinical disclosure** gate, the advisory (never-blocking) EVV mismatch behaviour, `SetDisputeWindow` as the *only* payout-eligibility trigger, the cut `payout_released` boolean, and the `IPaymentCaptureSimulator` seam — with a one-line pointer in `MEMORY.md`.