# 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<T>` 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}/<Name>/`; entities in
`Baya.Domain/Entities/Bookings/`; one `IEntityTypeConfiguration<T>` 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<T>`: 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`.
