start of manual testing
This commit is contained in:
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 @@
|
|||||||
|

|
||||||
|
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
|
||||||
|
|
||||||
|

|
||||||
|
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
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
# Balinyaar — Manual Testing Plan
|
||||||
|
|
||||||
|
The flow-by-flow walkthrough for testing the whole product against the **seeded lifecycle demo world**.
|
||||||
|
Written after the UI/backend/refinement phase chains completed (2026-07-26). Companion docs:
|
||||||
|
|
||||||
|
- **Bring-up**: [refinement/RUNBOOK.md](refinement/RUNBOOK.md) — how to boot API + client, OTP login mechanics.
|
||||||
|
- **Business rules with numbers**: `product/business/` — what "correct" means; the load-bearing numbers are
|
||||||
|
repeated inline below.
|
||||||
|
|
||||||
|
> **How to use this doc:** pick a flow section, log in as the named account, follow the steps, compare
|
||||||
|
> against *Expected*. Anything marked ⚠ **known gap** is pre-existing and filed (REQ-xxx) — record it as
|
||||||
|
> confirmation, not as a new bug. Anything else that deviates is a genuine finding.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Environment
|
||||||
|
|
||||||
|
| Piece | Where | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| API | `http://localhost:5002` (`cd server && dotnet run --project src/API/Baya.Web.Api/Baya.Web.Api.csproj`) | Boots in Development: migrates + seeds (reference + demo world + **lifecycle world**) against the DB in `appsettings.Development.json` — currently the **remote dev SQL Server** (`87.107.152.16`). Swagger at `/swagger`. |
|
||||||
|
| Client | `http://localhost:3000` (`cd client && npm run dev`) | `.env.development` already points at `http://localhost:5002`. |
|
||||||
|
| Crypto keys | `dotnet user-secrets` (from `server/src/API/Baya.Web.Api`) | The committed config carries `SET_VIA_USER_SECRETS_OR_ENV` placeholders. The remote DB's data is encrypted under the **dev keys** — any machine booting against it must set the same four user-secrets: `IdentitySettings:SecretKey` = `dev-only-jwe-signing-key-not-for-production-0123456789abcdef`, `IdentitySettings:Encryptkey` = `dev-only-16bytes`, `Seams:FieldEncryption:Key` = `local-dev-field-encryption-key-not-for-production`, `Seams:FieldEncryption:HashKey` = `local-dev-field-hash-key-not-for-production`. Booting with *different* field keys makes every phone lookup miss and every PII read throw `Padding is invalid`. |
|
||||||
|
| OTP codes | server console (`MOCK SMS — OTP code …`) or `GET /api/v1/dev/last_otp/{phone}` | Resend window 120 s, 5 wrong attempts per code, OTP valid 60 s. The OTP endpoints are rate-limited per IP — hammering logins in scripts will 429. |
|
||||||
|
| Reset | drop the `Baya` DB (`dotnet ef database drop --force --project src/Infrastructure/Baya.Infrastructure.Persistence --startup-project src/API/Baya.Web.Api`), reboot | Everything reseeds. Needed when the **time-relative scenarios age out** (see §3 note). |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Test accounts
|
||||||
|
|
||||||
|
All log in via phone-OTP at `/login`. Any listed account works immediately.
|
||||||
|
|
||||||
|
| Phone | Actor | Who | Carries |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `09120000001` | nurse (verified) | زهرا عزیزی | 4 variants (incl. the 5-session package), whole-city+2-district coverage, ★5.00 (1 published review), 3 completed bookings, a pending request in the inbox, the in-progress visit today, earnings incl. a **paid** payout and a **pending clawback** |
|
||||||
|
| `09120000002` | nurse (verified) | علی کریمی | Partner-sponsored (مرکز پرستاری آرامش), 2 completed bookings (one BNPL), a **draft** payout waiting, a hidden 1★ review, an expired request |
|
||||||
|
| `09120000003` | nurse (unverified) | مریم احمدی | Mid-pipeline verification (KYC+Shahkar passed, MoH license in review w/ document, criminal record pending) — **never searchable** |
|
||||||
|
| `09120000010` | customer | سارا محمدی | 2 patients, 1 address, 6 bookings across all states, pending/accepted/rejected requests, tickets, notifications, care records |
|
||||||
|
| `09120000011` | customer | رضا حسینی | 1 infant patient, 2 completed bookings (one BNPL w/ processing refund), expired + cancelled requests, a closed support ticket |
|
||||||
|
| `09120000020` | admin (`super_admin`) | نگار مدیری | Full `/admin` console incl. RBAC |
|
||||||
|
| `09120000021` | admin (`finance`) | کامران مالی | `/admin` scoped to money consoles (`useAdminCapabilities`) |
|
||||||
|
| `09120000030` | partner owner | بهنام رستگار | Owns the merchant-of-record center; **navigate to `/partner` manually** after login (⚠ REQ-038 — no `/me` partner signal) |
|
||||||
|
| any new `09xxxxxxxxx` | fresh user | — | Registers on first OTP verify → `/select-role` → onboarding. Use for first-run testing. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. The seeded world at a glance
|
||||||
|
|
||||||
|
Booking scenarios (all Tehran; seed-time-relative — **T0 = the boot that first seeded**):
|
||||||
|
|
||||||
|
| Key | Who | State | Money / attachments |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| pending request | محمدی → عزیزی | `pending_nurse_response`, 24 h countdown | nurse-inbox + C5 tracker fodder |
|
||||||
|
| accepted request | محمدی → عزیزی | `accepted_awaiting_payment` (24 h demo window, not the real 30 min) | checkout entry; **1 failed card attempt** (wallet history) |
|
||||||
|
| rejected / expired / cancelled requests | mixed | terminal request states | C5 terminal cards + inbox tabs |
|
||||||
|
| B1 `upcoming` | محمدی × عزیزی | confirmed, scheduled T0+3d, care instructions written | paid (card), invoice issued, coordination ticket |
|
||||||
|
| B2 `in_progress` | محمدی × عزیزی | 5-session package: 2 done, **session 3 checked-in now**, 2 scheduled | paid, emergency ticket logged |
|
||||||
|
| B3 `completed_in_window` | محمدی × عزیزی | completed T0−1d, dispute window open until T0+2d | review **pending moderation**, care record |
|
||||||
|
| B4 `completed_paid` | محمدی × عزیزی | completed T0−10d, **paid out** | ★5 **published** review (punctual/professional), care record |
|
||||||
|
| B5 `completed_eligible` | حسینی × کریمی | completed T0−5d, in the **draft batch** | hidden 1★ review, **out-of-range EVV** (1220 m) |
|
||||||
|
| B6 `bnpl_settled` | حسینی × کریمی | completed T0−6d, paid via SnappPay (net-of-fee) | **processing `bnpl_revert` 50% refund** (ETA ~10 business days), excluded from payouts, care record |
|
||||||
|
| B7 `cancelled_refunded` | محمدی × عزیزی | cancelled ≥24 h ahead (standard_24h, 100%) | **succeeded card refund** |
|
||||||
|
| C8 `clawback` | محمدی × عزیزی | completed T0−11d, paid out, then fully refunded | **pending nurse clawback**, support alert |
|
||||||
|
|
||||||
|
Plus: 9+ tickets (coordination per booking, 3 refund tickets, open support w/ an **internal admin note**,
|
||||||
|
closed support, emergency), notifications for every persona (mixed read/unread), 4 support alerts
|
||||||
|
(low-rating / EVV-mismatch / clawback / emergency), a paid + a draft payout batch, invoices for all 8
|
||||||
|
bookings (کریمی's resolve to the partner center as merchant-of-record).
|
||||||
|
|
||||||
|
> **Time decay:** the world is date-relative and ages. After ~24 h the pending request expires (the sweep
|
||||||
|
> runs every minute); after ~2 days B3's dispute window closes and the "review-eligible + in-window" state
|
||||||
|
> is gone; the accepted request's payment window lasts 24 h. **To refresh the timeline: drop the DB and
|
||||||
|
> reboot** (seeder guards make an in-place re-run a no-op, not a re-date).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Mock-vs-real map (critical for honest testing)
|
||||||
|
|
||||||
|
15 of 22 client domains hit the real API. **7 still run on in-browser mocks** (`USE_*_MOCK = true` in
|
||||||
|
`client/src/services/{domain}/constants.ts`) because a blocking backend REQ is deferred. On those screens
|
||||||
|
the UI shows **mock data regardless of the seeded DB** — test the UI/UX there, and verify the *server*
|
||||||
|
data through Swagger instead.
|
||||||
|
|
||||||
|
| Mocked domain | Screens affected | Server data still verifiable via |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `verification` | nurse `/nurse/verification/*` journey, admin `/admin/verification` queue | `GET nurse_verification/status` (nurse token), Swagger |
|
||||||
|
| `refunds` | customer cancel flow + `/bookings/[id]/refund_status`, wallet «استردادها» | `GET /api/v1/refunds/by_booking/{id}`, `GET /api/v1/admin_refunds` |
|
||||||
|
| `payouts` | nurse `/nurse/earnings*`, admin `/admin/payouts` | `GET /api/v1/nurse_payouts/earnings_balance|history`, `GET /api/v1/admin_payouts/batches` |
|
||||||
|
| `admin` | `/admin/config|holidays|audit|alerts|roles|users` | b1 admin endpoints in Swagger |
|
||||||
|
| `bnpl` | the whole D1–D5 BNPL checkout + wallet «اقساط» | `GET /api/v1/admin_bnpl/{id}` |
|
||||||
|
| `partnerCenter` | `/partner/*` portal + `/admin/partners` | `GET /api/v1/admin/partner-centers` |
|
||||||
|
| `patientRecords` | E2 family care record editor + nurse visit-note panel | `GET /api/v1/patients/{id}/care_records` |
|
||||||
|
|
||||||
|
**Real domains** (UI = server truth): auth, geography, patients, profiles, nurse-bank, addresses,
|
||||||
|
serviceAreas, catalog, search, bookingRequests, bookings, payment, reviews (incl. **admin moderation**),
|
||||||
|
tickets (incl. **admin ticket console**), notifications.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Flow walkthroughs
|
||||||
|
|
||||||
|
### 5.1 Auth & first-run (real)
|
||||||
|
|
||||||
|
| # | Steps | Expected |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| A1 | `/login` → fresh phone → request code → wrong code ×5 | Refused after 5 attempts until a fresh code; resend locked 120 s; no reveal whether the phone exists |
|
||||||
|
| A2 | Fresh phone, correct code | → `/select-role` (no roles yet) → pick خانواده → `/onboarding` wizard (relation → first patient) → customer home |
|
||||||
|
| A3 | Same phone again later | Straight to customer home (role remembered) |
|
||||||
|
| A4 | Nurse `09120000001` login | Routed to `/nurse` dashboard (never a flash of the customer shell) |
|
||||||
|
| A5 | Admin `09120000020` vs `09120000021` | Both land `/admin`; finance sees **only** money consoles in the sidebar |
|
||||||
|
| A6 | Deep link while logged out (e.g. `/bookings`) | → `/login?next=/bookings` → after login lands back on `/bookings` |
|
||||||
|
| A7 | Logged out, open `/` | Public landing (`/welcome` content, URL stays `/`) — ⚠ verify via `npm run build && npm start` (dev-server root-path quirk, ui-phase-13) |
|
||||||
|
|
||||||
|
### 5.2 Customer — discovery & booking request (real)
|
||||||
|
|
||||||
|
Login: `09120000010`.
|
||||||
|
|
||||||
|
| # | Steps | Expected |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| C1 | Home → search entry → category سالمند, region Tehran/whole city | Live count CTA > 0; unverified مریم احمدی **never** appears in any result |
|
||||||
|
| C2 | Results | عزیزی shows ★5.00 (1 نظر), 3 visits, tappable verified badge; district-3 search still shows her whole-city rows |
|
||||||
|
| C3 | Gender filter مرد | Only کریمی remains |
|
||||||
|
| C4 | Open عزیزی's profile | Trust dossier: badge → verification panel (credential **types only**, never numbers); services/prices tab; «نظرات» tab shows exactly **1** published review with tags — the pending + hidden ones never |
|
||||||
|
| C5 | «درخواست رزرو» → C4 form: patient حسن, address, tomorrow, morning window → submit | Request created; C5 tracker shows a **24 h** countdown ring |
|
||||||
|
| C6 | As عزیزی (second browser/profile): `/nurse/requests` | New request at top with urgency countdown; detail shows **only** customer notes + coarse city/district (⚠ stage-1 masked address) |
|
||||||
|
| C7 | Accept it | Customer C5 flips to accepted + **30-minute** payment window countdown (real window — the seeded one is a 24 h demo exception) |
|
||||||
|
| C8 | Reject another (with reason) | C5 terminal card + «درخواست دوباره» / «پرستاران مشابه» off-ramps |
|
||||||
|
|
||||||
|
### 5.3 Customer — checkout & payment (real contract, mock PSP)
|
||||||
|
|
||||||
|
| # | Steps | Expected |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| P1 | C5 of the seeded accepted request → «پرداخت» → C6 | Nurse identity + `<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 (D1–D5) | ⚠ Whole flow is client-**mock** (REQ-022/024); `/bookings/checkout/bnpl/gateway` only exists under `npm run dev`. Server-side BNPL truth: `admin_bnpl` via Swagger (B6 is `reverted`) |
|
||||||
|
|
||||||
|
### 5.4 Customer — bookings lifecycle (real)
|
||||||
|
|
||||||
|
| # | Steps | Expected |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| L1 | `/bookings` tabs | «در انتظار پاسخ»: pending request w/ countdown · «فعال»: B1, B2 · «گذشته»: B3, B4, B7, C8 |
|
||||||
|
| L2 | B1 (upcoming) detail | Status timeline at تایید شده; next-session headline T0+3d; `.ics` download; address card |
|
||||||
|
| L3 | B2 (package) detail | 5 sessions listed: 2 completed, 1 **در حال ویزیت** (presence headline), 2 scheduled |
|
||||||
|
| L4 | B3 review CTA | Star strip on the completed row → review form (context recap + moderation note) — but B3 already carries a pending review, so expect the **"under review"** state, never a second form |
|
||||||
|
| L5 | B4 | «مشاهده نظر» shows the published ★5 review state |
|
||||||
|
| L6 | B1 → لغو رزرو | Off-ramps first; tier disclosure: **≥48 h → 100%، ≥24 h → 50%، <24 h → 0%** (server tiers) — ⚠ the UI copy states a single 24 h boundary (REQ-065 divergence; record the exact numbers shown). ⚠ the actual cancel + refund-status screens run on the **mock** refunds domain — server truth via Swagger |
|
||||||
|
| L7 | B7 → وضعیت استرداد | ⚠ mock domain: UI shows mock stages. Server truth: refund `succeeded`, channel `psp_card` (`GET refunds/by_booking/{B7}`) |
|
||||||
|
| L8 | `/bookings/[B1]/invoice` | Real invoice: sequential number, VAT = 10% **on the commission line only**, مودیان state `pending`. ⚠ REQ-049: payment-method/reference/seller-fiscal block may be absent |
|
||||||
|
|
||||||
|
### 5.5 Customer — account, care circle, tickets, notifications
|
||||||
|
|
||||||
|
| # | Steps | Expected |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| E1 | `/patients` («حلقهٔ مراقبت») | حسن + فاطمه cards; add/edit/archive (real); tap → E2 record |
|
||||||
|
| E2 | `/patients/[حسن]/record` | ⚠ patientRecords domain is **mock**: meds/routine/tasks + the nurse visit-note history shown are mock data (the 2 real seeded notes are server-side only). Access-denied state testable with the mock's foreign-patient id 8888 |
|
||||||
|
| E3 | `/addresses` | Real CRUD; map is the canvas stand-in unless `NEXT_PUBLIC_NESHAN_KEY` is set |
|
||||||
|
| E4 | `/profile` | Identity header (server-masked phone), personal-info edit, language row, emergency-contact card; avatar upload round-trips |
|
||||||
|
| E5 | `/support/tickets` | Real inbox: refund tickets (`TKT-DEMORF0x`), open support `TKT-DEMOSUP1`, coordination tickets; thread of `TKT-DEMOSUP1` shows customer msg + admin reply + follow-up — **the internal admin note must never appear** |
|
||||||
|
| E6 | Send a message; send another containing `/fail` | Normal send lands; ⚠ `/fail` sentinel only drives the mock — on the real path it just sends |
|
||||||
|
| E7 | `/notifications` | Seeded rows, unread-first, day-grouped; mark-read flips the bell count. ⚠ **Notification titles/bodies are English** — server-rendered copy from the real handlers (finding F-4 §7) |
|
||||||
|
|
||||||
|
### 5.6 Nurse — daily ops (real except earnings)
|
||||||
|
|
||||||
|
Login: `09120000001`.
|
||||||
|
|
||||||
|
| # | Steps | Expected |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| N1 | `/nurse` dashboard | Next-visit card (B2 today), requests strip (pending w/ countdown), earnings snapshot (⚠ mock numbers — payouts domain), activation checklist collapsed to «فعال در جستجو» |
|
||||||
|
| N2 | `/nurse/visits` | Today's feed shows B2 session 3 **checked-in** → the check-out CTA is the hero action |
|
||||||
|
| N3 | Check out (confirm step) | Session completes; EVV panel updates. GPS: set `NEXT_PUBLIC_EVV_MOCK_GPS=in_range` in `client/.env.local` to force a passing location, else real geolocation runs (out-of-tolerance is **advisory** — banner + staff alert, never a block). Completing the **last** session would complete the booking and start its 72 h dispute clock |
|
||||||
|
| N4 | `/nurse/visits/[B2]` | Address card + map link (⚠ REQ-051: address may stay masked w/ quiet fallback note), gated care card (care instructions **readable** post-confirmation), visit-note composer (⚠ mock — patientRecords) |
|
||||||
|
| N5 | `/nurse/requests` | Tabs: pending (live countdown) / answered / expired. Accept behind confirm dialog → opens the payment window |
|
||||||
|
| N6 | `/nurse/earnings` + `/payouts` | ⚠ **Mock domain** — UI shows mock buckets. Server truth (Swagger, nurse token): `earnings_balance` = signed ledger sum (عزیزی: B1+B2+B3 payouts pending — B7 reversed, B4/C8 paid out), `history` shows the **paid** payout w/ masked IBAN |
|
||||||
|
| N7 | `/nurse/services` | 4 offerings incl. the package variant; edit/deactivate; the accepting-bookings toggle is real (pausing → disappears from search) |
|
||||||
|
| N8 | `/nurse/coverage` | Whole-city + districts 1&3; duplicate add → 409 message |
|
||||||
|
| N9 | `/nurse/bank` | Verified account, masked IBAN, ownership state «تأیید شده» |
|
||||||
|
| N10 | As کریمی (`09120000002`) | Draft-batch earnings waiting; B6's payout share reversed by the 50% refund; hidden review does **not** appear on his public profile (0 reviews) |
|
||||||
|
| N11 | As احمدی (`09120000003`) | Activation checklist far from complete; `/nurse/verification` — ⚠ **mock domain**: the journey UI shows mock state; the real mid-pipeline case (KYC ✓, Shahkar ✓, license in-review, criminal pending) is server-side (`nurse_verification/status` via Swagger). She must never appear in search |
|
||||||
|
|
||||||
|
### 5.7 Admin console
|
||||||
|
|
||||||
|
Login: `09120000020` (all consoles) and `09120000021` (money only).
|
||||||
|
|
||||||
|
| # | Console | Real/Mock | What the seeded world provides (server-side) |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| AD1 | `/admin/reviews` moderation queue | **Real** | B3's pending review — publish it → عزیزی's public aggregate recomputes (★ count 2); hide/reject paths; the hidden 1★ shows the hide flow already happened |
|
||||||
|
| AD2 | `/admin/tickets` | **Real** | Full queue incl. emergency (red) + refund tickets; thread of `TKT-DEMOSUP1` **shows** the internal note distinctly; composer's internal-note mode goes amber |
|
||||||
|
| AD3 | `/admin/verification` | ⚠ Mock | Real queue data (احمدی in review + document metadata) via Swagger `admin_verifications`; the document's signed-URL fetch 404s (no real bytes — expected) |
|
||||||
|
| AD4 | `/admin/payouts` | ⚠ Mock | Real: `admin_payouts/batches` shows the **completed** batch (2 links) + the **draft** batch; `batches/{id}/process` on the draft is the demoable money-out action; `eligible` preview |
|
||||||
|
| AD5 | Refunds (via ticket RefundPanel) | ⚠ Mock | Real: `admin_refunds` lists 3 refunds; `{id}/confirm_settlement` on the **processing BNPL** refund is the demoable action (posts the clearing leg) |
|
||||||
|
| AD6 | `/admin/alerts` | ⚠ Mock | Real: 4 seeded alerts (low_rating / evv_location_mismatch / nurse_clawback / emergency) via `support_alerts` endpoints |
|
||||||
|
| AD7 | `/admin/config`, `/admin/holidays`, `/admin/audit`, `/admin/users`, `/admin/roles` | ⚠ Mock | Real: b1 endpoints (config list incl. the §6 numbers, audit trail rows for every seeded money/trust entity — actor `null` = system) |
|
||||||
|
| AD8 | `/admin/partners` | ⚠ Mock | Real: `admin/partner-centers` shows مرکز پرستاری آرامش (MoR, verified, masked IBAN), کریمی sponsored |
|
||||||
|
|
||||||
|
### 5.8 Partner portal (⚠ mock — REQ-032/033/038)
|
||||||
|
|
||||||
|
Login `09120000030` → navigate to `/partner` manually. UI runs on mock center data; the real center +
|
||||||
|
MoR invoice resolution (کریمی's two invoices carry `issuing_entity_type = partner_center`) is verifiable
|
||||||
|
via Swagger. Test the portal chrome, the MoR settlement view, and the CSV export against mock data.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. The numbers the UI must respect
|
||||||
|
|
||||||
|
From seeded `platform_configs` (admins can change them; rates are **snapshotted** — a change never
|
||||||
|
re-prices an existing booking/invoice/deadline):
|
||||||
|
|
||||||
|
| Rule | Value |
|
||||||
|
| --- | --- |
|
||||||
|
| Nurse response deadline | **24 h** (frozen per request) |
|
||||||
|
| Payment window after accept | **30 min** (frozen per request) |
|
||||||
|
| Dispute window after completion | **72 h** → then payout-eligible |
|
||||||
|
| Platform commission | **15%** of gross (`gross = commission + payout`, always) |
|
||||||
|
| VAT | **10%**, on the **commission line only** |
|
||||||
|
| Cancellation tiers | ≥48 h → 100% · ≥24 h → 50% · <24 h → 0% (⚠ client copy says a single 24 h boundary — REQ-065) |
|
||||||
|
| BNPL provider fee | **7%** — a **platform expense**; the nurse payout is payment-method-invariant |
|
||||||
|
| BNPL refund ETA | ~**10 business days** (Fridays skipped); UI copy says 7–10 |
|
||||||
|
| EVV tolerance | **200 m**, advisory only |
|
||||||
|
| No-show threshold | **60 min** after scheduled start |
|
||||||
|
| Payout cadence | weekly generation (auto, draft); **processing stays a manual admin action** |
|
||||||
|
| Review alert | rating ≤ **2** raises a staff alert |
|
||||||
|
| OTP | resend 120 s · 5 attempts · code lives 60 s · session 30 d · access token 60 min |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Findings from this bring-up (decide / track)
|
||||||
|
|
||||||
|
Recorded while building the seed — each needs an owner decision:
|
||||||
|
|
||||||
|
| # | Finding | State |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| F-1 | **Committed real DB credential**: `appsettings.Development.json` ships the remote server's SQL login in git (the exact thing refinement-phase-5 removed; the pre-commit hook that blocks it isn't enabled on this clone). Recommendation: move the connection strings to user-secrets like the crypto keys and restore placeholders. | **Open — decision needed** |
|
||||||
|
| F-2 | **`/me` hardcoded `hasCustomerProfile`/`hasNurseProfile`/`nurseVerificationStatus`** to `false`/`not_started` for everyone (broke the customer home profile nudge + profile-hook invalidations). | **Fixed** this session (snapshot projection + `ToMeResult`) |
|
||||||
|
| F-3 | **Stored Persian text is normalized destructively**: an `ApplicationDbContext` save hook runs `Fa2En().FixPersianChars()` over every string property — Persian digits become ASCII and **ZWNJ becomes a space** in everything persisted (e.g. «شبانهروزی» → «شبانه روزی»), contradicting the client's STYLE.md ZWNJ rules. Any Persian-literal equality against stored data silently misses. | **Open — decision needed** (legacy starter behavior; removing it changes stored data shape) |
|
||||||
|
| F-4 | **Notification copy is English on the real path** — the b-phase handlers write `"Booking confirmed"`-style titles/bodies straight into `ops.Notifications`; the Persian UI renders them verbatim. | **Open — decision needed** (server-side i18n or client-side type→copy mapping) |
|
||||||
|
| F-5 | Mock PSP redirect goes to a dead host (`mock-psp.local`) — in-browser card completion impossible until a real PSP is configured (`Payments:Provider=zarinpal`); the Dev `bookings/convert` affordance is the workaround (§5.3 P3). | By design (refinement-phase-8 rails exist behind config) |
|
||||||
|
| F-6 | 7 client domains still mocked (§4) — the biggest lever for "test everything for real" is flipping `verification`, `refunds`, `payouts` (user halves are contract-ready; the blocking REQs are admin-side). Flipping a shared flag turns the admin half's screens into real-API screens against missing endpoints. | **Open — decision needed** (split flags per half, or deliver the admin REQs) |
|
||||||
|
| F-7 | Seeded object-storage keys (verification document, invoice PDFs) have no real bytes — signed-URL fetches 404 in admin viewers. | Accepted for dev (never fake bytes) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Suggested test order
|
||||||
|
|
||||||
|
1. **Auth & routing** (§5.1) — everything depends on it.
|
||||||
|
2. **Customer discovery → request → accept → convert → booking** (§5.2–5.4) — the core funnel, all real.
|
||||||
|
3. **Nurse ops** (§5.6) — inbox/EVV against the same bookings.
|
||||||
|
4. **Reviews + tickets + notifications** (§5.4 L4/L5, §5.5 E5–E7) — real, seeded both sides.
|
||||||
|
5. **Admin real consoles** (§5.7 AD1/AD2) — moderation + tickets.
|
||||||
|
6. **Mock-domain UI passes** (§5.3 P5, §5.5 E2, §5.6 N6/N11, §5.7 AD3–AD8, §5.8) — UX only, verify server truth via Swagger as listed.
|
||||||
|
7. Sweep §7's decisions.
|
||||||
@@ -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
|
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**.
|
`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/`)
|
### Terminal 2 — frontend (from `client/`)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -114,6 +123,7 @@ endpoint works). Use them to see the real path populated:
|
|||||||
| `09120000011` | customer | رضا حسینی (male) | 1 patient, 1 Tehran address |
|
| `09120000011` | customer | رضا حسینی (male) | 1 patient, 1 Tehran address |
|
||||||
| `09120000020` | admin (`super_admin`) | نگار مدیری (female) | full backoffice — **lands on `/admin`**, sees every console incl. RBAC |
|
| `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) |
|
| `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 —
|
> 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,
|
> no committed credential). To bootstrap a break-glass username+password admin, set both secrets before boot,
|
||||||
|
|||||||
@@ -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()`
|
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
|
**only under `IsDevelopment()`** — never in Production/Staging. The demo world (phones, which nurse is
|
||||||
verified) is in `dev/post-phase/refinement/RUNBOOK.md`.
|
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`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -131,6 +131,7 @@ if (args.Any(a => string.Equals(a, "migrate", StringComparison.OrdinalIgnoreCase
|
|||||||
{
|
{
|
||||||
await app.SeedPaymentGatewaysAsync();
|
await app.SeedPaymentGatewaysAsync();
|
||||||
await app.SeedDemoWorldAsync();
|
await app.SeedDemoWorldAsync();
|
||||||
|
await app.SeedDemoLifecycleAsync();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -142,12 +143,14 @@ if (!app.Environment.IsEnvironment("Testing"))
|
|||||||
if (app.Environment.IsDevelopment())
|
if (app.Environment.IsDevelopment())
|
||||||
{
|
{
|
||||||
// Local convenience: apply migrations + seed on boot. Development-only: a sandbox payment gateway
|
// 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
|
// (all-zeros merchant id), a demo marketplace (nurses/variants/search rows, customers/patients), and
|
||||||
// real-path screens aren't empty. Neither belongs in a deployed DB — both are idempotent.
|
// 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.ApplyMigrationsAsync();
|
||||||
await app.SeedDefaultUsersAsync();
|
await app.SeedDefaultUsersAsync();
|
||||||
await app.SeedPaymentGatewaysAsync();
|
await app.SeedPaymentGatewaysAsync();
|
||||||
await app.SeedDemoWorldAsync();
|
await app.SeedDemoWorldAsync();
|
||||||
|
await app.SeedDemoLifecycleAsync();
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -12,6 +12,9 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<InternalsVisibleTo Include="Baya.Test.Foundation" />
|
<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>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\Baya.Domain\Baya.Domain.csproj" />
|
<ProjectReference Include="..\Baya.Domain\Baya.Domain.csproj" />
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ internal static class IdentityDefaults
|
|||||||
snapshot.Gender,
|
snapshot.Gender,
|
||||||
snapshot.IsActive,
|
snapshot.IsActive,
|
||||||
snapshot.Roles,
|
snapshot.Roles,
|
||||||
HasCustomerProfile: false,
|
snapshot.HasCustomerProfile,
|
||||||
HasNurseProfile: false,
|
snapshot.HasNurseProfile,
|
||||||
NurseVerificationStatus: MeResult.VerificationNotStarted);
|
snapshot.NurseVerificationStatus);
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
#nullable enable
|
#nullable enable
|
||||||
namespace Baya.Application.Models.Identity;
|
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(
|
public record UserAccountSnapshot(
|
||||||
int Id,
|
int Id,
|
||||||
string? Phone,
|
string? Phone,
|
||||||
@@ -9,4 +11,7 @@ public record UserAccountSnapshot(
|
|||||||
string? LastName,
|
string? LastName,
|
||||||
string? Gender,
|
string? Gender,
|
||||||
bool IsActive,
|
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;
|
AverageRating = averageRating;
|
||||||
TotalReviews = totalReviews;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
+34
-4
@@ -1,6 +1,8 @@
|
|||||||
using Baya.Application.Contracts.Persistence;
|
using Baya.Application.Contracts.Persistence;
|
||||||
using Baya.Application.Models.Identity;
|
using Baya.Application.Models.Identity;
|
||||||
|
using Baya.Domain.Entities.Identity;
|
||||||
using Baya.Domain.Entities.User;
|
using Baya.Domain.Entities.User;
|
||||||
|
using Baya.Domain.Entities.Verification;
|
||||||
using Baya.Infrastructure.Persistence.Repositories.Common;
|
using Baya.Infrastructure.Persistence.Repositories.Common;
|
||||||
using Microsoft.EntityFrameworkCore;
|
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.
|
// 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)
|
.Where(u => u.Id == userId)
|
||||||
.Select(u => new UserAccountSnapshot(
|
.Select(u => new
|
||||||
|
{
|
||||||
u.Id,
|
u.Id,
|
||||||
u.PhoneNumber,
|
u.PhoneNumber,
|
||||||
u.Name,
|
u.Name,
|
||||||
u.FamilyName,
|
u.FamilyName,
|
||||||
u.Gender,
|
u.Gender,
|
||||||
u.IsActive,
|
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);
|
.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)
|
public Task<User> GetTrackedByIdAsync(int userId, CancellationToken cancellationToken)
|
||||||
|
|||||||
+16
-1
@@ -88,9 +88,10 @@ public static class ServiceCollectionExtensions
|
|||||||
throw new NotSupportedException(
|
throw new NotSupportedException(
|
||||||
$"Search backend '{searchBackend}' is not available — only 'sql' is implemented (Elasticsearch is deferred).");
|
$"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.
|
// only under IsDevelopment() — never against a production/staging DB.
|
||||||
services.AddScoped<DemoWorldSeeder>();
|
services.AddScoped<DemoWorldSeeder>();
|
||||||
|
services.AddScoped<DemoLifecycleSeeder>();
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
@@ -165,4 +166,18 @@ public static class ServiceCollectionExtensions
|
|||||||
var seeder = scope.ServiceProvider.GetRequiredService<DemoWorldSeeder>();
|
var seeder = scope.ServiceProvider.GetRequiredService<DemoWorldSeeder>();
|
||||||
await seeder.SeedAsync(CancellationToken.None);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+103
@@ -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);
|
||||||
+372
@@ -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 T0−4d keeps the still-in-draft-window bookings (dispute end T0−2d/−3d) out of this batch.
|
||||||
|
var generate = await sender.Send(
|
||||||
|
new GeneratePayoutBatchCommand(Today(now, -21), Today(now, -4)) { SystemInitiated = true },
|
||||||
|
cancellationToken);
|
||||||
|
if (!generate.IsSuccess)
|
||||||
|
{
|
||||||
|
logger.LogWarning("Demo lifecycle: paid payout batch generation failed — skipping the paid-batch scenario.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The seeder is the only writer at boot, so the newest batch is the one just generated.
|
||||||
|
var batchId = await db.Set<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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+485
@@ -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 (B3–B6) and the admin
|
||||||
|
// verification queue. is_verified is deliberately never touched (status != approved).
|
||||||
|
// ---------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private async Task EnsureVerificationPipelineAsync(PersonaWorld world, DateTime now, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var ahmadi = world.Nurses[2];
|
||||||
|
var verification = await db.Set<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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+661
@@ -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, 4–5 still ahead.
|
||||||
|
booking.TransitionTo(BookingStatus.InProgress, now.AddDays(-2));
|
||||||
|
|
||||||
|
var sessions = booking.Sessions.OrderBy(s => s.SessionIndex).ToList();
|
||||||
|
for (var i = 0; i < sessions.Count; i++)
|
||||||
|
{
|
||||||
|
sessions[i].ScheduledDate = Today(now, i - 2);
|
||||||
|
switch (i)
|
||||||
|
{
|
||||||
|
case < 2:
|
||||||
|
sessions[i].TransitionTo(BookingSessionStatus.InProgress);
|
||||||
|
sessions[i].TransitionTo(BookingSessionStatus.Completed);
|
||||||
|
sessions[i].SetPayoutEligible(now.AddDays(i - 2).AddHours(72));
|
||||||
|
break;
|
||||||
|
case 2:
|
||||||
|
sessions[i].TransitionTo(BookingSessionStatus.InProgress);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// "upcoming" stays exactly as the factory built it: confirmed, all sessions scheduled.
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user