# Contract — Booking requests (backend phase b8)

> The **pre-payment intent** layer of the engagement lifecycle: a customer requests a nurse; the nurse
> accepts/rejects before a config-driven response deadline; on accept a config-driven **30-minute payment
> window** opens. **No money and no `bookings` row exist here** — accept only opens the window (conversion is
> b9/b10). Assumes [`../conventions/api-conventions.md`](../conventions/api-conventions.md) +
> [`../conventions/money-and-types.md`](../conventions/money-and-types.md). Machine schema:
> [`../openapi/swagger.v1.json`](../openapi/README.md) (refreshed for b8).

**Status:** live as of backend-phase-b8 · **Frontend consumer:** frontend-phase-f7-b8

> **Routing note.** Routes are **action-style** (`[controller]/[action]`, snake_cased). All responses use the
> standard `{ succeeded, statusCode, data }` envelope; `data` shapes are below. Request bodies are camelCase.

## Key semantics (read first)

- **Two-table split, no money on a request.** A `booking_requests` row never carries a price/total and never
  creates a booking. Accept moves it to `accepted_awaiting_payment` and opens the payment window; b9 later
  consumes that and creates the `bookings` row (+ money), setting the request to `converted`.
- **Two-stage clinical disclosure (stage 1).** The nurse sees **only** the unencrypted, limited
  `customer_notes` — never a full clinical/care instruction (those are b9's encrypted, post-confirmation
  `booking_care_instructions`). The **nurse view of a request masks the full address** (address line / postal
  code / recipient) and shows only a coarse city/district location; the **customer/admin view** returns the
  full address.
- **Tenancy invariant (enforced at create).** The `patient` and `customer_address` must belong to the caller's
  customer; the `variant` must belong to the requested `nurse_id`. A mismatch is a clean `404` — never a leak.
- **Same-gender match is first-class.** `required_caregiver_gender` (`male`/`female`/`any`) is matched against
  the nurse's gender at request time. `male`/`female` must equal the nurse's gender; `any` matches either. A
  mismatch is a `400`. It is required on create and never silently defaulted.
- **Deadlines are frozen from config.** `nurse_response_deadline_at` = create-time `now +
  nurse_response_deadline_hours` (default 24h); `payment_deadline_at` = accept-time `now +
  booking_payment_deadline_minutes` (**30**). Both are absolute UTC timestamps stored on the row — a later
  config change never moves an existing request's deadlines.
- **Forward-only status machine.** Illegal transitions return `409`. Terminal states
  (`converted`/`rejected_by_nurse`/`expired_no_response`/`payment_deadline_expired`/`cancelled_by_customer`)
  have no outgoing edges. An accept after the response deadline, or on a non-pending request, is `409`.
- **Auto-expiry.** A background sweep (also an admin manual trigger) moves `pending_nurse_response →
  expired_no_response` past the response deadline and `accepted_awaiting_payment → payment_deadline_expired`
  past the payment window, and notifies the customer.
- **Timestamps** are UTC ISO-8601. **Ids** are `BIGINT`. **There is no money field anywhere in this domain.**

## Enums used
- `booking_request_status`: `pending_nurse_response` | `accepted_awaiting_payment` | `converted` |
  `rejected_by_nurse` | `expired_no_response` | `payment_deadline_expired` | `cancelled_by_customer`.
- `required_caregiver_gender`: `male` | `female` | `any`.

## Endpoints

### `POST api/v1/booking_requests/create`
- **Purpose:** a customer requests a nurse for a patient/variant/address/date.
- **Auth:** authenticated **customer (owner)** · **Rate-limited:** no · **Idempotency key:** no
- **Request body:**
  ```json
  {
    "nurseId": 42,
    "variantId": 8,
    "patientId": 5,
    "customerAddressId": 6,
    "requestedDate": "2026-08-01",
    "requestedTimeStart": "09:00:00",
    "requestedTimeEnd": "13:00:00",
    "requiredCaregiverGender": "female",
    "customerNotes": "Careful with the IV line."
  }
  ```
