backend phase 9
This commit is contained in:
@@ -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) |
|
||||
|
||||
|
||||
Reference in New Issue
Block a user