diff --git a/dev/manual-testing/image-1.png b/dev/manual-testing/image-1.png new file mode 100644 index 0000000..9495e33 Binary files /dev/null and b/dev/manual-testing/image-1.png differ diff --git a/dev/manual-testing/image-2.png b/dev/manual-testing/image-2.png new file mode 100644 index 0000000..af4238f Binary files /dev/null and b/dev/manual-testing/image-2.png differ diff --git a/dev/manual-testing/image-3.png b/dev/manual-testing/image-3.png new file mode 100644 index 0000000..d4e9896 Binary files /dev/null and b/dev/manual-testing/image-3.png differ diff --git a/dev/manual-testing/image-4.png b/dev/manual-testing/image-4.png new file mode 100644 index 0000000..c4b0a45 Binary files /dev/null and b/dev/manual-testing/image-4.png differ diff --git a/dev/manual-testing/image.png b/dev/manual-testing/image.png new file mode 100644 index 0000000..74fb9af Binary files /dev/null and b/dev/manual-testing/image.png differ diff --git a/dev/manual-testing/improvement-and-fix-1.md b/dev/manual-testing/improvement-and-fix-1.md new file mode 100644 index 0000000..9260819 --- /dev/null +++ b/dev/manual-testing/improvement-and-fix-1.md @@ -0,0 +1,66 @@ +![alt text](image.png) +in the login page, the main card has a big border radius number which should be less, and in fact for all papers that should happen + +the options under the main paper should move inside the paper and have a better positioning + +there is no need for the top bar in the login page, +the language and theme switch also should be only in the settings, not in every top bar in the app, + +the icon sets should be totally replaced with a more modern icon set, and the icon mappign should be realted. + +the default route should be dynamically deteceted, +i mean now whan i open the app and go to /fa or /en +it stays there and just a loading is displayed to me but it should be infered based on the user role and navigate the user to the right path +and if the + +i get this error when i enter the phone number and submit the form +# error start +## Error Type +Console Error + +## Error Message +Functions are not valid as a React child. This may happen if you return transformed instead of from render. Or maybe you meant to call this function rather than return it. +

{transformed}

