Files
baya-monorepo/dev/phases/backend/backend-phase-9.md
T
2026-06-28 21:59:59 +03:30

43 KiB
Raw Blame History

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 (booking_requests lifecycle), b1 (platform_configs, support_alerts, INotificationDispatcher), b0 (IFieldEncryptor, ICurrentUser, audit interceptor, REST/OperationResult), b4 (IGeocoder, customer_addresses coordinates) · Unlocks: payments capture b10, reviews b14, payouts b13; frontend f8-b9 Before you start, read ../_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). EVV (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 lifecycleb8 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 + notificationsb1 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 coordinatesb4 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 surfaceb0 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 and ../_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.mdthe 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.mdthe 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.mdthe 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 — 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 and 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, nullableadvisory: 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/NotFoundResultthe 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 INotificationDispatchernever 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 §(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 (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, 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 boundaryPOST .../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_outvisit_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. CancellationPOST 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 (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/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) — 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. 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.