# Backend Phase 8 — Booking requests & lifecycle (pre-payment intent)

> **Mission:** build the **pre-payment intent** layer of the engagement lifecycle — the money-free
> `booking_requests` table and its full state machine (request → accept → pay-window → expire/reject).
> A customer requests a specific nurse, patient, service variant, address, date, and **required caregiver
> gender**; the nurse sees only the limited unencrypted `customer_notes` (never the full clinical
> instructions) and accepts or rejects before a config-driven **response deadline**; on accept a
> config-driven **30-minute payment window** opens. **No `bookings` row and no money exist yet** — that
> conversion happens in b9/b10. This phase owns the two-stage clinical-disclosure boundary's *first*
> stage, the tenancy invariant, the same-gender filter, and the deadlines that the whole booking flow
> hinges on.
>
> **Track:** backend · **Depends on:** [backend-phase-3](backend-phase-3.md) (`customer_profiles`, `nurse_profiles`, `patients`, tenancy via `ICurrentUser`), [backend-phase-4](backend-phase-4.md) (`customer_addresses`), [backend-phase-5](backend-phase-5.md) (`nurse_service_variants` + the `IVariantSnapshotSerializer`), [backend-phase-1](backend-phase-1.md) (typed cached config accessor `IPlatformConfig`, the `IJobScheduler`/`BackgroundService` pattern, `notifications`), [backend-phase-7](backend-phase-7.md) (search & matching — the gender/match data customers discover nurses through) · **Unlocks:** bookings · sessions · care · EVV ([backend-phase-9](backend-phase-9.md)), and the booking-request UI ([frontend-phase-7-b8](../frontend/frontend-phase-7-b8.md))
> **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 the **booking-request** phase — the first half of the request→accept→pay→confirm engagement
model. The product deliberately splits the lifecycle into **two tables** so each keeps clean invariants:
a **money-free request phase** (`booking_requests`, this phase) and a **payment-backed booking phase**
(`bookings`, [backend-phase-9](backend-phase-9.md)). A `booking_requests` row can be rejected, time out,
or have its payment window lapse **without a booking ever existing** — merging the two would mean a swamp
of nullable money fields and tangled status logic. This phase ends in a complete, testable request
state machine; b9 picks it up at "payment captured" and converts an `accepted_awaiting_payment` request
into a confirmed `bookings` row.

Everything this phase needs already exists. A customer (with a `customer_profiles` row) has `patients`
and `customer_addresses`; a nurse (with a `nurse_profiles` row and a `gender`) has priced, bookable
`nurse_service_variants`; config exposes the deadline durations; search lets the customer find the nurse
in the first place. This phase wires those into a request a nurse can accept or reject.

**What already exists (do not rebuild) — confirmed from the prior phases:**

- **Customers, nurses, patients & tenancy** — [backend-phase-3](backend-phase-3.md) built
  `customer_profiles`, `nurse_profiles` (carrying `is_verified`, `is_accepting_bookings`, and the
  `average_rating`/`total_reviews` aggregates), and `patients` (the care recipient, separate from the
  payer, carrying a load-bearing `gender`). **Tenancy is resolved off `ICurrentUser.UserId` →
  `customer_profiles`/`nurse_profiles`** — a patient belongs to exactly one `customer_id`; b3 started the
  ownership scoping and **b8 enforces the full booking-request tenancy invariant** (§5). Read those
  entities; do not re-model them.
- **Addresses** — [backend-phase-4](backend-phase-4.md) built `customer_addresses` (encrypted address
  line + `decimal(9,6)` coordinates, `customer_id` FK → `customer_profiles`, filtered
  `UNIQUE(customer_id) WHERE is_primary=1`). The request points at one via `customer_address_id`. Do not
  geocode here — b4 already did; b9/EVV consumes the coordinates.
- **Service variants** — [backend-phase-5](backend-phase-5.md) built `nurse_service_variants` (the
  **atomic bookable unit**: a nurse + category + chosen option-set at a `price` (IRR `BIGINT`) +
  `price_unit`), and shipped **`IVariantSnapshotSerializer`** (a pure serializer for the canonical
  `variant_snapshot_json`). The request FKs a `variant_id`; **a variant belongs to exactly one nurse**,
  which the tenancy invariant verifies. **Do not compute any price/total here** — no money lives on a
  request; the serializer/snapshot/total are b9's concern.
- **Config, the job runner & notifications** — [backend-phase-1](backend-phase-1.md) built
  `platform_configs` + the typed cached accessor **`IPlatformConfig`** (`GetConfig<T>(key)`), seeded the
  deadline keys **`nurse_response_deadline_hours`** and **`booking_payment_deadline_minutes`** (= **30**),
  introduced the in-process **`IJobScheduler`/`BackgroundService`** interval-runner pattern (used for
  `PurgeOldReadNotifications`), and shipped the real in-app **`INotificationDispatcher`** that writes a
  `notifications` row. **Reuse all three** — read deadlines through `IPlatformConfig` (never hardcode),
  schedule the expiry sweep through the same job pattern, and emit request/accept/expiry notifications
  through `INotificationDispatcher`.