- **Success `200` payload (`data`):** a `BookingRequestDto` (customer view — full address), status
  `pending_nurse_response`, `nurseResponseDeadlineAt` set, `paymentDeadlineAt` null.
- **Failure cases:** `400` validation (missing/invalid gender, `requestedTimeEnd ≤ requestedTimeStart`,
  past date, notes > 1000, inactive variant, nurse not verified/accepting, **same-gender mismatch**);
  `401` unauthenticated; `403` not a customer / no customer profile; `404` patient/address not owned or
  variant not the nurse's or nurse absent.
- **Side effects:** in-app `booking_request_received` notification to the nurse (`data_json`:
  `booking_request_id`, `patient_display_name`, `requested_date`).

### `POST api/v1/booking_requests/accept/{id}`
- **Purpose:** the assigned nurse accepts a pending request, opening the 30-minute payment window.
- **Auth:** authenticated **nurse (assigned)** · path param `id` (BIGINT).
- **Request body:** none.
- **Success `200` payload (`data`):** `BookingRequestDto` (nurse view — masked address), status
  `accepted_awaiting_payment`, `paymentDeadlineAt = now + booking_payment_deadline_minutes` (30 min).
- **Failure cases:** `401`; `403` not a nurse; `404` not the caller's request; `409` not pending / already
  past the response deadline.
- **Side effects:** in-app `booking_request_accepted` notification to the customer (`data_json`:
  `booking_request_id`, `payment_deadline_at`). **No `bookings` row and no money are created.**

### `POST api/v1/booking_requests/reject/{id}`
- **Purpose:** the assigned nurse declines a pending request with a reason.
- **Auth:** authenticated **nurse (assigned)** · path param `id`.
- **Request body:** `{ "reason": "Fully booked that week." }` (required, ≤ 500 chars).
- **Success `200` payload (`data`):** `BookingRequestDto`, status `rejected_by_nurse`, `nurseRejectionReason` set.
- **Failure cases:** `400` empty/too-long reason; `401`; `403`; `404`; `409` not pending.
- **Side effects:** in-app `booking_request_rejected` notification to the customer.

### `POST api/v1/booking_requests/cancel/{id}`
- **Purpose:** the customer withdraws a request that is still `pending_nurse_response` or
  `accepted_awaiting_payment` (before paying).
- **Auth:** authenticated **customer (owner)** · path param `id`.
- **Request body:** none.
- **Success `200` payload (`data`):** `BookingRequestDto` (customer view), status `cancelled_by_customer`.
- **Failure cases:** `401`; `403`; `404` not owned; `409` request is terminal (converted/rejected/expired).

### `GET api/v1/booking_requests/list`
- **Purpose:** the role-scoped inbox (paginated).
- **Auth:** authenticated (customer **or** nurse).
- **Query params:** `status` (optional enum filter); `role` (`customer`|`nurse`, optional — disambiguates a
  user who holds both roles; inferred from the caller's profile when omitted); `page`/`pageSize` (default
  1 / 50, max 100).
- **Success `200` payload (`data`):** `PagedResult<BookingRequestListItemDto>` (`items`, `total`, `page`,
  `pageSize`). Actionable rows sort first. The **customer** inbox sets `counterpartyName` = nurse name +
  `nurseRating`; the **nurse** inbox sets `counterpartyName` = patient name + `customerNotes` (stage-1 only).
- **Failure cases:** `400` holds both roles and no `role` given; `401`. A caller with neither profile gets an
  empty page.

### `GET api/v1/booking_requests/get/{id}`
- **Purpose:** a single request.
- **Auth:** its **customer** (full address), its **nurse** (masked address), or an **admin** (full).
- **Success `200` payload (`data`):** `BookingRequestDto`.
- **Failure cases:** `401`; `404` absent **or** caller is neither party nor admin (existence not leaked).