+ + + at p (:null:null) + at OtpStep (src/components/auth/OtpStep.tsx:103:9) + at LoginFlow (src/components/auth/LoginFlow.tsx:52:9) + at LoginScreen (src/app/[locale]/(public-routes)/login/LoginScreen.tsx:12:7) + at Page (src\app\[locale]\(public-routes)\login\page.tsx:12:10) + +## Code Frame + 101 | {t('otp_title')} + 102 | +> 103 | + | ^ + 104 | {t.rich('otp_sent_to', { + 105 | // isolates the masked number so the RTL sent... + 106 | // never reorders the digit/bullet runs around it (the classi... + +Next.js version: 16.2.9 (Turbopack) +# Error End + +![alt text](image-1.png) +after submitting the phone number the otp input ui needs a better spacing, the first two input are stuck together. this is in fa locale, in the en locale its ok. + + +# nurse dashboard Start +![whole nurse dashboard page](image-2.png) +![cards](image-3.png) +![alt text](image-4.png) + +this dashboard should be totally refactorred, +the ui is kinda old and not a modren and with a good ux, +some text has not proper placing and some of them are too large, +the header and the type of accessing to the dashboard is too old style, +there must not be a header like that, and a side drawer, i modern smooth bottom navbar which have only three options ( as the groups of the drawer items are ) and just a forth item for the support and setting. +so each of the gropus of the side bar should have a root page which handles the links to the sub pages. and in those root pages its better to have a summary of that domain. +two notes: +- icons should be changed till here so i don't mention them again +- ui have much work to do to be acceptable. + +there is a horizontal scroll in this page, this should not be like that, +the whole app should be scoped to a mobile size app, i mean if i scretch the chrome responsive mode, the header and the footer should have a maximum width not to strech and fit to the space +no matter what is the device view port, this should be a mobile sized app. all that sidebar in the desktop width is not needed, at least for now, we have to make the app very user friendly with a mobile user mindset + diff --git a/dev/post-phase/manual-testing-plan.md b/dev/post-phase/manual-testing-plan.md new file mode 100644 index 0000000..baf83a0 --- /dev/null +++ b/dev/post-phase/manual-testing-plan.md @@ -0,0 +1,251 @@ +# Balinyaar — Manual Testing Plan + +The flow-by-flow walkthrough for testing the whole product against the **seeded lifecycle demo world**. +Written after the UI/backend/refinement phase chains completed (2026-07-26). Companion docs: + +- **Bring-up**: [refinement/RUNBOOK.md](refinement/RUNBOOK.md) — how to boot API + client, OTP login mechanics. +- **Business rules with numbers**: `product/business/` — what "correct" means; the load-bearing numbers are + repeated inline below. + +> **How to use this doc:** pick a flow section, log in as the named account, follow the steps, compare +> against *Expected*. Anything marked ⚠ **known gap** is pre-existing and filed (REQ-xxx) — record it as +> confirmation, not as a new bug. Anything else that deviates is a genuine finding. + +--- + +## 1. Environment + +| Piece | Where | Notes | +| --- | --- | --- | +| API | `http://localhost:5002` (`cd server && dotnet run --project src/API/Baya.Web.Api/Baya.Web.Api.csproj`) | Boots in Development: migrates + seeds (reference + demo world + **lifecycle world**) against the DB in `appsettings.Development.json` — currently the **remote dev SQL Server** (`87.107.152.16`). Swagger at `/swagger`. | +| Client | `http://localhost:3000` (`cd client && npm run dev`) | `.env.development` already points at `http://localhost:5002`. | +| Crypto keys | `dotnet user-secrets` (from `server/src/API/Baya.Web.Api`) | The committed config carries `SET_VIA_USER_SECRETS_OR_ENV` placeholders. The remote DB's data is encrypted under the **dev keys** — any machine booting against it must set the same four user-secrets: `IdentitySettings:SecretKey` = `dev-only-jwe-signing-key-not-for-production-0123456789abcdef`, `IdentitySettings:Encryptkey` = `dev-only-16bytes`, `Seams:FieldEncryption:Key` = `local-dev-field-encryption-key-not-for-production`, `Seams:FieldEncryption:HashKey` = `local-dev-field-hash-key-not-for-production`. Booting with *different* field keys makes every phone lookup miss and every PII read throw `Padding is invalid`. | +| OTP codes | server console (`MOCK SMS — OTP code …`) or `GET /api/v1/dev/last_otp/{phone}` | Resend window 120 s, 5 wrong attempts per code, OTP valid 60 s. The OTP endpoints are rate-limited per IP — hammering logins in scripts will 429. | +| Reset | drop the `Baya` DB (`dotnet ef database drop --force --project src/Infrastructure/Baya.Infrastructure.Persistence --startup-project src/API/Baya.Web.Api`), reboot | Everything reseeds. Needed when the **time-relative scenarios age out** (see §3 note). | + +--- + +## 2. Test accounts + +All log in via phone-OTP at `/login`. Any listed account works immediately. + +| Phone | Actor | Who | Carries | +| --- | --- | --- | --- | +| `09120000001` | nurse (verified) | زهرا عزیزی | 4 variants (incl. the 5-session package), whole-city+2-district coverage, ★5.00 (1 published review), 3 completed bookings, a pending request in the inbox, the in-progress visit today, earnings incl. a **paid** payout and a **pending clawback** | +| `09120000002` | nurse (verified) | علی کریمی | Partner-sponsored (مرکز پرستاری آرامش), 2 completed bookings (one BNPL), a **draft** payout waiting, a hidden 1★ review, an expired request | +| `09120000003` | nurse (unverified) | مریم احمدی | Mid-pipeline verification (KYC+Shahkar passed, MoH license in review w/ document, criminal record pending) — **never searchable** | +| `09120000010` | customer | سارا محمدی | 2 patients, 1 address, 6 bookings across all states, pending/accepted/rejected requests, tickets, notifications, care records | +| `09120000011` | customer | رضا حسینی | 1 infant patient, 2 completed bookings (one BNPL w/ processing refund), expired + cancelled requests, a closed support ticket | +| `09120000020` | admin (`super_admin`) | نگار مدیری | Full `/admin` console incl. RBAC | +| `09120000021` | admin (`finance`) | کامران مالی | `/admin` scoped to money consoles (`useAdminCapabilities`) | +| `09120000030` | partner owner | بهنام رستگار | Owns the merchant-of-record center; **navigate to `/partner` manually** after login (⚠ REQ-038 — no `/me` partner signal) | +| any new `09xxxxxxxxx` | fresh user | — | Registers on first OTP verify → `/select-role` → onboarding. Use for first-run testing. | + +--- + +## 3. The seeded world at a glance + +Booking scenarios (all Tehran; seed-time-relative — **T0 = the boot that first seeded**): + +| Key | Who | State | Money / attachments | +| --- | --- | --- | --- | +| pending request | محمدی → عزیزی | `pending_nurse_response`, 24 h countdown | nurse-inbox + C5 tracker fodder | +| accepted request | محمدی → عزیزی | `accepted_awaiting_payment` (24 h demo window, not the real 30 min) | checkout entry; **1 failed card attempt** (wallet history) | +| rejected / expired / cancelled requests | mixed | terminal request states | C5 terminal cards + inbox tabs | +| B1 `upcoming` | محمدی × عزیزی | confirmed, scheduled T0+3d, care instructions written | paid (card), invoice issued, coordination ticket | +| B2 `in_progress` | محمدی × عزیزی | 5-session package: 2 done, **session 3 checked-in now**, 2 scheduled | paid, emergency ticket logged | +| B3 `completed_in_window` | محمدی × عزیزی | completed T0−1d, dispute window open until T0+2d | review **pending moderation**, care record | +| B4 `completed_paid` | محمدی × عزیزی | completed T0−10d, **paid out** | ★5 **published** review (punctual/professional), care record | +| B5 `completed_eligible` | حسینی × کریمی | completed T0−5d, in the **draft batch** | hidden 1★ review, **out-of-range EVV** (1220 m) | +| B6 `bnpl_settled` | حسینی × کریمی | completed T0−6d, paid via SnappPay (net-of-fee) | **processing `bnpl_revert` 50% refund** (ETA ~10 business days), excluded from payouts, care record | +| B7 `cancelled_refunded` | محمدی × عزیزی | cancelled ≥24 h ahead (standard_24h, 100%) | **succeeded card refund** | +| C8 `clawback` | محمدی × عزیزی | completed T0−11d, paid out, then fully refunded | **pending nurse clawback**, support alert | + +Plus: 9+ tickets (coordination per booking, 3 refund tickets, open support w/ an **internal admin note**, +closed support, emergency), notifications for every persona (mixed read/unread), 4 support alerts +(low-rating / EVV-mismatch / clawback / emergency), a paid + a draft payout batch, invoices for all 8 +bookings (کریمی's resolve to the partner center as merchant-of-record). + +> **Time decay:** the world is date-relative and ages. After ~24 h the pending request expires (the sweep +> runs every minute); after ~2 days B3's dispute window closes and the "review-eligible + in-window" state +> is gone; the accepted request's payment window lasts 24 h. **To refresh the timeline: drop the DB and +> reboot** (seeder guards make an in-place re-run a no-op, not a re-date). + +--- + +## 4. Mock-vs-real map (critical for honest testing) + +15 of 22 client domains hit the real API. **7 still run on in-browser mocks** (`USE_*_MOCK = true` in +`client/src/services/{domain}/constants.ts`) because a blocking backend REQ is deferred. On those screens +the UI shows **mock data regardless of the seeded DB** — test the UI/UX there, and verify the *server* +data through Swagger instead. + +| Mocked domain | Screens affected | Server data still verifiable via | +| --- | --- | --- | +| `verification` | nurse `/nurse/verification/*` journey, admin `/admin/verification` queue | `GET nurse_verification/status` (nurse token), Swagger | +| `refunds` | customer cancel flow + `/bookings/[id]/refund_status`, wallet «استردادها» | `GET /api/v1/refunds/by_booking/{id}`, `GET /api/v1/admin_refunds` | +| `payouts` | nurse `/nurse/earnings*`, admin `/admin/payouts` | `GET /api/v1/nurse_payouts/earnings_balance|history`, `GET /api/v1/admin_payouts/batches` | +| `admin` | `/admin/config|holidays|audit|alerts|roles|users` | b1 admin endpoints in Swagger | +| `bnpl` | the whole D1–D5 BNPL checkout + wallet «اقساط» | `GET /api/v1/admin_bnpl/{id}` | +| `partnerCenter` | `/partner/*` portal + `/admin/partners` | `GET /api/v1/admin/partner-centers` | +| `patientRecords` | E2 family care record editor + nurse visit-note panel | `GET /api/v1/patients/{id}/care_records` | + +**Real domains** (UI = server truth): auth, geography, patients, profiles, nurse-bank, addresses, +serviceAreas, catalog, search, bookingRequests, bookings, payment, reviews (incl. **admin moderation**), +tickets (incl. **admin ticket console**), notifications. + +--- + +## 5. Flow walkthroughs + +### 5.1 Auth & first-run (real) + +| # | Steps | Expected | +| --- | --- | --- | +| A1 | `/login` → fresh phone → request code → wrong code ×5 | Refused after 5 attempts until a fresh code; resend locked 120 s; no reveal whether the phone exists | +| A2 | Fresh phone, correct code | → `/select-role` (no roles yet) → pick خانواده → `/onboarding` wizard (relation → first patient) → customer home | +| A3 | Same phone again later | Straight to customer home (role remembered) | +| A4 | Nurse `09120000001` login | Routed to `/nurse` dashboard (never a flash of the customer shell) | +| A5 | Admin `09120000020` vs `09120000021` | Both land `/admin`; finance sees **only** money consoles in the sidebar | +| A6 | Deep link while logged out (e.g. `/bookings`) | → `/login?next=/bookings` → after login lands back on `/bookings` | +| A7 | Logged out, open `/` | Public landing (`/welcome` content, URL stays `/`) — ⚠ verify via `npm run build && npm start` (dev-server root-path quirk, ui-phase-13) | + +### 5.2 Customer — discovery & booking request (real) + +Login: `09120000010`. + +| # | Steps | Expected | +| --- | --- | --- | +| C1 | Home → search entry → category سالمند, region Tehran/whole city | Live count CTA > 0; unverified مریم احمدی **never** appears in any result | +| C2 | Results | عزیزی shows ★5.00 (1 نظر), 3 visits, tappable verified badge; district-3 search still shows her whole-city rows | +| C3 | Gender filter مرد | Only کریمی remains | +| C4 | Open عزیزی's profile | Trust dossier: badge → verification panel (credential **types only**, never numbers); services/prices tab; «نظرات» tab shows exactly **1** published review with tags — the pending + hidden ones never | +| C5 | «درخواست رزرو» → C4 form: patient حسن, address, tomorrow, morning window → submit | Request created; C5 tracker shows a **24 h** countdown ring | +| C6 | As عزیزی (second browser/profile): `/nurse/requests` | New request at top with urgency countdown; detail shows **only** customer notes + coarse city/district (⚠ stage-1 masked address) | +| C7 | Accept it | Customer C5 flips to accepted + **30-minute** payment window countdown (real window — the seeded one is a 24 h demo exception) | +| C8 | Reject another (with reason) | C5 terminal card + «درخواست دوباره» / «پرستاران مشابه» off-ramps | + +### 5.3 Customer — checkout & payment (real contract, mock PSP) + +| # | Steps | Expected | +| --- | --- | --- | +| P1 | C5 of the seeded accepted request → «پرداخت» → C6 | Nurse identity + `` total (variant price), reconciling breakdown, escrow explainer. ⚠ REQ-046: avatar/badge enrichment may be absent on the real path | +| P2 | «ادامه پرداخت» (card) | Initiate succeeds → browser redirects to `https://mock-psp.local/...` — a **dead page. Expected**: the mock PSP has no UI (real E2E completion needs the Phase-8 real PSP config). | +| P3 | To actually complete a payment: Swagger → `POST /api/v1/bookings/convert` body `{"bookingRequestId": }` with the **customer's** bearer token (Dev-only capture simulator) | Booking appears under «رزروها» as confirmed; coordination ticket auto-opens; invoice issued; nurse gets a notification | +| P4 | Wallet «پرداخت‌ها» | ⚠ REQ-047: **empty on the real path** (no transactions-list endpoint). The seeded failed+succeeded transactions are visible in DB/Swagger only | +| P5 | BNPL branch (D1–D5) | ⚠ Whole flow is client-**mock** (REQ-022/024); `/bookings/checkout/bnpl/gateway` only exists under `npm run dev`. Server-side BNPL truth: `admin_bnpl` via Swagger (B6 is `reverted`) | + +### 5.4 Customer — bookings lifecycle (real) + +| # | Steps | Expected | +| --- | --- | --- | +| L1 | `/bookings` tabs | «در انتظار پاسخ»: pending request w/ countdown · «فعال»: B1, B2 · «گذشته»: B3, B4, B7, C8 | +| L2 | B1 (upcoming) detail | Status timeline at تایید شده; next-session headline T0+3d; `.ics` download; address card | +| L3 | B2 (package) detail | 5 sessions listed: 2 completed, 1 **در حال ویزیت** (presence headline), 2 scheduled | +| L4 | B3 review CTA | Star strip on the completed row → review form (context recap + moderation note) — but B3 already carries a pending review, so expect the **"under review"** state, never a second form | +| L5 | B4 | «مشاهده نظر» shows the published ★5 review state | +| L6 | B1 → لغو رزرو | Off-ramps first; tier disclosure: **≥48 h → 100%، ≥24 h → 50%، <24 h → 0%** (server tiers) — ⚠ the UI copy states a single 24 h boundary (REQ-065 divergence; record the exact numbers shown). ⚠ the actual cancel + refund-status screens run on the **mock** refunds domain — server truth via Swagger | +| L7 | B7 → وضعیت استرداد | ⚠ mock domain: UI shows mock stages. Server truth: refund `succeeded`, channel `psp_card` (`GET refunds/by_booking/{B7}`) | +| L8 | `/bookings/[B1]/invoice` | Real invoice: sequential number, VAT = 10% **on the commission line only**, مودیان state `pending`. ⚠ REQ-049: payment-method/reference/seller-fiscal block may be absent | + +### 5.5 Customer — account, care circle, tickets, notifications + +| # | Steps | Expected | +| --- | --- | --- | +| E1 | `/patients` («حلقهٔ مراقبت») | حسن + فاطمه cards; add/edit/archive (real); tap → E2 record | +| E2 | `/patients/[حسن]/record` | ⚠ patientRecords domain is **mock**: meds/routine/tasks + the nurse visit-note history shown are mock data (the 2 real seeded notes are server-side only). Access-denied state testable with the mock's foreign-patient id 8888 | +| E3 | `/addresses` | Real CRUD; map is the canvas stand-in unless `NEXT_PUBLIC_NESHAN_KEY` is set | +| E4 | `/profile` | Identity header (server-masked phone), personal-info edit, language row, emergency-contact card; avatar upload round-trips | +| E5 | `/support/tickets` | Real inbox: refund tickets (`TKT-DEMORF0x`), open support `TKT-DEMOSUP1`, coordination tickets; thread of `TKT-DEMOSUP1` shows customer msg + admin reply + follow-up — **the internal admin note must never appear** | +| E6 | Send a message; send another containing `/fail` | Normal send lands; ⚠ `/fail` sentinel only drives the mock — on the real path it just sends | +| E7 | `/notifications` | Seeded rows, unread-first, day-grouped; mark-read flips the bell count. ⚠ **Notification titles/bodies are English** — server-rendered copy from the real handlers (finding F-4 §7) | + +### 5.6 Nurse — daily ops (real except earnings) + +Login: `09120000001`. + +| # | Steps | Expected | +| --- | --- | --- | +| N1 | `/nurse` dashboard | Next-visit card (B2 today), requests strip (pending w/ countdown), earnings snapshot (⚠ mock numbers — payouts domain), activation checklist collapsed to «فعال در جستجو» | +| N2 | `/nurse/visits` | Today's feed shows B2 session 3 **checked-in** → the check-out CTA is the hero action | +| N3 | Check out (confirm step) | Session completes; EVV panel updates. GPS: set `NEXT_PUBLIC_EVV_MOCK_GPS=in_range` in `client/.env.local` to force a passing location, else real geolocation runs (out-of-tolerance is **advisory** — banner + staff alert, never a block). Completing the **last** session would complete the booking and start its 72 h dispute clock | +| N4 | `/nurse/visits/[B2]` | Address card + map link (⚠ REQ-051: address may stay masked w/ quiet fallback note), gated care card (care instructions **readable** post-confirmation), visit-note composer (⚠ mock — patientRecords) | +| N5 | `/nurse/requests` | Tabs: pending (live countdown) / answered / expired. Accept behind confirm dialog → opens the payment window | +| N6 | `/nurse/earnings` + `/payouts` | ⚠ **Mock domain** — UI shows mock buckets. Server truth (Swagger, nurse token): `earnings_balance` = signed ledger sum (عزیزی: B1+B2+B3 payouts pending — B7 reversed, B4/C8 paid out), `history` shows the **paid** payout w/ masked IBAN | +| N7 | `/nurse/services` | 4 offerings incl. the package variant; edit/deactivate; the accepting-bookings toggle is real (pausing → disappears from search) | +| N8 | `/nurse/coverage` | Whole-city + districts 1&3; duplicate add → 409 message | +| N9 | `/nurse/bank` | Verified account, masked IBAN, ownership state «تأیید شده» | +| N10 | As کریمی (`09120000002`) | Draft-batch earnings waiting; B6's payout share reversed by the 50% refund; hidden review does **not** appear on his public profile (0 reviews) | +| N11 | As احمدی (`09120000003`) | Activation checklist far from complete; `/nurse/verification` — ⚠ **mock domain**: the journey UI shows mock state; the real mid-pipeline case (KYC ✓, Shahkar ✓, license in-review, criminal pending) is server-side (`nurse_verification/status` via Swagger). She must never appear in search | + +### 5.7 Admin console + +Login: `09120000020` (all consoles) and `09120000021` (money only). + +| # | Console | Real/Mock | What the seeded world provides (server-side) | +| --- | --- | --- | --- | +| AD1 | `/admin/reviews` moderation queue | **Real** | B3's pending review — publish it → عزیزی's public aggregate recomputes (★ count 2); hide/reject paths; the hidden 1★ shows the hide flow already happened | +| AD2 | `/admin/tickets` | **Real** | Full queue incl. emergency (red) + refund tickets; thread of `TKT-DEMOSUP1` **shows** the internal note distinctly; composer's internal-note mode goes amber | +| AD3 | `/admin/verification` | ⚠ Mock | Real queue data (احمدی in review + document metadata) via Swagger `admin_verifications`; the document's signed-URL fetch 404s (no real bytes — expected) | +| AD4 | `/admin/payouts` | ⚠ Mock | Real: `admin_payouts/batches` shows the **completed** batch (2 links) + the **draft** batch; `batches/{id}/process` on the draft is the demoable money-out action; `eligible` preview | +| AD5 | Refunds (via ticket RefundPanel) | ⚠ Mock | Real: `admin_refunds` lists 3 refunds; `{id}/confirm_settlement` on the **processing BNPL** refund is the demoable action (posts the clearing leg) | +| AD6 | `/admin/alerts` | ⚠ Mock | Real: 4 seeded alerts (low_rating / evv_location_mismatch / nurse_clawback / emergency) via `support_alerts` endpoints | +| AD7 | `/admin/config`, `/admin/holidays`, `/admin/audit`, `/admin/users`, `/admin/roles` | ⚠ Mock | Real: b1 endpoints (config list incl. the §6 numbers, audit trail rows for every seeded money/trust entity — actor `null` = system) | +| AD8 | `/admin/partners` | ⚠ Mock | Real: `admin/partner-centers` shows مرکز پرستاری آرامش (MoR, verified, masked IBAN), کریمی sponsored | + +### 5.8 Partner portal (⚠ mock — REQ-032/033/038) + +Login `09120000030` → navigate to `/partner` manually. UI runs on mock center data; the real center + +MoR invoice resolution (کریمی's two invoices carry `issuing_entity_type = partner_center`) is verifiable +via Swagger. Test the portal chrome, the MoR settlement view, and the CSV export against mock data. + +--- + +## 6. The numbers the UI must respect + +From seeded `platform_configs` (admins can change them; rates are **snapshotted** — a change never +re-prices an existing booking/invoice/deadline): + +| Rule | Value | +| --- | --- | +| Nurse response deadline | **24 h** (frozen per request) | +| Payment window after accept | **30 min** (frozen per request) | +| Dispute window after completion | **72 h** → then payout-eligible | +| Platform commission | **15%** of gross (`gross = commission + payout`, always) | +| VAT | **10%**, on the **commission line only** | +| Cancellation tiers | ≥48 h → 100% · ≥24 h → 50% · <24 h → 0% (⚠ client copy says a single 24 h boundary — REQ-065) | +| BNPL provider fee | **7%** — a **platform expense**; the nurse payout is payment-method-invariant | +| BNPL refund ETA | ~**10 business days** (Fridays skipped); UI copy says 7–10 | +| EVV tolerance | **200 m**, advisory only | +| No-show threshold | **60 min** after scheduled start | +| Payout cadence | weekly generation (auto, draft); **processing stays a manual admin action** | +| Review alert | rating ≤ **2** raises a staff alert | +| OTP | resend 120 s · 5 attempts · code lives 60 s · session 30 d · access token 60 min | + +--- + +## 7. Findings from this bring-up (decide / track) + +Recorded while building the seed — each needs an owner decision: + +| # | Finding | State | +| --- | --- | --- | +| F-1 | **Committed real DB credential**: `appsettings.Development.json` ships the remote server's SQL login in git (the exact thing refinement-phase-5 removed; the pre-commit hook that blocks it isn't enabled on this clone). Recommendation: move the connection strings to user-secrets like the crypto keys and restore placeholders. | **Open — decision needed** | +| F-2 | **`/me` hardcoded `hasCustomerProfile`/`hasNurseProfile`/`nurseVerificationStatus`** to `false`/`not_started` for everyone (broke the customer home profile nudge + profile-hook invalidations). | **Fixed** this session (snapshot projection + `ToMeResult`) | +| F-3 | **Stored Persian text is normalized destructively**: an `ApplicationDbContext` save hook runs `Fa2En().FixPersianChars()` over every string property — Persian digits become ASCII and **ZWNJ becomes a space** in everything persisted (e.g. «شبانه‌روزی» → «شبانه روزی»), contradicting the client's STYLE.md ZWNJ rules. Any Persian-literal equality against stored data silently misses. | **Open — decision needed** (legacy starter behavior; removing it changes stored data shape) | +| F-4 | **Notification copy is English on the real path** — the b-phase handlers write `"Booking confirmed"`-style titles/bodies straight into `ops.Notifications`; the Persian UI renders them verbatim. | **Open — decision needed** (server-side i18n or client-side type→copy mapping) | +| F-5 | Mock PSP redirect goes to a dead host (`mock-psp.local`) — in-browser card completion impossible until a real PSP is configured (`Payments:Provider=zarinpal`); the Dev `bookings/convert` affordance is the workaround (§5.3 P3). | By design (refinement-phase-8 rails exist behind config) | +| F-6 | 7 client domains still mocked (§4) — the biggest lever for "test everything for real" is flipping `verification`, `refunds`, `payouts` (user halves are contract-ready; the blocking REQs are admin-side). Flipping a shared flag turns the admin half's screens into real-API screens against missing endpoints. | **Open — decision needed** (split flags per half, or deliver the admin REQs) | +| F-7 | Seeded object-storage keys (verification document, invoice PDFs) have no real bytes — signed-URL fetches 404 in admin viewers. | Accepted for dev (never fake bytes) | + +--- + +## 8. Suggested test order + +1. **Auth & routing** (§5.1) — everything depends on it. +2. **Customer discovery → request → accept → convert → booking** (§5.2–5.4) — the core funnel, all real. +3. **Nurse ops** (§5.6) — inbox/EVV against the same bookings. +4. **Reviews + tickets + notifications** (§5.4 L4/L5, §5.5 E5–E7) — real, seeded both sides. +5. **Admin real consoles** (§5.7 AD1/AD2) — moderation + tickets. +6. **Mock-domain UI passes** (§5.3 P5, §5.5 E2, §5.6 N6/N11, §5.7 AD3–AD8, §5.8) — UX only, verify server truth via Swagger as listed. +7. Sweep §7's decisions. diff --git a/dev/post-phase/refinement/RUNBOOK.md b/dev/post-phase/refinement/RUNBOOK.md index 5a4669a..60ee5e6 100644 --- a/dev/post-phase/refinement/RUNBOOK.md +++ b/dev/post-phase/refinement/RUNBOOK.md @@ -86,6 +86,15 @@ demo nurses with priced variants + Tehran coverage (and therefore real `nurse_se customers with patients and addresses. The console logs `Demo world seeded: 3 nurse(s), 2 customer(s)…` (or `already seeded — no-op` on a subsequent run). It is **idempotent** and **never runs outside Development**. +On top of that, the **lifecycle seeder** (`DemoLifecycleSeeder`) populates every flow's mid-state so manual +testing never starts from an empty world: booking requests in every status, 8 bookings across every reachable +state (incl. a 5-session package mid-engagement and a cancelled-and-refunded one), the balanced ledger, three +refunds (card-succeeded / BNPL-processing / post-payout clawback), a **paid** and a **draft** payout batch, +reviews in all moderation states, tickets, notifications, patient care records, a merchant-of-record partner +center, and a mid-pipeline verification case. The console logs `Demo lifecycle seeded: …` (or `already +seeded — no-op`). The full scenario map + per-flow walkthroughs live in +[`dev/post-phase/manual-testing-plan.md`](../manual-testing-plan.md). + ### Terminal 2 — frontend (from `client/`) ```bash @@ -114,6 +123,7 @@ endpoint works). Use them to see the real path populated: | `09120000011` | customer | رضا حسینی (male) | 1 patient, 1 Tehran address | | `09120000020` | admin (`super_admin`) | نگار مدیری (female) | full backoffice — **lands on `/admin`**, sees every console incl. RBAC | | `09120000021` | admin (`finance`) | کامران مالی (male) | scoped backoffice — lands on `/admin`, sidebar shows only the money consoles (`useAdminCapabilities` gating) | +| `09120000030` | partner-center owner | بهنام رستگار (male) | owns مرکز پرستاری آرامش (merchant-of-record, sponsors علی کریمی). No admin/nurse role — log in, then navigate to `/partner` manually (REQ-038: no `/me` partner signal yet) | > The old username+password `admin`/`qw123321` account is **no longer auto-seeded** (refinement-phase-5 — > no committed credential). To bootstrap a break-glass username+password admin, set both secrets before boot, diff --git a/server/CLAUDE.md b/server/CLAUDE.md index 9aadb5f..2f385fb 100644 --- a/server/CLAUDE.md +++ b/server/CLAUDE.md @@ -677,6 +677,22 @@ action to `sender.Send(...)`. Full conventions are in [CONVENTIONS.md](CONVENTIO guarding each persona on its phone number so re-runs are a no-op. Invoked via `SeedDemoWorldAsync()` **only under `IsDevelopment()`** — never in Production/Staging. The demo world (phones, which nurse is verified) is in `dev/post-phase/refinement/RUNBOOK.md`. +- **Development lifecycle seeder (manual-testing bring-up).** `Persistence/Services/Seeding/DemoLifecycleSeeder.cs` + (+ `.Money.cs`/`.Social.cs` partials + `DemoLifecycleDefinitions.cs`) layers a full **lifecycle** world on the + demo personas so every flow is manually testable: booking requests in every status, 8 bookings across every + reachable state (upcoming w/ care instructions, a 5-session package mid-engagement with EVV, completed + inside/past the dispute window, BNPL-settled, cancelled-with-refund, clawed-back), the balanced payment + ledger behind each (via `LedgerPosting`), refunds on all three forks, a **paid** and a **draft** payout batch + (dispatched through the real `GeneratePayoutBatch`/`ExecutePayoutBatch` commands), moderated reviews + + recomputed nurse aggregates, tickets (incl. an `is_internal` note + coordination tickets via the real + command), notifications, patient care records, a merchant-of-record partner center (portal user + `09120000030`, linked to the second nurse), and a mid-pipeline verification case for the unverified nurse. + States are reached through the entities' guarded transition methods + `BookingFactory` (Application grants + `InternalsVisibleTo` to Persistence for this); business timestamps are backdated explicitly. Idempotent per + scenario on natural keys — **never guard on a Persian string**: the `ApplicationDbContext` save hook + normalizes Persian digits/ZWNJ in every stored string, so a Persian literal never round-trips equal. + Invoked via `SeedDemoLifecycleAsync()` after the demo-world + gateway seeds, Development-only. Scenario + table + testing plan: `dev/post-phase/manual-testing-plan.md`. --- diff --git a/server/src/API/Baya.Web.Api/Program.cs b/server/src/API/Baya.Web.Api/Program.cs index 537051b..06d140b 100644 --- a/server/src/API/Baya.Web.Api/Program.cs +++ b/server/src/API/Baya.Web.Api/Program.cs @@ -131,6 +131,7 @@ if (args.Any(a => string.Equals(a, "migrate", StringComparison.OrdinalIgnoreCase { await app.SeedPaymentGatewaysAsync(); await app.SeedDemoWorldAsync(); + await app.SeedDemoLifecycleAsync(); } return; } @@ -142,12 +143,14 @@ if (!app.Environment.IsEnvironment("Testing")) if (app.Environment.IsDevelopment()) { // Local convenience: apply migrations + seed on boot. Development-only: a sandbox payment gateway - // (all-zeros merchant id) and a demo marketplace (nurses/variants/search rows, customers/patients) so the - // real-path screens aren't empty. Neither belongs in a deployed DB — both are idempotent. + // (all-zeros merchant id), a demo marketplace (nurses/variants/search rows, customers/patients), and + // the lifecycle demo world (bookings/money/reviews/tickets in every state) so the real-path screens + // aren't empty. None of it belongs in a deployed DB — all are idempotent. await app.ApplyMigrationsAsync(); await app.SeedDefaultUsersAsync(); await app.SeedPaymentGatewaysAsync(); await app.SeedDemoWorldAsync(); + await app.SeedDemoLifecycleAsync(); } else { diff --git a/server/src/Core/Baya.Application/Baya.Application.csproj b/server/src/Core/Baya.Application/Baya.Application.csproj index 7481fea..ee4e4bf 100644 --- a/server/src/Core/Baya.Application/Baya.Application.csproj +++ b/server/src/Core/Baya.Application/Baya.Application.csproj @@ -12,6 +12,9 @@ + + diff --git a/server/src/Core/Baya.Application/Features/Identity/IdentityDefaults.cs b/server/src/Core/Baya.Application/Features/Identity/IdentityDefaults.cs index c4279bd..86e5fca 100644 --- a/server/src/Core/Baya.Application/Features/Identity/IdentityDefaults.cs +++ b/server/src/Core/Baya.Application/Features/Identity/IdentityDefaults.cs @@ -79,7 +79,7 @@ internal static class IdentityDefaults snapshot.Gender, snapshot.IsActive, snapshot.Roles, - HasCustomerProfile: false, - HasNurseProfile: false, - NurseVerificationStatus: MeResult.VerificationNotStarted); + snapshot.HasCustomerProfile, + snapshot.HasNurseProfile, + snapshot.NurseVerificationStatus); } \ No newline at end of file diff --git a/server/src/Core/Baya.Application/Models/Identity/UserAccountSnapshot.cs b/server/src/Core/Baya.Application/Models/Identity/UserAccountSnapshot.cs index 50331e3..7b85be2 100644 --- a/server/src/Core/Baya.Application/Models/Identity/UserAccountSnapshot.cs +++ b/server/src/Core/Baya.Application/Models/Identity/UserAccountSnapshot.cs @@ -1,7 +1,9 @@ #nullable enable namespace Baya.Application.Models.Identity; -/// Raw account projection for the current user (phone unmasked — handlers mask it). +/// Raw account projection for the current user (phone unmasked — handlers mask it). The profile +/// flags/verification code default to the "nothing yet" shape so test doubles that only care about the +/// account core don't have to state them. public record UserAccountSnapshot( int Id, string? Phone, @@ -9,4 +11,7 @@ public record UserAccountSnapshot( string? LastName, string? Gender, bool IsActive, - IReadOnlyList Roles); \ No newline at end of file + IReadOnlyList Roles, + bool HasCustomerProfile = false, + bool HasNurseProfile = false, + string NurseVerificationStatus = "not_started"); \ No newline at end of file diff --git a/server/src/Core/Baya.Domain/Entities/Identity/NurseProfile.cs b/server/src/Core/Baya.Domain/Entities/Identity/NurseProfile.cs index 4da3b09..b72fc38 100644 --- a/server/src/Core/Baya.Domain/Entities/Identity/NurseProfile.cs +++ b/server/src/Core/Baya.Domain/Entities/Identity/NurseProfile.cs @@ -64,4 +64,10 @@ public class NurseProfile : BaseEntity AverageRating = averageRating; TotalReviews = totalReviews; } + + /// The single sanctioned write path for the completed-bookings aggregate — recomputed from + /// source (completed bookings rows), never accepted from a request. Mirrors + /// ; consumed by the search projection and the public profile. + public void SetCompletedBookings(int totalCompletedBookings) + => TotalCompletedBookings = totalCompletedBookings; } diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/UserAccountRepository.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/UserAccountRepository.cs index 66730df..911e748 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/UserAccountRepository.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/UserAccountRepository.cs @@ -1,6 +1,8 @@ using Baya.Application.Contracts.Persistence; using Baya.Application.Models.Identity; +using Baya.Domain.Entities.Identity; using Baya.Domain.Entities.User; +using Baya.Domain.Entities.Verification; using Baya.Infrastructure.Persistence.Repositories.Common; using Microsoft.EntityFrameworkCore; @@ -12,20 +14,48 @@ internal class UserAccountRepository : BaseAsyncRepository, IUserAccountRe { } - public Task GetAccountSnapshotAsync(int userId, CancellationToken cancellationToken) + public async Task GetAccountSnapshotAsync(int userId, CancellationToken cancellationToken) { // UserRoles carries a RevokedAt-is-null global filter, so revoked grants never surface here. - return TableNoTracking + // The profile flags/verification status are correlated subqueries (soft-delete filters apply), and + // the enum→code mapping happens after materialization — ToCode() is not SQL-translatable. + var row = await TableNoTracking .Where(u => u.Id == userId) - .Select(u => new UserAccountSnapshot( + .Select(u => new + { u.Id, u.PhoneNumber, u.Name, u.FamilyName, u.Gender, u.IsActive, - u.UserRoles.Select(ur => ur.Role.Name).ToList())) + Roles = u.UserRoles.Select(ur => ur.Role.Name).ToList(), + HasCustomerProfile = DbContext.Set().Any(p => p.UserId == u.Id), + HasNurseProfile = DbContext.Set().Any(p => p.UserId == u.Id), + VerificationStatus = DbContext.Set() + .Where(v => v.Nurse.UserId == u.Id) + .OrderByDescending(v => v.Id) + .Select(v => (VerificationStatus?)v.Status) + .FirstOrDefault() + }) .FirstOrDefaultAsync(cancellationToken); + + if (row is null) + return null; + + return new UserAccountSnapshot( + row.Id, + row.PhoneNumber, + row.Name, + row.FamilyName, + row.Gender, + row.IsActive, + row.Roles, + HasCustomerProfile: row.HasCustomerProfile, + HasNurseProfile: row.HasNurseProfile, + NurseVerificationStatus: row.VerificationStatus is { } status + ? status.ToCode() + : MeResult.VerificationNotStarted); } public Task GetTrackedByIdAsync(int userId, CancellationToken cancellationToken) diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/ServiceConfiguration/ServiceCollectionExtensions.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/ServiceConfiguration/ServiceCollectionExtensions.cs index e186123..d6ac101 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Persistence/ServiceConfiguration/ServiceCollectionExtensions.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/ServiceConfiguration/ServiceCollectionExtensions.cs @@ -88,9 +88,10 @@ public static class ServiceCollectionExtensions throw new NotSupportedException( $"Search backend '{searchBackend}' is not available — only 'sql' is implemented (Elasticsearch is deferred)."); - // Development-only demo marketplace seeder. Registered always (cheap), but invoked from Program.cs + // Development-only demo seeders. Registered always (cheap), but invoked from Program.cs // only under IsDevelopment() — never against a production/staging DB. services.AddScoped(); + services.AddScoped(); return services; } @@ -165,4 +166,18 @@ public static class ServiceCollectionExtensions var seeder = scope.ServiceProvider.GetRequiredService(); await seeder.SeedAsync(CancellationToken.None); } + + /// + /// Seeds the Development-only lifecycle demo world (booking requests in every status, bookings in + /// every reachable state with their balanced ledger, refunds/payouts/reviews/tickets/notifications, a + /// partner center, a mid-pipeline verification case) on top of 's + /// personas. Idempotent per scenario. Must run after the demo world + payment-gateway seeds; the + /// same IsDevelopment() gating rule applies. + /// + public static async Task SeedDemoLifecycleAsync(this WebApplication app) + { + await using var scope = app.Services.CreateAsyncScope(); + var seeder = scope.ServiceProvider.GetRequiredService(); + await seeder.SeedAsync(CancellationToken.None); + } } \ No newline at end of file diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Seeding/DemoLifecycleDefinitions.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Seeding/DemoLifecycleDefinitions.cs new file mode 100644 index 0000000..08cd3b4 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Seeding/DemoLifecycleDefinitions.cs @@ -0,0 +1,103 @@ +#nullable enable +namespace Baya.Infrastructure.Persistence.Services.Seeding; + +/// +/// The static, deterministic description of the Development lifecycle demo world the +/// materialises on top of 's personas: +/// which booking scenario exists in which state, the fixed ticket reference codes, and the one extra +/// partner-center persona. Kept as plain data so the exact scenario table reads at a glance and stays the +/// single source of truth for the runbook / testing plan. +/// +/// All timestamps are expressed as day/hour offsets from seed time (negative = past) because the +/// scenario meanings are relative ("completed inside the 72h dispute window"), not absolute dates. +/// +/// +internal static class DemoLifecycleDefinitions +{ + /// The partner-center dashboard owner (no admin/nurse role — the portal keys off the center's + /// admin_user_id; the customer role only gives the login a sane landing). + public const string PartnerAdminPhone = "09120000030"; + public const string PartnerAdminUserName = "demo_partner_rastegar"; + public const string PartnerAdminName = "بهنام"; + public const string PartnerAdminFamilyName = "رستگار"; + + public const string PartnerCenterName = "مرکز پرستاری آرامش"; + public const string PartnerCenterPermitNo = "MOH-C-1001"; // the natural idempotency key + public const string PartnerCenterEnamad = "ENAMAD-DEMO-771"; + + /// The extra 5-session package variant seeded for the multi-session booking scenario + /// (PostSurgery × Night so its option-set hash can never collide with the base seeder's variants). + public const string PackageVariantDisplayName = "پکیج ۵ جلسه‌ای مراقبت پس از جراحی"; + public const long PackageVariantPricePerSession = 900_000; + public const int PackageVariantSessionCount = 5; + + // Fixed, human-greppable ticket reference codes — UNIQUE-indexed, so they double as idempotency guards. + public const string RefundTicketCard = "TKT-DEMORF01"; + public const string RefundTicketBnpl = "TKT-DEMORF02"; + public const string RefundTicketClawback = "TKT-DEMORF03"; + public const string SupportTicketOpen = "TKT-DEMOSUP1"; + public const string SupportTicketClosed = "TKT-DEMOSUP2"; + public const string EmergencyTicket = "TKT-DEMOEMG1"; + + /// Prefix for the fake Shaparak reference codes (filtered-UNIQUE column — each txn appends its + /// scenario key). + public const string GatewayRefPrefix = "demo-shaparak-"; + + /// + /// The booking scenario table. One row per engagement, keyed by (stable, greppable). + /// Offsets are in days relative to seed time unless noted. A null means the + /// booking never completed. The request behind every booking is converted; the request-only + /// scenarios (pending/accepted/rejected/expired/cancelled) are seeded separately in the seeder. + /// + public static readonly BookingScenario[] Bookings = + [ + // B1 — paid, upcoming, care instructions written; the "what does a fresh confirmed booking look like" case. + new("upcoming", Customer: 0, Nurse: 0, VariantIndex: 0, ScheduledInDays: 3, + ConfirmedDaysAgo: 1, CompletedDaysAgo: null, Cancelled: false, WithCareInstructions: true), + + // B2 — the multi-session package, mid-engagement today: 2 sessions done, session 3 checked-in now. + new("in_progress", Customer: 0, Nurse: 0, VariantIndex: -1 /* the seeded package variant */, ScheduledInDays: 0, + ConfirmedDaysAgo: 4, CompletedDaysAgo: null, Cancelled: false, WithCareInstructions: true), + + // B3 — completed yesterday: dispute window still open, review still pending moderation. + new("completed_in_window", Customer: 0, Nurse: 0, VariantIndex: 2, ScheduledInDays: -1, + ConfirmedDaysAgo: 3, CompletedDaysAgo: 1, Cancelled: false, WithCareInstructions: false), + + // B4 — completed long ago, paid out, carries the published 5★ review. + new("completed_paid", Customer: 0, Nurse: 0, VariantIndex: 0, ScheduledInDays: -10, + ConfirmedDaysAgo: 12, CompletedDaysAgo: 10, Cancelled: false, WithCareInstructions: false), + + // B5 — completed, window closed, waiting in the draft batch; carries the hidden 1★ review + the + // out-of-range EVV check-in that feeds the admin EVV queue. + new("completed_eligible", Customer: 1, Nurse: 1, VariantIndex: 0, ScheduledInDays: -5, + ConfirmedDaysAgo: 7, CompletedDaysAgo: 5, Cancelled: false, WithCareInstructions: false), + + // B6 — paid via BNPL (settled net-of-fee), then disputed: a processing bnpl_revert refund excludes it + // from every payout batch. + new("bnpl_settled", Customer: 1, Nurse: 1, VariantIndex: 1, ScheduledInDays: -6, + ConfirmedDaysAgo: 8, CompletedDaysAgo: 6, Cancelled: false, WithCareInstructions: false), + + // B7 — cancelled ≥24h ahead (standard_24h, 100%): the succeeded card refund. + new("cancelled_refunded", Customer: 0, Nurse: 0, VariantIndex: 2, ScheduledInDays: 2, + ConfirmedDaysAgo: 3, CompletedDaysAgo: null, Cancelled: true, WithCareInstructions: false), + + // C8 — completed + paid out, then refunded: the post-payout clawback case. + new("clawback", Customer: 0, Nurse: 0, VariantIndex: 1, ScheduledInDays: -11, + ConfirmedDaysAgo: 13, CompletedDaysAgo: 11, Cancelled: false, WithCareInstructions: false), + ]; +} + +/// One booking scenario row. / index into +/// /; +/// indexes the nurse's variants in creation order (−1 = the seeded 5-session +/// package variant). +internal sealed record BookingScenario( + string Key, + int Customer, + int Nurse, + int VariantIndex, + int ScheduledInDays, + int ConfirmedDaysAgo, + int? CompletedDaysAgo, + bool Cancelled, + bool WithCareInstructions); diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Seeding/DemoLifecycleSeeder.Money.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Seeding/DemoLifecycleSeeder.Money.cs new file mode 100644 index 0000000..cb97fc1 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Seeding/DemoLifecycleSeeder.Money.cs @@ -0,0 +1,372 @@ +#nullable enable +using Baya.Application.Features.Invoices.Commands.IssueInvoice; +using Baya.Application.Features.Payouts.Commands.ExecutePayoutBatch; +using Baya.Application.Features.Payouts.Commands.GeneratePayoutBatch; +using Baya.Domain.Entities.Bnpl; +using Baya.Domain.Entities.Booking; +using Baya.Domain.Entities.Messaging; +using Baya.Domain.Entities.PartnerCenters; +using Baya.Domain.Entities.Payments; +using Baya.Domain.Entities.Payouts; +using Baya.Domain.Entities.Refunds; +using Baya.Domain.Entities.User; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Baya.Infrastructure.Persistence.Services.Seeding; + +internal sealed partial class DemoLifecycleSeeder +{ + // --------------------------------------------------------------------------------------------------- + // BNPL — the settled provider-financed order behind the "bnpl_settled" booking (net-of-fee model). + // --------------------------------------------------------------------------------------------------- + + private async Task EnsureBnplAsync(BookingWorld bookings, DateTime now, CancellationToken cancellationToken) + { + if (!bookings.ByKey.TryGetValue("bnpl_settled", out var booking)) + return; + + var txn = bookings.TxnByKey["bnpl_settled"]; + if (await db.Set().AnyAsync(b => b.PaymentTransactionId == txn.Id, cancellationToken)) + return; + + var commissionRate = await platformConfig.GetConfig("bnpl_provider_commission_rate", cancellationToken); + var fee = (long)Math.Round(booking.GrossPriceIrr * commissionRate, MidpointRounding.AwayFromZero); + var settledAt = booking.ConfirmedAt ?? now; + + var bnpl = new BnplTransaction + { + PaymentTransactionId = txn.Id, + ProviderCode = BnplProviderCodes.SnappPay, + MerchantOfRecord = "platform", + EligibilityStatus = "approved", + OrderAmountIrr = booking.GrossPriceIrr, + InstallmentCount = 4 + }; + bnpl.MarkTokenIssued("demo-bnpl-token-1", "demo-bnpl-order-1"); + bnpl.MarkVerified("demo-bnpl-order-1", "{\"source\":\"demo-lifecycle-seed\",\"step\":\"verify\"}"); + bnpl.MarkSettled( + settledAmountIrr: booking.GrossPriceIrr - fee, + commissionIrr: fee, + settledAt: settledAt.AddDays(1), + callbackPayloadJson: "{\"source\":\"demo-lifecycle-seed\",\"step\":\"settle\"}"); + db.Set().Add(bnpl); + await db.SaveChangesAsync(cancellationToken); // assigns bnpl.Id for the ledger source ref + + db.Set().AddRange(LedgerPosting.BnplSettle( + booking.Id, booking.NurseId, booking.GrossPriceIrr, booking.BalinyaarCommissionIrr, + booking.NursePayoutAmount, bnplCommissionIrr: fee, bnplTransactionId: bnpl.Id, createdAt: settledAt.AddDays(1))); + await db.SaveChangesAsync(cancellationToken); + } + + // --------------------------------------------------------------------------------------------------- + // Payout batches — dispatched through the REAL commands so eligibility, the first-payout IBAN gate, + // clawback netting, and the payout ledger all come from production code, not a reimplementation. + // --------------------------------------------------------------------------------------------------- + + private async Task EnsurePaidPayoutBatchAsync(BookingWorld bookings, DateTime now, CancellationToken cancellationToken) + { + if (!bookings.ByKey.TryGetValue("completed_paid", out var paidBooking)) + return; + if (await db.Set().AnyAsync(l => l.BookingId == paidBooking.Id, cancellationToken)) + return; + + // periodEnd T0−4d keeps the still-in-draft-window bookings (dispute end T0−2d/−3d) out of this batch. + var generate = await sender.Send( + new GeneratePayoutBatchCommand(Today(now, -21), Today(now, -4)) { SystemInitiated = true }, + cancellationToken); + if (!generate.IsSuccess) + { + logger.LogWarning("Demo lifecycle: paid payout batch generation failed — skipping the paid-batch scenario."); + return; + } + + // The seeder is the only writer at boot, so the newest batch is the one just generated. + var batchId = await db.Set() + .OrderByDescending(b => b.Id) + .Select(b => b.Id) + .FirstAsync(cancellationToken); + + var execute = await sender.Send(new ExecutePayoutBatchCommand(batchId), cancellationToken); + if (!execute.IsSuccess) + logger.LogWarning("Demo lifecycle: executing payout batch {BatchId} failed — it stays unprocessed.", batchId); + } + + private async Task EnsureDraftPayoutBatchAsync(BookingWorld bookings, DateTime now, CancellationToken cancellationToken) + { + if (!bookings.ByKey.TryGetValue("completed_eligible", out var eligibleBooking)) + return; + if (await db.Set().AnyAsync(l => l.BookingId == eligibleBooking.Id, cancellationToken)) + return; + + // Runs AFTER the refunds: the BNPL-refunded booking is genuinely excluded by the real eligibility + // query, and this batch deliberately stays draft so the admin "process" action is demoable. + var generate = await sender.Send( + new GeneratePayoutBatchCommand(Today(now, -3), Today(now, -1)) { SystemInitiated = true }, + cancellationToken); + if (!generate.IsSuccess) + logger.LogWarning("Demo lifecycle: draft payout batch generation failed — skipping the draft-batch scenario."); + } + + // --------------------------------------------------------------------------------------------------- + // Refunds — one per channel/fork: succeeded card, processing BNPL revert, post-payout clawback. + // Every refund is ticket-linked (b15: refunds.ticket_id is always non-null on the real path). + // --------------------------------------------------------------------------------------------------- + + private async Task EnsureRefundsAsync( + PersonaWorld world, BookingWorld bookings, DateTime now, CancellationToken cancellationToken) + { + var admin = world.AdminUserId; + + // 1) Cancelled booking → full (100%) card refund, already succeeded. + if (bookings.ByKey.TryGetValue("cancelled_refunded", out var cancelled) + && !await db.Set().AnyAsync(r => r.BookingId == cancelled.Id, cancellationToken)) + { + var customer = world.Customers[0]; + var ticket = await EnsureRefundTicketAsync( + DemoLifecycleDefinitions.RefundTicketCard, cancelled.Id, customer.UserId, admin, + "استرداد وجه رزرو لغوشده", now.AddDays(-1), cancellationToken); + + var refund = new Refund + { + PaymentTransactionId = bookings.TxnByKey["cancelled_refunded"].Id, + BookingId = cancelled.Id, + RequestedByCustomerId = customer.ProfileId, + TicketId = ticket.Id, + Amount = cancelled.GrossPriceIrr, + PlatformFeeRefundedIrr = cancelled.BalinyaarCommissionIrr, + NursePayoutRefundedIrr = cancelled.NursePayoutAmount, + RefundPercentage = 1.0m, + RefundChannel = RefundChannel.PspCard, + ReasonCategory = "customer_cancelled", + CancellationPolicyCode = cancelled.CancellationPolicyCode, + RefundPercentageApplied = cancelled.CancellationRefundPercentage, + ApprovedByAdminId = admin + }; + refund.MarkSucceededCard("demo-psp-ref-1", now.AddDays(-1)); + db.Set().Add(refund); + await db.SaveChangesAsync(cancellationToken); + + ticket.RefundId = refund.Id; + db.Set().AddRange(LedgerPosting.RefundReversalPrePayout( + cancelled.Id, cancelled.NurseId, refund.PlatformFeeRefundedIrr, refund.NursePayoutRefundedIrr, + refund.Id, now.AddDays(-1))); + db.Set().AddRange(LedgerPosting.RefundPayableClearing( + cancelled.Id, refund.Amount, refund.Id, now.AddDays(-1))); + await db.SaveChangesAsync(cancellationToken); + } + + // 2) Completed BNPL booking disputed → 50% refund via bnpl_revert, still processing (the + // admin confirm_settlement action stays demoable; the ~10-business-day ETA is surfaced). + if (bookings.ByKey.TryGetValue("bnpl_settled", out var bnplBooking) + && !await db.Set().AnyAsync(r => r.BookingId == bnplBooking.Id, cancellationToken)) + { + var customer = world.Customers[1]; + var ticket = await EnsureRefundTicketAsync( + DemoLifecycleDefinitions.RefundTicketBnpl, bnplBooking.Id, customer.UserId, admin, + "اعتراض به کیفیت خدمت و درخواست استرداد", now.AddDays(-1), cancellationToken); + + var amount = bnplBooking.GrossPriceIrr / 2; + var feeLeg = (long)Math.Round(bnplBooking.BalinyaarCommissionIrr * 0.5m, MidpointRounding.AwayFromZero); + var etaBusinessDays = await platformConfig.GetConfig("bnpl_refund_eta_business_days", cancellationToken); + + var refund = new Refund + { + PaymentTransactionId = bookings.TxnByKey["bnpl_settled"].Id, + BookingId = bnplBooking.Id, + RequestedByCustomerId = customer.ProfileId, + TicketId = ticket.Id, + Amount = amount, + PlatformFeeRefundedIrr = feeLeg, + NursePayoutRefundedIrr = amount - feeLeg, + RefundPercentage = 0.5m, + RefundChannel = RefundChannel.BnplRevert, + ReasonCategory = "service_quality", + ApprovedByAdminId = admin + }; + refund.MarkProcessing("demo-revert-1", Today(now, etaBusinessDays + 4)); + db.Set().Add(refund); + await db.SaveChangesAsync(cancellationToken); + + ticket.RefundId = refund.Id; + db.Set().AddRange(LedgerPosting.RefundReversalPrePayout( + bnplBooking.Id, bnplBooking.NurseId, refund.PlatformFeeRefundedIrr, refund.NursePayoutRefundedIrr, + refund.Id, now.AddDays(-1))); + // No clearing leg yet — that posts when the admin confirms the settlement (the demoable action). + + var bnpl = await db.Set() + .FirstOrDefaultAsync(b => b.PaymentTransactionId == refund.PaymentTransactionId, cancellationToken); + bnpl?.MarkReverted("demo-revert-txn-1", amount, providerCommissionReversedAmount: null, + revertedAt: now.AddDays(-1), callbackPayloadJson: "{\"source\":\"demo-lifecycle-seed\",\"step\":\"revert\"}"); + await db.SaveChangesAsync(cancellationToken); + } + + // 3) Paid-out booking refunded afterwards → the post-payout clawback fork (IBAN transfers are + // irreversible, so the nurse's already-paid share becomes a pending receivable). + if (bookings.ByKey.TryGetValue("clawback", out var clawedBooking) + && !await db.Set().AnyAsync(r => r.BookingId == clawedBooking.Id, cancellationToken)) + { + var customer = world.Customers[0]; + var ticket = await EnsureRefundTicketAsync( + DemoLifecycleDefinitions.RefundTicketClawback, clawedBooking.Id, customer.UserId, admin, + "شکایت پس از پرداخت و درخواست استرداد", now.AddHours(-20), cancellationToken); + + var refund = new Refund + { + PaymentTransactionId = bookings.TxnByKey["clawback"].Id, + BookingId = clawedBooking.Id, + RequestedByCustomerId = customer.ProfileId, + TicketId = ticket.Id, + Amount = clawedBooking.GrossPriceIrr, + PlatformFeeRefundedIrr = clawedBooking.BalinyaarCommissionIrr, + NursePayoutRefundedIrr = clawedBooking.NursePayoutAmount, + RefundPercentage = 1.0m, + RefundChannel = RefundChannel.PspCard, + ReasonCategory = "service_quality", + ApprovedByAdminId = admin + }; + refund.MarkSucceededCard("demo-psp-ref-2", now.AddHours(-20)); + db.Set().Add(refund); + await db.SaveChangesAsync(cancellationToken); + + ticket.RefundId = refund.Id; + db.Set().AddRange(LedgerPosting.ClawbackReversalPostPayout( + clawedBooking.Id, clawedBooking.NurseId, refund.PlatformFeeRefundedIrr, refund.NursePayoutRefundedIrr, + refund.Id, now.AddHours(-20))); + db.Set().AddRange(LedgerPosting.RefundPayableClearing( + clawedBooking.Id, refund.Amount, refund.Id, now.AddHours(-20))); + + var originalPayoutId = await db.Set() + .Where(l => l.BookingId == clawedBooking.Id) + .Select(l => (long?)l.PayoutId) + .FirstOrDefaultAsync(cancellationToken); + + db.Set().Add(new NurseClawback + { + NurseId = clawedBooking.NurseId, + BookingId = clawedBooking.Id, + RefundId = refund.Id, + OriginalPayoutId = originalPayoutId, + AmountIrr = refund.NursePayoutRefundedIrr + }); + await db.SaveChangesAsync(cancellationToken); + } + } + + private async Task EnsureRefundTicketAsync( + string referenceCode, long bookingId, int customerUserId, int adminUserId, + string subject, DateTime openedAt, CancellationToken cancellationToken) + { + var existing = await db.Set() + .FirstOrDefaultAsync(t => t.ReferenceCode == referenceCode, cancellationToken); + if (existing is not null) + return existing; + + var ticket = new Ticket + { + ReferenceCode = referenceCode, + Subject = subject, + Category = TicketCategory.Refund, + BookingId = bookingId, + OpenedById = customerUserId, + Participants = + [ + new TicketParticipant { UserId = customerUserId, RoleOnTicket = TicketParticipantRole.Customer }, + new TicketParticipant { UserId = adminUserId, RoleOnTicket = TicketParticipantRole.Admin, AddedById = adminUserId } + ], + Messages = + [ + new TicketMessage + { + SenderId = customerUserId, + Body = "سلام، لطفاً وضعیت استرداد وجه این رزرو را بررسی کنید.", + SentAt = openedAt + }, + new TicketMessage + { + SenderId = adminUserId, + Body = "درخواست شما ثبت شد و در حال بررسی است. نتیجه از همین گفتگو اطلاع‌رسانی می‌شود.", + SentAt = openedAt.AddMinutes(45) + } + ] + }; + db.Set().Add(ticket); + await db.SaveChangesAsync(cancellationToken); + return ticket; + } + + // --------------------------------------------------------------------------------------------------- + // Partner center — the licensed merchant-of-record sponsor, linked to the second nurse so the MoR + // invoice resolution and the partner portal have a real subject. + // --------------------------------------------------------------------------------------------------- + + private async Task EnsurePartnerCenterAsync(PersonaWorld world, CancellationToken cancellationToken) + { + var existing = await db.Set() + .FirstOrDefaultAsync(c => c.MohEstablishmentPermitNo == DemoLifecycleDefinitions.PartnerCenterPermitNo, cancellationToken); + if (existing is not null) + return existing.Id; + + var portalUser = await userManager.GetUserByPhoneNumber(DemoLifecycleDefinitions.PartnerAdminPhone); + if (portalUser is null) + { + portalUser = new User + { + UserName = DemoLifecycleDefinitions.PartnerAdminUserName, + PhoneNumber = DemoLifecycleDefinitions.PartnerAdminPhone, + PhoneNumberConfirmed = true, + PhoneVerifiedAt = clock.UtcNow, + Name = DemoLifecycleDefinitions.PartnerAdminName, + FamilyName = DemoLifecycleDefinitions.PartnerAdminFamilyName, + Gender = "male", + IsActive = true + }; + + var createResult = await userManager.CreateUser(portalUser); + if (!createResult.Succeeded) + throw new InvalidOperationException( + $"Demo lifecycle seed failed to create the partner portal user: {string.Join("; ", createResult.Errors.Select(e => e.Description))}"); + + // The portal keys off the center's admin_user_id, not a role; the customer role only gives the + // login a sane landing screen (there is deliberately no partner role in the vocabulary). + var role = await db.Set().FirstAsync(r => r.Name == RoleNames.Customer, cancellationToken); + await userManager.AddUserToRoleAsync(portalUser, role); + } + + var center = new PartnerCenter + { + Name = DemoLifecycleDefinitions.PartnerCenterName, + LegalEntityType = "private_center", + MohEstablishmentPermitNo = DemoLifecycleDefinitions.PartnerCenterPermitNo, + EnamadCode = DemoLifecycleDefinitions.PartnerCenterEnamad, + SettlementIban = "IR120170000000555566667777", // encrypted at rest by the EF converter + IsMerchantOfRecord = true, + CommissionRate = 0.05m, + AdminUserId = portalUser.Id + }; + center.Verify(clock.UtcNow.AddDays(-30)); + db.Set().Add(center); + await db.SaveChangesAsync(cancellationToken); + + // Karimi becomes the center's sponsored nurse — his bookings' invoices resolve to the center as MoR. + world.Nurses[1].Profile.PartnerCenterId = center.Id; + await db.SaveChangesAsync(cancellationToken); + + return center.Id; + } + + // --------------------------------------------------------------------------------------------------- + // Invoices — dispatched through the real IssueInvoice (sequential number, VAT-on-commission, + // merchant-of-record resolution). Idempotent per booking on the real path. + // --------------------------------------------------------------------------------------------------- + + private async Task EnsureInvoicesAsync(BookingWorld bookings, CancellationToken cancellationToken) + { + foreach (var (key, booking) in bookings.ByKey) + { + var result = await sender.Send(new IssueInvoiceCommand(booking.Id), cancellationToken); + if (!result.IsSuccess) + logger.LogDebug("Demo lifecycle: invoice for booking scenario '{Key}' not issued (already issued or ineligible).", key); + } + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Seeding/DemoLifecycleSeeder.Social.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Seeding/DemoLifecycleSeeder.Social.cs new file mode 100644 index 0000000..2c1fe0f --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Seeding/DemoLifecycleSeeder.Social.cs @@ -0,0 +1,485 @@ +#nullable enable +using Baya.Application.Features.Messaging.Commands.AutoCreateCoordinationTicket; +using Baya.Domain.Entities.Booking; +using Baya.Domain.Entities.Messaging; +using Baya.Domain.Entities.Reviews; +using Baya.Domain.Entities.SupportAlerts; +using Baya.Domain.Entities.Verification; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using OpsNotification = Baya.Domain.Entities.Notifications.Notification; + +namespace Baya.Infrastructure.Persistence.Services.Seeding; + +internal sealed partial class DemoLifecycleSeeder +{ + // --------------------------------------------------------------------------------------------------- + // Reviews — one per moderation state, plus the recomputed-from-source nurse aggregates. + // --------------------------------------------------------------------------------------------------- + + private async Task EnsureReviewsAsync( + PersonaWorld world, BookingWorld bookings, DateTime now, CancellationToken cancellationToken) + { + var mohammadi = world.Customers[0]; + var hosseini = world.Customers[1]; + var azizi = world.Nurses[0]; + var karimi = world.Nurses[1]; + var seededAny = false; + + // Published 5★ with tags — the public social proof on the paid-out booking. + if (bookings.ByKey.TryGetValue("completed_paid", out var paid) + && !await db.Set().AnyAsync(r => r.BookingId == paid.Id, cancellationToken)) + { + var review = new Review + { + BookingId = paid.Id, + CustomerProfileId = mohammadi.ProfileId, + NurseProfileId = azizi.Profile.Id, + Rating = 5, + Body = "بسیار دلسوز و حرفه‌ای. سر وقت رسید و همه‌چیز را با حوصله توضیح داد." + }; + review.Moderate(ReviewModerationStatus.Published, null, world.AdminUserId, now.AddDays(-6)); + db.Set().Add(review); + await db.SaveChangesAsync(cancellationToken); // assigns review.Id for the tag links + + var tagIds = await db.Set() + .Where(t => t.Code == ReviewTagCodes.Punctual || t.Code == ReviewTagCodes.Professional) + .Select(t => t.Id) + .ToListAsync(cancellationToken); + foreach (var tagId in tagIds) + db.Set().Add(new ReviewTagLink { ReviewId = review.Id, ReviewTagMasterId = tagId }); + seededAny = true; + } + + // Pending moderation — populates the admin moderation queue; never publicly visible. + if (bookings.ByKey.TryGetValue("completed_in_window", out var inWindow) + && !await db.Set().AnyAsync(r => r.BookingId == inWindow.Id, cancellationToken)) + { + db.Set().Add(new Review + { + BookingId = inWindow.Id, + CustomerProfileId = mohammadi.ProfileId, + NurseProfileId = azizi.Profile.Id, + Rating = 4, + Body = "مراقبت خوب بود، فقط کمی دیر رسیدند." + }); + seededAny = true; + } + + // Hidden 1★ — moderated away; excluded from the public list and the aggregate, feeds the + // low-rating support alert. + if (bookings.ByKey.TryGetValue("completed_eligible", out var eligible) + && !await db.Set().AnyAsync(r => r.BookingId == eligible.Id, cancellationToken)) + { + var review = new Review + { + BookingId = eligible.Id, + CustomerProfileId = hosseini.ProfileId, + NurseProfileId = karimi.Profile.Id, + Rating = 1, + Body = "اصلاً راضی نبودم." + }; + review.Moderate(ReviewModerationStatus.Hidden, "irrelevant_content", world.AdminUserId, now.AddDays(-4)); + db.Set().Add(review); + seededAny = true; + } + + if (seededAny) + await db.SaveChangesAsync(cancellationToken); + + // Aggregates recomputed from source, mirroring RecomputeNurseRating's published-only rule and the + // completed-bookings count — never free-entered numbers. + foreach (var nurse in new[] { azizi, karimi }) + { + var published = await db.Set() + .Where(r => r.NurseProfileId == nurse.Profile.Id && r.ModerationStatus == ReviewModerationStatus.Published) + .GroupBy(_ => 1) + .Select(g => new { Count = g.Count(), Sum = g.Sum(r => r.Rating) }) + .FirstOrDefaultAsync(cancellationToken); + nurse.Profile.SetReviewAggregates( + published is null ? 0m : Math.Round((decimal)published.Sum / published.Count, 2, MidpointRounding.AwayFromZero), + published?.Count ?? 0); + + var completed = await db.Set() + .CountAsync(b => b.NurseId == nurse.Profile.Id && b.Status == BookingStatus.Completed, cancellationToken); + nurse.Profile.SetCompletedBookings(completed); + } + await db.SaveChangesAsync(cancellationToken); + + await EnsureSupportAlertsAsync(bookings, cancellationToken); + } + + /// The staff worklist rows real usage would have raised: the low rating, the out-of-tolerance + /// EVV check-in, the clawback receivable, and the logged emergency. + private async Task EnsureSupportAlertsAsync(BookingWorld bookings, CancellationToken cancellationToken) + { + var seededAny = false; + + async Task EnsureAlertAsync(string type, string severity, string entityType, long entityId, long? bookingId, long? reviewId) + { + if (await db.Set().AnyAsync(a => a.Type == type && a.BookingId == bookingId, cancellationToken)) + return; + db.Set().Add(new SupportAlert + { + Type = type, + Severity = severity, + EntityType = entityType, + EntityId = entityId.ToString(), + BookingId = bookingId, + ReviewId = reviewId + }); + seededAny = true; + } + + if (bookings.ByKey.TryGetValue("completed_eligible", out var eligible)) + { + var review = await db.Set() + .Where(r => r.BookingId == eligible.Id) + .Select(r => (long?)r.Id) + .FirstOrDefaultAsync(cancellationToken); + if (review is { } reviewId) + await EnsureAlertAsync(SupportAlertType.LowRating, SupportAlertSeverity.Medium, "review", reviewId, eligible.Id, reviewId); + await EnsureAlertAsync(SupportAlertType.EvvLocationMismatch, SupportAlertSeverity.Medium, "booking", eligible.Id, eligible.Id, null); + } + + if (bookings.ByKey.TryGetValue("clawback", out var clawed)) + await EnsureAlertAsync(SupportAlertType.NurseClawback, SupportAlertSeverity.High, "booking", clawed.Id, clawed.Id, null); + + if (bookings.ByKey.TryGetValue("in_progress", out var inProgress)) + await EnsureAlertAsync(SupportAlertType.Emergency, SupportAlertSeverity.High, "booking", inProgress.Id, inProgress.Id, null); + + if (seededAny) + await db.SaveChangesAsync(cancellationToken); + } + + // --------------------------------------------------------------------------------------------------- + // Tickets — coordination via the real command; support/emergency threads hand-built (fixed reference + // codes double as the idempotency guards). The internal admin note proves the is_internal boundary. + // --------------------------------------------------------------------------------------------------- + + private async Task EnsureTicketsAsync( + PersonaWorld world, BookingWorld bookings, DateTime now, CancellationToken cancellationToken) + { + foreach (var (key, booking) in bookings.ByKey) + { + var result = await sender.Send(new AutoCreateCoordinationTicketCommand(booking.Id), cancellationToken); + if (!result.IsSuccess) + logger.LogDebug("Demo lifecycle: coordination ticket for '{Key}' not created.", key); + } + + var mohammadi = world.Customers[0]; + var hosseini = world.Customers[1]; + var admin = world.AdminUserId; + + // Open support thread with an is_internal admin note the user views must never surface. + if (!await db.Set().AnyAsync(t => t.ReferenceCode == DemoLifecycleDefinitions.SupportTicketOpen, cancellationToken)) + { + var openedAt = clock.UtcNow.AddDays(-2); + db.Set().Add(new Ticket + { + ReferenceCode = DemoLifecycleDefinitions.SupportTicketOpen, + Subject = "فاکتور رزرو نمایش داده نمی‌شود", + Category = TicketCategory.Support, + OpenedById = mohammadi.UserId, + Participants = + [ + new TicketParticipant { UserId = mohammadi.UserId, RoleOnTicket = TicketParticipantRole.Customer }, + new TicketParticipant { UserId = admin, RoleOnTicket = TicketParticipantRole.Admin, AddedById = admin } + ], + Messages = + [ + new TicketMessage + { + SenderId = mohammadi.UserId, + Body = "سلام، فاکتور رزرو اخیرم در بخش کیف پول باز نمی‌شود.", + SentAt = openedAt + }, + new TicketMessage + { + SenderId = admin, + IsInternal = true, + Body = "یادداشت داخلی: مشکل از سمت صدور فاکتور بود؛ صف مودیان بررسی شود.", + SentAt = openedAt.AddHours(1) + }, + new TicketMessage + { + SenderId = admin, + Body = "سلام! مشکل بررسی شد و فاکتور شما اکنون قابل مشاهده است. ممنون از صبوری‌تان.", + SentAt = openedAt.AddHours(2) + }, + new TicketMessage + { + SenderId = mohammadi.UserId, + Body = "ممنون، حل شد. 🌸", + SentAt = openedAt.AddDays(1) + } + ] + }); + await db.SaveChangesAsync(cancellationToken); + } + + // Closed support thread — the inbox's closed filter and the admin close/reopen actions. + if (!await db.Set().AnyAsync(t => t.ReferenceCode == DemoLifecycleDefinitions.SupportTicketClosed, cancellationToken)) + { + var openedAt = clock.UtcNow.AddDays(-5); + var ticket = new Ticket + { + ReferenceCode = DemoLifecycleDefinitions.SupportTicketClosed, + Subject = "سوال درباره نحوه تغییر شماره تماس", + Category = TicketCategory.Support, + OpenedById = hosseini.UserId, + Participants = + [ + new TicketParticipant { UserId = hosseini.UserId, RoleOnTicket = TicketParticipantRole.Customer }, + new TicketParticipant { UserId = admin, RoleOnTicket = TicketParticipantRole.Admin, AddedById = admin } + ], + Messages = + [ + new TicketMessage + { + SenderId = hosseini.UserId, + Body = "چطور می‌توانم شماره موبایل حسابم را عوض کنم؟", + SentAt = openedAt + }, + new TicketMessage + { + SenderId = admin, + Body = "در حال حاضر تغییر شماره از طریق پشتیبانی انجام می‌شود؛ مدارک هویتی لازم است.", + SentAt = openedAt.AddHours(3) + } + ] + }; + ticket.Close(admin, clock.UtcNow.AddDays(-3)); + db.Set().Add(ticket); + await db.SaveChangesAsync(cancellationToken); + } + + // Emergency aftermath thread (logged by the nurse mid-visit) — the admin queue's red row. + if (bookings.ByKey.TryGetValue("in_progress", out var inProgress) + && !await db.Set().AnyAsync(t => t.ReferenceCode == DemoLifecycleDefinitions.EmergencyTicket, cancellationToken)) + { + var azizi = world.Nurses[0]; + db.Set().Add(new Ticket + { + ReferenceCode = DemoLifecycleDefinitions.EmergencyTicket, + Subject = "گزارش تماس اضطراری حین ویزیت", + Category = TicketCategory.Emergency, + BookingId = inProgress.Id, + OpenedById = azizi.UserId, + Participants = + [ + new TicketParticipant { UserId = azizi.UserId, RoleOnTicket = TicketParticipantRole.Nurse }, + new TicketParticipant { UserId = admin, RoleOnTicket = TicketParticipantRole.Admin, AddedById = admin } + ], + Messages = + [ + new TicketMessage + { + SenderId = azizi.UserId, + Body = "حین ویزیت افت فشار ناگهانی داشتیم؛ با اورژانس تماس گرفتم و وضعیت پایدار شد. خانواده در جریان است.", + SentAt = clock.UtcNow.AddHours(-1) + } + ] + }); + await db.SaveChangesAsync(cancellationToken); + } + } + + // --------------------------------------------------------------------------------------------------- + // Patient care records — the encrypted, patient-scoped longitudinal history (handler-side encryption + // by design: no EF converter on body_encrypted). + // --------------------------------------------------------------------------------------------------- + + private async Task EnsureCareRecordsAsync( + PersonaWorld world, BookingWorld bookings, DateTime now, CancellationToken cancellationToken) + { + var seededAny = false; + + async Task EnsureRecordAsync(long patientId, long? bookingId, long nurseProfileId, string body, string? tasksJson, DateTime recordedAt) + { + if (await db.Set().AnyAsync( + r => r.PatientId == patientId && r.BookingId == bookingId, cancellationToken)) + return; + db.Set().Add(new PatientCareRecord + { + PatientId = patientId, + BookingId = bookingId, + NurseProfileId = nurseProfileId, + BodyEncrypted = encryptor.Encrypt(body), + TaskResultsJson = tasksJson, + RecordedAt = recordedAt + }); + seededAny = true; + } + + var aziziProfileId = world.Nurses[0].Profile.Id; + var karimiProfileId = world.Nurses[1].Profile.Id; + var patientHasan = world.Customers[0].PatientIds[0]; + var patientAmirali = world.Customers[1].PatientIds[0]; + + if (bookings.ByKey.TryGetValue("completed_paid", out var paid)) + await EnsureRecordAsync( + patientHasan, paid.Id, aziziProfileId, + "علائم حیاتی پایدار. قند خون ناشتا ۱۱۰. داروها طبق برنامه مصرف شد؛ اشتها خوب بود.", + "[{\"label\":\"کنترل قند خون\",\"done\":true},{\"label\":\"دادن داروهای صبح\",\"done\":true}]", + now.AddDays(-10).AddHours(4)); + + if (bookings.ByKey.TryGetValue("completed_in_window", out var inWindow)) + await EnsureRecordAsync( + patientHasan, inWindow.Id, aziziProfileId, + "فشار خون ۱۴ روی ۹. کمی بی‌قراری عصر داشتند که با پیاده‌روی کوتاه بهتر شد. زخم پا رو به بهبود است.", + "[{\"label\":\"پانسمان زخم پا\",\"done\":true},{\"label\":\"کنترل فشار خون\",\"done\":true},{\"label\":\"پیاده‌روی عصر\",\"done\":false}]", + now.AddDays(-1).AddHours(4)); + + if (bookings.ByKey.TryGetValue("bnpl_settled", out var bnplBooking)) + await EnsureRecordAsync( + patientAmirali, bnplBooking.Id, karimiProfileId, + "وضعیت عمومی نوزاد خوب. تغذیه منظم انجام شد و دمای بدن طبیعی بود.", + "[{\"label\":\"پایش دمای بدن\",\"done\":true}]", + now.AddDays(-6).AddHours(3)); + + if (seededAny) + await db.SaveChangesAsync(cancellationToken); + } + + // --------------------------------------------------------------------------------------------------- + // Mid-pipeline verification for the unverified nurse — feeds the nurse journey (B3–B6) and the admin + // verification queue. is_verified is deliberately never touched (status != approved). + // --------------------------------------------------------------------------------------------------- + + private async Task EnsureVerificationPipelineAsync(PersonaWorld world, DateTime now, CancellationToken cancellationToken) + { + var ahmadi = world.Nurses[2]; + var verification = await db.Set() + .FirstOrDefaultAsync(v => v.NurseId == ahmadi.Profile.Id, cancellationToken); + if (verification is null) + return; + if (await db.Set().AnyAsync(s => s.NurseVerificationId == verification.Id, cancellationToken)) + return; + + verification.Status = VerificationStatus.InReview; + verification.SubmittedAt = clock.UtcNow.AddDays(-3); + + var stepTypes = await db.Set() + .Where(t => t.IsActive) + .ToDictionaryAsync(t => t.Code, cancellationToken); + + VerificationStep AddStep(string code, VerificationStepStatus status, bool automated, string? responseJson) + { + var step = new VerificationStep + { + NurseVerificationId = verification.Id, + StepTypeId = stepTypes[code].Id, + Status = status, + IsAutomated = automated, + ExternalResponseJson = responseJson, + StartedAt = clock.UtcNow.AddDays(-3), + CompletedAt = status is VerificationStepStatus.Passed ? clock.UtcNow.AddDays(-3).AddMinutes(5) : null + }; + db.Set().Add(step); + return step; + } + + AddStep(VerificationStepTypeCodes.IdentityKyc, VerificationStepStatus.Passed, automated: true, + "{\"source\":\"demo-lifecycle-seed\",\"match\":true,\"confidence\":0.97}"); + AddStep(VerificationStepTypeCodes.ShahkarMatch, VerificationStepStatus.Passed, automated: true, + "{\"source\":\"demo-lifecycle-seed\",\"simOwnerMatch\":true}"); + var licenseStep = AddStep(VerificationStepTypeCodes.MohCompetencyLicense, VerificationStepStatus.InReview, automated: false, null); + AddStep(VerificationStepTypeCodes.CriminalRecord, VerificationStepStatus.Pending, automated: false, null); + await db.SaveChangesAsync(cancellationToken); // assigns step ids for the document FK + + // The uploaded evidence file's metadata. The object-storage bytes deliberately don't exist — the + // admin viewer's signed-URL fetch will 404, which is acceptable for a dev DB (never fake bytes). + db.Set().Add(new VerificationDocument + { + StepId = licenseStep.Id, + ObjectStorageKey = "verification/demo/ahmadi-moh-license.jpg", + IntegrityHash = "demo-integrity-hash", + ContentType = "image/jpeg", + FileSizeBytes = 245_000, + OriginalFileName = "moh-license.jpg", + UploadedByUserId = ahmadi.UserId + }); + await db.SaveChangesAsync(cancellationToken); + } + + // --------------------------------------------------------------------------------------------------- + // Notifications — direct ops rows with backdated CreatedAt, copying the REAL handlers' type/DataJson + // contract so the front-end deep-links resolve. (The real emitters write English copy; mirrored as-is.) + // --------------------------------------------------------------------------------------------------- + + private async Task EnsureNotificationsAsync( + PersonaWorld world, BookingWorld bookings, DateTime now, CancellationToken cancellationToken) + { + var azizi = world.Nurses[0]; + var karimi = world.Nurses[1]; + var mohammadi = world.Customers[0]; + + if (await db.Set().AnyAsync( + n => n.UserId == azizi.UserId && n.Type == "booking_request_received", cancellationToken)) + return; + + var pendingRequestId = await db.Set() + .Where(r => r.Status == BookingRequestStatus.PendingNurseResponse && r.NurseId == azizi.Profile.Id) + .Select(r => (long?)r.Id) + .FirstOrDefaultAsync(cancellationToken); + var acceptedRequest = await db.Set() + .Where(r => r.Status == BookingRequestStatus.AcceptedAwaitingPayment && r.CustomerId == mohammadi.ProfileId) + .Select(r => new { r.Id, r.PaymentDeadlineAt }) + .FirstOrDefaultAsync(cancellationToken); + var expiredRequestId = await db.Set() + .Where(r => r.Status == BookingRequestStatus.ExpiredNoResponse && r.NurseId == karimi.Profile.Id) + .Select(r => (long?)r.Id) + .FirstOrDefaultAsync(cancellationToken); + var publishedReviewId = bookings.ByKey.TryGetValue("completed_paid", out var paid) + ? await db.Set().Where(r => r.BookingId == paid.Id).Select(r => (long?)r.Id).FirstOrDefaultAsync(cancellationToken) + : null; + var upcomingId = bookings.ByKey.TryGetValue("upcoming", out var upcoming) ? upcoming.Id : (long?)null; + + void Add(int userId, string type, string title, string body, string dataJson, DateTime createdAt, bool read) + => db.Set().Add(new OpsNotification + { + UserId = userId, + Type = type, + Title = title, + Body = body, + DataJson = dataJson, + IsRead = read, + ReadAt = read ? new DateTimeOffset(createdAt.AddMinutes(30), TimeSpan.Zero) : null, + CreatedAt = new DateTimeOffset(createdAt, TimeSpan.Zero) + }); + + if (pendingRequestId is { } pendingId) + Add(azizi.UserId, "booking_request_received", "New booking request", + "You have a new booking request awaiting your response.", + $"{{\"booking_request_id\":{pendingId}}}", now.AddHours(-2), read: false); + + if (upcomingId is { } bookingId) + { + Add(mohammadi.UserId, "booking_confirmed", "Booking confirmed", + "Your payment was captured and your booking is confirmed.", + $"{{\"booking_id\":{bookingId}}}", now.AddDays(-1), read: true); + Add(azizi.UserId, "booking_confirmed_nurse", "New confirmed booking", + "A booking has been confirmed and paid. The care instructions and schedule are now available.", + $"{{\"booking_id\":{bookingId}}}", now.AddDays(-1), read: true); + } + + if (acceptedRequest is not null) + Add(mohammadi.UserId, "booking_request_accepted", "Booking request accepted", + "The nurse accepted your request. Pay within the payment window to confirm your booking.", + $"{{\"booking_request_id\":{acceptedRequest.Id},\"payment_deadline_at\":\"{acceptedRequest.PaymentDeadlineAt:O}\"}}", + now.AddHours(-1), read: false); + + if (publishedReviewId is { } reviewId) + Add(mohammadi.UserId, "review_moderated", "Your review was updated", + "Your review is now published.", + $"{{\"reviewId\":{reviewId},\"status\":\"published\"}}", now.AddDays(-6), read: true); + + if (expiredRequestId is { } expiredId) + Add(karimi.UserId, "booking_request_received", "New booking request", + "You have a new booking request awaiting your response.", + $"{{\"booking_request_id\":{expiredId}}}", now.AddDays(-3), read: true); + + await db.SaveChangesAsync(cancellationToken); + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Seeding/DemoLifecycleSeeder.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Seeding/DemoLifecycleSeeder.cs new file mode 100644 index 0000000..c25343f --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Seeding/DemoLifecycleSeeder.cs @@ -0,0 +1,661 @@ +#nullable enable +using Baya.Application.Common; +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Configuration; +using Baya.Application.Contracts.Identity; +using Baya.Application.Contracts.Search; +using Baya.Application.Features.Bookings; +using Baya.Application.Models.Booking; +using Baya.Application.Models.Catalog; +using Baya.Domain.Entities.Booking; +using Baya.Domain.Entities.Catalog; +using Baya.Domain.Entities.Geography; +using Baya.Domain.Entities.Identity; +using Baya.Domain.Entities.Payments; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using BookingEntity = Baya.Domain.Entities.Booking.Booking; + +namespace Baya.Infrastructure.Persistence.Services.Seeding; + +/// +/// Development-only seeder that layers a full lifecycle demo world on top of the +/// personas: booking requests in every status, bookings in every reachable +/// state (upcoming, mid-engagement with EVV, completed inside/past the dispute window, BNPL-settled, +/// cancelled-with-refund, clawed-back), the balanced payment ledger behind each, refunds on every channel, +/// a paid and a draft payout batch, moderated reviews, tickets (including an admin-internal note), +/// notifications, patient care records, a partner center, and a mid-pipeline verification case. +/// +/// Every state is reached through the entities' guarded transition methods and the real money +/// helpers (, ), never by raw status assignment, so +/// the seeded world satisfies the same invariants real usage produces. Payout batches and coordination +/// tickets are produced by dispatching the real Application commands. Timestamps that carry business +/// meaning are backdated explicitly (they are method arguments/settable by design); CreatedAt audit +/// stamps stay at seed time, which no flow depends on. +/// +/// +/// Idempotent per scenario: each aggregate is guarded on a natural key (the request's +/// customer/nurse/variant/date tuple, a ticket's fixed reference code, the partner permit number…), so +/// re-running on an already-seeded DB is a no-op. Callers must gate this on IsDevelopment() and run +/// it after and the payment-gateway seed. +/// +/// +internal sealed partial class DemoLifecycleSeeder( + ApplicationDbContext db, + IAppUserManager userManager, + ISender sender, + IPlatformConfig platformConfig, + IVariantSnapshotSerializer variantSerializer, + ISearchIndexMaintainer searchIndex, + IFieldEncryptor encryptor, + IDateTimeProvider clock, + ILogger logger) +{ + public async Task SeedAsync(CancellationToken cancellationToken) + { + var world = await LoadWorldAsync(cancellationToken); + if (world is null) + { + logger.LogWarning("Demo lifecycle seed skipped — the base demo world (personas/variants) is not present."); + return; + } + + var gateway = await db.Set() + .FirstOrDefaultAsync(g => g.Type == PaymentGatewayType.Standard, cancellationToken); + if (gateway is null) + { + logger.LogWarning("Demo lifecycle seed skipped — no standard payment gateway row (run SeedPaymentGatewaysAsync first)."); + return; + } + + var now = clock.UtcNow.UtcDateTime; + + var packageVariantId = await EnsurePackageVariantAsync(world, cancellationToken); + var newRequests = await EnsureRequestScenariosAsync(world, now, cancellationToken); + var bookings = await EnsureBookingScenariosAsync(world, packageVariantId, gateway.Id, now, cancellationToken); + + var centerId = await EnsurePartnerCenterAsync(world, cancellationToken); + await EnsureBnplAsync(bookings, now, cancellationToken); + + // Order is load-bearing (the world must be *reachable*, not merely constraint-satisfying): + // the paid batch runs BEFORE the refunds exist (so the clawback booking was genuinely paid out first), + // and the draft batch runs AFTER them (so the BNPL-refunded booking is genuinely excluded). + await EnsurePaidPayoutBatchAsync(bookings, now, cancellationToken); + await EnsureRefundsAsync(world, bookings, now, cancellationToken); + await EnsureDraftPayoutBatchAsync(bookings, now, cancellationToken); + + await EnsureReviewsAsync(world, bookings, now, cancellationToken); + await EnsureInvoicesAsync(bookings, cancellationToken); + await EnsureTicketsAsync(world, bookings, now, cancellationToken); + await EnsureCareRecordsAsync(world, bookings, now, cancellationToken); + await EnsureVerificationPipelineAsync(world, now, cancellationToken); + await EnsureNotificationsAsync(world, bookings, now, cancellationToken); + + // Re-derive the search projection last so the seeded rating/completed aggregates reach the index. + var rebuild = await searchIndex.RebuildAsync(cancellationToken); + + if (newRequests == 0 && bookings.CreatedCount == 0) + logger.LogInformation( + "Demo lifecycle already seeded — no-op. Search index re-derived: {Nurses} nurses, {Rows} rows.", + rebuild.NursesProcessed, rebuild.RowsWritten); + else + logger.LogInformation( + "Demo lifecycle seeded: {Requests} request scenario(s), {Bookings} booking scenario(s), partner center #{Center}. " + + "Search index: {Nurses} nurses, {Rows} rows.", + newRequests, bookings.CreatedCount, centerId, rebuild.NursesProcessed, rebuild.RowsWritten); + } + + // --------------------------------------------------------------------------------------------------- + // World loading — resolve the base-seeder personas into ids. Null when the base world isn't there. + // --------------------------------------------------------------------------------------------------- + + internal sealed record CustomerWorld( + int UserId, long ProfileId, IReadOnlyList PatientIds, CustomerAddress PrimaryAddress); + + internal sealed record NurseWorld( + int UserId, NurseProfile Profile, IReadOnlyList Variants, string Gender); + + internal sealed record PersonaWorld( + IReadOnlyList Customers, IReadOnlyList Nurses, int AdminUserId); + + private async Task LoadWorldAsync(CancellationToken cancellationToken) + { + var customers = new List(); + foreach (var persona in DemoWorldDefinitions.Customers) + { + var user = await userManager.GetUserByPhoneNumber(persona.Phone); + if (user is null) + return null; + + var profile = await db.Set() + .FirstOrDefaultAsync(p => p.UserId == user.Id, cancellationToken); + if (profile is null) + return null; + + var patients = await db.Set() + .Where(p => p.CustomerId == profile.Id) + .OrderBy(p => p.Id) + .Select(p => p.Id) + .ToListAsync(cancellationToken); + + var address = await db.Set() + .FirstOrDefaultAsync(a => a.CustomerId == profile.Id && a.IsPrimary, cancellationToken); + if (patients.Count == 0 || address is null) + return null; + + customers.Add(new CustomerWorld(user.Id, profile.Id, patients, address)); + } + + var nurses = new List(); + foreach (var persona in DemoWorldDefinitions.Nurses) + { + var user = await userManager.GetUserByPhoneNumber(persona.Phone); + if (user is null) + return null; + + // Tracked on purpose: the reviews step mutates the aggregates through the guarded setters. + var profile = await db.Set() + .FirstOrDefaultAsync(p => p.UserId == user.Id, cancellationToken); + if (profile is null) + return null; + + var variants = await db.Set() + .Include(v => v.Options) + .Where(v => v.NurseId == profile.Id) + .OrderBy(v => v.Id) + .ToListAsync(cancellationToken); + + nurses.Add(new NurseWorld(user.Id, profile, variants, persona.Gender)); + } + + var admin = await userManager.GetUserByPhoneNumber(DemoWorldDefinitions.Admins[0].Phone); + if (admin is null) + return null; + + return new PersonaWorld(customers, nurses, admin.Id); + } + + // --------------------------------------------------------------------------------------------------- + // The 5-session package variant (the multi-session engagement source). + // --------------------------------------------------------------------------------------------------- + + private async Task EnsurePackageVariantAsync(PersonaWorld world, CancellationToken cancellationToken) + { + var azizi = world.Nurses[0]; + + var shiftGroup = await db.Set() + .Include(g => g.Values) + .FirstAsync(g => g.ServiceCategoryId == null && g.NameEn == DemoWorldDefinitions.ShiftGroupNameEn, cancellationToken); + var nightValueId = shiftGroup.Values.First(v => v.NameEn == DemoWorldDefinitions.ShiftNight).Id; + + // Guarded on the duplicate-listing UNIQUE (nurse × category × option-set hash), not the display + // name: the DbContext save hook normalizes Persian digits/ZWNJ in every stored string, so a Persian + // literal never round-trips equal to what was persisted. + var optionSetHash = OptionSetHash.Compute([(shiftGroup.Id, nightValueId)]); + var existing = await db.Set() + .Where(v => v.NurseId == azizi.Profile.Id + && v.ServiceCategoryId == DemoWorldDefinitions.PostSurgery + && v.OptionSetHash == optionSetHash) + .Select(v => v.Id) + .FirstOrDefaultAsync(cancellationToken); + if (existing != 0) + return existing; + + var variant = new NurseServiceVariant + { + NurseId = azizi.Profile.Id, + ServiceCategoryId = DemoWorldDefinitions.PostSurgery, + Price = DemoLifecycleDefinitions.PackageVariantPricePerSession, + PriceUnit = PriceUnits.PerSession, + SessionCount = DemoLifecycleDefinitions.PackageVariantSessionCount, + DisplayName = DemoLifecycleDefinitions.PackageVariantDisplayName, + OptionSetHash = optionSetHash, + IsActive = true, + Options = new List + { + new() { OptionGroupId = shiftGroup.Id, OptionValueId = nightValueId } + } + }; + + db.Set().Add(variant); + await db.SaveChangesAsync(cancellationToken); + return variant.Id; + } + + // --------------------------------------------------------------------------------------------------- + // Request-only scenarios: pending / accepted-awaiting-payment / rejected / expired / cancelled. + // --------------------------------------------------------------------------------------------------- + + private async Task EnsureRequestScenariosAsync(PersonaWorld world, DateTime now, CancellationToken cancellationToken) + { + var mohammadi = world.Customers[0]; + var hosseini = world.Customers[1]; + var azizi = world.Nurses[0]; + var karimi = world.Nurses[1]; + + var created = 0; + + // Pending: the nurse inbox's live countdown + the customer's C5 tracker. Deadline must be in the + // future or the 1-minute booking_request_expiry job sweeps it before anyone can look at it. + created += await EnsureRequestAsync( + mohammadi, azizi, azizi.Variants[1].Id, mohammadi.PatientIds[0], + requestedDate: Today(now, 5), timeStart: new TimeOnly(9, 0), timeEnd: new TimeOnly(13, 0), + notes: "پدرم به کمک برای جابه‌جایی و مصرف دارو نیاز دارد.", + deadline: now.AddHours(24), configure: null, cancellationToken); + + // Accepted, awaiting payment: the checkout entry point. The real handler computes a 30-minute + // window; the demo freezes 24h so the scenario survives longer than half an hour of testing. + created += await EnsureRequestAsync( + mohammadi, azizi, azizi.Variants[0].Id, mohammadi.PatientIds[0], + requestedDate: Today(now, 7), timeStart: new TimeOnly(8, 0), timeEnd: new TimeOnly(8, 0), + notes: "مراقبت شبانه‌روزی برای دوران نقاهت.", + deadline: now.AddHours(24), + configure: r => r.Accept(paymentDeadlineAt: now.AddHours(24)), cancellationToken); + + // Rejected with a schedule-conflict reason (the C5 re-request off-ramp keys off this wording). + created += await EnsureRequestAsync( + mohammadi, azizi, azizi.Variants[2].Id, mohammadi.PatientIds[1], + requestedDate: Today(now, 1), timeStart: new TimeOnly(10, 0), timeEnd: new TimeOnly(18, 0), + notes: "مراقبت روزانه بعد از عمل زانو.", + deadline: now.AddHours(24), + configure: r => r.Reject("زمان درخواستی با برنامه من تداخل دارد."), cancellationToken); + + // Expired: no nurse response before the (backdated) deadline. + created += await EnsureRequestAsync( + hosseini, karimi, karimi.Variants[0].Id, hosseini.PatientIds[0], + requestedDate: Today(now, 1), timeStart: new TimeOnly(9, 0), timeEnd: new TimeOnly(17, 0), + notes: "مراقبت روزانه در منزل.", + deadline: now.AddDays(-2), + configure: r => r.ExpireNoResponse(), cancellationToken); + + // Cancelled by the customer before any response. + created += await EnsureRequestAsync( + hosseini, karimi, karimi.Variants[1].Id, hosseini.PatientIds[0], + requestedDate: Today(now, 4), timeStart: new TimeOnly(8, 0), timeEnd: new TimeOnly(8, 0), + notes: "لطفاً نادیده بگیرید — برنامه تغییر کرد.", + deadline: now.AddHours(24), + configure: r => r.CancelByCustomer(), cancellationToken); + + return created; + } + + private async Task EnsureRequestAsync( + CustomerWorld customer, + NurseWorld nurse, + long variantId, + long patientId, + DateOnly requestedDate, + TimeOnly timeStart, + TimeOnly timeEnd, + string notes, + DateTime deadline, + Action? configure, + CancellationToken cancellationToken) + { + if (await RequestExistsAsync(customer.ProfileId, nurse.Profile.Id, variantId, requestedDate, cancellationToken)) + return 0; + + var request = new BookingRequest + { + CustomerId = customer.ProfileId, + NurseId = nurse.Profile.Id, + PatientId = patientId, + VariantId = variantId, + CustomerAddressId = customer.PrimaryAddress.Id, + RequiredCaregiverGender = nurse.Gender, + RequestedDate = requestedDate, + RequestedTimeStart = timeStart, + RequestedTimeEnd = timeEnd, + CustomerNotes = notes, + NurseResponseDeadlineAt = deadline + }; + configure?.Invoke(request); + + db.Set().Add(request); + await db.SaveChangesAsync(cancellationToken); + return 1; + } + + private Task RequestExistsAsync( + long customerId, long nurseId, long variantId, DateOnly requestedDate, CancellationToken cancellationToken) + => db.Set().AnyAsync( + r => r.CustomerId == customerId && r.NurseId == nurseId + && r.VariantId == variantId && r.RequestedDate == requestedDate, + cancellationToken); + + // --------------------------------------------------------------------------------------------------- + // Booking scenarios — request → (real factory) booking → sessions/EVV → payment txn → balanced ledger. + // --------------------------------------------------------------------------------------------------- + + /// The seeded (or found) bookings by scenario key, plus how many were newly created. + internal sealed class BookingWorld + { + public Dictionary ByKey { get; } = []; + public Dictionary TxnByKey { get; } = []; + public int CreatedCount { get; set; } + } + + private async Task EnsureBookingScenariosAsync( + PersonaWorld world, long packageVariantId, long gatewayId, DateTime now, CancellationToken cancellationToken) + { + var result = new BookingWorld(); + var sequence = 0; + + foreach (var scenario in DemoLifecycleDefinitions.Bookings) + { + sequence++; + var customer = world.Customers[scenario.Customer]; + var nurse = world.Nurses[scenario.Nurse]; + var variant = scenario.VariantIndex >= 0 + ? nurse.Variants[scenario.VariantIndex] + : await db.Set().Include(v => v.Options) + .FirstAsync(v => v.Id == packageVariantId, cancellationToken); + + var requestedDate = Today(now, scenario.ScheduledInDays); + + var existing = await db.Set() + .Where(r => r.CustomerId == customer.ProfileId && r.NurseId == nurse.Profile.Id + && r.VariantId == variant.Id && r.RequestedDate == requestedDate) + .Select(r => r.Id) + .FirstOrDefaultAsync(cancellationToken); + if (existing != 0) + { + var found = await db.Set().Include(b => b.Sessions) + .FirstAsync(b => b.BookingRequestId == existing, cancellationToken); + var foundTxn = await db.Set() + .FirstAsync(t => t.BookingId == found.Id && t.Status == PaymentTransactionStatus.Succeeded, cancellationToken); + result.ByKey[scenario.Key] = found; + result.TxnByKey[scenario.Key] = foundTxn; + continue; + } + + var confirmedAt = now.AddDays(-scenario.ConfirmedDaysAgo); + + var request = new BookingRequest + { + CustomerId = customer.ProfileId, + NurseId = nurse.Profile.Id, + PatientId = customer.PatientIds[0], + VariantId = variant.Id, + CustomerAddressId = customer.PrimaryAddress.Id, + RequiredCaregiverGender = nurse.Gender, + RequestedDate = requestedDate, + RequestedTimeStart = new TimeOnly(9, 0), + RequestedTimeEnd = new TimeOnly(13, 0), + CustomerNotes = "هماهنگی از طریق پیام انجام شد.", + NurseResponseDeadlineAt = confirmedAt + }; + request.Accept(paymentDeadlineAt: confirmedAt); + db.Set().Add(request); + await db.SaveChangesAsync(cancellationToken); // assigns request.Id for the 1:1 booking FK + + var booking = BookingFactory.Create( + await BuildConversionSourceAsync(request, customer, nurse, variant, cancellationToken), + rate: await GetFeeRateAsync(cancellationToken), + now: confirmedAt, + pspFeeAmount: null, + variantSerializer); + request.MarkConverted(); + + ApplyLifecycleState(booking, scenario, now); + db.Set().Add(booking); + await db.SaveChangesAsync(cancellationToken); // assigns booking/session ids for EVV + ledger refs + + SeedVisitVerifications(booking, scenario, customer.PrimaryAddress, now); + + if (scenario.WithCareInstructions) + db.Set().Add(BuildCareInstructions(booking.Id)); + + // The txn commits first so the ledger legs can reference its real id (append-only source refs). + var txn = BuildSucceededTransaction(booking, request, customer.ProfileId, gatewayId, scenario, sequence); + db.Set().Add(txn); + await db.SaveChangesAsync(cancellationToken); + + // Card bookings post the balanced capture group now; the BNPL booking posts BnplSettle in the + // money step (its group references the bnpl_transactions row, which doesn't exist yet). + if (scenario.Key != "bnpl_settled") + { + db.Set().AddRange(LedgerPosting.CardCapture( + booking.Id, booking.NurseId, booking.GrossPriceIrr, booking.BalinyaarCommissionIrr, + booking.NursePayoutAmount, txn.Id, createdAt: confirmedAt)); + await db.SaveChangesAsync(cancellationToken); + } + + result.ByKey[scenario.Key] = booking; + result.TxnByKey[scenario.Key] = txn; + result.CreatedCount++; + } + + // One deliberately-failed extra payment attempt so the wallet's payment history has a failed row. + await EnsureFailedAttemptAsync(world, gatewayId, cancellationToken); + + return result; + } + + /// Walks the booking + its sessions along the real transition edges for the scenario. + private static void ApplyLifecycleState(BookingEntity booking, BookingScenario scenario, DateTime now) + { + if (scenario.Cancelled) + { + // ≥ 24h before start → the standard_24h tier, 100% refundable (only un-started sessions). + booking.TransitionTo(BookingStatus.Cancelled, now.AddDays(-1), CancellationActor.Customer, "برنامه سفر تغییر کرد."); + booking.RecordCancellationSnapshot(CancellationPolicyCode.Standard24h, 100m, booking.GrossPriceIrr); + foreach (var session in booking.Sessions) + session.TransitionTo(BookingSessionStatus.Cancelled); + return; + } + + if (scenario.CompletedDaysAgo is { } daysAgo) + { + var completedAt = now.AddDays(-daysAgo); + booking.TransitionTo(BookingStatus.InProgress, completedAt.AddHours(-4)); + booking.TransitionTo(BookingStatus.Completed, completedAt); + booking.SetDisputeWindow(completedAt.AddHours(72)); + + foreach (var session in booking.Sessions) + { + session.ScheduledDate = DateOnly.FromDateTime(completedAt); + session.TransitionTo(BookingSessionStatus.InProgress); + session.TransitionTo(BookingSessionStatus.Completed); + session.SetPayoutEligible(completedAt.AddHours(72)); + } + return; + } + + if (scenario.Key == "in_progress") + { + // The 5-session package mid-engagement: 2 done, session 3 running right now, 4–5 still ahead. + booking.TransitionTo(BookingStatus.InProgress, now.AddDays(-2)); + + var sessions = booking.Sessions.OrderBy(s => s.SessionIndex).ToList(); + for (var i = 0; i < sessions.Count; i++) + { + sessions[i].ScheduledDate = Today(now, i - 2); + switch (i) + { + case < 2: + sessions[i].TransitionTo(BookingSessionStatus.InProgress); + sessions[i].TransitionTo(BookingSessionStatus.Completed); + sessions[i].SetPayoutEligible(now.AddDays(i - 2).AddHours(72)); + break; + case 2: + sessions[i].TransitionTo(BookingSessionStatus.InProgress); + break; + } + } + } + // "upcoming" stays exactly as the factory built it: confirmed, all sessions scheduled. + } + + /// EVV rows for every non-scheduled session. The draft-batch booking's check-in is deliberately + /// out of tolerance so the admin EVV queue has a flagged row to review. + private void SeedVisitVerifications( + BookingEntity booking, BookingScenario scenario, CustomerAddress address, DateTime now) + { + var outOfRange = scenario.Key == "completed_eligible"; + + foreach (var session in booking.Sessions) + { + if (session.Status is not (BookingSessionStatus.InProgress or BookingSessionStatus.Completed)) + continue; + + var visitDay = session.ScheduledDate.ToDateTime(session.ScheduledTimeStart, DateTimeKind.Utc); + var evv = new VisitVerification + { + BookingSessionId = session.Id, + CheckInAt = session.Status == BookingSessionStatus.InProgress ? now.AddHours(-1) : visitDay, + CheckInLat = address.Latitude + (outOfRange ? 0.0110m : 0.0003m), + CheckInLng = address.Longitude, + CheckInAddressMatch = !outOfRange, + CheckInDistanceMeters = outOfRange ? 1220m : 38m + }; + evv.MarkCheckedIn(); + + if (session.Status == BookingSessionStatus.Completed) + { + evv.CheckOutAt = visitDay.AddHours(4); + evv.CheckOutLat = evv.CheckInLat; + evv.CheckOutLng = evv.CheckInLng; + evv.MarkCompleted(); + } + + db.Set().Add(evv); + } + } + + private BookingCareInstruction BuildCareInstructions(long bookingId) => new() + { + BookingId = bookingId, + CurrentConditions = "فشار خون بالا، دیابت نوع دو تحت کنترل.", + Medications = "متفورمین ۵۰۰ (صبح و شب)، لوزارتان ۲۵ (صبح).", + Allergies = "پنی‌سیلین.", + SpecialInstructions = "اندازه‌گیری قند خون پیش از هر وعده و ثبت آن.", + EmergencyContactName = "بهرام محمدی", + EmergencyContactPhone = "09121110010" + }; + + private static PaymentTransaction BuildSucceededTransaction( + BookingEntity booking, + BookingRequest request, + long customerId, + long gatewayId, + BookingScenario scenario, + int sequence) + { + var txn = new PaymentTransaction + { + BookingRequestId = request.Id, + CustomerId = customerId, + GatewayId = gatewayId, + Amount = booking.GrossPriceIrr, + GatewayTransactionId = $"demo-txn-{sequence:D4}", + GatewayReferenceCode = $"{DemoLifecycleDefinitions.GatewayRefPrefix}{sequence:D4}", + IsInstallment = scenario.Key == "bnpl_settled" + }; + txn.MarkSucceeded(booking.Id, "100", "{\"source\":\"demo-lifecycle-seed\"}"); + return txn; + } + + private async Task BuildConversionSourceAsync( + BookingRequest request, + CustomerWorld customer, + NurseWorld nurse, + NurseServiceVariant variant, + CancellationToken cancellationToken) + { + var patientName = await db.Set() + .Where(p => p.Id == request.PatientId) + .Select(p => p.DisplayName) + .FirstAsync(cancellationToken); + + return new BookingConversionSource( + request.Id, + request.Status, + customer.ProfileId, + customer.UserId, + nurse.Profile.Id, + nurse.UserId, + request.PatientId, + patientName, + variant.Id, + customer.PrimaryAddress.Id, + request.RequestedDate, + request.RequestedTimeStart, + request.RequestedTimeEnd, + await BuildVariantSnapshotAsync(variant, cancellationToken), + await BuildAddressSnapshotAsync(customer.PrimaryAddress, cancellationToken)); + } + + private async Task BuildVariantSnapshotAsync( + NurseServiceVariant variant, CancellationToken cancellationToken) + { + var category = await db.Set() + .FirstAsync(c => c.Id == variant.ServiceCategoryId, cancellationToken); + + var options = new List(); + foreach (var option in variant.Options) + { + var group = await db.Set().FirstAsync(g => g.Id == option.OptionGroupId, cancellationToken); + var value = await db.Set().FirstAsync(v => v.Id == option.OptionValueId, cancellationToken); + options.Add(new VariantOptionDto(group.Id, group.NameFa, group.NameEn, value.Id, value.NameFa, value.NameEn)); + } + + return new VariantSnapshot( + variant.Id, variant.ServiceCategoryId, category.NameFa, category.NameEn, + variant.Price, variant.PriceUnit, variant.SessionCount, variant.DisplayName, options); + } + + private async Task BuildAddressSnapshotAsync( + CustomerAddress address, CancellationToken cancellationToken) + { + var city = await db.Set().FirstAsync(c => c.Id == address.CityId, cancellationToken); + var district = address.DistrictId is { } districtId + ? await db.Set().FirstAsync(d => d.Id == districtId, cancellationToken) + : null; + + return new AddressSnapshot( + address.Id, address.Title, city.Id, city.NameFa, city.NameEn, + district?.Id, district?.NameFa, district?.NameEn, + address.AddressLine, address.PostalCode, address.RecipientName, address.RecipientPhone, + address.Latitude, address.Longitude); + } + + /// A failed card attempt against the accepted-awaiting-payment request — the wallet history's + /// failed row. Guarded on its fixed gateway reference. + private async Task EnsureFailedAttemptAsync(PersonaWorld world, long gatewayId, CancellationToken cancellationToken) + { + const string reference = DemoLifecycleDefinitions.GatewayRefPrefix + "fail"; + if (await db.Set().AnyAsync(t => t.GatewayReferenceCode == reference, cancellationToken)) + return; + + var mohammadi = world.Customers[0]; + var accepted = await db.Set() + .Where(r => r.CustomerId == mohammadi.ProfileId && r.Status == BookingRequestStatus.AcceptedAwaitingPayment) + .Select(r => new { r.Id, r.Variant.Price }) + .FirstOrDefaultAsync(cancellationToken); + if (accepted is null) + return; + + var txn = new PaymentTransaction + { + BookingRequestId = accepted.Id, + CustomerId = mohammadi.ProfileId, + GatewayId = gatewayId, + Amount = accepted.Price, + GatewayTransactionId = "demo-txn-fail", + GatewayReferenceCode = reference + }; + txn.MarkFailed("51", "{\"error\":\"insufficient_funds\",\"source\":\"demo-lifecycle-seed\"}"); + db.Set().Add(txn); + await db.SaveChangesAsync(cancellationToken); + } + + private decimal? _feeRate; + + /// The snapshotted commission rate — read through the same typed config the real handlers use. + private async ValueTask GetFeeRateAsync(CancellationToken cancellationToken) + => _feeRate ??= await platformConfig.GetConfig("platform_fee_rate", cancellationToken); + + private static DateOnly Today(DateTime now, int offsetDays) => DateOnly.FromDateTime(now.AddDays(offsetDays)); +} diff --git a/server/src/Tests/Baya.Test.Api/DemoLifecycleSeederTests.cs b/server/src/Tests/Baya.Test.Api/DemoLifecycleSeederTests.cs new file mode 100644 index 0000000..1ca191d --- /dev/null +++ b/server/src/Tests/Baya.Test.Api/DemoLifecycleSeederTests.cs @@ -0,0 +1,251 @@ +using System.Net; +using Baya.Domain.Entities.Bnpl; +using Baya.Domain.Entities.Booking; +using Baya.Domain.Entities.Identity; +using Baya.Domain.Entities.Messaging; +using Baya.Domain.Entities.Payments; +using Baya.Domain.Entities.Payouts; +using Baya.Domain.Entities.Refunds; +using Baya.Domain.Entities.Reviews; +using Baya.Domain.Entities.Verification; +using Baya.Infrastructure.Persistence; +using Baya.Infrastructure.Persistence.Services.Seeding; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using BookingEntity = Baya.Domain.Entities.Booking.Booking; + +namespace Baya.Test.Api; + +/// +/// Proves the Development lifecycle seeder produces a coherent, invariant-satisfying world: every request +/// and booking state exists, every ledger group balances, the money/one-per-booking uniques hold, the +/// moderation/publish gate is respected on the public route, and a second run is a no-op. +/// +public class DemoLifecycleSeederTests(BayaApiFactory factory) : IClassFixture +{ + private async Task RunSeedersAsync() + { + using var scope = factory.Services.CreateScope(); + + var world = scope.ServiceProvider.GetRequiredService(); + await world.SeedAsync(CancellationToken.None); + + // The Testing host never runs SeedPaymentGatewaysAsync (a Program.cs Development-only step) — the + // lifecycle seeder needs the gateway row, so mirror that tiny insert here. + var db = scope.ServiceProvider.GetRequiredService(); + if (!await db.Set().AnyAsync(g => g.Type == PaymentGatewayType.Standard)) + { + db.Set().Add(new PaymentGateway + { + ProviderCode = "zarinpal", + Type = PaymentGatewayType.Standard, + DisplayName = "ZarinPal (test)", + ConfigJson = "{}", + IsActive = true, + Priority = 0 + }); + await db.SaveChangesAsync(); + } + + var lifecycle = scope.ServiceProvider.GetRequiredService(); + await lifecycle.SeedAsync(CancellationToken.None); + } + + [Fact] + public async Task Seed_CoversEveryRequestAndBookingState() + { + await RunSeedersAsync(); + + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var requestStatuses = await db.Set().Select(r => r.Status).Distinct().ToListAsync(); + Assert.Contains(BookingRequestStatus.PendingNurseResponse, requestStatuses); + Assert.Contains(BookingRequestStatus.AcceptedAwaitingPayment, requestStatuses); + Assert.Contains(BookingRequestStatus.RejectedByNurse, requestStatuses); + Assert.Contains(BookingRequestStatus.ExpiredNoResponse, requestStatuses); + Assert.Contains(BookingRequestStatus.CancelledByCustomer, requestStatuses); + Assert.Contains(BookingRequestStatus.Converted, requestStatuses); + + var bookingStatuses = await db.Set().Select(b => b.Status).Distinct().ToListAsync(); + Assert.Contains(BookingStatus.Confirmed, bookingStatuses); + Assert.Contains(BookingStatus.InProgress, bookingStatuses); + Assert.Contains(BookingStatus.Completed, bookingStatuses); + Assert.Contains(BookingStatus.Cancelled, bookingStatuses); + + // The multi-session package: 5 sessions, 2 completed, 1 in progress (checked in), 2 scheduled. + var package = await db.Set().Include(b => b.Sessions) + .Where(b => b.SessionCount == 5) + .SingleAsync(); + Assert.Equal(2, package.Sessions.Count(s => s.Status == BookingSessionStatus.Completed)); + Assert.Equal(1, package.Sessions.Count(s => s.Status == BookingSessionStatus.InProgress)); + Assert.Equal(2, package.Sessions.Count(s => s.Status == BookingSessionStatus.Scheduled)); + } + + [Fact] + public async Task Seed_EveryLedgerGroupBalances_AndMoneyUniquesHold() + { + await RunSeedersAsync(); + + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + // Σ(debit) = Σ(credit) per transaction group — the double-entry invariant over the whole world. + var groups = await db.Set() + .GroupBy(e => e.TransactionGroupId) + .Select(g => new + { + Debit = g.Where(e => e.Direction == LedgerDirection.Debit).Sum(e => e.AmountIrr), + Credit = g.Where(e => e.Direction == LedgerDirection.Credit).Sum(e => e.AmountIrr) + }) + .ToListAsync(); + Assert.NotEmpty(groups); + Assert.All(groups, g => Assert.Equal(g.Debit, g.Credit)); + + // Exactly one succeeded capture per booking; the three-amount split reconciles on every booking. + var bookings = await db.Set().ToListAsync(); + Assert.All(bookings, b => Assert.Equal(b.GrossPriceIrr, b.BalinyaarCommissionIrr + b.NursePayoutAmount)); + foreach (var booking in bookings) + Assert.Equal(1, await db.Set() + .CountAsync(t => t.BookingId == booking.Id && t.Status == PaymentTransactionStatus.Succeeded)); + + // Session split: Σ(visit_payout_amount) = nurse_payout_amount, exactly. + foreach (var booking in bookings) + { + var sessionSum = await db.Set() + .Where(s => s.BookingId == booking.Id) + .SumAsync(s => s.VisitPayoutAmount); + Assert.Equal(booking.NursePayoutAmount, sessionSum); + } + + // BNPL settle: settled = order − commission, and its refund is processing (clearing deferred). + var bnpl = await db.Set().SingleAsync(); + Assert.Equal(BnplStatus.Reverted, bnpl.Status); + Assert.Equal(bnpl.OrderAmountIrr - bnpl.BnplCommissionIrr, bnpl.SettledAmountIrr); + } + + [Fact] + public async Task Seed_PayoutWorld_PaidBatchDraftBatchAndClawback() + { + await RunSeedersAsync(); + + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var batches = await db.Set().Include(b => b.Payouts).ToListAsync(); + Assert.Contains(batches, b => b.Status == PayoutBatchStatus.Completed); + Assert.Contains(batches, b => b.Status == PayoutBatchStatus.Draft); + + var paidBatch = batches.Single(b => b.Status == PayoutBatchStatus.Completed); + Assert.All(paidBatch.Payouts, p => Assert.Equal(PayoutStatus.Paid, p.Status)); + Assert.All(paidBatch.Payouts, p => Assert.Equal(p.GrossEarningsIrr - p.ClawbackAppliedIrr, p.NetAmountIrr)); + + // One payout link per linked booking, and the refunded BNPL booking is in no batch at all. + var links = await db.Set().ToListAsync(); + Assert.Equal(links.Count, links.Select(l => l.BookingId).Distinct().Count()); + var bnplTxn = await db.Set().SingleAsync(); + var bnplBookingId = await db.Set() + .Where(t => t.Id == bnplTxn.PaymentTransactionId) + .Select(t => t.BookingId) + .SingleAsync(); + Assert.DoesNotContain(links, l => l.BookingId == bnplBookingId); + + // The post-payout refund opened a pending clawback tied to the original payout. + var clawback = await db.Set().SingleAsync(); + Assert.Equal(ClawbackStatus.Pending, clawback.Status); + Assert.NotNull(clawback.OriginalPayoutId); + + // Three refunds, one per scenario: succeeded card, processing bnpl_revert, succeeded clawback. + var refunds = await db.Set().ToListAsync(); + Assert.Equal(3, refunds.Count); + Assert.All(refunds, r => Assert.Equal(r.Amount, r.PlatformFeeRefundedIrr + r.NursePayoutRefundedIrr)); + Assert.All(refunds, r => Assert.NotNull(r.TicketId)); + Assert.Contains(refunds, r => r.Status == RefundStatus.Processing && r.RefundChannel == RefundChannel.BnplRevert); + Assert.Equal(2, refunds.Count(r => r.Status == RefundStatus.Succeeded && r.RefundChannel == RefundChannel.PspCard)); + } + + [Fact] + public async Task Seed_PublicReviews_ShowOnlyThePublishedOne_AndAggregatesMatch() + { + await RunSeedersAsync(); + + long verifiedNurseId; + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + verifiedNurseId = await db.Set() + .Where(r => r.ModerationStatus == ReviewModerationStatus.Published) + .Select(r => r.NurseProfileId) + .SingleAsync(); + + // The aggregate mirrors the published-only recompute rule. + var profile = await db.Set().SingleAsync(p => p.Id == verifiedNurseId); + Assert.Equal(1, profile.TotalReviews); + Assert.Equal(5.00m, profile.AverageRating); + Assert.True(profile.TotalCompletedBookings > 0); + } + + var client = factory.CreateClient(); + var response = await client.GetAsync($"/api/v1/nurses/{verifiedNurseId}/reviews"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var data = await AuthTestClient.ReadDataAsync(response); + Assert.Equal(1, data.GetProperty("aggregate").GetProperty("publishedCount").GetInt32()); + Assert.Equal(1, data.GetProperty("reviews").GetProperty("total").GetInt32()); + } + + [Fact] + public async Task Seed_TicketsCarryTheInternalNote_AndVerificationStaysUnapproved() + { + await RunSeedersAsync(); + + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + // The internal admin note exists in the store — the user-facing thread queries must strip it + // (enforced at the query layer; asserted here so the demo world genuinely exercises the boundary). + var support = await db.Set().Include(t => t.Messages) + .SingleAsync(t => t.ReferenceCode == DemoLifecycleDefinitions.SupportTicketOpen); + Assert.Contains(support.Messages, m => m.IsInternal); + + // The mid-pipeline verification case: steps exist, but is_verified stays false and the nurse + // must have no searchable rows. + var ahmadi = await db.Set().SingleAsync(p => !p.IsVerified); + var verification = await db.Set().SingleAsync(v => v.NurseId == ahmadi.Id); + Assert.Equal(VerificationStatus.InReview, verification.Status); + Assert.True(await db.Set().AnyAsync(s => s.NurseVerificationId == verification.Id)); + Assert.False(ahmadi.IsVerified); + } + + [Fact] + public async Task Seed_IsIdempotent_SecondRunAddsNothing() + { + await RunSeedersAsync(); + + int requests, bookings, refunds, reviews, tickets, batches, ledger; + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + requests = await db.Set().CountAsync(); + bookings = await db.Set().CountAsync(); + refunds = await db.Set().CountAsync(); + reviews = await db.Set().CountAsync(); + tickets = await db.Set().CountAsync(); + batches = await db.Set().CountAsync(); + ledger = await db.Set().CountAsync(); + } + + await RunSeedersAsync(); + + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + Assert.Equal(requests, await db.Set().CountAsync()); + Assert.Equal(bookings, await db.Set().CountAsync()); + Assert.Equal(refunds, await db.Set().CountAsync()); + Assert.Equal(reviews, await db.Set().CountAsync()); + Assert.Equal(tickets, await db.Set().CountAsync()); + Assert.Equal(batches, await db.Set().CountAsync()); + Assert.Equal(ledger, await db.Set().CountAsync()); + } + } +}