- **Search & matching** — [backend-phase-7](backend-phase-7.md) built the `nurse_search_index` and the
  `INurseSearch` query (filterable by category/city/district/**gender**/price). The customer arrives at
  this phase *from* a search result, having already filtered on `required_caregiver_gender` against nurse
  gender; this phase **re-validates** that same-gender match server-side at request time (search is a
  discovery aid, not the authority).
- **Cross-cutting plumbing** — [backend-phase-0](backend-phase-0.md): the REST surface (`BaseController`,
  snake_case routing, rate limiting), the CQRS pipeline (`ISender`/`ICommand`/`IQuery`,
  `ValidateCommandBehavior`, `OperationResult<T>`), `IDateTimeProvider` (use it — **never `DateTime.Now`**
  — deadlines and expiry are time-sensitive and must be testable), `ICurrentUser`, the audit-field
  interceptor, and `ICacheService`. **Reuse these; introduce no new seam.**

> **`bookings`, `booking_sessions`, `booking_care_instructions`, the three-amount money split, the
> conversion command, and `dispute_window_ends_at`** are owned by **[backend-phase-9](backend-phase-9.md)**
> (with payment capture from [backend-phase-10](backend-phase-10.md)). This phase **ends at
> `accepted_awaiting_payment`**; it must **not** create a booking, post a ledger entry, compute a price,
> or persist `variant_snapshot_json`/`address_snapshot_json`. **(DEFERRED → b9/b10.)** The request's
> `converted` terminal status is *set by b9* when it consumes the accepted request — this phase models the
> status value and the 1:1 link but does not perform the conversion.
>
> **`cancellation_policies`, `CancelBooking`, refunds, and `recurring_booking_schedules`** are also b9+
> / DEFERRED — do not build them here. A customer cancelling a *request* (before any booking) is in scope
> as the `cancelled_by_customer` transition (§3.3); a *booking* cancellation is not.

## 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).
- **Product — business rules (source of truth):**
  [`product/business/05-booking-and-scheduling.md`](../../../product/business/05-booking-and-scheduling.md)
  — the **request → accept → pay → confirm lifecycle**, the two-table phase split (*no money on a
  request*), the **response deadline** computed-and-frozen-from-config, the **30-minute payment window**,
  the request statuses, and (b) the Iran-specific note that the platform **deliberately keeps the nurse's
  per-request accept/reject autonomy** (availability is soft guidance, never a hard auto-accept). Read
  (c) MVP vs DEFERRED so you don't pull booking/session/cancellation scope forward.
- **Product — data model (source of truth):**
  [`product/data-model/05-booking-and-scheduling.md`](../../../product/data-model/05-booking-and-scheduling.md)
  — the **canonical `booking_requests` schema**: the FK set + the **tenancy invariant** (patient & address
  belong to `customer_id`; variant belongs to `nurse_id`), `required_caregiver_gender`
  (`male`/`female`/`any`), `requested_date`/`requested_time_start`/`requested_time_end`,
  **unencrypted request-stage `customer_notes`**, the exact `status` set, `nurse_response_deadline_at`
  (frozen from config), `payment_deadline_at` (set on accept), and `nurse_rejection_reason`. Note the
  **1:1 → `bookings` on conversion** relation (which b9 owns).