### `POST api/v1/admin_booking_requests/expire`
- **Purpose:** admin/test manual trigger of the expiry sweep (the same command the recurring job runs).
- **Auth:** **admin** (`DynamicPermission`) · **Rate-limited:** no.
- **Request body:** none.
- **Success `200` payload (`data`):** `{ "expiredNoResponse": 0, "paymentDeadlineExpired": 0 }` — counts moved
  this run (idempotent; re-running on a drained set returns zeros).
- **Failure cases:** `401`; `403` non-admin.

## Shared shapes

- `BookingRequestDto` (full single-request view):
  `id` (long), `status` (enum), `nurseId` (long), `nurseName` (string), `nurseRating` (decimal),
  `nurseTotalReviews` (int), `patientId` (long), `patientName` (string), `variantId` (long),
  `variantLabel` (string), `variantPriceUnit` (string), `customerAddressId` (long), `addressTitle` (string),
  `cityId` (long), `cityNameFa`/`cityNameEn` (string), `districtId` (long?, null = whole city),
  `districtNameFa`/`districtNameEn` (string?), `addressLine`/`postalCode`/`recipientName`/`recipientPhone`
  (string?, **null in the nurse view** — masked), `requiredCaregiverGender` (enum?), `requestedDate` (date),
  `requestedTimeStart`/`requestedTimeEnd` (time), `customerNotes` (string?, stage-1 plaintext),
  `nurseResponseDeadlineAt` (UTC datetime), `paymentDeadlineAt` (UTC datetime?, null until accept),
  `nurseRejectionReason` (string?), `createdAt` (UTC datetime).
- `BookingRequestListItemDto` (inbox row):
  `id`, `status`, `counterpartyName` (nurse name for customer view / patient name for nurse view),
  `nurseRating` (decimal?, customer view only), `requiredCaregiverGender` (enum?), `requestedDate`,
  `requestedTimeStart`, `requestedTimeEnd`, `nurseResponseDeadlineAt`, `paymentDeadlineAt`,
  `customerNotes` (string?, **nurse view only**).
- `ExpireBookingRequestsResult`: `expiredNoResponse` (int), `paymentDeadlineExpired` (int).

## Changelog
- b8 — initial contract (create/accept/reject/cancel + role-scoped list + single get + admin expire).

---

## Refinement phase 3 additions (REQ-013/014/016/017)

- **`BookingRequestDto`** gains `variantPrice` (IRR digit-string — the chosen variant's *display rate*, not
  an engagement total; the request stays money-free), `nurseAvatarUrl` (nullable), and `bookingId`
  (nullable — the booking created once the request is `converted`, for the confirmation deep-link).
- **`BookingRequestListItemDto`** gains `variantLabel` (self-describing inbox row) and `patientAge`
  (nullable coarse triage age).
- **`GET api/v1/booking_requests/checkout_summary/{id}`** (owner-scoped) — the C6 money breakdown:
  `{ bookingRequestId, requestStatus, nurseName, patientName, variantLabel, variantPriceUnit, sessionCount,
  requestedDate, requestedTimeStart, requestedTimeEnd, paymentDeadlineAt, serviceCostIrr, commissionIrr,
  vatIrr, vatRate, totalIrr, grossPriceIrr, balinyaarCommissionIrr, nursePayoutAmount }`. All IRR
  digit-strings, computed server-side. **Canonical rates:** `platform_fee_rate = 0.15`, `vat_rate = 0.10`.
  VAT is **carved out of the commission** so `serviceCostIrr + commissionIrr + vatIrr = totalIrr = gross`
  (the captured amount); `commissionIrr` is the commission **net of VAT**, and the raw b10 amounts
  (`grossPriceIrr = balinyaarCommissionIrr + nursePayoutAmount`) are surfaced alongside.
