start of manual testing

This commit is contained in:
hamid
2026-07-27 00:54:22 +03:30
parent 12ce7fa7de
commit bd06ef0016
21 changed files with 2289 additions and 12 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

@@ -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 <transformed /> from render. Or maybe you meant to call this function rather than return it.
<p>{transformed}</p>
at p (<anonymous>: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 | </Typography>
> 103 | <Typography variant="body2" sx={{ color: 'text.secondary' }}>
| ^
104 | {t.rich('otp_sent_to', {
105 | // <bdi dir="ltr"> 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
+251
View File
@@ -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 T01d, dispute window open until T0+2d | review **pending moderation**, care record |
| B4 `completed_paid` | محمدی × عزیزی | completed T010d, **paid out** | ★5 **published** review (punctual/professional), care record |
| B5 `completed_eligible` | حسینی × کریمی | completed T05d, in the **draft batch** | hidden 1★ review, **out-of-range EVV** (1220 m) |
| B6 `bnpl_settled` | حسینی × کریمی | completed T06d, 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 T011d, 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 D1D5 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 + `<Money>` 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": <id>}` 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 (D1D5) | ⚠ 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 710 |
| 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.25.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 E5E7) — 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 AD3AD8, §5.8) — UX only, verify server truth via Swagger as listed.
7. Sweep §7's decisions.
+10
View File
@@ -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,
+16
View File
@@ -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`.
---
+5 -2
View File
@@ -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
{
@@ -12,6 +12,9 @@
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="Baya.Test.Foundation" />
<!-- Lets the Development-only DemoLifecycleSeeder build bookings through the real BookingFactory
(both frozen snapshots + the three-amount split) instead of duplicating the conversion logic. -->
<InternalsVisibleTo Include="Baya.Infrastructure.Persistence" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Baya.Domain\Baya.Domain.csproj" />
@@ -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);
}
@@ -1,7 +1,9 @@
#nullable enable
namespace Baya.Application.Models.Identity;
/// <summary>Raw account projection for the current user (phone unmasked — handlers mask it).</summary>
/// <summary>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.</summary>
public record UserAccountSnapshot(
int Id,
string? Phone,
@@ -9,4 +11,7 @@ public record UserAccountSnapshot(
string? LastName,
string? Gender,
bool IsActive,
IReadOnlyList<string> Roles);
IReadOnlyList<string> Roles,
bool HasCustomerProfile = false,
bool HasNurseProfile = false,
string NurseVerificationStatus = "not_started");
@@ -64,4 +64,10 @@ public class NurseProfile : BaseEntity<long>
AverageRating = averageRating;
TotalReviews = totalReviews;
}
/// <summary>The single sanctioned write path for the completed-bookings aggregate — recomputed from
/// source (completed <c>bookings</c> rows), never accepted from a request. Mirrors
/// <see cref="SetReviewAggregates"/>; consumed by the search projection and the public profile.</summary>
public void SetCompletedBookings(int totalCompletedBookings)
=> TotalCompletedBookings = totalCompletedBookings;
}
@@ -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<User>, IUserAccountRe
{
}
public Task<UserAccountSnapshot> GetAccountSnapshotAsync(int userId, CancellationToken cancellationToken)
public async Task<UserAccountSnapshot> 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<CustomerProfile>().Any(p => p.UserId == u.Id),
HasNurseProfile = DbContext.Set<NurseProfile>().Any(p => p.UserId == u.Id),
VerificationStatus = DbContext.Set<NurseVerification>()
.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<User> GetTrackedByIdAsync(int userId, CancellationToken cancellationToken)
@@ -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<DemoWorldSeeder>();
services.AddScoped<DemoLifecycleSeeder>();
return services;
}
@@ -165,4 +166,18 @@ public static class ServiceCollectionExtensions
var seeder = scope.ServiceProvider.GetRequiredService<DemoWorldSeeder>();
await seeder.SeedAsync(CancellationToken.None);
}
/// <summary>
/// Seeds the <b>Development-only</b> 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 <see cref="SeedDemoWorldAsync"/>'s
/// personas. Idempotent per scenario. Must run <b>after</b> the demo world + payment-gateway seeds; the
/// same <c>IsDevelopment()</c> gating rule applies.
/// </summary>
public static async Task SeedDemoLifecycleAsync(this WebApplication app)
{
await using var scope = app.Services.CreateAsyncScope();
var seeder = scope.ServiceProvider.GetRequiredService<DemoLifecycleSeeder>();
await seeder.SeedAsync(CancellationToken.None);
}
}
@@ -0,0 +1,103 @@
#nullable enable
namespace Baya.Infrastructure.Persistence.Services.Seeding;
/// <summary>
/// The static, deterministic description of the Development <b>lifecycle</b> demo world the
/// <see cref="DemoLifecycleSeeder"/> materialises on top of <see cref="DemoWorldDefinitions"/>'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.
/// <para>
/// All timestamps are expressed as <b>day/hour offsets from seed time</b> (negative = past) because the
/// scenario meanings are relative ("completed inside the 72h dispute window"), not absolute dates.
/// </para>
/// </summary>
internal static class DemoLifecycleDefinitions
{
/// <summary>The partner-center dashboard owner (no admin/nurse role — the portal keys off the center's
/// <c>admin_user_id</c>; the customer role only gives the login a sane landing).</summary>
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";
/// <summary>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).</summary>
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";
/// <summary>Prefix for the fake Shaparak reference codes (filtered-UNIQUE column — each txn appends its
/// scenario key).</summary>
public const string GatewayRefPrefix = "demo-shaparak-";
/// <summary>
/// The booking scenario table. One row per engagement, keyed by <see cref="Key"/> (stable, greppable).
/// Offsets are in days relative to seed time unless noted. A null <see cref="CompletedDaysAgo"/> means the
/// booking never completed. The request behind every booking is <c>converted</c>; the request-only
/// scenarios (pending/accepted/rejected/expired/cancelled) are seeded separately in the seeder.
/// </summary>
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),
];
}
/// <summary>One booking scenario row. <see cref="Customer"/>/<see cref="Nurse"/> index into
/// <see cref="DemoWorldDefinitions.Customers"/>/<see cref="DemoWorldDefinitions.Nurses"/>;
/// <see cref="VariantIndex"/> indexes the nurse's variants in creation order (1 = the seeded 5-session
/// package variant).</summary>
internal sealed record BookingScenario(
string Key,
int Customer,
int Nurse,
int VariantIndex,
int ScheduledInDays,
int ConfirmedDaysAgo,
int? CompletedDaysAgo,
bool Cancelled,
bool WithCareInstructions);
@@ -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<BnplTransaction>().AnyAsync(b => b.PaymentTransactionId == txn.Id, cancellationToken))
return;
var commissionRate = await platformConfig.GetConfig<decimal>("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<BnplTransaction>().Add(bnpl);
await db.SaveChangesAsync(cancellationToken); // assigns bnpl.Id for the ledger source ref
db.Set<LedgerEntry>().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<NursePayoutBookingLink>().AnyAsync(l => l.BookingId == paidBooking.Id, cancellationToken))
return;
// periodEnd T04d keeps the still-in-draft-window bookings (dispute end T02d/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<NursePayoutBatch>()
.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<NursePayoutBookingLink>().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<Refund>().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<Refund>().Add(refund);
await db.SaveChangesAsync(cancellationToken);
ticket.RefundId = refund.Id;
db.Set<LedgerEntry>().AddRange(LedgerPosting.RefundReversalPrePayout(
cancelled.Id, cancelled.NurseId, refund.PlatformFeeRefundedIrr, refund.NursePayoutRefundedIrr,
refund.Id, now.AddDays(-1)));
db.Set<LedgerEntry>().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<Refund>().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<int>("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<Refund>().Add(refund);
await db.SaveChangesAsync(cancellationToken);
ticket.RefundId = refund.Id;
db.Set<LedgerEntry>().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<BnplTransaction>()
.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<Refund>().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<Refund>().Add(refund);
await db.SaveChangesAsync(cancellationToken);
ticket.RefundId = refund.Id;
db.Set<LedgerEntry>().AddRange(LedgerPosting.ClawbackReversalPostPayout(
clawedBooking.Id, clawedBooking.NurseId, refund.PlatformFeeRefundedIrr, refund.NursePayoutRefundedIrr,
refund.Id, now.AddHours(-20)));
db.Set<LedgerEntry>().AddRange(LedgerPosting.RefundPayableClearing(
clawedBooking.Id, refund.Amount, refund.Id, now.AddHours(-20)));
var originalPayoutId = await db.Set<NursePayoutBookingLink>()
.Where(l => l.BookingId == clawedBooking.Id)
.Select(l => (long?)l.PayoutId)
.FirstOrDefaultAsync(cancellationToken);
db.Set<NurseClawback>().Add(new NurseClawback
{
NurseId = clawedBooking.NurseId,
BookingId = clawedBooking.Id,
RefundId = refund.Id,
OriginalPayoutId = originalPayoutId,
AmountIrr = refund.NursePayoutRefundedIrr
});
await db.SaveChangesAsync(cancellationToken);
}
}
private async Task<Ticket> EnsureRefundTicketAsync(
string referenceCode, long bookingId, int customerUserId, int adminUserId,
string subject, DateTime openedAt, CancellationToken cancellationToken)
{
var existing = await db.Set<Ticket>()
.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<Ticket>().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<long> EnsurePartnerCenterAsync(PersonaWorld world, CancellationToken cancellationToken)
{
var existing = await db.Set<PartnerCenter>()
.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<Role>().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<PartnerCenter>().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);
}
}
}
@@ -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<Review>().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<Review>().Add(review);
await db.SaveChangesAsync(cancellationToken); // assigns review.Id for the tag links
var tagIds = await db.Set<ReviewTagMaster>()
.Where(t => t.Code == ReviewTagCodes.Punctual || t.Code == ReviewTagCodes.Professional)
.Select(t => t.Id)
.ToListAsync(cancellationToken);
foreach (var tagId in tagIds)
db.Set<ReviewTagLink>().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<Review>().AnyAsync(r => r.BookingId == inWindow.Id, cancellationToken))
{
db.Set<Review>().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<Review>().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<Review>().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<Review>()
.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<Booking>()
.CountAsync(b => b.NurseId == nurse.Profile.Id && b.Status == BookingStatus.Completed, cancellationToken);
nurse.Profile.SetCompletedBookings(completed);
}
await db.SaveChangesAsync(cancellationToken);
await EnsureSupportAlertsAsync(bookings, cancellationToken);
}
/// <summary>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.</summary>
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<SupportAlert>().AnyAsync(a => a.Type == type && a.BookingId == bookingId, cancellationToken))
return;
db.Set<SupportAlert>().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<Review>()
.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<Ticket>().AnyAsync(t => t.ReferenceCode == DemoLifecycleDefinitions.SupportTicketOpen, cancellationToken))
{
var openedAt = clock.UtcNow.AddDays(-2);
db.Set<Ticket>().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<Ticket>().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<Ticket>().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<Ticket>().AnyAsync(t => t.ReferenceCode == DemoLifecycleDefinitions.EmergencyTicket, cancellationToken))
{
var azizi = world.Nurses[0];
db.Set<Ticket>().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<PatientCareRecord>().AnyAsync(
r => r.PatientId == patientId && r.BookingId == bookingId, cancellationToken))
return;
db.Set<PatientCareRecord>().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 (B3B6) 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<NurseVerification>()
.FirstOrDefaultAsync(v => v.NurseId == ahmadi.Profile.Id, cancellationToken);
if (verification is null)
return;
if (await db.Set<VerificationStep>().AnyAsync(s => s.NurseVerificationId == verification.Id, cancellationToken))
return;
verification.Status = VerificationStatus.InReview;
verification.SubmittedAt = clock.UtcNow.AddDays(-3);
var stepTypes = await db.Set<VerificationStepType>()
.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<VerificationStep>().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<VerificationDocument>().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<OpsNotification>().AnyAsync(
n => n.UserId == azizi.UserId && n.Type == "booking_request_received", cancellationToken))
return;
var pendingRequestId = await db.Set<BookingRequest>()
.Where(r => r.Status == BookingRequestStatus.PendingNurseResponse && r.NurseId == azizi.Profile.Id)
.Select(r => (long?)r.Id)
.FirstOrDefaultAsync(cancellationToken);
var acceptedRequest = await db.Set<BookingRequest>()
.Where(r => r.Status == BookingRequestStatus.AcceptedAwaitingPayment && r.CustomerId == mohammadi.ProfileId)
.Select(r => new { r.Id, r.PaymentDeadlineAt })
.FirstOrDefaultAsync(cancellationToken);
var expiredRequestId = await db.Set<BookingRequest>()
.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<Review>().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<OpsNotification>().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);
}
}
@@ -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;
/// <summary>
/// Development-only seeder that layers a full <b>lifecycle</b> demo world on top of the
/// <see cref="DemoWorldSeeder"/> 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.
/// <para>
/// Every state is reached through the entities' <b>guarded transition methods</b> and the real money
/// helpers (<see cref="BookingFactory"/>, <see cref="LedgerPosting"/>), 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 <b>real Application commands</b>. Timestamps that carry business
/// meaning are backdated explicitly (they are method arguments/settable by design); <c>CreatedAt</c> audit
/// stamps stay at seed time, which no flow depends on.
/// </para>
/// <para>
/// 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 <c>IsDevelopment()</c> and run
/// it <b>after</b> <see cref="DemoWorldSeeder"/> and the payment-gateway seed.
/// </para>
/// </summary>
internal sealed partial class DemoLifecycleSeeder(
ApplicationDbContext db,
IAppUserManager userManager,
ISender sender,
IPlatformConfig platformConfig,
IVariantSnapshotSerializer variantSerializer,
ISearchIndexMaintainer searchIndex,
IFieldEncryptor encryptor,
IDateTimeProvider clock,
ILogger<DemoLifecycleSeeder> 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<PaymentGateway>()
.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<long> PatientIds, CustomerAddress PrimaryAddress);
internal sealed record NurseWorld(
int UserId, NurseProfile Profile, IReadOnlyList<NurseServiceVariant> Variants, string Gender);
internal sealed record PersonaWorld(
IReadOnlyList<CustomerWorld> Customers, IReadOnlyList<NurseWorld> Nurses, int AdminUserId);
private async Task<PersonaWorld?> LoadWorldAsync(CancellationToken cancellationToken)
{
var customers = new List<CustomerWorld>();
foreach (var persona in DemoWorldDefinitions.Customers)
{
var user = await userManager.GetUserByPhoneNumber(persona.Phone);
if (user is null)
return null;
var profile = await db.Set<CustomerProfile>()
.FirstOrDefaultAsync(p => p.UserId == user.Id, cancellationToken);
if (profile is null)
return null;
var patients = await db.Set<Patient>()
.Where(p => p.CustomerId == profile.Id)
.OrderBy(p => p.Id)
.Select(p => p.Id)
.ToListAsync(cancellationToken);
var address = await db.Set<CustomerAddress>()
.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<NurseWorld>();
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<NurseProfile>()
.FirstOrDefaultAsync(p => p.UserId == user.Id, cancellationToken);
if (profile is null)
return null;
var variants = await db.Set<NurseServiceVariant>()
.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<long> EnsurePackageVariantAsync(PersonaWorld world, CancellationToken cancellationToken)
{
var azizi = world.Nurses[0];
var shiftGroup = await db.Set<ServiceOptionGroup>()
.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<NurseServiceVariant>()
.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<NurseServiceVariantOption>
{
new() { OptionGroupId = shiftGroup.Id, OptionValueId = nightValueId }
}
};
db.Set<NurseServiceVariant>().Add(variant);
await db.SaveChangesAsync(cancellationToken);
return variant.Id;
}
// ---------------------------------------------------------------------------------------------------
// Request-only scenarios: pending / accepted-awaiting-payment / rejected / expired / cancelled.
// ---------------------------------------------------------------------------------------------------
private async Task<int> 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<int> EnsureRequestAsync(
CustomerWorld customer,
NurseWorld nurse,
long variantId,
long patientId,
DateOnly requestedDate,
TimeOnly timeStart,
TimeOnly timeEnd,
string notes,
DateTime deadline,
Action<BookingRequest>? 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<BookingRequest>().Add(request);
await db.SaveChangesAsync(cancellationToken);
return 1;
}
private Task<bool> RequestExistsAsync(
long customerId, long nurseId, long variantId, DateOnly requestedDate, CancellationToken cancellationToken)
=> db.Set<BookingRequest>().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.
// ---------------------------------------------------------------------------------------------------
/// <summary>The seeded (or found) bookings by scenario key, plus how many were newly created.</summary>
internal sealed class BookingWorld
{
public Dictionary<string, BookingEntity> ByKey { get; } = [];
public Dictionary<string, PaymentTransaction> TxnByKey { get; } = [];
public int CreatedCount { get; set; }
}
private async Task<BookingWorld> 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<NurseServiceVariant>().Include(v => v.Options)
.FirstAsync(v => v.Id == packageVariantId, cancellationToken);
var requestedDate = Today(now, scenario.ScheduledInDays);
var existing = await db.Set<BookingRequest>()
.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<BookingEntity>().Include(b => b.Sessions)
.FirstAsync(b => b.BookingRequestId == existing, cancellationToken);
var foundTxn = await db.Set<PaymentTransaction>()
.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<BookingRequest>().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<BookingEntity>().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<BookingCareInstruction>().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<PaymentTransaction>().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<LedgerEntry>().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;
}
/// <summary>Walks the booking + its sessions along the real transition edges for the scenario.</summary>
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, 45 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.
}
/// <summary>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.</summary>
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<VisitVerification>().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<BookingConversionSource> BuildConversionSourceAsync(
BookingRequest request,
CustomerWorld customer,
NurseWorld nurse,
NurseServiceVariant variant,
CancellationToken cancellationToken)
{
var patientName = await db.Set<Patient>()
.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<VariantSnapshot> BuildVariantSnapshotAsync(
NurseServiceVariant variant, CancellationToken cancellationToken)
{
var category = await db.Set<ServiceCategory>()
.FirstAsync(c => c.Id == variant.ServiceCategoryId, cancellationToken);
var options = new List<VariantOptionDto>();
foreach (var option in variant.Options)
{
var group = await db.Set<ServiceOptionGroup>().FirstAsync(g => g.Id == option.OptionGroupId, cancellationToken);
var value = await db.Set<ServiceOptionValue>().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<AddressSnapshot> BuildAddressSnapshotAsync(
CustomerAddress address, CancellationToken cancellationToken)
{
var city = await db.Set<City>().FirstAsync(c => c.Id == address.CityId, cancellationToken);
var district = address.DistrictId is { } districtId
? await db.Set<District>().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);
}
/// <summary>A failed card attempt against the accepted-awaiting-payment request — the wallet history's
/// failed row. Guarded on its fixed gateway reference.</summary>
private async Task EnsureFailedAttemptAsync(PersonaWorld world, long gatewayId, CancellationToken cancellationToken)
{
const string reference = DemoLifecycleDefinitions.GatewayRefPrefix + "fail";
if (await db.Set<PaymentTransaction>().AnyAsync(t => t.GatewayReferenceCode == reference, cancellationToken))
return;
var mohammadi = world.Customers[0];
var accepted = await db.Set<BookingRequest>()
.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<PaymentTransaction>().Add(txn);
await db.SaveChangesAsync(cancellationToken);
}
private decimal? _feeRate;
/// <summary>The snapshotted commission rate — read through the same typed config the real handlers use.</summary>
private async ValueTask<decimal> GetFeeRateAsync(CancellationToken cancellationToken)
=> _feeRate ??= await platformConfig.GetConfig<decimal>("platform_fee_rate", cancellationToken);
private static DateOnly Today(DateTime now, int offsetDays) => DateOnly.FromDateTime(now.AddDays(offsetDays));
}
@@ -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;
/// <summary>
/// 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.
/// </summary>
public class DemoLifecycleSeederTests(BayaApiFactory factory) : IClassFixture<BayaApiFactory>
{
private async Task RunSeedersAsync()
{
using var scope = factory.Services.CreateScope();
var world = scope.ServiceProvider.GetRequiredService<DemoWorldSeeder>();
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<ApplicationDbContext>();
if (!await db.Set<PaymentGateway>().AnyAsync(g => g.Type == PaymentGatewayType.Standard))
{
db.Set<PaymentGateway>().Add(new PaymentGateway
{
ProviderCode = "zarinpal",
Type = PaymentGatewayType.Standard,
DisplayName = "ZarinPal (test)",
ConfigJson = "{}",
IsActive = true,
Priority = 0
});
await db.SaveChangesAsync();
}
var lifecycle = scope.ServiceProvider.GetRequiredService<DemoLifecycleSeeder>();
await lifecycle.SeedAsync(CancellationToken.None);
}
[Fact]
public async Task Seed_CoversEveryRequestAndBookingState()
{
await RunSeedersAsync();
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var requestStatuses = await db.Set<BookingRequest>().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<BookingEntity>().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<BookingEntity>().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<ApplicationDbContext>();
// Σ(debit) = Σ(credit) per transaction group — the double-entry invariant over the whole world.
var groups = await db.Set<LedgerEntry>()
.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<BookingEntity>().ToListAsync();
Assert.All(bookings, b => Assert.Equal(b.GrossPriceIrr, b.BalinyaarCommissionIrr + b.NursePayoutAmount));
foreach (var booking in bookings)
Assert.Equal(1, await db.Set<PaymentTransaction>()
.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<BookingSession>()
.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<BnplTransaction>().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<ApplicationDbContext>();
var batches = await db.Set<NursePayoutBatch>().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<NursePayoutBookingLink>().ToListAsync();
Assert.Equal(links.Count, links.Select(l => l.BookingId).Distinct().Count());
var bnplTxn = await db.Set<BnplTransaction>().SingleAsync();
var bnplBookingId = await db.Set<PaymentTransaction>()
.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<NurseClawback>().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<Refund>().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<ApplicationDbContext>();
verifiedNurseId = await db.Set<Review>()
.Where(r => r.ModerationStatus == ReviewModerationStatus.Published)
.Select(r => r.NurseProfileId)
.SingleAsync();
// The aggregate mirrors the published-only recompute rule.
var profile = await db.Set<NurseProfile>().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<ApplicationDbContext>();
// 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<Ticket>().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<NurseProfile>().SingleAsync(p => !p.IsVerified);
var verification = await db.Set<NurseVerification>().SingleAsync(v => v.NurseId == ahmadi.Id);
Assert.Equal(VerificationStatus.InReview, verification.Status);
Assert.True(await db.Set<VerificationStep>().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<ApplicationDbContext>();
requests = await db.Set<BookingRequest>().CountAsync();
bookings = await db.Set<BookingEntity>().CountAsync();
refunds = await db.Set<Refund>().CountAsync();
reviews = await db.Set<Review>().CountAsync();
tickets = await db.Set<Ticket>().CountAsync();
batches = await db.Set<NursePayoutBatch>().CountAsync();
ledger = await db.Set<LedgerEntry>().CountAsync();
}
await RunSeedersAsync();
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
Assert.Equal(requests, await db.Set<BookingRequest>().CountAsync());
Assert.Equal(bookings, await db.Set<BookingEntity>().CountAsync());
Assert.Equal(refunds, await db.Set<Refund>().CountAsync());
Assert.Equal(reviews, await db.Set<Review>().CountAsync());
Assert.Equal(tickets, await db.Set<Ticket>().CountAsync());
Assert.Equal(batches, await db.Set<NursePayoutBatch>().CountAsync());
Assert.Equal(ledger, await db.Set<LedgerEntry>().CountAsync());
}
}
}