Files
2026-06-28 21:59:59 +03:30

453 lines
43 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 (b10b13).
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) — 0100), `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
0100, 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`.