- **Type, money & gender rules on the wire:**
  [`../../contracts/conventions/money-and-types.md`](../../contracts/conventions/money-and-types.md) —
  **gender is load-bearing** (`required_caregiver_gender` = `male`/`female`/`any`, matched against nurse
  gender; never default or drop it silently); timestamps are **UTC ISO-8601**; enums cross the wire as
  stable string codes; ids are `BIGINT`. (There is **no money** on a request — but read this so the
  request's wire shape is consistent with the rest of the contract.)
- **Contract conventions:**
  [`../../contracts/conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md)
  (envelope, snake_case routes, status codes — `400` validation/business, `403`/`404` tenancy, `409`
  state-machine conflict — mandatory list pagination, locale handling).
- **Code to mirror (existing patterns):** a b3/b4/b5 feature folder under
  `Baya.Application/Features/<Area>/{Commands|Queries}/<Name>/` (request `record` + `internal sealed`
  handler + `OperationResult`, validator picked up by `ValidateCommandBehavior`); an
  `IEntityTypeConfiguration<T>` under `Persistence/Configuration/<Area>Config/`; a `sealed` controller
  under `Baya.Web.Api/Controllers/V1/`; b1's **`IPlatformConfig.GetConfig<T>`** usage and its
  **`IJobScheduler`/`BackgroundService`** retention job (mirror it for the expiry sweep); how b3 resolves
  tenancy off `ICurrentUser`; how reads use `AsNoTracking()` + `.Select()` projection + pagination.
- **Prior handoffs:** `dev/shared-working-context/backend/handoff/after-backend-phase-5.md` (the variant
  shape + `IVariantSnapshotSerializer`), `.../after-backend-phase-4.md` (the `customer_addresses` shape),
  `.../after-backend-phase-3.md` (profiles/patients + the tenancy + role policies), and
  `.../after-backend-phase-1.md` (the config accessor keys + the job-runner pattern + notifications).

## 3. Scope — build this

A vertical slice per capability: entity + EF config + **one additive migration** → command/query
handler(s) → controller endpoint → contract. Everything is `async` with `CancellationToken` threaded
through; reads are `AsNoTracking()` + `.Select()` projection + pagination; writes go through
`IUnitOfWork` with a single `CommitAsync`. **There is no money and no `bookings` row anywhere in this
phase.**

### 3.1 Entity, config & migration

Add **`booking_requests`** as **one additive EF Core migration** on top of the existing baseline, with one
`IEntityTypeConfiguration<BookingRequest>` in `Persistence/Configuration/BookingConfig/`. The entity lives
in the Booking domain area (`Baya.Domain/Entities/Booking/`); ids are `BIGINT`.

- **`booking_requests`** — the pre-payment intent. **No money columns, ever.**
  - **FKs:** `id` (BIGINT PK), `customer_id` (BIGINT FK → `customer_profiles`), `nurse_id` (BIGINT FK →
    `nurse_profiles`), `patient_id` (BIGINT FK → `patients`), `variant_id` (BIGINT FK →
    `nurse_service_variants`), `customer_address_id` (BIGINT FK → `customer_addresses`).
  - `required_caregiver_gender` (NVARCHAR(10), **nullable**) — closed code set `male` | `female` | `any`.
    Same-gender care is decisive for bodily care; this is a **first-class filter**, matched against the
    nurse's `gender` at request time (§5), not a soft preference.
  - `requested_date` (DATE), `requested_time_start` (TIME), `requested_time_end` (TIME) — for multi-day
    engagements these are the **engagement start**; per-visit scheduling is b9's `booking_sessions`.
  - `customer_notes` (NVARCHAR(1000), **nullable, UNENCRYPTED**) — the **only** clinical context the nurse
    sees pre-accept (Principle 6 / two-stage disclosure, stage 1). **Do not** route this through
    `IFieldEncryptor`; it is deliberately limited and plaintext. The full encrypted care instructions are
    b9's `booking_care_instructions`.
  - `status` (NVARCHAR(50)) — closed code set:
    `pending_nurse_response` → `accepted_awaiting_payment` → `converted` / `rejected_by_nurse` /
    `expired_no_response` / `payment_deadline_expired` / `cancelled_by_customer`. Guarded by a forward-only
    transition check (§3.4).
  - `nurse_response_deadline_at` (DATETIME2(7), **not null**) — **computed once from config at creation
    and frozen** (`now + nurse_response_deadline_hours`); immune to later config changes.
  - `payment_deadline_at` (DATETIME2(7), **nullable**) — null until accept; set to
    `now + booking_payment_deadline_minutes` (= 30) **on accept**, then frozen.
  - `nurse_rejection_reason` (NVARCHAR(500), nullable) — set on reject.
  - Audit fields (`CreatedAt/ModifiedAt/CreatedById/ModifiedById`, stamped by the b0 interceptor) +
    soft-delete (`deleted_at`) with the `!IsDeleted` global query filter.
  - **Indexes:** `(nurse_id, status)` for the **nurse inbox** ordered list; `(customer_id, status)` for
    the **customer inbox**; `(status, nurse_response_deadline_at)` and
    `(status, payment_deadline_at)` so the **expiry sweep** (§3.4) selects stale rows by a covering index
    instead of scanning. Do **not** add a `variant_snapshot_json` / `address_snapshot_json` column here —
    those land on `bookings` in b9.

> **Do not add `bookings`, `booking_sessions`, `booking_care_instructions`, `visit_verifications`, or
> `cancellation_policies` in this migration.** They are b9's. This migration adds exactly one table.

### 3.2 Customer-side — create & cancel a request

Feature folder `Baya.Application/Features/Booking/` (Commands/Queries sub-folders per the surrounding
convention). **Customer-owner-only** for these writes; tenancy resolved via `ICurrentUser.UserId` →
`customer_profiles`.

- **`CreateBookingRequestCommand`** (`Commands/CreateBookingRequest/`) — the customer requests a nurse.
  Input: `nurse_id`, `variant_id`, `patient_id`, `customer_address_id`, `requested_date`,
  `requested_time_start`, `requested_time_end`, `required_caregiver_gender`, optional `customer_notes`.
  The handler, in one transaction:
  1. **Resolve the caller's `customer_id`** from `ICurrentUser` → `customer_profiles` (never trust a
     `customer_id` from the body).
  2. **Tenancy invariant (§5):** load the `patients` row and assert `patient.customer_id == customer_id`;
     load the `customer_addresses` row and assert `address.customer_id == customer_id`; load the
     `nurse_service_variants` row and assert `variant.nurse_id == nurse_id`. Any mismatch ⇒ a clean
     `NotFoundResult`/`FailureResult` (never a raw 500, never leaking another customer's data).
  3. **Bookability checks:** the variant is active (`is_active`), the nurse `is_verified` and
     `is_accepting_bookings`. A request against an inactive variant or an unbookable nurse fails cleanly.
  4. **Same-gender match (§5):** if `required_caregiver_gender` is `male`/`female`, assert the nurse's
     `gender` equals it; `any` matches either. A mismatch is a validation failure naming the conflict.
  5. **Compute & freeze `nurse_response_deadline_at`** = `IDateTimeProvider.UtcNow +
     IPlatformConfig.GetConfig<int>("nurse_response_deadline_hours")`. **Read from config — never
     hardcode.** Store the absolute timestamp on the row so a later config change cannot move it.
  6. Insert the `booking_requests` row with `status = pending_nurse_response`, `payment_deadline_at =
     null`; `CommitAsync` once.
  7. **Notify the nurse** of the new request via `INotificationDispatcher` (a `booking_request_received`
     notification type, `data_json` carrying the request id + patient display name + requested date — no
     PII beyond what stage-1 disclosure allows).
  - FluentValidation: all FKs present/positive; `requested_time_end > requested_time_start`;
    `requested_date` not in the past; `required_caregiver_gender` in the closed set when supplied;
    `customer_notes` ≤ 1000 chars.
- **`CancelBookingRequestCommand`** (`Commands/CancelBookingRequest/`) — the customer withdraws a request
  that is still `pending_nurse_response` **or** `accepted_awaiting_payment` (before they pay).
  Owner-tenancy; transition to `cancelled_by_customer` through the guard (§3.4). A request already
  `converted`/`rejected_by_nurse`/`expired_*` cannot be cancelled ⇒ `409` conflict. (This is a *request*
  cancellation only — a *booking* cancellation with refund tiers is b9+/DEFERRED.)

### 3.3 Nurse-side — accept & reject

**Nurse-owner-only**; tenancy resolved via `ICurrentUser.UserId` → `nurse_profiles`; the request's
`nurse_id` must equal the caller's nurse id (else `403`/`NotFound`).

- **`AcceptBookingRequestCommand`** (`Commands/AcceptBookingRequest/`) — the assigned nurse accepts a
  `pending_nurse_response` request. The handler:
  1. Load the request scoped to the caller's `nurse_id`; assert `status == pending_nurse_response` (else
     `409`). Assert `nurse_response_deadline_at` has **not** already passed (`IDateTimeProvider.UtcNow`);
     an accept after the deadline is rejected with a clear message (the sweep may not have run yet — the
     command must self-guard, not rely on the job).
  2. **Set & freeze `payment_deadline_at`** = `IDateTimeProvider.UtcNow +
     IPlatformConfig.GetConfig<int>("booking_payment_deadline_minutes")` (= 30). **From config, never
     hardcode 30.**
  3. Transition `status → accepted_awaiting_payment` through the guard; `CommitAsync`.
  4. **Notify the customer** (`booking_request_accepted`, `data_json` with the request id + the
     `payment_deadline_at` so the client can show the 30-minute countdown).
  - **Two-stage disclosure (§5):** the accept handler — and the nurse inbox/detail queries — expose
    **only `customer_notes`**, never any encrypted care instructions (those don't exist until b9 and are
    gated to post-confirmation). There is nothing encrypted to leak here *because there is nothing
    encrypted on a request* — but the contract and queries must make the stage-1-only boundary explicit so
    b9 inherits it correctly.
- **`RejectBookingRequestCommand`** (`Commands/RejectBookingRequest/`) — the assigned nurse declines a
  `pending_nurse_response` request with a required `nurse_rejection_reason`. Transition →
  `rejected_by_nurse`; notify the customer (`booking_request_rejected`). Reject is only valid from
  `pending_nurse_response` (else `409`). FluentValidation: reason non-empty, ≤ 500 chars.

### 3.4 Status guard & the expiry sweep (this phase owns the scheduled job)

- **Forward-only transition guard.** Add an allowed-transition table/helper for `booking_requests.status`
  and route **every** write through it; an illegal transition returns a `409` `OperationResult` conflict,
  never a silent overwrite. Allowed edges:
  - `pending_nurse_response` → `accepted_awaiting_payment` | `rejected_by_nurse` | `expired_no_response`
    | `cancelled_by_customer`
  - `accepted_awaiting_payment` → `converted` (b9 only) | `payment_deadline_expired` |
    `cancelled_by_customer`
  - all of `converted` / `rejected_by_nurse` / `expired_no_response` / `payment_deadline_expired` /
    `cancelled_by_customer` are **terminal** (no outgoing edges).
- **`ExpireBookingRequestsCommand`** (`Commands/ExpireBookingRequests/`) + a hosted **`BackgroundService`**
  registered through the b1 `IJobScheduler` pattern, running on a short interval (e.g. every minute —
  config-driven if b1 exposes an interval key, else a sensible constant documented in the report). On each
  tick, in a single bounded, paginated pass (do **not** load the whole table):
  - select `pending_nurse_response` rows where `nurse_response_deadline_at <= UtcNow` → transition →
    `expired_no_response`; notify the customer (`booking_request_expired_no_response`).
  - select `accepted_awaiting_payment` rows where `payment_deadline_at <= UtcNow` → transition →
    `payment_deadline_expired`; notify the customer (`booking_request_payment_window_expired`).
  - The sweep uses the covering indexes from §3.1, batches its updates, threads `CancellationToken`, and is
    **idempotent/re-entrant** (a row already moved by a concurrent accept/reject is simply skipped — the
    `status` predicate in the `WHERE` is the guard; never assume a row is still pending just because the
    previous tick saw it). Expiry is computed against `IDateTimeProvider.UtcNow` so tests can drive it
    deterministically.
  - The command is also exposed as an **admin/manual trigger** endpoint (§3.6) so a human/test can fire the
    sweep on demand without waiting for the interval.

### 3.5 Queries — customer inbox, nurse inbox, single request

- **`ListBookingRequestsQuery`** (`Queries/ListBookingRequests/`) — paginated, **role-aware**: a customer
  sees their own requests (scoped to `customer_id`); a nurse sees requests addressed to them (scoped to
  `nurse_id`). Optional `status` filter. `AsNoTracking()` + `.Select()` projection returning: request id,
  status, the **counterparty** (nurse display name + rating for the customer view; patient display name +
  requested date for the nurse view), `requested_date`/times, `required_caregiver_gender`,
  `nurse_response_deadline_at`, `payment_deadline_at`, and — for the **nurse view** —
  `customer_notes` (stage-1 only). Order: actionable first (e.g. `pending_nurse_response` /
  `accepted_awaiting_payment` before terminal states), then by deadline. **Never** project encrypted/care
  fields (there are none, and there must never be any added to this query).
- **`GetBookingRequestQuery`** (`Queries/GetBookingRequest/`) — a single request, visible to its
  **customer or its nurse** (admin too). Returns the full request projection: the resolved variant label
  (via the b5 variant projection — read the canonical offering, do not duplicate price math), patient +
  address summary (the **customer** sees their own full address; the **nurse** sees only what stage-1
  disclosure allows — a coarse location/the request fields, **not** the encrypted full address line),
  both deadlines, status, and `customer_notes`. A caller who is neither the request's customer nor its
  nurse ⇒ `403`/`NotFound`.

### 3.6 REST endpoints

Controllers under `Baya.Web.Api/Controllers/V1/` (`sealed`, `BaseController`, inject `ISender`,
`[controller]`/`[action]` snake_case tokens, `base.OperationResult(...)`, narrowest authorize policy,
lists paginated). Routes shown logically; the snake_case transformer produces the real segments.

| Verb & route | Maps to | Auth |
| --- | --- | --- |
| `POST /v1/booking_requests` | `CreateBookingRequestCommand` | customer (owner) |
| `POST /v1/booking_requests/{id}/cancel` | `CancelBookingRequestCommand` | customer (owner) |
| `POST /v1/booking_requests/{id}/accept` | `AcceptBookingRequestCommand` | nurse (assigned) |
| `POST /v1/booking_requests/{id}/reject` | `RejectBookingRequestCommand` | nurse (assigned) |
| `GET /v1/booking_requests` | `ListBookingRequestsQuery` | customer or nurse (role-scoped) |
| `GET /v1/booking_requests/{id}` | `GetBookingRequestQuery` | customer / nurse (party) / admin |
| `POST /v1/admin/booking_requests/expire` | `ExpireBookingRequestsCommand` (manual trigger) | admin |

### 3.7 Out of scope (DEFERRED — do not build)

- **`bookings`, the three-amount money split (`gross_price_irr`/`balinyaar_commission_irr`/
  `nurse_payout_amount`), `psp_fee_amount`, the conversion command, `booking_sessions`,
  `booking_care_instructions`, `visit_verifications`, `dispute_window_ends_at`,
  `variant_snapshot_json`/`address_snapshot_json`** — **(DEFERRED → [backend-phase-9](backend-phase-9.md)
  + [backend-phase-10](backend-phase-10.md)).** This phase stops at `accepted_awaiting_payment`; b9
  consumes the accepted request, captures payment (b10), and sets the request to `converted`.
- **`cancellation_policies`, `CancelBooking`/`CancelSession`, refund tiers, `recurring_booking_schedules`,
  `nurse_availability_slots`/`_exceptions` hard-blocking** — **(DEFERRED)**; availability stays *soft
  guidance* (search-only). The nurse always individually accepts/rejects — never auto-accept from
  availability.
- **SMS/push notification channels** — `INotificationDispatcher` writes **in-app only** (the channel
  abstraction exists from b0/b1; SMS/push are DEFERRED). Emit the in-app notification; do not add a real
  SMS path.

## 4. Mocks & seams in this phase

**None introduced.** This phase owns no third-party integration — booking-request data is fully Balinyaar's.
It **reuses** existing seams; it must **not** redefine them:

| Reused seam | From | Used for |
| --- | --- | --- |
| `IPlatformConfig` | [b1](backend-phase-1.md) | reading `nurse_response_deadline_hours` & `booking_payment_deadline_minutes` (cached) |
| `INotificationDispatcher` | [b1](backend-phase-1.md) (real in-app) | request-received / accepted / rejected / expired in-app notifications |
| `IJobScheduler` / `BackgroundService` | [b1](backend-phase-1.md) | the recurring expiry sweep |
| `IDateTimeProvider` | [b0](backend-phase-0.md) | deadline computation + expiry, testably |
| `ICurrentUser` | [b0](backend-phase-0.md) | tenancy resolution (customer/nurse) |

Because this phase **mocks nothing**, there is **no `mocks-registry.md` row to add** — state that
explicitly in your report (§8) so the next agent doesn't go looking. The expiry job is an *internal*
hosted service, not an external seam; it does not go in the mock registry.

## 5. Critical rules you must not get wrong

- **NO money exists on a `booking_requests` row — and no `bookings` row exists yet.** The request phase is
  deliberately money-free; a `bookings` row exists **only** when the nurse accepted **AND** payment was
  captured (b9/b10). Never add a money column to `booking_requests`, never compute a price/total here,
  never create a booking on accept alone. Accept only opens the payment window.
- **Two-stage clinical disclosure (Principle 6), stage 1.** Pre-accept (and pre-payment), the nurse sees
  **only the unencrypted, limited `customer_notes`** — never any full clinical/care instructions. Those
  are b9's encrypted `booking_care_instructions`, readable **only post-confirmation** by the assigned
  nurse + admin. Do not add an encrypted clinical field to this table; do not surface anything beyond
  `customer_notes` in the nurse inbox/detail queries. This boundary is the *entire reason* the request and
  booking phases are split — preserve it exactly so b9 inherits a clean seam.
- **Tenancy invariant — enforced before the request is created.** The `patient` **and** the
  `customer_address` must belong to the caller's `customer_id`; the `variant` must belong to the
  requested `nurse_id`. Resolve `customer_id`/`nurse_id` from `ICurrentUser`, never from the request body.
  A cross-customer patient/address or a variant that isn't the nurse's is a clean failure, never a
  success and never a leak of the other party's data.
- **`required_caregiver_gender` is a first-class same-gender filter, not a soft preference.** When it is
  `male`/`female`, it **must** be matched against the nurse's `gender` at request time and rejected on
  mismatch; `any` matches either. Same-gender bodily care is decisive in the Iranian context. Never
  default it silently or treat it as advisory.
- **Deadlines come from config and are frozen on the row — never hardcoded, never recomputed.** Compute
  `nurse_response_deadline_at` from `nurse_response_deadline_hours` at **create** time and
  `payment_deadline_at` from `booking_payment_deadline_minutes` (= 30) at **accept** time, both via
  `IPlatformConfig` + `IDateTimeProvider`, and **store the absolute timestamp**. A later config change must
  **not** move an existing request's deadlines. Do not read "30 minutes" as a literal anywhere.
- **The nurse always individually accepts or rejects.** Availability slots are soft guidance only (b5-area,
  DEFERRED) — never auto-accept a request from availability. This deliberate per-request autonomy also
  underpins the worker-misclassification posture; do not erode it.
- **Status transitions go through the forward-only guard.** Every accept/reject/cancel/expire/convert
  edge is validated; illegal transitions return `409`, never a silent overwrite. Terminal states have no
  outgoing edges. The expiry sweep's `WHERE status = …` predicate is the concurrency guard — a row moved
  by a racing accept is simply skipped.
- **Time is injected, expiry is idempotent.** Use `IDateTimeProvider.UtcNow` everywhere (no `DateTime.Now`);
  accept/reject **self-guard** against an already-passed deadline rather than trusting that the sweep ran;
  the sweep is bounded, paginated, re-entrant, and safe to run concurrently with user actions.
- **The request → booking link is 1:1 and owned by b9.** Model the `converted` status and let b9 create
  the `bookings` row that points back at the request; this phase never writes that link itself.

## 6. Definition of Done

The shared [definition-of-done.md](../_shared/definition-of-done.md), plus:

- [ ] `booking_requests` exists via **one additive migration** with the §3.1 columns, the closed `status`
      set, the **unencrypted** `customer_notes`, `nurse_response_deadline_at` (not null) + nullable
      `payment_deadline_at`, the FK set, the inbox/expiry indexes, soft-delete filter, and **no money
      column**.
- [ ] `CreateBookingRequestCommand` enforces the **tenancy invariant** (patient + address ∈ `customer_id`,
      variant ∈ `nurse_id`), the **same-gender** match, bookability (active variant, verified/accepting
      nurse), and **freezes `nurse_response_deadline_at` from `nurse_response_deadline_hours`**; it
      notifies the nurse.
- [ ] `AcceptBookingRequestCommand` sets `payment_deadline_at = now + booking_payment_deadline_minutes`
      (**30, from config**), transitions to `accepted_awaiting_payment`, notifies the customer, and
      self-guards against an expired response deadline. `RejectBookingRequestCommand` requires a reason and
      transitions to `rejected_by_nurse`. `CancelBookingRequestCommand` handles `cancelled_by_customer`.
- [ ] The forward-only **status guard** rejects illegal transitions with `409`; `ExpireBookingRequestsCommand`
      + its `BackgroundService` transition `pending_nurse_response → expired_no_response` and
      `accepted_awaiting_payment → payment_deadline_expired`, paginated, idempotent, time-injected, and is
      also reachable via the admin manual-trigger endpoint.
- [ ] `ListBookingRequestsQuery` (role-scoped customer/nurse inbox, paginated, projected) and
      `GetBookingRequestQuery` (party/admin only) are implemented; the **nurse inbox exposes only
      `customer_notes`** and never any care/clinical field.
- [ ] Tests prove the §7 scenarios (incl. **cross-customer patient rejected**, **same-gender mismatch
      rejected**, accept sets the 30-min window, the **expiry job transitions stale rows**); `dotnet build
      Baya.sln` zero new warnings; `dotnet test Baya.sln` green including this phase's tests.
- [ ] The contract `dev/contracts/domains/booking-requests.md` is written, the `swagger.json` snapshot is
      refreshed, and the `server/CLAUDE.md` *Project map* notes the new `Features/Booking` area, the
      `BookingConfig` configuration folder, and the request-expiry `BackgroundService`.

## 7. How to test (what a human can verify after this phase)

Run the API (`dotnet run --project src/API/Baya.Web.Api/...`) against a reachable SQL Server; use Swagger
or curl, signing in as the relevant role. These expected results become the "what can be tested" section
of your report.

1. **Create a request (happy path).** As a customer, `POST /v1/booking_requests` with your own
   `patient_id` + `customer_address_id`, a nurse's `variant_id` (that nurse `is_verified` &
   `is_accepting_bookings`), a future `requested_date`, `required_caregiver_gender: any`, and a short
   `customer_notes` → `200`, status `pending_nurse_response`, `nurse_response_deadline_at` set to
   `now + nurse_response_deadline_hours`, `payment_deadline_at` null. The nurse receives an in-app
   notification.
2. **Cross-customer patient is rejected.** Repeat the create but with a `patient_id` belonging to a
   **different** customer → a clean tenancy failure (`403`/`404` `OperationResult`), **not** a 500 and
   **no** request row created. Same for an `customer_address_id` you don't own and a `variant_id` that
   isn't the requested nurse's.
3. **Same-gender mismatch is rejected.** Request `required_caregiver_gender: female` against a `male`
   nurse → a validation failure naming the gender conflict; `any` against any nurse succeeds.
4. **Nurse accepts → 30-minute window.** As the assigned nurse, `POST /v1/booking_requests/{id}/accept` →
   `200`, status `accepted_awaiting_payment`, `payment_deadline_at = now + booking_payment_deadline_minutes`
   (30 min). The customer gets a `booking_request_accepted` notification carrying the deadline. No
   `bookings` row exists (there is no bookings table yet).
5. **Nurse rejects.** `POST /v1/booking_requests/{id}/reject` with a reason on a fresh pending request →
   `200`, status `rejected_by_nurse`, `nurse_rejection_reason` stored, customer notified. Rejecting a
   non-pending request → `409`.
6. **Nurse inbox shows only `customer_notes`.** As the nurse, `GET /v1/booking_requests?status=pending_nurse_response`
   → the request appears with `customer_notes` and the per-request countdown to `nurse_response_deadline_at`,
   and **no** encrypted/clinical field of any kind.
7. **Customer cancels before paying.** On an `accepted_awaiting_payment` request, the customer
   `POST /v1/booking_requests/{id}/cancel` → `200`, status `cancelled_by_customer`. Cancelling a
   `converted`/`rejected`/`expired` request → `409`.
8. **The expiry job transitions stale requests.** Create a request, then (using the injected clock in a
   test, or by waiting / a short test config) advance past the response deadline and
   `POST /v1/admin/booking_requests/expire` (admin) → the pending request becomes `expired_no_response`;
   an accepted request past `payment_deadline_at` becomes `payment_deadline_expired`. Both notify the
   customer. Running the trigger again is a no-op (idempotent).
9. **Tenancy on read.** As a third party (neither the request's customer nor nurse),
   `GET /v1/booking_requests/{id}` → `403`/`NotFound`, never the request's data.

## 8. Hand off & document (close the phase)

- **Docs to update (same change):** `server/CLAUDE.md` *Project map* — add the `Features/Booking`
  (booking-requests) feature area, the new `booking_requests` table + the
  `Persistence/Configuration/BookingConfig/` folder, and the request-expiry `BackgroundService` (note it
  reuses the b1 `IJobScheduler` pattern). If you established the forward-only status-guard helper as a
  reusable pattern (b9 will reuse it for the `bookings` machine), note it in `server/CONVENTIONS.md`. If
  you discovered/decided any business rule not already in the product docs, reflect it in
  [`product/business/05-booking-and-scheduling.md`](../../../product/business/05-booking-and-scheduling.md)
  or [`product/data-model/05-booking-and-scheduling.md`](../../../product/data-model/05-booking-and-scheduling.md)
  (no invented rules — record decisions, and regenerate the HTML view per `product/CLAUDE.md` if you
  touched Markdown).
- **Contract to write:** publish **`dev/contracts/domains/booking-requests.md`** — the §3.6 routes;
  request/response shapes for create/accept/reject/cancel and the two queries; the `status` enum
  (`pending_nurse_response`/`accepted_awaiting_payment`/`converted`/`rejected_by_nurse`/
  `expired_no_response`/`payment_deadline_expired`/`cancelled_by_customer`) and `required_caregiver_gender`
  enum (`male`/`female`/`any`); the deadline timestamps (UTC ISO-8601); the **tenancy** and
  **stage-1-disclosure** notes (nurse view exposes only `customer_notes`); the failure cases and status
  codes (`400` validation/same-gender, `403`/`404` tenancy, `409` illegal transition / already-expired) —
  per [`../../contracts/conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md)
  and [`money-and-types.md`](../../contracts/conventions/money-and-types.md) (gender section), starting
  from the [domain contract template](../../contracts/domains/_TEMPLATE.md). Refresh the `swagger.json`
  snapshot per [`../../contracts/openapi/README.md`](../../contracts/openapi/README.md) so
  [frontend-phase-7-b8](../frontend/frontend-phase-7-b8.md) can derive its types (it does not guess
  shapes).
- **Handoff & report:** write `shared-working-context/backend/handoff/after-backend-phase-8.md` — the
  request lifecycle is live end-to-end (create/accept/reject/cancel/expire); the deadlines are config-
  driven and frozen; **what b9 must consume** (an `accepted_awaiting_payment` request → create the
  `bookings` row, set the request to `converted` through the guard, and that the **full encrypted care
  instructions are b9's stage-2** — this phase intentionally exposes only `customer_notes`); **what
  f7-b8 can now build** (the request form C4, the awaiting-acceptance/status tracker C5 with the response
  + payment countdowns, and the nurse incoming-requests inbox with accept/reject). Append your phase
  summary to `shared-working-context/backend/STATUS.md`, and write `reports/backend-phase-8-report.md`
  (what was built, **what is now testable and exactly how** — the §7 steps, that **nothing is mocked**
  here, the contract produced, and follow-ups for b9: conversion, sessions, two-stage stage-2 care
  instructions). State explicitly in the report that this phase adds **no** `mocks-registry.md` row.
- **Memory:** save a `project`-type memory note for the non-obvious decisions here — the **two-table phase
  split / no-money-on-a-request** rule, the **stage-1 (`customer_notes`-only) vs stage-2 (encrypted
  care-instructions) disclosure boundary**, the **deadlines-frozen-from-config** rule (which config keys),
  the **tenancy invariant** (patient+address ∈ customer, variant ∈ nurse), the **same-gender match at
  request time**, and the **forward-only status guard + idempotent expiry sweep** — with a one-line
  `MEMORY.md` pointer.
