43 KiB
Backend Phase 9 — Bookings, sessions, care instructions & EVV
Mission: turn an accepted, paid request into a real engagement. On payment capture, convert a
booking_requestsrow into abookingsrow 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 Nbooking_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_requestslifecycle), b1 (platform_configs,support_alerts,INotificationDispatcher), b0 (IFieldEncryptor,ICurrentUser, audit interceptor, REST/OperationResult), b4 (IGeocoder,customer_addressescoordinates) · 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 (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).
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 lifecycle — b8 builtbooking_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-stagecustomer_notes, frozennurse_response_deadline_at+payment_deadline_at,nurse_rejection_reason, and thestatusmachinepending_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 anaccepted_awaiting_paymentrequest 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 toconverted.platform_configstyped cached accessor +support_alerts+ notifications — b1 built the typed, cached config reader (readdispute_window_hoursdefault72,evv_location_tolerance_meters, and the no-show late threshold through it — never hardcode), thesupport_alertstable + raise API (this phase raiseslocation_mismatchandno_showalerts), and the real in-appnotificationswrite behindINotificationDispatcher.IGeocoder+ address coordinates — b4 builtcustomer_addresses(with lat/lng) and theIGeocoderseam. This phase reusesIGeocoderfor the EVV address-match distance computation; it does not introduce a new geo seam.IFieldEncryptor,ICurrentUser+ audit interceptor, the REST surface — b0 builtIFieldEncryptor(encryptsaddress_snapshot_jsonand thebooking_care_instructionscolumns; never logs plaintext),ICurrentUser+ the audit-field SaveChanges interceptor, the rate limiter, theBaseController+OperationResult<T>envelope, CQRS viamartinothamar/Mediator, andIDateTimeProvider.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.mdand../_shared/backend-conventions-checklist.md— especially the Performance, caching, money, idempotency block (IRRBIGINT, the three-amount split, encrypted PII columns through the field-encryptor seam, projected + paginated reads).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— 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 oncompletedalone).product/data-model/05-booking-and-scheduling.md— the canonical schema forbookings(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 onbooking_session_id), andcancellation_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/**(orFeatures/BookingRequests/**) command/query structure, validators, and thebooking_requestsconfig; b4'scustomer_addressesconfig +IGeocoderusage; b1's typed config accessor andsupport_alertsraise API +INotificationDispatcher; b0'sIFieldEncryptorusage on encrypted columns and theBaseController/OperationResultpattern. - Contract conventions:
../../contracts/conventions/api-conventions.mdandmoney-and-types.md(IRRBIGINT, the envelope, enum casing). - Prior handoffs:
dev/shared-working-context/backend/handoff/after-backend-phase-8.md,…-4.md,…-1.md,…-0.md, andreports/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_centersis 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 inbooking_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_releasedwas CUT — do NOT add any boolean "paid" flag. Paid-ness is derived later from anurse_payout_booking_linksrow + the ledger (b13).- Relations: 1:1 ←
booking_requests; 1:N →booking_sessions; 1:1 →booking_care_instructions; referenced later bypayment_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 ofnurse_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_amountfor the booking — the split must reconcile exactly (distribute the remainder of integer division onto the last session so no Rial is lost or created). Allvisit_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 withinevv_location_tolerance_metersof 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.statusand the parentbookings.statusmust 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 itscode+refund_percentageare 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(forapplies_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), andAdminCancellationPoliciesController(admin). Allsealed : BaseController, injectISender, returnbase.OperationResult(...), snake_case[controller]/[action]routes,CancellationTokenthreaded. 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 + inaccepted_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§(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 theIPaymentCaptureSimulatorseam 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 setsdispute_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_amountare alllong/BIGINT. Commission is computed by integer-roundinggross × platform_fee_rateat conversion and the rate is snapshotted intoplatform_fee_rate; no float survives into storage. gross_price_irr = balinyaar_commission_irr + nurse_payout_amount, all amounts ≥ 0 — a DB CHECK onbookings, andnurse_payout_amountis 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
bookingsrow from an unpaid or unaccepted request; conversion runs only from anaccepted_awaiting_paymentrequest with a successful capture (theIPaymentCaptureSimulatornow / realsucceededtransaction 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 cancellationcode+refund_percentageare 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_releasedboolean was CUT — never reintroduce it. Do not add any boolean "paid"/ "payout done" flag tobookingsorbooking_sessions. Paid-ness is derived later from anurse_payout_booking_linksrow + the ledger (b13). - Payout is eligible ONLY after
dispute_window_ends_atpasses with no open dispute — never oncompletedalone.SetDisputeWindowsetsdispute_window_ends_at = completed_at + config(dispute_window_hours, 72)(and per-sessionpayout_eligible_at); b13 gates the payout on that, not on thecompletedstatus. EVV check-out is necessary but not sufficient. Σ(visit_payout_amount) = nurse_payout_amountacross 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 encryptedbooking_care_instructionsare readable only post-confirmation and only by the assigned nurse + admin — never the customer, never an unassigned nurse, never pre-confirmation.GetCareInstructionsQueryenforces this; the fields are never projected into a list or logged. - A single-visit booking still creates exactly one
booking_sessionso EVV and payout follow one uniform path.GenerateBookingSessionsalways produces ≥ 1. - EVV address mismatch is advisory only. On a check-in outside
evv_location_tolerance_meters, raise alocation_mismatchsupport_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 fromplatform_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.statusandbookings.statusstay consistent via the documented mapping (checked_in↔ sessionin_progress↔ bookingin_progress; all sessionscompleted↔ bookingcompleted). No transition may contradict EVV. - Cancellation refunds only un-started sessions. Mid-engagement cancel refunds only sessions still
scheduledwith no check-in;in_progress/completedsessions are not refunded. The applicable policy is resolved by lead time + actor and snapshotted at cancel time. - Tenancy + access discipline.
GetBookingDetail/ListBookings/ListSessionsForNurseare scoped to the authenticated customer or nurse viaICurrentUser— 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 itsIEntityTypeConfiguration<T>: thegross = commission + payout(all ≥ 0) DB CHECK onbookings, thebooking_request_id/booking_id(care) /booking_session_id(EVV) UNIQUE 1:1 indexes, the encryptedaddress_snapshot_json+ care-instruction columns, thecancellation_policies.codeUNIQUE + seeded tiers, and soft-delete/audit wiring per conventions. - All §3.2 commands/queries implemented (CQRS,
OperationResult, projected + paginated reads, validators), withBookingsController,BookingSessionsController,AdminEvvController,AdminCancellationPoliciesController. IPaymentCaptureSimulatorintroduced (Application interface, Infrastructure mock, DI via aServiceConfiguration/extension, config-selected). Noif (mock)in handlers.- Conversion computes the three amounts correctly (
gross = commission + payout), writes both snapshots (address encrypted), setssession_count, generates ≥ 1 session with reconcilingvisit_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_mismatchalert without blocking; the last check-out completes the booking andSetDisputeWindowsetsdispute_window_ends_at(+ per-sessionpayout_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
DetectNoShowSessionscommand 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;
SetDisputeWindowon completion; the cancellation policy resolution/snapshot + un-started-only refund computation; the transition guard. ≥ 1WebApplicationFactoryintegration test per controller (happy path, 401, validation 400, and a 403 for the disclosure boundary).dotnet build Baya.slnzero new warnings;dotnet test Baya.slngreen. - The
Baya.Application/Features/Bookings/**area is reflected in the Project map inserver/CLAUDE.md; theIPaymentCaptureSimulatorseam noted where seams are documented. - The contract
dev/contracts/domains/bookings-evv.mdwritten and theswagger.jsonsnapshot 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.
- Convert a request → booking (mock capture) —
POST api/v1/bookings/convertfor the accepted request → abookingsrow appears (confirmed), the request flips toconverted, the three amounts sum (gross_price_irr = balinyaar_commission_irr + nurse_payout_amount, all ≥ 0),platform_fee_rateand the snapshots are populated (address_snapshot_jsonencrypted),session_countis set, and Nbooking_sessionsare generated withΣ visit_payout_amount = nurse_payout_amount. Re-convertthe same request → the same booking is returned (no second booking — idempotent). - Single-visit uniformity — convert a
session_count = 1request → exactly onebooking_sessionsrow is created. - Care instructions — disclosure boundary —
POST .../care_instructionson the confirmed booking, thenGET .../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 inGET api/v1/bookings/{id}or any list. - EVV check-in/out marks a session completed — as the assigned nurse,
POST .../booking_sessions/{id}/check_inwith in-range GPS → the session →in_progress, the booking →in_progress;POST .../check_out→visit_verifications.status = completed, the session →completed. - GPS mismatch raises an alert without blocking — check in with out-of-range GPS (force the
IGeocodermock distance pastevv_location_tolerance_meters) → the check-in still succeeds, the session goesin_progress,check_in_address_match = false, and alocation_mismatchsupport_alertsrow + a notification are created. Confirm it appears inGET api/v1/admin_evv?type=mismatch. - 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'spayout_eligible_atis set. Confirm the booking is not payout-eligible before that timestamp passes. - Cancellation —
POST api/v1/bookings/{id}/cancel(or a session) → the applicablecancellation_policiestier is resolved by lead time + actor, itscode+refund_percentageare 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.) - Transition guard — attempt an illegal transition (e.g.
confirmed → completedskippingin_progress, orcompletedwhile a session is stillin_progress) →OperationResultfailure, no state change. - No-show — trigger
DetectNoShowSessions(admin/test endpoint) for a session pastscheduled_time_start + thresholdwith no check-in → ano_showsupport_alerts+ family notification; the session →missed.
8. Hand off & document (close the phase)
- Docs to update: the Project map in
server/CLAUDE.md(add theFeatures/Bookings/**area + theIPaymentCaptureSimulatorseam). If you discover/confirm a rule the product docs don't capture (e.g. the exactvisit_payout_amountremainder-on-last-session split, the EVV-state ↔ booking-state mapping table, the seeded cancellation tiers, or ano_showthreshold default), record it inproduct/business/05-booking-and-scheduling.md/product/business/06-evv-and-service-delivery.md(and regenerate the HTML view perproduct/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; theBookingStatus/BookingSessionStatus/VisitVerificationStatus/CancellationActorenums; the booking/session/EVV/care-instruction DTO shapes (IRRBIGINT; 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_alertson mismatch/no-show, snapshot freezing). Republish theswagger.jsonsnapshot 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 behindIPaymentCaptureSimulatorand the real conversion trigger arrives with b10 payments). Append tobackend/STATUS.md. Writedev/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_centerswiring in b15). Updatedev/shared-working-context/reports/mocks-registry.md(theIPaymentCaptureSimulatorrow → 🟡). - Memory: save a
projectmemory 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_amountreconciliation, the snapshot-freezes-history discipline, the two-stage clinical disclosure gate, the advisory (never-blocking) EVV mismatch behaviour,SetDisputeWindowas the only payout-eligibility trigger, the cutpayout_releasedboolean, and theIPaymentCaptureSimulatorseam — with a one-line pointer inMEMORY.md.