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