backend phase 9

This commit is contained in:
hamid
2026-07-06 19:23:44 +03:30
parent 2cfc082a04
commit 12c7e51c32
101 changed files with 11666 additions and 8 deletions
+132
View File
@@ -0,0 +1,132 @@
# Contract — Bookings, Sessions, EVV & Cancellation (backend phase b9)
> One-line: the post-payment engagement — convert a paid request into a booking + N sessions, the two-stage
> care-instructions boundary, per-session EVV check-in/out, dispute-window gating, and cancellation. Assumes
> [`../conventions/api-conventions.md`](../conventions/api-conventions.md) +
> [`../conventions/money-and-types.md`](../conventions/money-and-types.md). Machine schema:
> [`../openapi/swagger.v1.json`](../openapi/README.md).
**Status:** live as of backend-phase-b9 · **Frontend consumer:** frontend-phase-f8-b9
All money is **IRR Rials, integer, on the wire as a string of digits** (`"15000000"`). The three booking
amounts always satisfy `gross_price_irr = balinyaar_commission_irr + nurse_payout_amount`. Timestamps are UTC
ISO-8601. Enums cross as their stable string codes.
## Enums used
- `BookingStatus`: `pending_payment` | `confirmed` | `in_progress` | `completed` | `disputed` | `closed` | `cancelled`.
- `BookingSessionStatus`: `scheduled` | `in_progress` | `completed` | `missed` | `cancelled`.
- `VisitVerificationStatus`: `pending` | `checked_in` | `completed`.
- `CancellationActor` (`applies_to` / `cancelled_by`): `customer` | `nurse` | `admin`.
## Endpoints
### `POST api/v1/bookings/convert`
- **Purpose:** The (mock) payment-capture conversion — creates the booking 1:1 from an
`accepted_awaiting_payment` request. In b10 the real card capture calls this path directly.
- **Auth:** authenticated (owning customer or admin) · **Rate-limited:** yes (sensitive) · **Idempotent:** yes
(replay returns the existing booking).
- **Request body:** `{ "bookingRequestId": 123 }`
- **Success `200` (`data`):** the `Booking` shape (below), `status = confirmed`.
- **Failure:** `400` bad id, `401` unauth, `404` request not found / not the caller's, `409` request not
awaiting payment / no longer convertible, `400` capture failed (no booking created).
- **Notes:** computes the three amounts (commission = round(`gross × platform_fee_rate`), rate snapshotted),
writes `variant_snapshot_json` + **encrypted** `address_snapshot_json`, generates ≥ 1 session with
`Σ visit_payout_amount = nurse_payout_amount`, flips the request → `converted`, notifies both parties.
### `GET api/v1/bookings/get/{id}`
- **Purpose:** Booking header + money summary + sessions + timeline. **Auth:** authenticated, tenancy-scoped
(customer own / nurse assigned / admin all). The **nurse** view omits `addressSnapshotJson`. Never includes
care-instruction clinical fields. **Failure:** `401`, `404` (not found / not a party → no leak).
### `GET api/v1/bookings/list?role=customer|nurse|all&status=&page=&page_size=`
- **Purpose:** role-scoped "My bookings" (paginated, projected). `role=all` is admin-only (`403` otherwise).
**Success:** `PagedResult<BookingListItem>`.
### `POST api/v1/bookings/transition/{id}`
- **Purpose:** admin/dispute status move. **Auth:** admin (`403` otherwise). **Body:**
`{ "targetStatus": "disputed", "reason": "…" }`. **Failure:** `409` (illegal edge, or contradicts EVV —
e.g. `in_progress` with no session checked in, `completed` with a live session), `400` (use `cancel` for
cancellation). Completing here also opens the dispute window.
### `POST api/v1/bookings/cancel/{id}`
- **Purpose:** cancel a whole booking. **Auth:** owning customer / assigned nurse / admin · **Rate-limited:** yes.
**Body:** `{ "reason": "…" }`. **Success:** `CancellationResult`. Resolves + **snapshots** the policy
(`code` + `refund_percentage`) onto the booking, cancels only un-started (`scheduled`) sessions, computes the
refundable amount. **No refund ledger is posted (b11).** **Failure:** `400` no reason, `409` not cancellable.
### `POST api/v1/bookings/submit_care_instructions/{id}`
- **Purpose:** write/update the encrypted stage-2 `booking_care_instructions`. **Auth:** owning customer or
admin, booking must be `confirmed`+. **Body:** `CareInstructions` (all optional strings). **Failure:** `409`
not confirmed, `404` not found.
### `GET api/v1/bookings/care_instructions/{id}`
- **Purpose:** the **gated** stage-2 read. **Auth:** **assigned nurse or admin only, post-confirmation.** Any
other caller (customer, unassigned nurse, pre-confirmation) → `404` (never leaks). Returns decrypted
`CareInstructions`. **This is the two-stage disclosure boundary.**
### `POST api/v1/booking_sessions/check_in/{id}`
- **Purpose:** assigned nurse clocks in. **Auth:** assigned nurse. **Body:** `{ "latitude": 35.6892,
"longitude": 51.389 }` (both nullable — GPS-denied still checks in, flagged). Moves the session + booking to
`in_progress`; computes the **advisory** address match against `evv_location_tolerance_meters`. A mismatch
raises a `location_mismatch` support alert + notifies **without blocking**. **Success:** `VisitVerification`.
**Failure:** `401`, `403` (not a nurse), `404` (not the nurse's session), `409` (not startable).
### `POST api/v1/booking_sessions/check_out/{id}`
- **Purpose:** assigned nurse clocks out — must follow an open check-in. Completes the session's EVV, sets its
`payout_eligible_at`, and — when all sessions are settled — completes the booking + sets
`dispute_window_ends_at`. **Failure:** `400` no open check-in, `409` not checkout-able.
### `GET api/v1/booking_sessions/today?date=&page=&page_size=`
- **Purpose:** the nurse's sessions for a day (default all), with check-in/out CTA state. **Auth:** nurse,
tenancy-scoped. **Success:** `PagedResult<BookingSessionListItem>`.
### `GET api/v1/booking_sessions/evv/{id}`
- **Purpose:** per-session EVV detail. **Auth:** owning nurse + admin only (raw GPS gated); others → `404`.
### `POST api/v1/booking_sessions/cancel/{id}`
- **Purpose:** cancel a single un-started session · **Rate-limited:** yes. Snapshots the policy + computes the
session's refundable share. **Failure:** `409` if the session already started.
### `GET api/v1/admin_evv/list?type=mismatch|no_show&page=&page_size=`
- **Purpose:** admin EVV-review queue. **Auth:** admin policy · **Rate-limited:** yes. **Success:**
`PagedResult<AdminEvvItem>`.
### `POST api/v1/admin_evv/detect_no_shows`
- **Purpose:** the manual no-show sweep trigger (the recurring cron is DEFERRED). **Auth:** admin. Marks
overdue scheduled sessions `missed`, raises `no_show` alerts + notifies. **Success:** `{ "missed": N }`.
### `POST api/v1/admin_cancellation_policies/upsert` · `GET api/v1/admin_cancellation_policies/list`
- **Purpose:** admin CRUD of cancellation tiers (keyed by unique `code`). **Auth:** admin policy. Editing a
policy never mutates an already-snapshotted cancellation. **Failure:** `400` (percentage not 0100, bad
actor, min ≥ max).
## Shared shapes
- **`Booking`** (`bookings/get`, `convert`, `transition`): `id`, `bookingRequestId`, `status` (`BookingStatus`),
`nurseId`, `nurseName`, `patientId`, `patientName`, `variantId`, `variantSnapshotJson` (string),
`customerAddressId`, `addressSnapshotJson` (string, **null for the nurse view**), `grossPriceIrr`,
`balinyaarCommissionIrr`, `nursePayoutAmount`, `pspFeeAmount` (money strings; psp nullable),
`platformFeeRate` (decimal), `sessionCount` (int), `scheduledDate`/`scheduledTimeStart`/`scheduledTimeEnd`,
`confirmedAt`/`completedAt`/`cancelledAt` (nullable), `cancelledBy`/`cancellationReason`/
`cancellationPolicyCode` (nullable), `cancellationRefundPercentage` (nullable decimal), `refundableAmountIrr`
(nullable money string), `disputeWindowEndsAt` (nullable), `createdAt`, `sessions[]`.
- **`BookingSessionSummary`** (embedded): `id`, `sessionIndex`, schedule, `status` (`BookingSessionStatus`),
`visitPayoutAmount` (money string), `payoutEligibleAt` (nullable), `evvStatus` (`VisitVerificationStatus`),
`checkInAt`/`checkOutAt` (nullable), `checkInAddressMatch` (nullable bool).
- **`BookingListItem`**: `id`, `status`, `counterpartyName`, `scheduledDate`, `sessionCount`, `amountIrr`
(gross for customer / payout for nurse), `disputeWindowEndsAt`, `createdAt`.
- **`BookingSessionListItem`**: `sessionId`, `bookingId`, `sessionIndex`, `patientName`, schedule, `status`,
`evvStatus`.
- **`CareInstructions`**: `bookingId` + `currentConditions`/`medications`/`allergies`/`specialInstructions`/
`emergencyContactName`/`emergencyContactPhone` (all nullable). **Encrypted at rest; gated read.**
- **`VisitVerification`**: `id`, `bookingSessionId`, `status`, `checkInAt`/`checkInLat`/`checkInLng`,
`checkOutAt`/`checkOutLat`/`checkOutLng`, `checkInAddressMatch`, `checkInDistanceMeters` (raw GPS gated).
- **`AdminEvvItem`**: `sessionId`, `bookingId`, `nurseId`, `sessionStatus`, `scheduledDate`,
`scheduledTimeStart`, `checkInAt`, `checkInAddressMatch`, `checkInDistanceMeters`.
- **`CancellationResult`**: `bookingId`, `sessionId` (nullable), `bookingStatus`, `policyCode`,
`refundPercentage`, `refundableAmountIrr` (money string).
- **`CancellationPolicy`**: `id`, `code`, `appliesTo`, `hoursBeforeStartMin`/`Max` (nullable), `refundPercentage`,
`feeAmountIrr` (money string), `feeRate` (nullable), `isActive`.
## Changelog
- b9 — initial contract (bookings + sessions + care instructions + EVV + cancellation; capture mocked via
`IPaymentCaptureSimulator`, real trigger arrives with b10).
File diff suppressed because it is too large Load Diff
@@ -12,6 +12,25 @@ One block per completed backend phase. Newest at the top. Backend lane writes he
- **Notes for frontend:** <anything load-bearing>
-->
## backend-phase-9 — Bookings, sessions, care instructions & EVV — 2026-07-06
- **Shipped:** the post-payment engine via one additive migration in the **`booking`** schema — **5 tables**
`Bookings` / `BookingSessions` / `BookingCareInstructions` / `VisitVerifications` / `CancellationPolicies`
(seeded 4 tiers) + 2 config rows (`no_show_threshold_minutes`, `no_show_scan_cadence_hours`). `Convert`
(mock-capture → booking 1:1, three-amount split, snapshots, N sessions), care-instructions submit + **gated**
read, EVV `check_in`/`check_out` (advisory address match, dispute window on completion), `transition`,
`cancel` booking/session (policy snapshot + un-started refund), `detect_no_shows`, cancellation-policy CRUD.
Controllers: `Bookings` / `BookingSessions` / `AdminEvv` / `AdminCancellationPolicies`.
- **Contracts:** dev/contracts/domains/bookings-evv.md + openapi snapshot refreshed (yes).
- **Mocked:** **`IPaymentCaptureSimulator`** introduced (🟡) — the temporary conversion trigger; reuses
`IGeocoder`/`IFieldEncryptor`/`INotificationDispatcher`/`ISupportAlertService`. Real capture trigger = b10.
- **Gate:** build clean (0 new code warnings) / tests green (269: 186 foundation + 4 identity + 79 API).
- **Handoff:** backend/handoff/after-backend-phase-9.md
- **Notes for frontend (f8-b9):** money is an **IRR digit-string** (three amounts reconcile). The nurse view
omits `addressSnapshotJson`; care-instruction clinical fields are **assigned-nurse/admin only, post-confirmation**
(`GET bookings/care_instructions/{id}`); raw EVV GPS is gated to owning nurse + admin. Routes are action-style
(`bookings/get/{id}`, `booking_sessions/check_in/{id}`, `booking_sessions/today`, `admin_evv/list`). Payout
eligibility comes from `disputeWindowEndsAt`/`payoutEligibleAt`, never `completed` alone.
## backend-phase-8 — Booking requests (pre-payment intent) — 2026-07-06
- **Shipped:** the money-free request lifecycle via one additive migration — new **`booking`** schema,
**1 table** `BookingRequests`. The customer-side (`CreateBookingRequest` — tenancy invariant
@@ -0,0 +1,46 @@
# Handoff — after backend phase 9 (Bookings, sessions, care instructions & EVV)
**The booking engine is live.** A paid request now becomes a real engagement: `bookings` + N `booking_sessions`,
encrypted `booking_care_instructions`, per-session `visit_verifications` (EVV), and the dispute-window gate.
Capture is **mocked** behind `IPaymentCaptureSimulator`; the real conversion trigger arrives with **b10 payments**.
## What f8-b9 can now build
- **Booking detail & "My bookings"** — `GET bookings/get/{id}`, `GET bookings/list?role=customer|nurse|all&status=`.
Header + money summary (three amounts as **digit-strings**, `platformFeeRate`, `pspFeeAmount`) + sessions +
status timeline. The **nurse view omits `addressSnapshotJson`**.
- **Nurse EVV** — `GET booking_sessions/today?date=` (today's visits + CTA state), `POST booking_sessions/check_in/{id}`
and `POST booking_sessions/check_out/{id}` (send `{latitude, longitude}`; both nullable if GPS denied),
`GET booking_sessions/evv/{id}` (raw GPS, owning nurse + admin only).
- **Care instructions** — `POST bookings/submit_care_instructions/{id}` (customer/admin, booking must be confirmed+),
`GET bookings/care_instructions/{id}` (**assigned nurse + admin only, post-confirmation** — the two-stage
disclosure boundary; everyone else gets 404).
- **Status timeline & cancel** — `POST bookings/cancel/{id}` (customer/nurse/admin; returns the policy snapshot +
refundable amount), `POST booking_sessions/cancel/{id}` (single un-started session).
- **Admin** — `GET admin_evv/list?type=mismatch|no_show`, `POST admin_evv/detect_no_shows`,
`POST admin_cancellation_policies/upsert` + `GET admin_cancellation_policies/list`, `POST bookings/transition/{id}`.
## Live endpoints / contracts
- Contract: [`dev/contracts/domains/bookings-evv.md`](../../contracts/domains/bookings-evv.md); machine schema in
the refreshed [`swagger.v1.json`](../../contracts/openapi/swagger.v1.json).
- Enums: `BookingStatus`, `BookingSessionStatus`, `VisitVerificationStatus`, `CancellationActor`.
## Load-bearing rules the client must honour
- **Money is IRR integer, on the wire as a digit-string.** `grossPriceIrr = balinyaarCommissionIrr + nursePayoutAmount`.
Never coerce to a JS number for math.
- **Payout eligibility is derived from `disputeWindowEndsAt` / per-session `payoutEligibleAt`, not from `completed`.**
- **EVV address mismatch is advisory** — a flagged check-in still succeeds; surface it, don't block.
- **Care-instruction clinical fields never appear in a list or the booking detail** — only via the gated read.
## Mocked here → make real later
- **`IPaymentCaptureSimulator`** (🟡) — the temporary conversion trigger. In **b10**, the real card capture calls
`ConvertRequestToBooking` directly on a `payment_transactions.succeeded`; this seam is then removed. A config
switch (`Seams:PaymentCapture:ForceFailure`) exercises the "capture failed → no booking" path today.
- The **no-show cron** is DEFERRED — `POST admin_evv/detect_no_shows` runs the same idempotent command a scheduler
will later call (config `no_show_scan_cadence_hours`).
## Consumed by later backend phases
- **b10** — real capture posts the ledger and calls the conversion; sets the real `psp_fee_amount`.
- **b11** — refund execution consumes the frozen `cancellationPolicyCode` + `refundableAmountIrr` (no ledger posted here).
- **b13** — payout batching consumes `disputeWindowEndsAt` / `payoutEligibleAt`.
- **b14** — reviews on a completed booking.
- **b15** — `partner_centers` wires the nullable `partner_center_id`.
@@ -0,0 +1,78 @@
# Backend phase 9 report — Bookings, sessions, care instructions & EVV
## What was built
- **Domain (`Baya.Domain/Entities/Booking/`):** `Booking`, `BookingSession`, `BookingCareInstruction`,
`VisitVerification`, `CancellationPolicy` (+ `CancellationPolicyCode` seed codes), the `BookingStatus` /
`BookingSessionStatus` / `VisitVerificationStatus` / `CancellationActor` code sets, the `BookingTransitions`
/ `BookingSessionTransitions` guards, and `BookingAmounts` (the pure integer-money split/reconciliation).
- **Persistence:** one migration `BookingsSessionsEvvCancellation` — the five `booking`-schema tables with the
`gross = commission + payout` (all ≥ 0) **DB CHECK**, the `booking_request_id` / `booking_id` (care) /
`booking_session_id` (EVV) UNIQUE 1:1 indexes, encrypted `address_snapshot_json` + care columns, seeded
cancellation tiers + 2 new `platform_configs` rows. `BookingConfig/` configs; `BookingRepository` +
`CancellationPolicyRepository` on `IUnitOfWork`; `IBookingRequestRepository` gained
`GetTrackedByIdAsync` + `GetConversionSourceAsync`.
- **Application (`Features/Bookings/`):** `ConvertRequestToBooking`, `SubmitCareInstructions`, `CheckInVisit`,
`CheckOutVisit`, `TransitionBookingStatus`, `CancelBooking`, `CancelSession`, `DetectNoShowSessions`,
`UpsertCancellationPolicy` (commands) + `GetBookingDetail`, `ListBookings`, `ListSessionsForNurse`,
`GetCareInstructions`, `GetVisitVerification`, `ListAdminEvv`, `ListCancellationPolicies` (queries), plus
`BookingMapper`, `CancellationHelper`, `GeoDistance`.
- **Seam:** `IPaymentCaptureSimulator` (Application `Contracts/Common`) + `MockPaymentCaptureSimulator`
(CrossCutting), registered in `AddCrossCuttingSeams`, config `Seams:PaymentCapture`.
- **API:** `BookingsController`, `BookingSessionsController`, `AdminEvvController`,
`AdminCancellationPoliciesController` (convert/cancel + admin EVV/policies are rate-limited).
## What is now testable, and exactly how (per §7 of the phase)
1. **Convert**`POST bookings/convert` (as the owning customer) with an `accepted_awaiting_payment` request →
a `confirmed` booking whose three amounts sum, snapshots are populated (address encrypted at rest), N
sessions reconcile (`Σ visit_payout = nurse_payout`), request → `converted`. Re-convert → same booking.
2. **Single-visit** — a `session_count=1` request → exactly one session.
3. **Care disclosure** — submit as customer, then `GET bookings/care_instructions/{id}`: assigned nurse + admin
get the decrypted fields; customer / unassigned nurse / pre-confirmation → 404. Never in list/detail.
4. **EVV**`check_in` (in-range GPS) → session + booking `in_progress`; `check_out` → session `completed`,
EVV `completed`.
5. **Mismatch**`check_in` out-of-range → still succeeds, `check_in_address_match=false`, a
`evv_location_mismatch` support alert + notification; visible in `admin_evv/list?type=mismatch`.
6. **Completion** — last `check_out` → booking `completed`, `dispute_window_ends_at = completed_at + 72h`
(config), each completed session's `payout_eligible_at` set; not payout-eligible before that.
7. **Cancellation**`bookings/cancel/{id}` resolves the tier by lead-time + actor, snapshots `code` +
`refund_percentage`, refunds only un-started sessions; a later policy edit leaves the snapshot unchanged.
8. **Transition guard** — an illegal/EVV-contradicting transition → `OperationResult` failure, no state change.
9. **No-show**`admin_evv/detect_no_shows` for an overdue scheduled session → `missed` + `no_show` alert +
family notification.
Automated coverage: 42 booking foundation tests (SQLite host over the real EF model + real handlers) — the
three-amount split + session reconciliation, the transition guards, the two-stage disclosure gate, the
advisory-mismatch-raises-alert-without-blocking path, `SetDisputeWindow` on completion, and the
policy-snapshot immutability — plus one WebApplicationFactory integration test per controller (happy path /
401 / 400 / disclosure not-found). Full suite green (269 tests). `dotnet build` zero new code warnings.
## What is mocked, and how to make it real
- **`IPaymentCaptureSimulator`** — see `reports/mocks-registry.md`. In b10 the real card capture calls
`ConvertRequestToBooking` directly on a `payment_transactions.succeeded`; remove the seam + mock. A config
switch forces a failed capture today so the "no booking on failure" path is covered.
## Decisions recorded (not in the product docs before)
- The `visit_payout_amount` split places the remainder of integer division on the **last** session so
`Σ = nurse_payout_amount` exactly.
- The EVV-state ↔ booking-state mapping (`checked_in` ↔ session `in_progress` ↔ booking `in_progress`; all
sessions settled ↔ booking `completed`).
- Seeded cancellation tiers: `standard_24h` (customer ≥24h → 100%), `standard_inside_24h` (customer <24h →
50%, open lower bound so an already-started cancel still resolves), `nurse_no_show` (nurse → 100% + a
modelled penalty whose posting is deferred to b13), `admin_cancellation` (admin → 100%).
- `no_show_threshold_minutes` default **60**; `no_show_scan_cadence_hours` default **1**.
- The cancellation snapshot (`cancellation_policy_code` / `cancellation_refund_percentage` /
`refundable_amount_irr`) lives on the `bookings` row for MVP — the typed per-event/refund record lands in b11;
`booking_sessions.cancellation_event_id` is a nullable column left unset until then.
## Contracts produced / consumed
- Produced: `dev/contracts/domains/bookings-evv.md` + refreshed `swagger.v1.json`. Consumes b8's
`booking_requests`, b5's `IVariantSnapshotSerializer`, b4's `IGeocoder` + address coords, b1's config /
`support_alerts` / `INotificationDispatcher`, b0's `IFieldEncryptor` / `ICurrentUser` / `OperationResult`.
## Follow-ups
- **b10** — real card capture (`payment_transactions`, ledger) → replaces the `IPaymentCaptureSimulator` trigger.
- **b11** — refund execution consumes the frozen policy snapshot + `refundable_amount_irr`; adds the
cancellation-event/refund records that `booking_sessions.cancellation_event_id` will reference.
- **b13** — payout batching consumes `dispute_window_ends_at` / `payout_eligible_at`; posts the nurse penalty.
- **b14** — reviews on a completed booking. **b15**`partner_centers` wires `partner_center_id`.
- The **no-show cron** and a **recurring dispute-window/close sweep** remain DEFERRED (hosted-scheduler pattern).
@@ -33,6 +33,7 @@ Status legend: 🔴 not built · 🟡 mocked (seam + fake impl in place) · 🟢
| `IFieldEncryptor` | backend-phase-0 | PII encryption — AES-256-CBC + HMAC hash from a local symmetric key (`SymmetricFieldEncryptor`, `Baya.Infrastructure.CrossCutting/Seams/`) | `Seams:FieldEncryption:Key`, `Seams:FieldEncryption:HashKey` | KMS / column encryption / Key Vault / HSM | 🟡 |
| `INotificationDispatcher` | backend-phase-0/**1** | Notification channels — **in-app write is now real** (`InAppNotificationDispatcher`, `Persistence/Services/Notifications/`, writes an `ops.Notifications` row); b0 log stub removed. SMS/push channels still deferred (no-op) behind the same seam | _none_ | Add SMS (`ISmsSender`) / push (FCM) channels; polling → Redis pub/sub or SignalR later | 🟡 |
| `ILicenseVerificationService` | backend-phase-15 | eNamad / MoH establishment-permit — manual approve | _tbd_ | Real registry/API | 🔴 |
| `IPaymentCaptureSimulator` | backend-phase-9 | The **temporary conversion trigger** standing in for b10's real card capture. `MockPaymentCaptureSimulator` (`Baya.Infrastructure.CrossCutting/Seams/`) returns a deterministic *succeeded* capture (a fake `gateway_reference` + a configurable `psp_fee_amount`) so `ConvertRequestToBookingCommand` is exercisable now; a config switch forces a *failed* capture (→ no booking is created). **This is the trigger, not a parallel money path** — registered singleton in `AddCrossCuttingSeams` | `Seams:PaymentCapture:ForceFailure` (default `false`), `Seams:PaymentCapture:PspFeeAmount` (default unset) | In b10: 1) build the real card capture (`payment_transactions`, PSP/IPG client, webhook verify); 2) on a real `payment_transactions.succeeded`, call `ConvertRequestToBooking` **directly** (the same conversion command that computes the three-amount split + generates sessions) instead of this seam; 3) remove the `IPaymentCaptureSimulator` registration + `MockPaymentCaptureSimulator`; the conversion/idempotency logic is unchanged | 🟡 |
| `INurseSearch` | backend-phase-7 | The search-service seam (read side). **The MVP impl `SqlNurseSearch` (`Persistence/Services/Search/`) is REAL, not a mock** — it reads the maintained `nurse_search_index WHERE is_searchable=1`, applies the category/city/district (NULL=whole-city)/gender/price filters + rating sort + pagination, projected & `AsNoTracking`. Registered by `AddPersistenceServices`, config-selected. Only the DEFERRED Elasticsearch backend is unbuilt | `Search:Backend` (default `sql`; any other value throws until Elastic ships) | 1) add an Elasticsearch client package (`Elastic.Clients.Elasticsearch`) to `Directory.Packages.props`; 2) define the index mapping (the `NurseSearchResultDto` fields + `is_searchable`); 3) implement `ElasticNurseSearch : INurseSearch` (same filters/sort/paging) reading the ES index; 4) build the feeder that consumes the `ISearchIndexMaintainer` change events via an **outbox/CDC** stream into ES (see the next row); 5) point `Search:Backend=elastic` in config — **callers unchanged**; 6) keep the SQL index as the projection/fallback + the reconciliation source (`RebuildAsync`); 7) test filter/sort/paging parity vs `SqlNurseSearch` | 🟢 SQL real; Elastic 🟡 |
| `ISearchIndexMaintainer` (the "`ISearchIndexWriter`" event shape) | backend-phase-7 | The index-maintenance seam (write side). **The inline SQL path is REAL**`SearchIndexMaintainer` (`Persistence/Services/Search/`) re-derives `nurse_search_index` from source and **stages** it inside the owning source write's unit of work (single `CommitAsync`), invoked from the b3/b4/b5/b6 handlers (`ReindexVariantAsync`/`ReindexNurseAsync`/`FanOutServiceAreaAsync`/`RemoveServiceAreaRowsAsync`/`RebuildAsync`). Only the **outbox/queue routing** for an async Elastic feeder is deferred — the seam is shaped so the same change events can later be emitted to an outbox instead of an inline upsert | _none_ | 1) introduce an `outbox` table + a SaveChanges interceptor that captures each maintainer change as an event row in the same transaction; 2) a background feeder (Hangfire/Quartz or a hosted service) reads the outbox and applies to `ElasticNurseSearch`; 3) keep the inline SQL upsert as the projection/fallback so `RebuildAsync` stays the reconciliation path; 4) test that an outbox replay converges to the same rows as the inline path | 🟡 outbox deferred (inline real) |