From de53f9d8a6eb839040555631f8dea7d1558dc810 Mon Sep 17 00:00:00 2001 From: hamid Date: Thu, 9 Jul 2026 04:09:35 +0330 Subject: [PATCH] backend phase 13 & frontend phase 6 --- client/CLAUDE.md | 16 +- client/messages/en.json | 65 +- client/messages/fa.json | 65 +- .../(customer)/bookings/request/page.tsx | 32 + .../search/nurse/[nurseId]/page.tsx | 247 + .../(customer)/search/page.tsx | 221 +- .../(customer)/search/results/page.tsx | 136 + .../(customer)/search/useSearchFilters.ts | 68 + .../NurseResultCard/NurseResultCard.test.tsx | 82 + .../NurseResultCard/NurseResultCard.tsx | 117 + .../src/components/NurseResultCard/index.tsx | 2 + .../ServicePriceRow/ServicePriceRow.test.tsx | 36 + .../ServicePriceRow/ServicePriceRow.tsx | 48 + .../src/components/ServicePriceRow/index.tsx | 2 + .../src/components/common/AppIcon/config.ts | 5 + client/src/components/index.tsx | 6 + client/src/constants/routes.ts | 8 +- client/src/services/search/apis/clientApi.ts | 116 + client/src/services/search/apis/index.ts | 10 + client/src/services/search/apis/mockApi.ts | 130 + client/src/services/search/apis/seed.ts | 180 + client/src/services/search/constants.ts | 24 + client/src/services/search/filterParams.ts | 69 + .../search/hooks/useDebouncedValue.ts | 17 + .../services/search/hooks/useNurseProfile.ts | 18 + .../services/search/hooks/useNurseSearch.ts | 23 + client/src/services/search/index.ts | 3 + client/src/services/search/keys.ts | 37 + client/src/services/search/types.ts | 129 + dev/contracts/domains/payouts.md | 99 + dev/contracts/openapi/swagger.v1.json | 1221 +++- dev/shared-working-context/backend/STATUS.md | 19 + .../backend/handoff/after-backend-phase-13.md | 51 + dev/shared-working-context/frontend/STATUS.md | 23 + .../frontend/requests/for-backend.md | 21 + .../reports/backend-phase-13-report.md | 76 + .../reports/frontend-phase-6-report.md | 88 + .../reports/mocks-registry.md | 4 +- product/business/10-payouts.md | 18 + server/CLAUDE.md | 40 +- .../Controllers/V1/AdminPayoutsController.cs | 74 + .../Controllers/V1/NursePayoutsController.cs | 28 + .../Payments/IBankTransferProvider.cs | 71 + .../Persistence/IPayoutRepository.cs | 77 + .../Contracts/Persistence/IUnitOfWork.cs | 1 + .../ExecutePayoutBatchCommand.Handler.cs | 114 + .../ExecutePayoutBatchCommand.Validator.cs | 11 + .../ExecutePayoutBatchCommand.cs | 12 + .../GeneratePayoutBatchCommand.Handler.cs | 160 + .../GeneratePayoutBatchCommand.Validator.cs | 18 + .../GeneratePayoutBatchCommand.cs | 12 + .../MarkPayoutFailedCommand.Handler.cs | 41 + .../MarkPayoutFailedCommand.Validator.cs | 12 + .../MarkPayoutFailedCommand.cs | 8 + .../RetryFailedPayoutCommand.Handler.cs | 82 + .../RetryFailedPayoutCommand.Validator.cs | 11 + .../RetryFailedPayoutCommand.cs | 9 + .../Features/Payouts/PayoutSettlement.cs | 58 + .../ComputeEligibleEarningsQuery.Handler.cs | 35 + .../ComputeEligibleEarningsQuery.Validator.cs | 18 + .../ComputeEligibleEarningsQuery.cs | 12 + .../GetBatchDetailQuery.Handler.cs | 23 + .../GetBatchDetail/GetBatchDetailQuery.cs | 10 + .../GetNursePayoutHistoryQuery.Handler.cs | 33 + .../GetNursePayoutHistoryQuery.cs | 10 + .../ListPayoutBatchesQuery.Handler.cs | 21 + .../ListPayoutBatchesQuery.cs | 9 + .../Models/Payouts/PayoutProjections.cs | 87 + .../Entities/Payments/LedgerPosting.cs | 54 +- .../Entities/Payouts/NursePayout.cs | 93 + .../Entities/Payouts/NursePayoutBatch.cs | 88 + .../Payouts/NursePayoutBookingLink.cs | 32 + .../Entities/Payouts/PayoutBatchStatus.cs | 26 + .../Payouts/PayoutBatchTransitions.cs | 25 + .../Entities/Payouts/PayoutStatus.cs | 24 + .../Payouts/PayoutStatusTransitions.cs | 25 + .../Entities/Refunds/NurseClawback.cs | 12 + .../Seams/MockBankTransferProvider.cs | 49 + .../Seams/SeamOptions.cs | 18 + .../ServiceCollectionExtension.cs | 7 + .../ApplicationDbContext.cs | 7 + .../PlatformConfigConfig.cs | 2 + .../PayoutsConfig/NursePayoutBatchConfig.cs | 32 + .../NursePayoutBookingLinkConfig.cs | 31 + .../PayoutsConfig/NursePayoutConfig.cs | 40 + ...260709000908_NursePayoutEngine.Designer.cs | 5260 +++++++++++++++++ .../20260709000908_NursePayoutEngine.cs | 248 + .../ApplicationDbContextModelSnapshot.cs | 273 + .../Repositories/Common/UnitOfWork.cs | 2 + .../Repositories/PayoutRepository.cs | 241 + .../ServiceCollectionExtensions.cs | 8 +- .../Payments/NursePayoutLinkStatusService.cs | 34 + .../Payments/NursePayoutStatusService.cs | 35 - .../Baya.Test.Api/AdminPayoutsApiTests.cs | 171 + .../Baya.Test.Api/NursePayoutsApiTests.cs | 48 + .../Payouts/PayoutHandlerTests.cs | 246 + .../Payouts/PayoutsTestHost.cs | 286 + 97 files changed, 11969 insertions(+), 77 deletions(-) create mode 100644 client/src/app/[locale]/(private-routes)/(customer)/bookings/request/page.tsx create mode 100644 client/src/app/[locale]/(private-routes)/(customer)/search/nurse/[nurseId]/page.tsx create mode 100644 client/src/app/[locale]/(private-routes)/(customer)/search/results/page.tsx create mode 100644 client/src/app/[locale]/(private-routes)/(customer)/search/useSearchFilters.ts create mode 100644 client/src/components/NurseResultCard/NurseResultCard.test.tsx create mode 100644 client/src/components/NurseResultCard/NurseResultCard.tsx create mode 100644 client/src/components/NurseResultCard/index.tsx create mode 100644 client/src/components/ServicePriceRow/ServicePriceRow.test.tsx create mode 100644 client/src/components/ServicePriceRow/ServicePriceRow.tsx create mode 100644 client/src/components/ServicePriceRow/index.tsx create mode 100644 client/src/services/search/apis/clientApi.ts create mode 100644 client/src/services/search/apis/index.ts create mode 100644 client/src/services/search/apis/mockApi.ts create mode 100644 client/src/services/search/apis/seed.ts create mode 100644 client/src/services/search/constants.ts create mode 100644 client/src/services/search/filterParams.ts create mode 100644 client/src/services/search/hooks/useDebouncedValue.ts create mode 100644 client/src/services/search/hooks/useNurseProfile.ts create mode 100644 client/src/services/search/hooks/useNurseSearch.ts create mode 100644 client/src/services/search/index.ts create mode 100644 client/src/services/search/keys.ts create mode 100644 client/src/services/search/types.ts create mode 100644 dev/contracts/domains/payouts.md create mode 100644 dev/shared-working-context/backend/handoff/after-backend-phase-13.md create mode 100644 dev/shared-working-context/reports/backend-phase-13-report.md create mode 100644 dev/shared-working-context/reports/frontend-phase-6-report.md create mode 100644 server/src/API/Baya.Web.Api/Controllers/V1/AdminPayoutsController.cs create mode 100644 server/src/API/Baya.Web.Api/Controllers/V1/NursePayoutsController.cs create mode 100644 server/src/Core/Baya.Application/Contracts/Payments/IBankTransferProvider.cs create mode 100644 server/src/Core/Baya.Application/Contracts/Persistence/IPayoutRepository.cs create mode 100644 server/src/Core/Baya.Application/Features/Payouts/Commands/ExecutePayoutBatch/ExecutePayoutBatchCommand.Handler.cs create mode 100644 server/src/Core/Baya.Application/Features/Payouts/Commands/ExecutePayoutBatch/ExecutePayoutBatchCommand.Validator.cs create mode 100644 server/src/Core/Baya.Application/Features/Payouts/Commands/ExecutePayoutBatch/ExecutePayoutBatchCommand.cs create mode 100644 server/src/Core/Baya.Application/Features/Payouts/Commands/GeneratePayoutBatch/GeneratePayoutBatchCommand.Handler.cs create mode 100644 server/src/Core/Baya.Application/Features/Payouts/Commands/GeneratePayoutBatch/GeneratePayoutBatchCommand.Validator.cs create mode 100644 server/src/Core/Baya.Application/Features/Payouts/Commands/GeneratePayoutBatch/GeneratePayoutBatchCommand.cs create mode 100644 server/src/Core/Baya.Application/Features/Payouts/Commands/MarkPayoutFailed/MarkPayoutFailedCommand.Handler.cs create mode 100644 server/src/Core/Baya.Application/Features/Payouts/Commands/MarkPayoutFailed/MarkPayoutFailedCommand.Validator.cs create mode 100644 server/src/Core/Baya.Application/Features/Payouts/Commands/MarkPayoutFailed/MarkPayoutFailedCommand.cs create mode 100644 server/src/Core/Baya.Application/Features/Payouts/Commands/RetryFailedPayout/RetryFailedPayoutCommand.Handler.cs create mode 100644 server/src/Core/Baya.Application/Features/Payouts/Commands/RetryFailedPayout/RetryFailedPayoutCommand.Validator.cs create mode 100644 server/src/Core/Baya.Application/Features/Payouts/Commands/RetryFailedPayout/RetryFailedPayoutCommand.cs create mode 100644 server/src/Core/Baya.Application/Features/Payouts/PayoutSettlement.cs create mode 100644 server/src/Core/Baya.Application/Features/Payouts/Queries/ComputeEligibleEarnings/ComputeEligibleEarningsQuery.Handler.cs create mode 100644 server/src/Core/Baya.Application/Features/Payouts/Queries/ComputeEligibleEarnings/ComputeEligibleEarningsQuery.Validator.cs create mode 100644 server/src/Core/Baya.Application/Features/Payouts/Queries/ComputeEligibleEarnings/ComputeEligibleEarningsQuery.cs create mode 100644 server/src/Core/Baya.Application/Features/Payouts/Queries/GetBatchDetail/GetBatchDetailQuery.Handler.cs create mode 100644 server/src/Core/Baya.Application/Features/Payouts/Queries/GetBatchDetail/GetBatchDetailQuery.cs create mode 100644 server/src/Core/Baya.Application/Features/Payouts/Queries/GetNursePayoutHistory/GetNursePayoutHistoryQuery.Handler.cs create mode 100644 server/src/Core/Baya.Application/Features/Payouts/Queries/GetNursePayoutHistory/GetNursePayoutHistoryQuery.cs create mode 100644 server/src/Core/Baya.Application/Features/Payouts/Queries/ListPayoutBatches/ListPayoutBatchesQuery.Handler.cs create mode 100644 server/src/Core/Baya.Application/Features/Payouts/Queries/ListPayoutBatches/ListPayoutBatchesQuery.cs create mode 100644 server/src/Core/Baya.Application/Models/Payouts/PayoutProjections.cs create mode 100644 server/src/Core/Baya.Domain/Entities/Payouts/NursePayout.cs create mode 100644 server/src/Core/Baya.Domain/Entities/Payouts/NursePayoutBatch.cs create mode 100644 server/src/Core/Baya.Domain/Entities/Payouts/NursePayoutBookingLink.cs create mode 100644 server/src/Core/Baya.Domain/Entities/Payouts/PayoutBatchStatus.cs create mode 100644 server/src/Core/Baya.Domain/Entities/Payouts/PayoutBatchTransitions.cs create mode 100644 server/src/Core/Baya.Domain/Entities/Payouts/PayoutStatus.cs create mode 100644 server/src/Core/Baya.Domain/Entities/Payouts/PayoutStatusTransitions.cs create mode 100644 server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/MockBankTransferProvider.cs create mode 100644 server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/PayoutsConfig/NursePayoutBatchConfig.cs create mode 100644 server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/PayoutsConfig/NursePayoutBookingLinkConfig.cs create mode 100644 server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/PayoutsConfig/NursePayoutConfig.cs create mode 100644 server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260709000908_NursePayoutEngine.Designer.cs create mode 100644 server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260709000908_NursePayoutEngine.cs create mode 100644 server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/PayoutRepository.cs create mode 100644 server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Payments/NursePayoutLinkStatusService.cs delete mode 100644 server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Payments/NursePayoutStatusService.cs create mode 100644 server/src/Tests/Baya.Test.Api/AdminPayoutsApiTests.cs create mode 100644 server/src/Tests/Baya.Test.Api/NursePayoutsApiTests.cs create mode 100644 server/src/Tests/Baya.Test.Foundation/Payouts/PayoutHandlerTests.cs create mode 100644 server/src/Tests/Baya.Test.Foundation/Payouts/PayoutsTestHost.cs diff --git a/client/CLAUDE.md b/client/CLAUDE.md index 2e6fc66..7fef783 100644 --- a/client/CLAUDE.md +++ b/client/CLAUDE.md @@ -119,9 +119,15 @@ client/ │ │ ├── (customer)/ # Customer (family) app — mobile-first, bottom-tab nav; no URL segment │ │ │ ├── layout.tsx # 'use client' — wraps CustomerLayout │ │ │ ├── page.tsx # / (A5 home — 'use client'; greeting+avatar, search bar, data-driven category grid, first-login onboarding gate + record/profile nudges) - │ │ │ ├── search/page.tsx # /search — DEFERRED→f6 stub (PlaceholderScreen; Home search bar + category tiles land here carrying q/category_id) + │ │ │ ├── search/ # /search — f6 discovery: C1 filter screen (page.tsx: reused category grid + f3 region picker + prominent same-gender facet + Toman price + live-count CTA; useSearchFilters colocated controller) → results/ (C2) → nurse/[nurseId]/ (C3) +│ │ │ │ ├── page.tsx # C1 search & filter; reads ?category_id preselect; pushes filter set to C2 as URL query params +│ │ │ │ ├── useSearchFilters.ts # C1 colocated filter controller (debounced Toman price → IRR; derives the canonical NurseSearchFilters) +│ │ │ │ ├── results/page.tsx # C2 results — rating-sorted NurseResultCard list; all four states (skeleton/empty-relax/error/populated); load-more; filters live in the URL (the cache key) +│ │ │ │ └── nurse/[nurseId]/page.tsx # C3 nurse profile — badges (TrustBadge + نظام پرستاری) + attribute chips + ServicePriceRow list + latest review; "درخواست رزرو" hands off to /bookings/request (f7) │ │ │ ├── onboarding/page.tsx # /onboarding — A3→A4 wizard (relation → first patient) - │ │ │ ├── bookings/page.tsx # /bookings + │ │ │ ├── bookings/ + │ │ │ │ ├── page.tsx # /bookings + │ │ │ │ └── request/page.tsx # /bookings/request — f6→f7 booking handoff target (DEFERRED→f7 stub; echoes carried nurse/variant/required_gender intent) │ │ │ ├── patients/page.tsx # /patients — E1 list/CRUD (add/edit dialog reusing PatientForm, soft-archive) │ │ │ ├── addresses/page.tsx # /addresses — F3 address book (cascading region dropdowns + map-pin picker, set-primary) │ │ │ ├── wallet/page.tsx # /wallet @@ -166,6 +172,8 @@ client/ │ ├── VariantCard/ # f4 nurse offering card: display_name, PriceDisplay, active/deactivated distinction, edit/deactivate (no delete) (tested) │ ├── TrustBadge/ # f5 public trust signal (verified/unverified/expired) off --bal-* tokens — nurse profile + reused by f6 search/public profile (tested) │ ├── DocumentUpload/ # f5 reusable doc uploader: client type/size validation, progress %, success/retry, re-upload on reject; server-metadata truth (local-capture mode too) (tested) + │ ├── NurseResultCard/ # f6 C2 result card: avatar+name, reused verified TrustBadge, rating+review count, optional distance chip, "from X تومان/unit" via PriceDisplay; presentational + memoized (tested) + │ ├── ServicePriceRow/ # f6 C3 service line: localised name + PriceDisplay (money util + i18n unit label); reused by the booking summary later (tested) │ ├── geography/ # F3 geo composites: CascadingRegionSelect, AddressMapPicker (map-pin stand-in), AddressForm, AddressCard (each tested) │ └── auth/ # Auth-flow composites: LoginFlow, PhoneStep, OtpStep, RoleRouter, SelectRole, AuthCard, BrandMark, AuthSplash, useCountdown ├── i18n/ @@ -215,6 +223,7 @@ client/ │ ├── addresses/ # F3 customer address book CRUD + set-primary (single-primary invariant; invalidate-on-mutation) │ ├── serviceAreas/ # F3 nurse coverage areas add/remove (areaExists dup-guard; districtId=null = whole city) │ ├── catalog/ # F4 catalog skeleton + nurse pricing variants (b5). Reference data (categories, category option groups) cached session-long like geography (Infinity staleTime); myVariants invalidated on mutation. useServiceCategories/useCategoryOptionGroups/useMyVariants/useCreateVariant/useUpdateVariant/useSetVariantActive; seam+mock+client; names.ts locale-label helper + │ ├── search/ # F6 family discovery (b7). The **filter object IS the query key** (searchKeys.results + canonicalizeSearchFilters): identical/reverted filters reuse cache with zero network (keepPreviousData avoids flashing). useNurseSearch/useNurseProfile/useDebouncedValue; filterParams.ts = the shared C1↔C2 URL (de)serializer; seam+mock(PRIMARY)+client. Mock supplies name/avatar/distance/profile/reviews that b7's index row + b5/b6 reads don't yet expose (gap filed in for-backend.md). Every returned row is verified-by-invariant — the UI never re-filters │ ├── verification/ # F5 nurse trust flow (b6). ONE cached status() query drives B3+B6; every mutation invalidates it. useVerificationStatus/useStartVerification/useSubmitIdentity/useRunBankVerification/useUploadVerificationDocument/useSubmitCredentials/useNurseTrustBadge; seam+mock(primary)+client; validation.ts (national-ID checksum); types export ownBadgeState/publicBadgeState/isApproved │ └── {domain}/ │ ├── types.ts # Request/response types + the domain's Api interface (the seam) @@ -308,7 +317,8 @@ async function MyServerComponent() { - `'coverage'` — the nurse coverage-area editor (whole-city/specific-district scope, chips, duplicate + "won't appear in search" warnings) - `'catalog'` — **shared** catalog vocabulary: the five `price_unit` labels + count nouns + the estimated-total label (read by `PriceDisplay`; f6 reuses it customer-side) - `'services'` — the f4 nurse Services & prices surface (offerings list, the variant builder steps/fields/validation, the duplicate-listing warning, deactivate confirm) -- `'search'` — the f4 deferred `/search` placeholder (title + "arrives next phase" + query/category echo); f6 fills it out +- `'search'` — the f6 discovery flow (C1/C2/C3): filter section labels, the same-gender facet + hint, sort/count (ICU plural), all four result states + "relax filters" suggestions, card labels (rating/distance/from-price), profile badges (تاییدشده/نظام پرستاری)/attribute chips/specialty codes/services/latest review, and the "درخواست رزرو" CTA +- `'booking'` — the f6→f7 booking-request handoff placeholder (title + "arrives next phase" + carried nurse/variant/gender echo); f7 fills it out - `'verification'` — the f5 nurse trust flow: B3/B4/B5/B6 copy, per-step labels + status labels (keyed off code, never derived), the DocumentUpload state chrome, TrustBadge labels, the honesty-sensitive manual-vs-auto copy, the publish-gate + shared-SIM/mismatch messages - `'auth'` — the phone-OTP login flow, role router, and SelectRole screen (`common.brand`/`brand_tagline` for the wordmark) diff --git a/client/messages/en.json b/client/messages/en.json index 6095f24..2a0965c 100644 --- a/client/messages/en.json +++ b/client/messages/en.json @@ -310,10 +310,67 @@ "saved_toast": "Changes saved" }, "search": { - "title": "Search", - "deferred": "Search and results arrive in the next phase.", - "query_echo": "You searched for “{query}”.", - "category_echo": "Filtered to the selected service category." + "title": "Find a nurse", + "subtitle": "Only verified, background-checked nurses appear here.", + "section_category": "Care category", + "section_location": "City", + "section_gender": "Caregiver gender", + "section_date": "Date", + "section_price": "Price range (optional)", + "gender_female": "Woman", + "gender_male": "Man", + "gender_any": "No preference", + "gender_hint": "For personal and bodily care, many families prefer a same-gender caregiver. Your choice is carried into the booking request.", + "date_hint": "We pass your preferred date to the nurse — it does not remove nurses from the results.", + "price_hint": "Leave blank to see every price.", + "price_min": "From", + "price_max": "To", + "toman": "Toman", + "categories_error": "Couldn't load categories.", + "cta_choose_category_city": "Choose a category and city", + "cta_loading": "Counting nurses…", + "cta_view_results": "View {count, plural, =0 {no nurses} one {# nurse} other {# nurses}}", + "results_loading_title": "Searching…", + "results_count": "{count, plural, =0 {No nurses} one {# nurse} other {# nurses}}", + "sort_label": "Sort", + "sort_rating": "Rating", + "results_error": "Something went wrong loading results.", + "retry": "Try again", + "load_more": "Load more", + "empty_title": "No nurses match your filters", + "empty_suggest_gender": "Try removing the gender filter.", + "empty_suggest_district": "Clear the district to search the whole city.", + "empty_suggest_city": "Try a nearby city like Mashhad, Isfahan, or Shiraz.", + "empty_cta": "Adjust filters", + "unnamed_nurse": "Nurse", + "reviews_count": "({count, plural, =0 {no reviews} one {# review} other {# reviews}})", + "distance_km": "{km} km", + "price_from": "from", + "profile_not_found_title": "Nurse unavailable", + "profile_not_found_body": "This nurse is no longer available.", + "profile_not_found_cta": "Back to search", + "profile_error_title": "Couldn't load profile", + "profile_error_body": "Something went wrong loading this nurse.", + "badge_ino": "Nursing Council", + "years_experience": "{years} years' experience", + "specialty_elderly": "Elderly care", + "specialty_icu": "Critical care", + "specialty_pediatric": "Pediatric care", + "specialty_post_surgery": "Post-surgery care", + "specialty_wound_care": "Wound care", + "services_title": "Services & prices", + "services_empty": "No services listed yet.", + "latest_review_title": "Latest review", + "no_reviews": "No reviews yet.", + "request_booking": "Request booking" + }, + "booking": { + "request_title": "Booking request", + "deferred": "The booking request form arrives in the next phase.", + "handoff_echo": "Nurse #{nurse}, service #{variant}, caregiver: {gender}.", + "gender_female": "woman", + "gender_male": "man", + "gender_any": "no preference" }, "auth": { "customer_title": "Sign in to Balinyaar", diff --git a/client/messages/fa.json b/client/messages/fa.json index 954dba2..08bb070 100644 --- a/client/messages/fa.json +++ b/client/messages/fa.json @@ -310,10 +310,67 @@ "saved_toast": "تغییرات ذخیره شد" }, "search": { - "title": "جستجو", - "deferred": "جستجو و نتایج در فاز بعدی اضافه می‌شود.", - "query_echo": "جستجوی شما: «{query}».", - "category_echo": "محدود به دستهٔ خدمت انتخاب‌شده." + "title": "یافتن پرستار", + "subtitle": "فقط پرستاران تاییدشده و دارای صلاحیت اینجا نمایش داده می‌شوند.", + "section_category": "دستهٔ مراقبت", + "section_location": "شهر", + "section_gender": "جنسیت پرستار", + "section_date": "تاریخ", + "section_price": "بازهٔ قیمت (اختیاری)", + "gender_female": "خانم", + "gender_male": "آقا", + "gender_any": "فرقی ندارد", + "gender_hint": "برای مراقبت‌های شخصی و بدنی، بسیاری از خانواده‌ها پرستار هم‌جنس را ترجیح می‌دهند. انتخاب شما به درخواست رزرو منتقل می‌شود.", + "date_hint": "تاریخ موردنظر شما به پرستار اطلاع داده می‌شود و پرستاری را از نتایج حذف نمی‌کند.", + "price_hint": "برای دیدن همهٔ قیمت‌ها خالی بگذارید.", + "price_min": "از", + "price_max": "تا", + "toman": "تومان", + "categories_error": "بارگذاری دسته‌ها ممکن نشد.", + "cta_choose_category_city": "یک دسته و شهر انتخاب کنید", + "cta_loading": "در حال شمارش پرستاران…", + "cta_view_results": "مشاهده {count} پرستار", + "results_loading_title": "در حال جستجو…", + "results_count": "{count} پرستار", + "sort_label": "مرتب‌سازی", + "sort_rating": "امتیاز", + "results_error": "در بارگذاری نتایج مشکلی پیش آمد.", + "retry": "تلاش دوباره", + "load_more": "نمایش بیشتر", + "empty_title": "پرستاری با فیلترهای شما پیدا نشد", + "empty_suggest_gender": "فیلتر جنسیت را بردارید.", + "empty_suggest_district": "برای جستجوی کل شهر، منطقه را خالی کنید.", + "empty_suggest_city": "شهر نزدیک دیگری مانند مشهد، اصفهان یا شیراز را امتحان کنید.", + "empty_cta": "تغییر فیلترها", + "unnamed_nurse": "پرستار", + "reviews_count": "({count} نظر)", + "distance_km": "{km} کیلومتر", + "price_from": "از", + "profile_not_found_title": "پرستار در دسترس نیست", + "profile_not_found_body": "این پرستار دیگر در دسترس نیست.", + "profile_not_found_cta": "بازگشت به جستجو", + "profile_error_title": "بارگذاری پروفایل ممکن نشد", + "profile_error_body": "در بارگذاری این پرستار مشکلی پیش آمد.", + "badge_ino": "نظام پرستاری", + "years_experience": "{years} سال سابقه", + "specialty_elderly": "مراقبت از سالمند", + "specialty_icu": "مراقبت‌های ویژه", + "specialty_pediatric": "مراقبت از کودک", + "specialty_post_surgery": "مراقبت پس از جراحی", + "specialty_wound_care": "مراقبت زخم", + "services_title": "خدمات و قیمت‌ها", + "services_empty": "هنوز خدمتی ثبت نشده است.", + "latest_review_title": "آخرین نظر", + "no_reviews": "هنوز نظری ثبت نشده است.", + "request_booking": "درخواست رزرو" + }, + "booking": { + "request_title": "درخواست رزرو", + "deferred": "فرم درخواست رزرو در فاز بعدی اضافه می‌شود.", + "handoff_echo": "پرستار #{nurse}، خدمت #{variant}، جنسیت مراقب: {gender}.", + "gender_female": "خانم", + "gender_male": "آقا", + "gender_any": "فرقی ندارد" }, "auth": { "customer_title": "ورود به بلینیار", diff --git a/client/src/app/[locale]/(private-routes)/(customer)/bookings/request/page.tsx b/client/src/app/[locale]/(private-routes)/(customer)/bookings/request/page.tsx new file mode 100644 index 0000000..d4d612c --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/(customer)/bookings/request/page.tsx @@ -0,0 +1,32 @@ +'use client'; +import { Suspense } from 'react'; +import { useSearchParams } from 'next/navigation'; +import { useTranslations } from 'next-intl'; +import { AppLoading, PlaceholderScreen } from '@/components'; + +/** + * Booking-request handoff target — **DEFERRED → frontend-phase-7-b8**. C3's "درخواست رزرو" lands here + * carrying the selected nurse + variant + the same-gender intent (`required_gender`, which becomes + * `required_caregiver_gender` in b8) + city/category. f7 builds the actual request form; this placeholder + * confirms the intent arrived so the CTA doesn't dead-end. `useSearchParams` needs a Suspense boundary. + */ +export default function BookingRequestPage() { + return ( + }> + + + ); +} + +function BookingRequestDeferred() { + const t = useTranslations('booking'); + const params = useSearchParams(); + const gender = params.get('required_gender'); + const echo = t('handoff_echo', { + nurse: params.get('nurse_id') ?? '—', + variant: params.get('variant_id') ?? '—', + gender: gender ? t(`gender_${gender}`) : t('gender_any'), + }); + + return ; +} diff --git a/client/src/app/[locale]/(private-routes)/(customer)/search/nurse/[nurseId]/page.tsx b/client/src/app/[locale]/(private-routes)/(customer)/search/nurse/[nurseId]/page.tsx new file mode 100644 index 0000000..f1cf7bc --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/(customer)/search/nurse/[nurseId]/page.tsx @@ -0,0 +1,247 @@ +'use client'; +import { useLocale, useTranslations } from 'next-intl'; +import { useParams, useRouter, useSearchParams } from 'next/navigation'; +import { Avatar, Box, Chip, Divider, Paper, Skeleton, Stack, Typography } from '@mui/material'; +import { AppButton, AppIcon, ServicePriceRow, TrustBadge } from '@/components'; +import { ROUTES } from '@/constants'; +import { ApiError } from '@/lib/api/errors'; +import { formatShamsiDate } from '@/utils'; +import { useNurseProfile } from '@/services/search'; +import type { NurseProfile } from '@/services/search/types'; + +/** + * C3 — Nurse profile (پروفایل پرستار): identity + trust badges (✓ تاییدشده, نظام پرستاری), attribute + * chips, the priced services list (ServicePriceRow), and the latest-review snippet. The primary CTA + * "درخواست رزرو" hands the selected nurse + variant + `required_caregiver_gender` + city/category to the + * f7 booking route (the form itself is DEFERRED → f7). States: loading skeleton, not-found, error/retry. + */ +export default function NurseProfilePage() { + const t = useTranslations('search'); + const router = useRouter(); + const locale = useLocale(); + const routeParams = useParams<{ nurseId: string }>(); + const query = useSearchParams(); + + const nurseId = Number(routeParams.nurseId); + const { data: profile, isLoading, isError, error, refetch } = useNurseProfile( + Number.isInteger(nurseId) && nurseId > 0 ? nurseId : undefined, + ); + + if (isLoading) return ; + + if (isError) { + const notFound = error instanceof ApiError && error.status === 404; + return ( + + + {notFound ? t('profile_not_found_title') : t('profile_error_title')} + + + {notFound ? t('profile_not_found_body') : t('profile_error_body')} + + (notFound ? router.push(`/${locale}${ROUTES.SEARCH}`) : refetch())} + sx={{ m: 0 }} + > + {notFound ? t('profile_not_found_cta') : t('retry')} + + + ); + } + + if (!profile) return null; + + const requestBooking = () => { + const carriedVariant = query.get('variant_id'); + const variantId = carriedVariant ?? String(profile.services[0]?.variantId ?? ''); + const params = new URLSearchParams(); + params.set('nurse_id', String(profile.nurseId)); + if (variantId) params.set('variant_id', variantId); + // The same-gender intent chosen on C1, carried BEFORE booking (becomes required_caregiver_gender in f7/b8). + const requiredGender = query.get('required_gender'); + if (requiredGender) params.set('required_gender', requiredGender); + const cityId = query.get('city_id'); + if (cityId) params.set('city_id', cityId); + const categoryId = query.get('service_category_id'); + if (categoryId) params.set('service_category_id', categoryId); + const date = query.get('date'); + if (date) params.set('date', date); + router.push(`/${locale}${ROUTES.BOOKING_REQUEST}?${params.toString()}`); + }; + + return ( + + + + + + + + {t('request_booking')} + + + ); +} + +function ProfileHeader({ profile }: { profile: NurseProfile }) { + const t = useTranslations('search'); + const locale = useLocale(); + const name = profile.nurseName.trim() || t('unnamed_nurse'); + const rating = new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US', { + minimumFractionDigits: 1, + maximumFractionDigits: 1, + }).format(profile.averageRating); + + return ( + + + + {name.charAt(0)} + + + + {name} + + + + + {rating} + + + {t('reviews_count', { count: profile.totalReviews })} + + + + + + + + {profile.inoMembership ? ( + } + label={t('badge_ino')} + sx={{ backgroundColor: 'var(--bal-primary-soft)', color: 'var(--bal-primary)', fontWeight: 700 }} + /> + ) : null} + + + {profile.bio ? ( + + {profile.bio} + + ) : null} + + ); +} + +function AttributeChips({ profile }: { profile: NurseProfile }) { + const t = useTranslations('search'); + const locale = useLocale(); + const chips: string[] = []; + if (profile.yearsExperience != null && profile.yearsExperience > 0) { + const years = new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US').format(profile.yearsExperience); + chips.push(t('years_experience', { years })); + } + for (const code of profile.attributeChips) { + chips.push(t.has(`specialty_${code}`) ? t(`specialty_${code}`) : code); + } + if (chips.length === 0) return null; + + return ( + + {chips.map((label) => ( + + ))} + + ); +} + +function ServicesSection({ profile }: { profile: NurseProfile }) { + const t = useTranslations('search'); + return ( + + + {t('services_title')} + + {profile.services.length === 0 ? ( + + {t('services_empty')} + + ) : ( + + {profile.services.map((service) => ( + + ))} + + )} + + ); +} + +function LatestReview({ profile }: { profile: NurseProfile }) { + const t = useTranslations('search'); + const locale = useLocale(); + const review = profile.latestReview; + + return ( + + + {t('latest_review_title')} + + {!review ? ( + + {t('no_reviews')} + + ) : ( + + + + + {new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US').format(review.rating)} + + + + {review.authorMasked} · {formatShamsiDate(review.createdAt, locale)} + + + {review.body} + + )} + + ); +} + +function ProfileSkeleton() { + return ( + + + + + + + + + + + + + ); +} diff --git a/client/src/app/[locale]/(private-routes)/(customer)/search/page.tsx b/client/src/app/[locale]/(private-routes)/(customer)/search/page.tsx index 2584344..3c2c7ad 100644 --- a/client/src/app/[locale]/(private-routes)/(customer)/search/page.tsx +++ b/client/src/app/[locale]/(private-routes)/(customer)/search/page.tsx @@ -1,35 +1,220 @@ 'use client'; -import { Suspense } from 'react'; -import { useSearchParams } from 'next/navigation'; -import { useTranslations } from 'next-intl'; -import { AppLoading, PlaceholderScreen } from '@/components'; +import { Suspense, type FunctionComponent, type ReactNode } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { useLocale, useTranslations } from 'next-intl'; +import { + Box, + InputAdornment, + Paper, + Skeleton, + Stack, + TextField, + ToggleButton, + ToggleButtonGroup, + Typography, +} from '@mui/material'; +import { AppButton, AppIcon, AppLoading, CategoryTile } from '@/components'; +import CascadingRegionSelect from '@/components/geography/CascadingRegionSelect'; +import { ROUTES } from '@/constants'; +import { useServiceCategories } from '@/services/catalog'; +import { pickCatalogName } from '@/services/catalog/names'; +import { useNurseSearch } from '@/services/search'; +import { filtersToSearchParams } from '@/services/search/filterParams'; +import type { NurseGender } from '@/services/search/types'; +import { useSearchFilters } from './useSearchFilters'; /** - * Search landing — **DEFERRED → frontend-phase-6-b7**. The A5 Home search bar and category tiles - * navigate here carrying a `q` / `category_id`; f6 builds the actual results, filters, and nurse - * cards. This placeholder just acknowledges the intent so the Home CTAs don't dead-end. `useSearchParams` - * needs a Suspense boundary under static rendering. + * C1 — Search & filter (جستجو و فیلتر): the discovery entry screen. Pick a care category (reusing the + * f4 catalog grid), a city (reusing the f3 cascading region picker; district optional = whole city), + * the **prominent same-gender facet**, and an optional Toman price range; a live result count drives the + * "مشاهده N پرستار" CTA into C2. Availability (date) is intent-only at MVP — it is carried to booking, + * never used to hard-filter results. `useSearchParams` needs a Suspense boundary under static rendering. */ export default function SearchPage() { return ( }> - + ); } -function SearchDeferred() { +const GENDER_OPTIONS: readonly (NurseGender | 'any')[] = ['female', 'male', 'any']; + +function SearchFilterScreen() { const t = useTranslations('search'); + const router = useRouter(); + const locale = useLocale(); const params = useSearchParams(); - const query = params.get('q'); - const categoryId = params.get('category_id'); - const echo = query ? t('query_echo', { query }) : categoryId ? t('category_echo') : undefined; + + const initialCategoryRaw = Number(params.get('category_id')); + const initialCategoryId = Number.isInteger(initialCategoryRaw) && initialCategoryRaw > 0 ? initialCategoryRaw : undefined; + + const controller = useSearchFilters(initialCategoryId); + const { data, isFetching } = useNurseSearch(controller.filters); + const count = data?.total; + + const goToResults = () => { + const query = filtersToSearchParams(controller.filters); + if (controller.dateIntent) query.set('date', controller.dateIntent); + router.push(`/${locale}${ROUTES.SEARCH_RESULTS}?${query.toString()}`); + }; + + const ctaLabel = !controller.isReady + ? t('cta_choose_category_city') + : isFetching || count == null + ? t('cta_loading') + : t('cta_view_results', { count }); return ( - + + + + {t('title')} + + + {t('subtitle')} + + + + + + + + + + + { + if (value != null) controller.setGender(value === 'any' ? undefined : value); + }} + > + {GENDER_OPTIONS.map((option) => ( + + {t(`gender_${option}`)} + + ))} + + + + + controller.setDateIntent(event.target.value)} + slotProps={{ inputLabel: { shrink: true } }} + /> + + + + + + + + + + + {ctaLabel} + + ); } + +const FilterSection: FunctionComponent<{ title: string; hint?: string; children: ReactNode }> = ({ + title, + hint, + children, +}) => ( + + + {title} + + {hint ? ( + + {hint} + + ) : null} + {children} + +); + +const PriceField: FunctionComponent<{ + label: string; + value: string; + onChange: (value: string) => void; + adornment: string; +}> = ({ label, value, onChange, adornment }) => ( + onChange(event.target.value)} + inputMode="numeric" + fullWidth + slotProps={{ + input: { endAdornment: {adornment} }, + }} + /> +); + +/** The reused f4 category grid (data-driven from the cached catalog reference data), with selection. */ +const CategorySelect: FunctionComponent<{ selectedId: number | null; onSelect: (id: number) => void }> = ({ + selectedId, + onSelect, +}) => { + const t = useTranslations('search'); + const locale = useLocale(); + const { data, isLoading, isError } = useServiceCategories(); + const categories = data?.items ?? []; + + return ( + + {isLoading ? ( + + {[0, 1, 2, 3].map((key) => ( + + ))} + + ) : isError ? ( + + + {t('categories_error')} + + + ) : ( + + {categories.map((category) => ( + onSelect(category.id)} + /> + ))} + + )} + + ); +}; diff --git a/client/src/app/[locale]/(private-routes)/(customer)/search/results/page.tsx b/client/src/app/[locale]/(private-routes)/(customer)/search/results/page.tsx new file mode 100644 index 0000000..e3aa608 --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/(customer)/search/results/page.tsx @@ -0,0 +1,136 @@ +'use client'; +import { Suspense, useCallback, useMemo, useState } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { useLocale, useTranslations } from 'next-intl'; +import { Box, MenuItem, Paper, Skeleton, Stack, TextField, Typography } from '@mui/material'; +import { AppButton, AppIcon, AppLoading, NurseResultCard } from '@/components'; +import { ROUTES } from '@/constants'; +import { useNurseSearch } from '@/services/search'; +import { searchParamsToFilters } from '@/services/search/filterParams'; +import { SEARCH_PAGE_SIZE } from '@/services/search/constants'; +import type { NurseSearchResult } from '@/services/search/types'; + +/** + * C2 — Results (نتایج جستجو): the rating-sorted list of **only verified, accepting** nurses for the + * carried filter set. The filter set lives in the URL (the deep-linkable, back/forward-safe cache key), + * so returning to a prior filter URL is a cache hit with zero network calls (`useNurseSearch` + + * `keepPreviousData`). Renders all four states (loading skeletons / empty "relax filters" / error-retry + * / populated). Tapping a card opens C3, carrying the nurse + variant + gender intent. + */ +export default function SearchResultsPage() { + return ( + }> + + + ); +} + +function ResultsScreen() { + const t = useTranslations('search'); + const locale = useLocale(); + const router = useRouter(); + const params = useSearchParams(); + + const [pageSize, setPageSize] = useState(SEARCH_PAGE_SIZE); + + // The URL is the source of truth for the filter set; grow only the page size for "load more". + const filters = useMemo(() => ({ ...searchParamsToFilters(params), pageSize }), [params, pageSize]); + const dateIntent = params.get('date') ?? undefined; + + const { data, isLoading, isError, isFetching, refetch } = useNurseSearch(filters); + const items = data?.items ?? []; + const total = data?.total ?? 0; + const hasMore = items.length < total; + + const openProfile = useCallback( + (nurse: NurseSearchResult) => { + const query = new URLSearchParams(); + query.set('variant_id', String(nurse.variantId)); + query.set('service_category_id', String(filters.serviceCategoryId)); + query.set('city_id', String(filters.cityId)); + if (filters.nurseGender) query.set('required_gender', filters.nurseGender); + if (dateIntent) query.set('date', dateIntent); + router.push(`/${locale}${ROUTES.SEARCH_NURSE}/${nurse.nurseId}?${query.toString()}`); + }, + [router, locale, filters.serviceCategoryId, filters.cityId, filters.nurseGender, dateIntent], + ); + + const backToFilters = () => router.push(`/${locale}${ROUTES.SEARCH}`); + + return ( + + + + {isLoading ? t('results_loading_title') : t('results_count', { count: total })} + + {/* Rating is the only MVP sort; rendered as a control with a single option. Other sorts DEFERRED. */} + + {t('sort_rating')} + + + + {isLoading ? ( + + {[0, 1, 2, 3].map((key) => ( + + ))} + + ) : isError ? ( + + + {t('results_error')} + + refetch()} sx={{ m: 0 }}> + {t('retry')} + + + ) : items.length === 0 ? ( + + ) : ( + + {items.map((nurse) => ( + + ))} + {hasMore ? ( + setPageSize((size) => size + SEARCH_PAGE_SIZE)} + disabled={isFetching} + sx={{ m: 0, alignSelf: 'center' }} + > + {t('load_more')} + + ) : null} + + )} + + ); +} + +/** The "no nurses match → relax your filters" state with concrete, product-aligned suggestions. */ +function EmptyState({ onRelax }: { onRelax: () => void }) { + const t = useTranslations('search'); + return ( + + + + {t('empty_title')} + + + + {t('empty_suggest_gender')} + + + {t('empty_suggest_district')} + + + {t('empty_suggest_city')} + + + + {t('empty_cta')} + + + ); +} diff --git a/client/src/app/[locale]/(private-routes)/(customer)/search/useSearchFilters.ts b/client/src/app/[locale]/(private-routes)/(customer)/search/useSearchFilters.ts new file mode 100644 index 0000000..005b1ae --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/(customer)/search/useSearchFilters.ts @@ -0,0 +1,68 @@ +import { useMemo, useState } from 'react'; +import { toEnglishDigits, tomanToRial } from '@/utils'; +import { useDebouncedValue } from '@/services/search'; +import { SEARCH_FILTER_DEBOUNCE_MS, SEARCH_PAGE_SIZE } from '@/services/search/constants'; +import type { NurseGender, NurseSearchFilters } from '@/services/search/types'; +import type { CascadingRegionValue } from '@/components/geography/CascadingRegionSelect'; + +const EMPTY_REGION: CascadingRegionValue = { provinceId: null, cityId: null, districtId: null }; + +/** Toman input → IRR-Rial digit-string at the field boundary; undefined for blank/invalid input. */ +function tomanInputToIrr(toman: string): string | undefined { + const digits = toEnglishDigits(toman).trim(); + if (!/^\d+$/.test(digits)) return undefined; + return tomanToRial(digits); +} + +/** + * The C1 filter controller — fast-changing UI state kept **colocated** (not in a high context provider, + * phase §5). Holds the category, cascading region, same-gender facet, and Toman price inputs, and + * derives the canonical `NurseSearchFilters` that becomes the live-count query key and the C2 URL. The + * price inputs are **debounced** so typing doesn't fan out one search per keystroke before the value + * joins the query key. `districtId = null` (whole city) is carried as an omitted filter, never a bogus id. + */ +export function useSearchFilters(initialCategoryId?: number) { + const [categoryId, setCategoryId] = useState(initialCategoryId ?? null); + const [region, setRegion] = useState(EMPTY_REGION); + const [gender, setGender] = useState(undefined); + const [priceMinToman, setPriceMinToman] = useState(''); + const [priceMaxToman, setPriceMaxToman] = useState(''); + const [dateIntent, setDateIntent] = useState(''); + + const debouncedMin = useDebouncedValue(priceMinToman, SEARCH_FILTER_DEBOUNCE_MS); + const debouncedMax = useDebouncedValue(priceMaxToman, SEARCH_FILTER_DEBOUNCE_MS); + + const filters: NurseSearchFilters = useMemo( + () => ({ + serviceCategoryId: categoryId ?? 0, + cityId: region.cityId ?? 0, + districtId: region.districtId ?? undefined, + nurseGender: gender, + priceMin: tomanInputToIrr(debouncedMin), + priceMax: tomanInputToIrr(debouncedMax), + sort: 'rating', + page: 1, + pageSize: SEARCH_PAGE_SIZE, + }), + [categoryId, region.cityId, region.districtId, gender, debouncedMin, debouncedMax], + ); + + const isReady = filters.serviceCategoryId > 0 && filters.cityId > 0; + + return { + categoryId, + setCategoryId, + region, + setRegion, + gender, + setGender, + priceMinToman, + setPriceMinToman, + priceMaxToman, + setPriceMaxToman, + dateIntent, + setDateIntent, + filters, + isReady, + }; +} diff --git a/client/src/components/NurseResultCard/NurseResultCard.test.tsx b/client/src/components/NurseResultCard/NurseResultCard.test.tsx new file mode 100644 index 0000000..ebae434 --- /dev/null +++ b/client/src/components/NurseResultCard/NurseResultCard.test.tsx @@ -0,0 +1,82 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { ThemeProvider } from '../../theme'; +import type { NurseSearchResult } from '@/services/search/types'; + +// next-intl echoes keys; locale = en so the rating/price format with ASCII digits we can assert on. +jest.mock('next-intl', () => ({ + useTranslations: () => (key: string) => key, + useLocale: () => 'en', +})); + +import NurseResultCard from './NurseResultCard'; + +const NURSE: NurseSearchResult = { + nurseId: 1, + variantId: 11, + serviceCategoryId: 1, + nurseName: 'Maryam Rezaei', + avatarUrl: null, + isVerified: true, + averageRating: 4.9, + totalReviews: 37, + totalCompletedBookings: 52, + distanceKm: 2.4, + priceFromIrr: '2800000', + priceUnit: 'per_hour', + nurseGender: 'female', + cityId: 101, + districtId: 1003, +}; + +function renderCard(nurse: NurseSearchResult, onSelect = jest.fn()) { + render( + + + , + ); + return onSelect; +} + +describe(' component', () => { + it('renders the name, the reused verified badge, and the rating', () => { + renderCard(NURSE); + expect(screen.getByText('Maryam Rezaei')).toBeInTheDocument(); + expect(screen.getByText('badge_verified')).toBeInTheDocument(); + expect(screen.getByText('4.9')).toBeInTheDocument(); + expect(screen.getByText('reviews_count')).toBeInTheDocument(); + }); + + it('renders the "from" price line as grouped Toman via the money util', () => { + renderCard(NURSE); + expect(screen.getByText('price_from')).toBeInTheDocument(); + // 2,800,000 IRR = 280,000 Toman. + expect(screen.getByText(/280,000/)).toBeInTheDocument(); + }); + + it('shows the distance chip only when distanceKm is present', () => { + const { rerender } = render( + + + , + ); + expect(screen.getByText('distance_km')).toBeInTheDocument(); + + rerender( + + + , + ); + expect(screen.queryByText('distance_km')).not.toBeInTheDocument(); + }); + + it('falls back to a label when the name is missing (b7 join gap)', () => { + renderCard({ ...NURSE, nurseName: '' }); + expect(screen.getByText('unnamed_nurse')).toBeInTheDocument(); + }); + + it('calls onSelect with the nurse row when clicked', () => { + const onSelect = renderCard(NURSE); + fireEvent.click(screen.getByRole('button')); + expect(onSelect).toHaveBeenCalledWith(NURSE); + }); +}); diff --git a/client/src/components/NurseResultCard/NurseResultCard.tsx b/client/src/components/NurseResultCard/NurseResultCard.tsx new file mode 100644 index 0000000..ab79e8d --- /dev/null +++ b/client/src/components/NurseResultCard/NurseResultCard.tsx @@ -0,0 +1,117 @@ +import { memo } from 'react'; +import { useLocale, useTranslations } from 'next-intl'; +import { Avatar, Box, Paper, Stack, Typography } from '@mui/material'; +import AppIcon from '../common/AppIcon'; +import TrustBadge from '../TrustBadge'; +import PriceDisplay from '../PriceDisplay'; +import type { NurseSearchResult } from '@/services/search/types'; + +export interface NurseResultCardProps { + /** One search-result row (a bookable variant in a covered area). */ + nurse: NurseSearchResult; + /** Tapping the card opens the nurse profile (C3), carrying the row (nurse + variant + gender intent). */ + onSelect: (nurse: NurseSearchResult) => void; +} + +function ratingText(rating: number, locale: string): string { + return new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US', { + minimumFractionDigits: 1, + maximumFractionDigits: 1, + }).format(rating); +} + +/** + * The C2 result card: avatar, name, the reused ✓ تاییدشده verified badge, rating + review count, an + * optional distance chip (only when `distanceKm` is present), and the "from X تومان/ساعت" rate (via the + * shared `PriceDisplay` money util). Presentational + memoized so a list of N cards doesn't re-render on + * unrelated state — pass a stable `onSelect` (e.g. `useCallback`). Every returned row is verified by the + * search-index invariant, so the badge is always shown. + * @component NurseResultCard + */ +const NurseResultCard = ({ nurse, onSelect }: NurseResultCardProps) => { + const t = useTranslations('search'); + const locale = useLocale(); + + const name = nurse.nurseName.trim() || t('unnamed_nurse'); + const initial = name.charAt(0); + const distance = + nurse.distanceKm != null + ? new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US', { maximumFractionDigits: 1 }).format( + nurse.distanceKm, + ) + : null; + + return ( + onSelect(nurse)} + role="button" + tabIndex={0} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + onSelect(nurse); + } + }} + sx={{ + p: 2, + display: 'flex', + gap: 2, + alignItems: 'flex-start', + border: '1px solid', + borderColor: 'divider', + borderRadius: 2, + cursor: 'pointer', + transition: 'border-color 120ms ease', + '&:hover': { borderColor: 'var(--bal-primary)' }, + '&:focus-visible': { outline: '2px solid var(--bal-primary)', outlineOffset: 2 }, + }} + > + + {initial} + + + + + + {name} + + + + + + + + + {ratingText(nurse.averageRating, locale)} + + + {t('reviews_count', { count: nurse.totalReviews })} + + + + {distance != null ? ( + + + + {t('distance_km', { km: distance })} + + + ) : null} + + + + + {t('price_from')} + + + + + + ); +}; + +export default memo(NurseResultCard); diff --git a/client/src/components/NurseResultCard/index.tsx b/client/src/components/NurseResultCard/index.tsx new file mode 100644 index 0000000..a6fa5a3 --- /dev/null +++ b/client/src/components/NurseResultCard/index.tsx @@ -0,0 +1,2 @@ +export { default } from './NurseResultCard'; +export type { NurseResultCardProps } from './NurseResultCard'; diff --git a/client/src/components/ServicePriceRow/ServicePriceRow.test.tsx b/client/src/components/ServicePriceRow/ServicePriceRow.test.tsx new file mode 100644 index 0000000..f5d7358 --- /dev/null +++ b/client/src/components/ServicePriceRow/ServicePriceRow.test.tsx @@ -0,0 +1,36 @@ +import { render, screen } from '@testing-library/react'; +import { ThemeProvider } from '../../theme'; + +// next-intl is mocked to echo keys; locale = en so the money util groups with ASCII digits we can assert on. +jest.mock('next-intl', () => ({ + useTranslations: () => (key: string) => key, + useLocale: () => 'en', +})); + +import ServicePriceRow from './ServicePriceRow'; + +function renderRow(props: React.ComponentProps) { + return render( + + + , + ); +} + +describe(' component', () => { + it('renders the service name', () => { + renderRow({ displayName: 'Daytime elderly care', priceIrr: '2800000', priceUnit: 'per_hour' }); + expect(screen.getByText('Daytime elderly care')).toBeInTheDocument(); + }); + + it('renders the price as grouped Toman via the shared money display', () => { + // 2,800,000 IRR = 280,000 Toman. + renderRow({ displayName: 'Daytime elderly care', priceIrr: '2800000', priceUnit: 'per_hour' }); + expect(screen.getByText(/280,000/)).toBeInTheDocument(); + }); + + it('renders the unit label off the price_unit code (never hardcoded)', () => { + renderRow({ displayName: 'Live-in care', priceIrr: '85000000', priceUnit: 'per_24h' }); + expect(screen.getByText('unit_per_24h')).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/ServicePriceRow/ServicePriceRow.tsx b/client/src/components/ServicePriceRow/ServicePriceRow.tsx new file mode 100644 index 0000000..a5b9668 --- /dev/null +++ b/client/src/components/ServicePriceRow/ServicePriceRow.tsx @@ -0,0 +1,48 @@ +import { FunctionComponent } from 'react'; +import { Stack, Typography } from '@mui/material'; +import PriceDisplay from '../PriceDisplay'; +import type { PriceUnit } from '@/services/catalog/types'; + +export interface ServicePriceRowProps { + /** The variant/service name, already localised by the caller. */ + displayName: string; + /** IRR Rials as a digit-string (wire shape); rendered as Toman via the money util in PriceDisplay. */ + priceIrr: string; + /** Drives the unit label — an i18n key off the code, never hardcoded. */ + priceUnit: PriceUnit; + /** Duration/count carried for later booking-summary reuse; not shown as a total here. */ + sessionCount?: number | null; +} + +/** + * One offered-service line: the service name on the start edge, the priced rate on the end edge. The + * money + unit label render through the shared `PriceDisplay` (which uses the f0 money util and the + * i18n `catalog` unit labels) — never re-implemented here. Used on the C3 nurse profile now and reused + * by the booking summary (f7+). + * @component ServicePriceRow + */ +const ServicePriceRow: FunctionComponent = ({ + displayName, + priceIrr, + priceUnit, + sessionCount, +}) => ( + + + {displayName} + + + +); + +export default ServicePriceRow; diff --git a/client/src/components/ServicePriceRow/index.tsx b/client/src/components/ServicePriceRow/index.tsx new file mode 100644 index 0000000..4340722 --- /dev/null +++ b/client/src/components/ServicePriceRow/index.tsx @@ -0,0 +1,2 @@ +export { default } from './ServicePriceRow'; +export type { ServicePriceRowProps } from './ServicePriceRow'; diff --git a/client/src/components/common/AppIcon/config.ts b/client/src/components/common/AppIcon/config.ts index d412280..523b66a 100644 --- a/client/src/components/common/AppIcon/config.ts +++ b/client/src/components/common/AppIcon/config.ts @@ -55,6 +55,9 @@ import RefreshIcon from '@mui/icons-material/RefreshOutlined'; import IdentityIcon from '@mui/icons-material/BadgeOutlined'; import LicenseIcon from '@mui/icons-material/WorkspacePremiumOutlined'; import PublishIcon from '@mui/icons-material/RocketLaunchOutlined'; +// Search & discovery — the customer nurse-finding flow (f6/b7): rating star, filter controls +import StarIcon from '@mui/icons-material/Star'; +import TuneIcon from '@mui/icons-material/TuneOutlined'; /** * List of all available Icon names @@ -123,4 +126,6 @@ export const ICONS /* Note: Setting type disables property autocomplete :( was - identity: IdentityIcon, license: LicenseIcon, publish: PublishIcon, + star: StarIcon, + tune: TuneIcon, }; diff --git a/client/src/components/index.tsx b/client/src/components/index.tsx index 3dd6ca3..fe34d4e 100644 --- a/client/src/components/index.tsx +++ b/client/src/components/index.tsx @@ -17,6 +17,8 @@ import PriceDisplay from './PriceDisplay'; import VariantCard from './VariantCard'; import TrustBadge from './TrustBadge'; import DocumentUpload from './DocumentUpload'; +import NurseResultCard from './NurseResultCard'; +import ServicePriceRow from './ServicePriceRow'; export { UserInfo, @@ -36,6 +38,8 @@ export { VariantCard, TrustBadge, DocumentUpload, + NurseResultCard, + ServicePriceRow, }; export type { PlaceholderScreenProps } from './PlaceholderScreen'; export type { OtpInputProps } from './OtpInput'; @@ -53,3 +57,5 @@ export type { PriceDisplayProps } from './PriceDisplay'; export type { VariantCardProps } from './VariantCard'; export type { TrustBadgeProps } from './TrustBadge'; export type { DocumentUploadProps, UploadedDocInfo } from './DocumentUpload'; +export type { NurseResultCardProps } from './NurseResultCard'; +export type { ServicePriceRowProps } from './ServicePriceRow'; diff --git a/client/src/constants/routes.ts b/client/src/constants/routes.ts index 9bf523a..37d80e4 100644 --- a/client/src/constants/routes.ts +++ b/client/src/constants/routes.ts @@ -7,9 +7,15 @@ export const ROUTES = { HOME: '/', // First-login "who is care for?" flow (A3→A4); re-enterable from the patient list. ONBOARDING: '/onboarding', - // Search & discovery — the Home search bar + category tiles navigate here (results built in f6). + // Search & discovery (f6) — C1 filter screen; the Home search bar + category tiles navigate here. SEARCH: '/search', + // C2 results list — C1 pushes here carrying the filter set as query params (the deep-linkable key). + SEARCH_RESULTS: '/search/results', + // C3 nurse profile base — append `/{nurseId}` (results cards + the booking handoff read this). + SEARCH_NURSE: '/search/nurse', BOOKINGS: '/bookings', + // Booking-request handoff target (f7 owns the form) — C3's "درخواست رزرو" lands here with intent. + BOOKING_REQUEST: '/bookings/request', PATIENTS: '/patients', // Address book — cascading region dropdowns + map-pin picker; reached from the profile hub. ADDRESSES: '/addresses', diff --git a/client/src/services/search/apis/clientApi.ts b/client/src/services/search/apis/clientApi.ts new file mode 100644 index 0000000..f022d8c --- /dev/null +++ b/client/src/services/search/apis/clientApi.ts @@ -0,0 +1,116 @@ +import { clientFetch } from '@/lib/api/client'; +import { unwrap, type ApiEnvelope, type Paginated } from '@/lib/api/types'; +import type { PriceUnit } from '@/services/catalog/types'; +import type { TrustBadge } from '@/services/verification/types'; +import { SEARCH_PAGE_SIZE } from '../constants'; +import type { + NurseGender, + NurseProfile, + NurseSearchFilters, + NurseSearchResult, + SearchApi, +} from '../types'; + +const SEARCH_BASE = '/api/v1/search'; +const NURSES_BASE = '/api/v1/nurses'; + +/** The b7 `NurseSearchResultDto` (the projected index row) — the exact wire shape we map from. */ +interface NurseSearchResultDto { + variantId: number; + nurseId: number; + serviceCategoryId: number; + price: string; + priceUnit: PriceUnit; + nurseGender: NurseGender; + averageRating: number; + totalReviews: number; + totalCompletedBookings: number; + cityId: number; + districtId: number | null; +} + +/** The INO-membership credential type code (see b6 verification). */ +const INO_MEMBERSHIP_CODE = 'ino_membership'; + +/** + * Real HTTP implementation of the `SearchApi` seam (b7 `search/nurses`, b6 trust badge). Routes are + * action-style + snake_case; query params are snake_case per the contract; JSON fields are camelCase and + * `clientFetch` returns the raw envelope, so we `unwrap()`. + * + * NOT the primary implementation this phase (`USE_SEARCH_MOCK = true`): b7's index row omits the nurse + * **display name, avatar, and distance** the C2 card renders, and there is **no** aggregated + * nurse-profile endpoint (name/bio/specialties/full services list/latest review) for C3 — only the b6 + * trust badge is public. Both gaps are filed in + * `dev/shared-working-context/frontend/requests/for-backend.md`. This client maps everything b7/b6 + * currently provide (leaving the missing fields blank) so the swap is a single config flip once the + * backend lands the join + profile route. + */ +export const searchClientApi: SearchApi = { + searchNurses: async (filters: NurseSearchFilters): Promise> => { + const query = new URLSearchParams(); + query.set('service_category_id', String(filters.serviceCategoryId)); + query.set('city_id', String(filters.cityId)); + if (filters.districtId != null) query.set('district_id', String(filters.districtId)); + if (filters.nurseGender) query.set('nurse_gender', filters.nurseGender); + if (filters.priceMin) query.set('min_price', filters.priceMin); + if (filters.priceMax) query.set('max_price', filters.priceMax); + if (filters.priceUnit) query.set('price_unit', filters.priceUnit); + query.set('page', String(filters.page || 1)); + query.set('page_size', String(filters.pageSize || SEARCH_PAGE_SIZE)); + + const paged = unwrap( + await clientFetch>>( + `${SEARCH_BASE}/nurses?${query.toString()}`, + ), + ); + + return { + ...paged, + items: paged.items.map((dto) => ({ + nurseId: dto.nurseId, + variantId: dto.variantId, + serviceCategoryId: dto.serviceCategoryId, + // Gap (filed): b7 does not yet join the nurse's name/avatar; the card falls back to a label. + nurseName: '', + avatarUrl: null, + // Every returned row is searchable by the index invariant. + isVerified: true, + averageRating: dto.averageRating, + totalReviews: dto.totalReviews, + totalCompletedBookings: dto.totalCompletedBookings, + // Gap (filed): no geo-distance in the index row yet. + distanceKm: null, + priceFromIrr: dto.price, + priceUnit: dto.priceUnit, + nurseGender: dto.nurseGender, + cityId: dto.cityId, + districtId: dto.districtId, + })), + }; + }, + + getNurseProfile: async (nurseId: number): Promise => { + // Only the public trust badge is available today; the aggregated profile (name/bio/specialties/ + // services list/latest review) is filed for the backend. Compose what b6 exposes; leave the rest blank. + const badge = unwrap( + await clientFetch>(`${NURSES_BASE}/${nurseId}/trust_badge`), + ); + + return { + nurseId: badge.nurseId, + nurseName: '', + avatarUrl: null, + bio: null, + yearsExperience: null, + averageRating: 0, + totalReviews: 0, + totalCompletedBookings: 0, + isVerified: badge.isVerified, + inoMembership: badge.credentialTypes.includes(INO_MEMBERSHIP_CODE), + attributeChips: badge.credentialTypes, + services: [], + latestReview: null, + nurseGender: 'female', + }; + }, +}; diff --git a/client/src/services/search/apis/index.ts b/client/src/services/search/apis/index.ts new file mode 100644 index 0000000..0bf5fd9 --- /dev/null +++ b/client/src/services/search/apis/index.ts @@ -0,0 +1,10 @@ +import { USE_SEARCH_MOCK } from '../constants'; +import type { SearchApi } from '../types'; +import { searchClientApi } from './clientApi'; +import { searchMockApi } from './mockApi'; + +/** + * The selected SearchApi implementation — the single seam the hooks import. Selection is by config + * (USE_SEARCH_MOCK), never by scattered `if (mock)` checks. + */ +export const searchApi: SearchApi = USE_SEARCH_MOCK ? searchMockApi : searchClientApi; diff --git a/client/src/services/search/apis/mockApi.ts b/client/src/services/search/apis/mockApi.ts new file mode 100644 index 0000000..fa08d82 --- /dev/null +++ b/client/src/services/search/apis/mockApi.ts @@ -0,0 +1,130 @@ +import { sleep } from '@/utils'; +import { ApiError } from '@/lib/api/errors'; +import type { Paginated } from '@/lib/api/types'; +import { SEARCH_PAGE_SIZE } from '../constants'; +import type { + NurseProfile, + NurseProfileServiceRow, + NurseSearchFilters, + NurseSearchResult, + SearchApi, +} from '../types'; +import { SEED_NURSES, type SeedNurse, type SeedVariant } from './seed'; + +const MOCK_LATENCY_MS = 300; + +/** Flatten every seeded nurse's variants into candidate search rows (one row per variant×area). */ +function allRows(): { nurse: SeedNurse; variant: SeedVariant }[] { + return SEED_NURSES.flatMap((nurse) => nurse.variants.map((variant) => ({ nurse, variant }))); +} + +/** + * The b7 geography rule: a **city-only** search (no `districtId`) matches every row in the city; a + * **district** search matches that district's rows **plus** whole-city (`null`) rows. + */ +function matchesDistrict(rowDistrictId: number | null, filterDistrictId?: number): boolean { + if (filterDistrictId == null) return true; + return rowDistrictId === filterDistrictId || rowDistrictId === null; +} + +function withinPrice(priceIrr: string, min?: string, max?: string): boolean { + const value = BigInt(priceIrr); + if (min != null && min !== '' && value < BigInt(min)) return false; + if (max != null && max !== '' && value > BigInt(max)) return false; + return true; +} + +function toResult(nurse: SeedNurse, variant: SeedVariant): NurseSearchResult { + return { + nurseId: nurse.nurseId, + variantId: variant.variantId, + serviceCategoryId: variant.serviceCategoryId, + nurseName: nurse.nurseName, + avatarUrl: nurse.avatarUrl, + isVerified: true, + averageRating: nurse.averageRating, + totalReviews: nurse.totalReviews, + totalCompletedBookings: nurse.totalCompletedBookings, + distanceKm: variant.distanceKm, + priceFromIrr: variant.priceIrr, + priceUnit: variant.priceUnit, + nurseGender: nurse.gender, + cityId: variant.cityId, + districtId: variant.districtId, + }; +} + +/** + * In-memory mock behind the `SearchApi` seam. Reproduces the b7 filter + geography + rating-sort + * semantics over verified-only fixtures, so C1/C2/C3 (incl. the empty state and the caching revert) + * demo end-to-end. Mirrors the real shapes for a one-line swap once the backend join/profile endpoints + * land (`USE_SEARCH_MOCK = false`). + */ +export const searchMockApi: SearchApi = { + searchNurses: async (filters: NurseSearchFilters): Promise> => { + await sleep(MOCK_LATENCY_MS); + + if (!(filters.serviceCategoryId > 0) || !(filters.cityId > 0)) { + throw new ApiError(400, 'service_category_id and city_id are required', 'invalid_filters'); + } + if (filters.priceMin && filters.priceMax && BigInt(filters.priceMin) > BigInt(filters.priceMax)) { + throw new ApiError(400, 'min_price must not exceed max_price', 'invalid_price_range'); + } + + const matched = allRows() + .filter(({ nurse, variant }) => { + if (variant.serviceCategoryId !== filters.serviceCategoryId) return false; + if (variant.cityId !== filters.cityId) return false; + if (!matchesDistrict(variant.districtId, filters.districtId)) return false; + if (filters.nurseGender && nurse.gender !== filters.nurseGender) return false; + if (filters.priceUnit && variant.priceUnit !== filters.priceUnit) return false; + if (!withinPrice(variant.priceIrr, filters.priceMin, filters.priceMax)) return false; + return true; + }) + // Rating desc, tiebroken by review count then ids so paging is deterministic (contract order). + .sort( + (a, b) => + b.nurse.averageRating - a.nurse.averageRating || + b.nurse.totalReviews - a.nurse.totalReviews || + a.nurse.nurseId - b.nurse.nurseId || + a.variant.variantId - b.variant.variantId, + ) + .map(({ nurse, variant }) => toResult(nurse, variant)); + + const pageSize = filters.pageSize || SEARCH_PAGE_SIZE; + const page = filters.page || 1; + const start = (page - 1) * pageSize; + return { items: matched.slice(start, start + pageSize), total: matched.length, page, pageSize }; + }, + + getNurseProfile: async (nurseId: number): Promise => { + await sleep(MOCK_LATENCY_MS); + const nurse = SEED_NURSES.find((candidate) => candidate.nurseId === nurseId); + if (!nurse) throw new ApiError(404, 'Nurse not found', 'not_found'); + + const services: NurseProfileServiceRow[] = nurse.variants.map((variant) => ({ + variantId: variant.variantId, + displayName: variant.displayName, + priceIrr: variant.priceIrr, + priceUnit: variant.priceUnit, + sessionCount: variant.sessionCount, + })); + + return { + nurseId: nurse.nurseId, + nurseName: nurse.nurseName, + avatarUrl: nurse.avatarUrl, + bio: nurse.bio, + yearsExperience: nurse.yearsExperience, + averageRating: nurse.averageRating, + totalReviews: nurse.totalReviews, + totalCompletedBookings: nurse.totalCompletedBookings, + isVerified: true, + inoMembership: nurse.inoMembership, + attributeChips: nurse.attributeChips, + services, + latestReview: nurse.latestReview, + nurseGender: nurse.gender, + }; + }, +}; diff --git a/client/src/services/search/apis/seed.ts b/client/src/services/search/apis/seed.ts new file mode 100644 index 0000000..d57a823 --- /dev/null +++ b/client/src/services/search/apis/seed.ts @@ -0,0 +1,180 @@ +import type { PriceUnit } from '@/services/catalog/types'; +import type { NurseGender, NurseReviewSnippet } from '../types'; + +/** + * Canned discovery fixtures for the client-side mock — real-shaped verified nurses so C1/C2/C3 demo + * before the backend join/profile endpoints land. Ids align with the sibling mocks so the end-to-end + * flow works with the geo picker + category grid: `serviceCategoryId` uses the catalog seed + * (1 = elderly, 2 = post-surgery, 3 = infant, 4 = chronic), `cityId`/`districtId` use the geography + * seed (Tehran = 101 with districts 1001…1022, Karaj = 801, whole-city = `null`). Mashhad/Isfahan/Shiraz + * are intentionally left with **no** nurses so the C2 "relax your filters" empty state is reachable. + * + * Every nurse here is verified + accepting by construction (the invariant the real index enforces), so + * the mock never returns an unverified row. `price` is IRR Rials as a digit-string (Toman × 10). + * Avatars are `null` on purpose (initials fallback) to keep the demo self-contained — no remote images. + */ + +/** One priced, bookable offering of a seeded nurse, matched in a covered area. */ +export interface SeedVariant { + variantId: number; + serviceCategoryId: number; + displayName: string; + priceIrr: string; + priceUnit: PriceUnit; + sessionCount: number | null; + cityId: number; + /** `null` = the nurse covers the whole city. */ + districtId: number | null; + /** Approximate distance from the searched area (mock-only stand-in for a future geo-distance join). */ + distanceKm: number | null; +} + +/** A seeded verified nurse + their offerings and latest review (the mock's source of truth). */ +export interface SeedNurse { + nurseId: number; + nurseName: string; + avatarUrl: string | null; + bio: string; + yearsExperience: number; + gender: NurseGender; + averageRating: number; + totalReviews: number; + totalCompletedBookings: number; + inoMembership: boolean; + /** Specialty codes → i18n labels (never rendered raw). */ + attributeChips: string[]; + variants: SeedVariant[]; + latestReview: NurseReviewSnippet | null; +} + +export const SEED_NURSES: SeedNurse[] = [ + { + nurseId: 1, + nurseName: 'مریم رضایی', + avatarUrl: null, + bio: 'پرستار سالمند با تمرکز بر مراقبت‌های شبانه‌روزی و پانسمان زخم.', + yearsExperience: 8, + gender: 'female', + averageRating: 4.9, + totalReviews: 37, + totalCompletedBookings: 52, + inoMembership: true, + attributeChips: ['elderly', 'wound_care'], + variants: [ + { variantId: 11, serviceCategoryId: 1, displayName: 'مراقبت روزانه سالمند', priceIrr: '2800000', priceUnit: 'per_hour', sessionCount: null, cityId: 101, districtId: 1003, distanceKm: 2.4 }, + { variantId: 12, serviceCategoryId: 1, displayName: 'مراقبت شبانه‌روزی سالمند', priceIrr: '85000000', priceUnit: 'per_24h', sessionCount: null, cityId: 101, districtId: 1003, distanceKm: 2.4 }, + ], + latestReview: { + rating: 5, + body: 'بسیار دلسوز و منظم بودند. مادرم کاملاً راضی بود.', + authorMasked: 'ز. م.', + createdAt: '2026-06-20T09:30:00Z', + }, + }, + { + nurseId: 2, + nurseName: 'سارا احمدی', + avatarUrl: null, + bio: 'پرستار مراقبت از سالمند و بیماری‌های مزمن، فعال در سراسر شهر تهران.', + yearsExperience: 6, + gender: 'female', + averageRating: 4.7, + totalReviews: 21, + totalCompletedBookings: 33, + inoMembership: true, + attributeChips: ['elderly', 'icu'], + variants: [ + { variantId: 21, serviceCategoryId: 1, displayName: 'مراقبت روزانه سالمند', priceIrr: '2500000', priceUnit: 'per_hour', sessionCount: null, cityId: 101, districtId: null, distanceKm: 5.1 }, + { variantId: 22, serviceCategoryId: 4, displayName: 'مدیریت بیماری مزمن', priceIrr: '30000000', priceUnit: 'per_day', sessionCount: null, cityId: 101, districtId: null, distanceKm: 5.1 }, + ], + latestReview: { + rating: 5, + body: 'برخورد حرفه‌ای و به‌موقع. حتماً دوباره درخواست می‌دهم.', + authorMasked: 'م. ک.', + createdAt: '2026-06-28T14:10:00Z', + }, + }, + { + nurseId: 3, + nurseName: 'زهرا موسوی', + avatarUrl: null, + bio: 'متخصص مراقبت پس از جراحی و پانسمان تخصصی زخم.', + yearsExperience: 10, + gender: 'female', + averageRating: 4.8, + totalReviews: 44, + totalCompletedBookings: 61, + inoMembership: true, + attributeChips: ['post_surgery', 'wound_care'], + variants: [ + { variantId: 31, serviceCategoryId: 2, displayName: 'مراقبت پس از جراحی', priceIrr: '3200000', priceUnit: 'per_hour', sessionCount: null, cityId: 101, districtId: 1005, distanceKm: 3.8 }, + ], + latestReview: { + rating: 4, + body: 'مراقبت خوبی داشتند، فقط کمی دیر رسیدند.', + authorMasked: 'ح. ر.', + createdAt: '2026-05-30T11:00:00Z', + }, + }, + { + nurseId: 4, + nurseName: 'علی کریمی', + avatarUrl: null, + bio: 'پرستار مراقبت‌های ویژه و مدیریت بیماری‌های مزمن.', + yearsExperience: 7, + gender: 'male', + averageRating: 4.6, + totalReviews: 18, + totalCompletedBookings: 27, + inoMembership: false, + attributeChips: ['icu'], + variants: [ + { variantId: 41, serviceCategoryId: 4, displayName: 'مدیریت بیماری مزمن', priceIrr: '2900000', priceUnit: 'per_hour', sessionCount: null, cityId: 101, districtId: 1002, distanceKm: 6.7 }, + ], + latestReview: { + rating: 5, + body: 'دقیق و مسئولیت‌پذیر. پیگیری داروها عالی بود.', + authorMasked: 'ع. ن.', + createdAt: '2026-06-15T08:45:00Z', + }, + }, + { + nurseId: 5, + nurseName: 'رضا حسینی', + avatarUrl: null, + bio: 'پرستار سالمند در کرج، فعال در تمام مناطق شهر.', + yearsExperience: 5, + gender: 'male', + averageRating: 4.5, + totalReviews: 12, + totalCompletedBookings: 19, + inoMembership: false, + attributeChips: ['elderly'], + variants: [ + { variantId: 51, serviceCategoryId: 1, displayName: 'مراقبت روزانه سالمند', priceIrr: '2200000', priceUnit: 'per_hour', sessionCount: null, cityId: 801, districtId: null, distanceKm: null }, + ], + latestReview: null, + }, + { + nurseId: 6, + nurseName: 'فاطمه صادقی', + avatarUrl: null, + bio: 'پرستار نوزاد با تجربه در مراقبت روزانه و شبانه.', + yearsExperience: 9, + gender: 'female', + averageRating: 4.9, + totalReviews: 29, + totalCompletedBookings: 40, + inoMembership: true, + attributeChips: ['pediatric'], + variants: [ + { variantId: 61, serviceCategoryId: 3, displayName: 'مراقبت روزانه نوزاد', priceIrr: '3000000', priceUnit: 'per_hour', sessionCount: null, cityId: 101, districtId: 1008, distanceKm: 4.2 }, + ], + latestReview: { + rating: 5, + body: 'با نوزاد ما فوق‌العاده مهربان بودند. بسیار حرفه‌ای.', + authorMasked: 'س. ط.', + createdAt: '2026-07-01T16:20:00Z', + }, + }, +]; diff --git a/client/src/services/search/constants.ts b/client/src/services/search/constants.ts new file mode 100644 index 0000000..b550316 --- /dev/null +++ b/client/src/services/search/constants.ts @@ -0,0 +1,24 @@ +/** + * When true, the search domain is served by the in-memory mock (`apis/mockApi.ts`) behind the + * `SearchApi` seam. **Mock is primary this phase:** b7's search-index row and the b5/b6 reads do not + * yet expose the display name, avatar, distance, bio, specialties, full services list, or latest review + * that C2/C3 render (gap filed in `dev/shared-working-context/frontend/requests/for-backend.md`). The + * mock supplies real-shaped fixtures so C1/C2/C3 demo end-to-end. Flip to false once the backend fills + * the gap — no hook/component changes (see `dev/shared-working-context/reports/frontend-phase-6-report.md`). + */ +export const USE_SEARCH_MOCK = true; + +/** + * Results are read-heavy and change slowly, so a revisit (or a filter **revert**) serves from cache + * within the stale window instead of refetching — the headline caching behaviour of this phase. A + * generous `gcTime` keeps prior filter sets warm so back/forward navigation is instant. + */ +export const SEARCH_RESULTS_STALE_TIME = 5 * 60 * 1000; // 5m +export const SEARCH_PROFILE_STALE_TIME = 5 * 60 * 1000; // 5m +export const SEARCH_GC_TIME = 30 * 60 * 1000; // 30m + +/** api-conventions default/max page sizes (max 100 server-side); a page of result cards. */ +export const SEARCH_PAGE_SIZE = 20; + +/** Debounce window for the price-range inputs so keystrokes don't fan out one request per character. */ +export const SEARCH_FILTER_DEBOUNCE_MS = 400; diff --git a/client/src/services/search/filterParams.ts b/client/src/services/search/filterParams.ts new file mode 100644 index 0000000..e8a29dd --- /dev/null +++ b/client/src/services/search/filterParams.ts @@ -0,0 +1,69 @@ +import { PRICE_UNITS, type PriceUnit } from '@/services/catalog/types'; +import { SEARCH_PAGE_SIZE } from './constants'; +import type { NurseGender, NurseSearchFilters } from './types'; + +/** + * Single source of truth for the C1 → C2 filter **query string** (snake_case, matching the b7 contract + * params), so C1 (which writes the URL) and C2 (which reads it via `useSearchParams`) never drift. The + * URL is the deep-linkable, back/forward-safe carrier of the filter set; C2 turns it back into a + * `NurseSearchFilters`, which is what becomes the React Query cache key. + */ + +/** Minimal read surface shared by `URLSearchParams` and Next's `ReadonlyURLSearchParams`. */ +interface ParamReader { + get(name: string): string | null; +} + +const GENDERS: readonly NurseGender[] = ['male', 'female']; + +function parsePositiveInt(raw: string | null): number | undefined { + if (raw == null) return undefined; + const value = Number(raw); + return Number.isInteger(value) && value > 0 ? value : undefined; +} + +function parseGender(raw: string | null): NurseGender | undefined { + return raw != null && GENDERS.includes(raw as NurseGender) ? (raw as NurseGender) : undefined; +} + +function parsePriceUnit(raw: string | null): PriceUnit | undefined { + return raw != null && PRICE_UNITS.includes(raw as PriceUnit) ? (raw as PriceUnit) : undefined; +} + +/** IRR digit-string or undefined (never a float; leaves bogus input out). */ +function parseIrrString(raw: string | null): string | undefined { + return raw != null && /^\d+$/.test(raw) ? raw : undefined; +} + +/** Serialise a filter set to snake_case URL params, omitting every absent optional filter. */ +export function filtersToSearchParams(filters: NurseSearchFilters): URLSearchParams { + const params = new URLSearchParams(); + params.set('service_category_id', String(filters.serviceCategoryId)); + params.set('city_id', String(filters.cityId)); + if (filters.districtId != null) params.set('district_id', String(filters.districtId)); + if (filters.nurseGender) params.set('nurse_gender', filters.nurseGender); + if (filters.priceMin) params.set('min_price', filters.priceMin); + if (filters.priceMax) params.set('max_price', filters.priceMax); + if (filters.priceUnit) params.set('price_unit', filters.priceUnit); + return params; +} + +/** + * Rebuild a `NurseSearchFilters` from URL params. `serviceCategoryId`/`cityId` fall back to `0` when + * absent/invalid — the query hook is disabled until both are `> 0`, so an incomplete URL is inert + * rather than an error. Sort is always rating (MVP); page/pageSize reset to the first page. + */ +export function searchParamsToFilters(params: ParamReader): NurseSearchFilters { + return { + serviceCategoryId: parsePositiveInt(params.get('service_category_id')) ?? 0, + cityId: parsePositiveInt(params.get('city_id')) ?? 0, + districtId: parsePositiveInt(params.get('district_id')), + nurseGender: parseGender(params.get('nurse_gender')), + priceMin: parseIrrString(params.get('min_price')), + priceMax: parseIrrString(params.get('max_price')), + priceUnit: parsePriceUnit(params.get('price_unit')), + sort: 'rating', + page: 1, + pageSize: SEARCH_PAGE_SIZE, + }; +} diff --git a/client/src/services/search/hooks/useDebouncedValue.ts b/client/src/services/search/hooks/useDebouncedValue.ts new file mode 100644 index 0000000..51653e3 --- /dev/null +++ b/client/src/services/search/hooks/useDebouncedValue.ts @@ -0,0 +1,17 @@ +import { useEffect, useState } from 'react'; + +/** + * Returns a debounced copy of `value` that only updates after `delayMs` of no changes. Used by the C1 + * filter controller for the price-range inputs so typing doesn't fan out one search request per + * keystroke (phase §5 "Debounce input") — the debounced value is what becomes part of the query key. + */ +export function useDebouncedValue(value: T, delayMs: number): T { + const [debounced, setDebounced] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => setDebounced(value), delayMs); + return () => clearTimeout(timer); + }, [value, delayMs]); + + return debounced; +} diff --git a/client/src/services/search/hooks/useNurseProfile.ts b/client/src/services/search/hooks/useNurseProfile.ts new file mode 100644 index 0000000..686adba --- /dev/null +++ b/client/src/services/search/hooks/useNurseProfile.ts @@ -0,0 +1,18 @@ +import { useQuery } from '@tanstack/react-query'; +import { searchApi } from '../apis'; +import { searchKeys } from '../keys'; +import { SEARCH_GC_TIME, SEARCH_PROFILE_STALE_TIME } from '../constants'; + +/** + * The C3 nurse-profile query, keyed on `searchKeys.profile(nurseId)` and enabled only when an id is + * present. Cached for the stale window so returning from the booking handoff serves from cache. + */ +export function useNurseProfile(nurseId: number | undefined) { + return useQuery({ + queryKey: searchKeys.profile(nurseId ?? -1), + queryFn: () => searchApi.getNurseProfile(nurseId as number), + enabled: nurseId != null, + staleTime: SEARCH_PROFILE_STALE_TIME, + gcTime: SEARCH_GC_TIME, + }); +} diff --git a/client/src/services/search/hooks/useNurseSearch.ts b/client/src/services/search/hooks/useNurseSearch.ts new file mode 100644 index 0000000..da5ee38 --- /dev/null +++ b/client/src/services/search/hooks/useNurseSearch.ts @@ -0,0 +1,23 @@ +import { keepPreviousData, useQuery } from '@tanstack/react-query'; +import { searchApi } from '../apis'; +import { searchKeys } from '../keys'; +import { SEARCH_GC_TIME, SEARCH_RESULTS_STALE_TIME } from '../constants'; +import type { NurseSearchFilters } from '../types'; + +/** + * The C2 discovery query. **The filter object is the query key** (`searchKeys.results`), so an + * identical filter set is served straight from cache — changing a filter and reverting to a previous + * set is a cache hit with zero network calls. `placeholderData: keepPreviousData` keeps the previous + * page/results on screen while a new filter loads, so the list never flashes empty. Enabled only once + * the two required facets (category + city) are chosen. + */ +export function useNurseSearch(filters: NurseSearchFilters) { + return useQuery({ + queryKey: searchKeys.results(filters), + queryFn: () => searchApi.searchNurses(filters), + enabled: filters.serviceCategoryId > 0 && filters.cityId > 0, + staleTime: SEARCH_RESULTS_STALE_TIME, + gcTime: SEARCH_GC_TIME, + placeholderData: keepPreviousData, + }); +} diff --git a/client/src/services/search/index.ts b/client/src/services/search/index.ts new file mode 100644 index 0000000..d85ddae --- /dev/null +++ b/client/src/services/search/index.ts @@ -0,0 +1,3 @@ +export { useNurseSearch } from './hooks/useNurseSearch'; +export { useNurseProfile } from './hooks/useNurseProfile'; +export { useDebouncedValue } from './hooks/useDebouncedValue'; diff --git a/client/src/services/search/keys.ts b/client/src/services/search/keys.ts new file mode 100644 index 0000000..d9cb19e --- /dev/null +++ b/client/src/services/search/keys.ts @@ -0,0 +1,37 @@ +import type { NurseSearchFilters } from './types'; + +/** + * React Query key factory for the search domain. + * + * **The filter object IS the query key** (phase §5, the caching contract). `results(filters)` keys on a + * **canonical** serialization of the full filter object — a stable key order with every *absent* optional + * filter omitted (never carried as `undefined`). Two filter sets that are semantically equal therefore + * produce the identical key, so changing a filter and **reverting** to a previous set is a cache hit with + * zero network calls (React Query hashes query keys deterministically; canonicalizing here makes the + * intent explicit and keeps the URL/query-param serialization aligned with the cache key). + */ + +/** Canonical, order-stable filter object with absent optionals omitted (the cache key + query params). */ +export function canonicalizeSearchFilters(filters: NurseSearchFilters): Record { + const canonical: Record = { + serviceCategoryId: filters.serviceCategoryId, + cityId: filters.cityId, + sort: filters.sort, + page: filters.page, + pageSize: filters.pageSize, + }; + if (filters.districtId != null) canonical.districtId = filters.districtId; + if (filters.nurseGender != null) canonical.nurseGender = filters.nurseGender; + if (filters.priceMin != null && filters.priceMin !== '') canonical.priceMin = filters.priceMin; + if (filters.priceMax != null && filters.priceMax !== '') canonical.priceMax = filters.priceMax; + if (filters.priceUnit != null) canonical.priceUnit = filters.priceUnit; + return canonical; +} + +export const searchKeys = { + all: ['search'] as const, + results: (filters: NurseSearchFilters) => + [...searchKeys.all, 'results', canonicalizeSearchFilters(filters)] as const, + profiles: () => [...searchKeys.all, 'profile'] as const, + profile: (nurseId: number) => [...searchKeys.profiles(), nurseId] as const, +}; diff --git a/client/src/services/search/types.ts b/client/src/services/search/types.ts new file mode 100644 index 0000000..8db7fd5 --- /dev/null +++ b/client/src/services/search/types.ts @@ -0,0 +1,129 @@ +import type { Paginated } from '@/lib/api/types'; +import type { PriceUnit } from '@/services/catalog/types'; + +/** + * Search & discovery domain — the family-facing nurse-finding layer. Shapes are derived from the b7 + * contract (`dev/contracts/domains/search.md`) plus the b6 trust badge / b5 variant reads for the + * profile. The wire is **camelCase** and `clientFetch` unwraps the `ApiResult` envelope, so these + * are the post-`unwrap()` payloads. + * + * Load-bearing semantics (see the contract "Key semantics" + phase §5): + * - **Every returned row is already bookable.** The `nurse_search_index` invariant guarantees a hit + * only when the nurse is verified + not suspended + accepting + the variant is active. The UI must + * **never** re-filter for verification, and never surface an unverified/paused nurse. + * - **The result unit is the variant, not the nurse** — a nurse with several variants/areas can appear + * as several hits. + * - **`districtId = null` ⇒ whole city**, both directions; the client omits `districtId` for a + * whole-city search rather than sending a bogus value. + * - **Same-gender is first-class** — `nurseGender` is an up-front filter, never silently defaulted or + * dropped, and the chosen value is carried into the booking request as `required_caregiver_gender` + * (f7), surfaced *before* booking. + * - **Money is an IRR digit-string** (`price`) — rendered only via the money util, never parsed to a float. + * - **Rating sort only (MVP).** + * + * @remarks b7's `NurseSearchResultDto` and the b5/b6 reads do **not** yet expose the nurse's display + * name, avatar, distance, bio, specialties, full services list, or latest review that C2/C3 render. Those + * gaps are served by the in-memory mock (`apis/mockApi.ts`, primary this phase) and filed for the backend + * in `dev/shared-working-context/frontend/requests/for-backend.md`; the real client + * (`apis/clientApi.ts`) maps what b7/b6/b5 currently provide and is swapped in when the endpoints land. + */ + +/** A caregiver's gender — the same-gender matching facet (`any` is expressed by omitting the filter). */ +export type NurseGender = 'male' | 'female'; + +/** The only MVP result ordering. Rendered as a control with one option; other sorts are DEFERRED. */ +export type SearchSort = 'rating'; + +/** The filter object — this **is** the React Query cache key (see `keys.ts`) and the C2 query string. */ +export interface NurseSearchFilters { + serviceCategoryId: number; + cityId: number; + /** Omit for a whole-city search; "empty district = whole city" (never send a bogus district). */ + districtId?: number; + /** Omit = فرقی ندارد / any gender. Never defaulted silently. */ + nurseGender?: NurseGender; + /** Inclusive IRR-Rial digit-string bounds; compared like-for-like within a `priceUnit`. */ + priceMin?: string; + priceMax?: string; + /** Compare only like-for-like listings (e.g. only `per_hour`). */ + priceUnit?: PriceUnit; + sort: SearchSort; + page: number; + pageSize: number; +} + +/** A single C2 result card row (one bookable variant matched in a covered area). */ +export interface NurseSearchResult { + nurseId: number; + variantId: number; + serviceCategoryId: number; + /** Display name (mock/future-backend; the real b7 row omits it — card falls back to a label). */ + nurseName: string; + avatarUrl: string | null; + /** Always `true` by the search-index invariant — the UI relies on this, never re-checks it. */ + isVerified: boolean; + averageRating: number; + totalReviews: number; + totalCompletedBookings: number; + /** Kilometres from the searched area; `null` when unknown — the card hides the distance chip. */ + distanceKm: number | null; + /** The variant's `price` as an IRR-Rial digit-string; rendered via the money util only. */ + priceFromIrr: string; + priceUnit: PriceUnit; + nurseGender: NurseGender; + cityId: number; + /** `null` = the nurse covers the whole city. */ + districtId: number | null; +} + +/** One offered variant on the C3 profile — the bookable unit; reused by the ServicePriceRow. */ +export interface NurseProfileServiceRow { + variantId: number; + displayName: string; + /** IRR-Rial digit-string; rendered via the money util + the localized `priceUnit` label. */ + priceIrr: string; + priceUnit: PriceUnit; + sessionCount?: number | null; +} + +/** A short latest-review snippet for C3 (the full reviews tab is DEFERRED → f13). */ +export interface NurseReviewSnippet { + rating: number; + body: string; + /** Author name already masked server-side (PII rule); rendered verbatim. */ + authorMasked: string; + /** UTC ISO-8601; displayed via the Shamsi date util. */ + createdAt: string; +} + +/** The C3 nurse-profile payload. */ +export interface NurseProfile { + nurseId: number; + nurseName: string; + avatarUrl: string | null; + bio: string | null; + yearsExperience: number | null; + averageRating: number; + totalReviews: number; + totalCompletedBookings: number; + /** Always `true` for a discoverable nurse (invariant); drives the ✓ تاییدشده badge. */ + isVerified: boolean; + /** نظام پرستاری (INO membership) — render the badge only when `true`. */ + inoMembership: boolean; + /** Specialty **codes** (mapped to i18n labels, never rendered raw); the C3 attribute chips. */ + attributeChips: string[]; + services: NurseProfileServiceRow[]; + latestReview?: NurseReviewSnippet | null; + nurseGender: NurseGender; +} + +/** + * The search domain's API seam — the real HTTP client and the in-memory mock both implement this + * interface; selection is by config (`USE_SEARCH_MOCK`), never scattered `if (mock)` checks. + */ +export interface SearchApi { + /** The single family-facing discovery query over the maintained search index. */ + searchNurses(filters: NurseSearchFilters): Promise>; + /** The C3 nurse profile (identity + badges + services + latest review). */ + getNurseProfile(nurseId: number): Promise; +} diff --git a/dev/contracts/domains/payouts.md b/dev/contracts/domains/payouts.md new file mode 100644 index 0000000..52e38f1 --- /dev/null +++ b/dev/contracts/domains/payouts.md @@ -0,0 +1,99 @@ +# Contract — Payouts (backend phase b13) + +> The weekly nurse-payout engine: an admin previews eligible earnings, opens a draft batch, submits it to the +> (mocked) PAYA/SATNA bank rail, retries/marks failed payouts, and reads batches; a nurse reads their own payout +> history. Assumes [`../conventions/api-conventions.md`](../conventions/api-conventions.md) + +> [`../conventions/money-and-types.md`](../conventions/money-and-types.md). Machine schema: +> [`../openapi/swagger.v1.json`](../openapi/swagger.v1.json). + +**Status:** live as of backend-phase-b13 · **Frontend consumer:** frontend-phase-f12-b13 + +All money is IRR `BIGINT` and crosses the wire as a **digit string** (`"8500000"`). Dates are `yyyy-MM-dd`. +List query params are **camelCase** (`page`, `pageSize`, `status`, `periodStart`, `periodEnd`) — not snake_case. +The response envelope is the standard `{ data, … }`; the shapes below are the `data`. + +## Enums used +- `PayoutBatchStatus`: `draft` | `processing` | `partially_failed` | `completed` | `failed` — the batch lifecycle. + A draft is materialized but unsubmitted; `partially_failed` has some paid + some failed (retryable). +- `PayoutStatus`: `pending` | `submitted` | `paid` | `failed` — the per-payout lifecycle (forward-only; `paid` is an + irreversible transfer with no outgoing edge; `failed` re-submits on retry). + +## Endpoints + +### `GET api/v1/admin_payouts/eligible` +- **Purpose:** Preview the payout-eligible, unpaid earnings for a window, grouped by nurse (the dry-run before a batch). +- **Auth:** admin (dynamic-permission policy) · **Rate-limited:** yes · **Idempotency key:** n/a (read). +- **Query params:** `periodStart` (date, required), `periodEnd` (date, required, ≤ today, ≥ periodStart), `page` (default 1), `pageSize` (default 20, max 100). +- **Success `200` (`data`):** `PagedResult`. +- **Failure cases:** `400` periodStart > periodEnd or periodEnd in the future; `401` unauthenticated; `403` non-admin. +- **Notes:** Eligible = booking `status='completed'` AND `dispute_window_ends_at < now` AND no active refund AND not already paid. The `periodEnd` is holiday-shifted the same way a generate would shift it. A nurse without a verified primary IBAN is **flagged** (`hasVerifiedPrimaryIban=false`), not dropped. Pending clawbacks are netted into the preview. + +### `POST api/v1/admin_payouts/batches` +- **Purpose:** Open a `draft` batch: select eligible bookings, materialize one payout per nurse (net of clawbacks), link each booking under the UNIQUE guard, snapshot the verified primary IBAN. **No money moves.** +- **Auth:** admin · **Rate-limited:** yes · **Idempotency:** the `booking_id` UNIQUE link makes a re-run over an overlapping window unable to re-select an already-paid booking. +- **Request body:** `{ "periodStart": "2026-06-01", "periodEnd": "2026-06-30" }` +- **Success `200` (`data`):** `GeneratePayoutBatchResult` — the draft batch, its materialized payouts, and the nurses skipped (with reasons). +- **Failure cases:** `400` invalid period; `401`/`403`; a plain failure when **no eligible bookings** in the window or **no eligible nurse has a verified primary IBAN**; `409` a concurrent run already claimed one of the bookings (the UNIQUE backstop). +- **Notes:** `period_end`/`processing_date` are shifted off bank-closed days via `IHolidayCalendar`. `total_amount = Σ net_amount_irr`, `payout_count = COUNT(payouts)`. + +### `POST api/v1/admin_payouts/batches/{id}/process` +- **Purpose:** Submit a draft (or partially-failed) batch to the bank rail — the one irreversible money-out step. +- **Auth:** admin · **Rate-limited:** yes · **Idempotency key:** yes (`payout-batch:{id}`; a retried process never re-sends a paid payout or re-posts the ledger). +- **Path params:** `id` (long) — the batch id. **Body:** none. +- **Success `200` (`data`):** `ExecutePayoutBatchResult`. +- **Failure cases:** `401`/`403`; `404` batch not found; `409` the batch already `failed` (open a new one). A re-process of a `completed` batch is an idempotent `200`. +- **Notes:** Per accepted transfer it posts `DEBIT nurse_payable / CREDIT escrow_held` (paid net) and, for a netted clawback, `DEBIT nurse_payable / CREDIT nurse_clawback_receivable` + marks the `nurse_clawbacks` row `recovered`. Batch ends `completed` (all paid) or `partially_failed` (some failed). PAYA vs SATNA is chosen by `payout_satna_threshold_irr`. + +### `POST api/v1/admin_payouts/{payoutId}/retry` +- **Purpose:** Re-submit a single `failed` payout (holiday-aware). +- **Auth:** admin · **Rate-limited:** yes · **Idempotency key:** yes (`payout:{id}:retry`). +- **Path params:** `payoutId` (long). **Body:** none. +- **Success `200` (`data`):** `true`. +- **Failure cases:** `400` a `processing_date` failure when banks are closed today, or a `channel` failure when the rail declines again; `401`/`403`; `404` payout not found; `409` the payout is not `failed`. An already-`paid` payout returns an idempotent `200`. +- **Notes:** On success it posts the ledger + nets clawbacks like the first process and re-settles the batch (`partially_failed → completed` when it was the last failure). + +### `POST api/v1/admin_payouts/{payoutId}/mark_failed` +- **Purpose:** Record a reconciled bank rejection on a payout — no ledger movement (no money left). +- **Auth:** admin · **Rate-limited:** yes. +- **Path params:** `payoutId` (long). **Request body:** `{ "failureReason": "invalid_sheba" }` +- **Success `200` (`data`):** `true`. +- **Failure cases:** `400` empty reason; `401`/`403`; `404` not found; `409` the payout is `paid` (a confirmed transfer can't be failed). An already-`failed` payout is an idempotent `200`. + +### `GET api/v1/admin_payouts/batches/{id}` +- **Purpose:** Batch header + its paginated payouts (status, net, masked IBAN, transfer reference) + the bookings each covers. +- **Auth:** admin · **Rate-limited:** yes. +- **Path params:** `id` (long). **Query:** `page` (default 1), `pageSize` (default 50, max 200). +- **Success `200` (`data`):** `PayoutBatchDetailDto`. +- **Failure cases:** `401`/`403`; `404` not found. + +### `GET api/v1/admin_payouts/batches` +- **Purpose:** Admin reconciliation list of batches. +- **Auth:** admin · **Rate-limited:** yes. +- **Query:** `status` (optional `PayoutBatchStatus`), `page` (default 1), `pageSize` (default 20, max 100). +- **Success `200` (`data`):** `PagedResult`. + +### `GET api/v1/nurse_payouts/history` +- **Purpose:** The signed-in nurse's own payouts (tenancy-scoped) — status, net, masked IBAN + transfer reference, clawback applied, the batch window. +- **Auth:** authenticated (nurse) · **Rate-limited:** no. +- **Query:** `page` (default 1), `pageSize` (default 20, max 100). +- **Success `200` (`data`):** `PagedResult`. +- **Failure cases:** `401` unauthenticated. A caller who is not a nurse gets an empty page (never another nurse's data). + +## Shared shapes +- `EligibleNurseEarningsDto`: `nurseId` (long), `nurseName` (string?), `bookingCount` (int), `grossEarningsIrr` (string), `clawbackAppliedIrr` (string), `netAmountIrr` (string), `hasVerifiedPrimaryIban` (bool). +- `PayoutBatchDto`: `id` (long), `periodStart`/`periodEnd`/`processingDate` (date), `totalAmount` (string), `payoutCount` (int), `status` (`PayoutBatchStatus`), `initiatedByAdminId` (int), `processedAt` (datetime?), `failureNotes` (string?), `createdAt` (datetime). +- `PayoutDto`: `id` (long), `nurseId` (long), `nurseName` (string?), `maskedIban` (string, last-4 only), `grossEarningsIrr`/`clawbackAppliedIrr`/`netAmountIrr`/`amount` (string), `bookingCount` (int), `status` (`PayoutStatus`), `transferReference` (string?), `paidAt` (datetime?), `failureReason` (string?), `bookings` (`PayoutBookingLinkDto[]`). +- `PayoutBookingLinkDto`: `bookingId` (long), `sessionId` (long?), `payoutAmountIrr` (string). +- `PayoutBatchDetailDto`: `batch` (`PayoutBatchDto`), `payouts` (`PayoutDto[]`), `total` (int), `page` (int), `pageSize` (int). +- `SkippedNurseDto`: `nurseId` (long), `nurseName` (string?), `grossEarningsIrr` (string), `reason` (string, e.g. `no_verified_primary_iban`). +- `GeneratePayoutBatchResult`: `batch` (`PayoutBatchDto`), `payouts` (`PayoutDto[]`), `skipped` (`SkippedNurseDto[]`). +- `ExecutePayoutBatchResult`: `batchId` (long), `status` (`PayoutBatchStatus`), `paidCount` (int), `failedCount` (int), `totalPaid` (string). +- `NursePayoutHistoryDto`: `id` (long), `batchId` (long), `status` (`PayoutStatus`), `grossEarningsIrr`/`clawbackAppliedIrr`/`netAmountIrr` (string), `maskedIban` (string), `transferReference` (string?), `paidAt` (datetime?), `periodStart`/`periodEnd` (date). + +## Side effects +- **Ledger:** process/retry post balanced groups out of `nurse_payable` (payout + clawback-recovery). Never a `payout_released` boolean — paid-ness derives from a link row + the ledger. +- **One payout per booking, forever** via the `nurse_payout_booking_links.booking_id` UNIQUE. +- **Bank rail** is mocked behind `IBankTransferProvider` (PAYA/SATNA) — no real transfer. + +## Changelog +- b13 — initial contract. diff --git a/dev/contracts/openapi/swagger.v1.json b/dev/contracts/openapi/swagger.v1.json index bd66aa1..c80ae49 100644 --- a/dev/contracts/openapi/swagger.v1.json +++ b/dev/contracts/openapi/swagger.v1.json @@ -2217,6 +2217,620 @@ ] } }, + "/api/v1/admin_payouts/eligible": { + "get": { + "tags": [ + "AdminPayouts" + ], + "operationId": "AdminPayouts_Eligible", + "parameters": [ + { + "name": "PeriodStart", + "in": "query", + "schema": { + "type": "string", + "format": "date" + }, + "x-position": 1 + }, + { + "name": "PeriodEnd", + "in": "query", + "schema": { + "type": "string", + "format": "date" + }, + "x-position": 2 + }, + { + "name": "Page", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + }, + "x-position": 3 + }, + { + "name": "PageSize", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + }, + "x-position": 4 + } + ], + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfPagedResultOfEligibleNurseEarningsDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/admin_payouts/batches": { + "post": { + "tags": [ + "AdminPayouts" + ], + "operationId": "AdminPayouts_Generate", + "requestBody": { + "x-name": "command", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GeneratePayoutBatchCommand" + } + } + }, + "required": true, + "x-position": 1 + }, + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfGeneratePayoutBatchResult" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + }, + "get": { + "tags": [ + "AdminPayouts" + ], + "operationId": "AdminPayouts_List", + "parameters": [ + { + "name": "Status", + "in": "query", + "schema": { + "type": "string", + "nullable": true + }, + "x-position": 1 + }, + { + "name": "Page", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + }, + "x-position": 2 + }, + { + "name": "PageSize", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + }, + "x-position": 3 + } + ], + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfPagedResultOfPayoutBatchDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/admin_payouts/batches/{id}/process": { + "post": { + "tags": [ + "AdminPayouts" + ], + "operationId": "AdminPayouts_Process", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + }, + "x-position": 1 + } + ], + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfExecutePayoutBatchResult" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/admin_payouts/batches/{id}": { + "get": { + "tags": [ + "AdminPayouts" + ], + "summary": "Retrieves a AdminPayout by unique id", + "operationId": "AdminPayouts_Get", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "A unique id for the AdminPayout", + "schema": { + "type": "integer", + "format": "int64" + }, + "x-position": 1 + }, + { + "name": "page", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 1 + }, + "x-position": 2 + }, + { + "name": "pageSize", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 50 + }, + "x-position": 3 + } + ], + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfPayoutBatchDetailDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/admin_payouts/{payoutId}/retry": { + "post": { + "tags": [ + "AdminPayouts" + ], + "operationId": "AdminPayouts_Retry", + "parameters": [ + { + "name": "payoutId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + }, + "x-position": 1 + } + ], + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfBoolean" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/admin_payouts/{payoutId}/mark_failed": { + "post": { + "tags": [ + "AdminPayouts" + ], + "operationId": "AdminPayouts_MarkFailed", + "parameters": [ + { + "name": "payoutId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + }, + "x-position": 1 + } + ], + "requestBody": { + "x-name": "body", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MarkPayoutFailedBody" + } + } + }, + "required": true, + "x-position": 2 + }, + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfBoolean" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, "/api/v1/admin_refunds": { "post": { "tags": [ @@ -6679,7 +7293,7 @@ "tags": [ "Me" ], - "description": "Role claims live inside the access token — after selecting a role the client should\n refresh its tokens to pick the new role up.", + "description": "Role claims live inside the access token \u2014 after selecting a role the client should\n refresh its tokens to pick the new role up.", "operationId": "Me_SelectRole", "requestBody": { "x-name": "command", @@ -7418,6 +8032,91 @@ ] } }, + "/api/v1/nurse_payouts/history": { + "get": { + "tags": [ + "NursePayouts" + ], + "operationId": "NursePayouts_History", + "parameters": [ + { + "name": "Page", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + }, + "x-position": 1 + }, + { + "name": "PageSize", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + }, + "x-position": 2 + } + ], + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfPagedResultOfNursePayoutHistoryDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, "/api/v1/nurse_profiles/upsert": { "post": { "tags": [ @@ -11662,6 +12361,434 @@ } } }, + "ApiResultOfPagedResultOfEligibleNurseEarningsDto": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/PagedResultOfEligibleNurseEarningsDto" + } + ] + } + } + } + ] + }, + "PagedResultOfEligibleNurseEarningsDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "items": { + "type": "array", + "nullable": true, + "items": { + "$ref": "#/components/schemas/EligibleNurseEarningsDto" + } + }, + "total": { + "type": "integer", + "format": "int32" + }, + "page": { + "type": "integer", + "format": "int32" + }, + "pageSize": { + "type": "integer", + "format": "int32" + } + } + }, + "EligibleNurseEarningsDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "nurseId": { + "type": "integer", + "format": "int64" + }, + "nurseName": { + "type": "string", + "nullable": true + }, + "bookingCount": { + "type": "integer", + "format": "int32" + }, + "grossEarningsIrr": { + "type": "string" + }, + "clawbackAppliedIrr": { + "type": "string" + }, + "netAmountIrr": { + "type": "string" + }, + "hasVerifiedPrimaryIban": { + "type": "boolean" + } + } + }, + "ApiResultOfGeneratePayoutBatchResult": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/GeneratePayoutBatchResult" + } + ] + } + } + } + ] + }, + "GeneratePayoutBatchResult": { + "type": "object", + "additionalProperties": false, + "properties": { + "batch": { + "$ref": "#/components/schemas/PayoutBatchDto" + }, + "payouts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PayoutDto" + } + }, + "skipped": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SkippedNurseDto" + } + } + } + }, + "PayoutBatchDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "periodStart": { + "type": "string", + "format": "date" + }, + "periodEnd": { + "type": "string", + "format": "date" + }, + "processingDate": { + "type": "string", + "format": "date" + }, + "totalAmount": { + "type": "string" + }, + "payoutCount": { + "type": "integer", + "format": "int32" + }, + "status": { + "type": "string" + }, + "initiatedByAdminId": { + "type": "integer", + "format": "int32" + }, + "processedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "failureNotes": { + "type": "string", + "nullable": true + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + } + }, + "PayoutDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "nurseId": { + "type": "integer", + "format": "int64" + }, + "nurseName": { + "type": "string", + "nullable": true + }, + "maskedIban": { + "type": "string" + }, + "grossEarningsIrr": { + "type": "string" + }, + "clawbackAppliedIrr": { + "type": "string" + }, + "netAmountIrr": { + "type": "string" + }, + "amount": { + "type": "string" + }, + "bookingCount": { + "type": "integer", + "format": "int32" + }, + "status": { + "type": "string" + }, + "transferReference": { + "type": "string", + "nullable": true + }, + "paidAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "failureReason": { + "type": "string", + "nullable": true + }, + "bookings": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PayoutBookingLinkDto" + } + } + } + }, + "PayoutBookingLinkDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "bookingId": { + "type": "integer", + "format": "int64" + }, + "sessionId": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "payoutAmountIrr": { + "type": "string" + } + } + }, + "SkippedNurseDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "nurseId": { + "type": "integer", + "format": "int64" + }, + "nurseName": { + "type": "string", + "nullable": true + }, + "grossEarningsIrr": { + "type": "string" + }, + "reason": { + "type": "string" + } + } + }, + "GeneratePayoutBatchCommand": { + "type": "object", + "additionalProperties": false, + "properties": { + "periodStart": { + "type": "string", + "format": "date" + }, + "periodEnd": { + "type": "string", + "format": "date" + } + } + }, + "ApiResultOfExecutePayoutBatchResult": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/ExecutePayoutBatchResult" + } + ] + } + } + } + ] + }, + "ExecutePayoutBatchResult": { + "type": "object", + "additionalProperties": false, + "properties": { + "batchId": { + "type": "integer", + "format": "int64" + }, + "status": { + "type": "string" + }, + "paidCount": { + "type": "integer", + "format": "int32" + }, + "failedCount": { + "type": "integer", + "format": "int32" + }, + "totalPaid": { + "type": "string" + } + } + }, + "ApiResultOfPayoutBatchDetailDto": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/PayoutBatchDetailDto" + } + ] + } + } + } + ] + }, + "PayoutBatchDetailDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "batch": { + "$ref": "#/components/schemas/PayoutBatchDto" + }, + "payouts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PayoutDto" + } + }, + "total": { + "type": "integer", + "format": "int32" + }, + "page": { + "type": "integer", + "format": "int32" + }, + "pageSize": { + "type": "integer", + "format": "int32" + } + } + }, + "ApiResultOfPagedResultOfPayoutBatchDto": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/PagedResultOfPayoutBatchDto" + } + ] + } + } + } + ] + }, + "PagedResultOfPayoutBatchDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "items": { + "type": "array", + "nullable": true, + "items": { + "$ref": "#/components/schemas/PayoutBatchDto" + } + }, + "total": { + "type": "integer", + "format": "int32" + }, + "page": { + "type": "integer", + "format": "int32" + }, + "pageSize": { + "type": "integer", + "format": "int32" + } + } + }, + "MarkPayoutFailedBody": { + "type": "object", + "description": "The mark-failed body (the payout id comes from the route).", + "additionalProperties": false, + "properties": { + "failureReason": { + "type": "string", + "nullable": true + } + } + }, "ApiResultOfCreateRefundResult": { "allOf": [ { @@ -14615,6 +15742,98 @@ } } }, + "ApiResultOfPagedResultOfNursePayoutHistoryDto": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/PagedResultOfNursePayoutHistoryDto" + } + ] + } + } + } + ] + }, + "PagedResultOfNursePayoutHistoryDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "items": { + "type": "array", + "nullable": true, + "items": { + "$ref": "#/components/schemas/NursePayoutHistoryDto" + } + }, + "total": { + "type": "integer", + "format": "int32" + }, + "page": { + "type": "integer", + "format": "int32" + }, + "pageSize": { + "type": "integer", + "format": "int32" + } + } + }, + "NursePayoutHistoryDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "batchId": { + "type": "integer", + "format": "int64" + }, + "status": { + "type": "string" + }, + "grossEarningsIrr": { + "type": "string" + }, + "clawbackAppliedIrr": { + "type": "string" + }, + "netAmountIrr": { + "type": "string" + }, + "maskedIban": { + "type": "string" + }, + "transferReference": { + "type": "string", + "nullable": true + }, + "paidAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "periodStart": { + "type": "string", + "format": "date" + }, + "periodEnd": { + "type": "string", + "format": "date" + } + } + }, "ApiResultOfNurseProfileDto": { "allOf": [ { diff --git a/dev/shared-working-context/backend/STATUS.md b/dev/shared-working-context/backend/STATUS.md index 7d8aad4..a820d89 100644 --- a/dev/shared-working-context/backend/STATUS.md +++ b/dev/shared-working-context/backend/STATUS.md @@ -12,6 +12,25 @@ One block per completed backend phase. Newest at the top. Backend lane writes he - **Notes for frontend:** --> +## backend-phase-13 — Weekly nurse payouts (mocked PAYA/SATNA) — 2026-07-09 +- **Shipped:** new `payouts` schema, 3 tables — `NursePayoutBatches` (holiday-shifted period/processing dates), + `NursePayouts` (net-split CHECK, encrypted `iban_snapshot`, forward-only `PayoutStatus`), `NursePayoutBookingLinks` + (**unconditional `UNIQUE(booking_id)`** = one-payout-per-booking-ever). One migration (`NursePayoutEngine`). + `Features/Payouts/*` (compute-eligible / generate-batch / process / retry / mark-failed + admin batch-detail/list + + nurse history; shared `PayoutSettlement` step). `IPayoutRepository`. Controllers `AdminPayouts` (admin, rate-limited) + + `NursePayouts` (nurse, tenancy-scoped). New seam **`IBankTransferProvider`** (mock PAYA/SATNA). Swapped + `INursePayoutStatus` to the authoritative link-based `NursePayoutLinkStatusService` (deleted the interim one). Added + 2 config keys (`payout_satna_threshold_irr`, `require_bnpl_settlement_for_payout`). +- **Contracts:** `dev/contracts/domains/payouts.md` + openapi snapshot refreshed (yes — 7 payout paths). +- **Mocked:** `IBankTransferProvider` → 🟡; `INursePayoutStatus` → 🟢 (real link lookup). Reuse `IHolidayCalendar`, + `IFieldEncryptor`, `IDistributedLock`, `ICacheService`. See reports/mocks-registry.md. +- **Gate:** build clean (0 new code warnings) / tests green (329: 223 foundation + 102 api + 4 identity; +9 payout + unit + 6 payout api). Migration builds; swagger serves all 7 payout paths. +- **Handoff:** backend/handoff/after-backend-phase-13.md +- **Notes for frontend:** f12-b13 = nurse `nurse_payouts/history` (own payouts, masked IBAN, digit-string money) + + admin payout console (`admin_payouts/eligible|batches|batches/{id}|batches/{id}/process|{payoutId}/retry|mark_failed`). + Query params camelCase (`page`/`pageSize`/`status`/`periodStart`/`periodEnd`). Money is a digit string. + ## backend-phase-12 — BNPL: provider-financed installments (mocked) — 2026-07-09 - **Shipped:** `payments.BnplTransactions` (1:1 with `payment_transaction`, `UNIQUE(payment_transaction_id)`, settle-split CHECK, forward-only `BnplStatus` machine); `Features/Bnpl/*` (eligibility/initiate/verify/settle/ diff --git a/dev/shared-working-context/backend/handoff/after-backend-phase-13.md b/dev/shared-working-context/backend/handoff/after-backend-phase-13.md new file mode 100644 index 0000000..774e420 --- /dev/null +++ b/dev/shared-working-context/backend/handoff/after-backend-phase-13.md @@ -0,0 +1,51 @@ +# Handoff — after backend-phase-13 (Weekly nurse payouts) + +**The payout engine is live — a nurse's earnings are now real money.** This is the last money-out leg of the +payments arc (b10 ledger → b11 refunds/clawbacks → b12 BNPL → **b13 payouts**). An admin previews eligible +earnings, opens a weekly batch, and submits it to a **mocked PAYA/SATNA bank rail**; each booking is paid in +**exactly one** payout across all batches (a `nurse_payout_booking_links.booking_id` UNIQUE), pending clawbacks are +netted, the verified primary IBAN is snapshotted, and the outbound `nurse_payable → escrow_held` ledger movement is +posted. Everything is **holiday-aware** (a Nowruz-landing batch shifts off bank-closed days). No `payout_released` +boolean exists — paid-ness is derived from a link row + the ledger. + +## What the frontend (f12-b13) can now build +- **Nurse earnings / payout history** — `GET nurse_payouts/history` (authenticated nurse, own payouts only, + paginated): `status` (`pending|submitted|paid|failed`), `netAmountIrr`, `grossEarningsIrr`, `clawbackAppliedIrr`, + the **masked** IBAN, `transferReference`, `paidAt`, and the batch window. Money is a **digit string**. Show the + clawback line when `clawbackAppliedIrr > "0"` ("earnings held to recover a prior overpayment"). +- **Admin payout console:** + - `GET admin_payouts/eligible?periodStart=&periodEnd=` — dry-run preview per nurse; flags any nurse with + `hasVerifiedPrimaryIban=false` (they won't be paid until they register a verified primary account). + - `POST admin_payouts/batches {periodStart, periodEnd}` — open a `draft` batch → returns the batch + payouts + + the `skipped` nurses (with reasons). No money moves yet. + - `POST admin_payouts/batches/{id}/process` — submit to the rail → `completed` / `partially_failed`. + - `POST admin_payouts/{payoutId}/retry` and `POST admin_payouts/{payoutId}/mark_failed {failureReason}`. + - `GET admin_payouts/batches/{id}` (header + payouts + linked bookings) and `GET admin_payouts/batches?status=`. + +## Contracts +- **`dev/contracts/domains/payouts.md`** — all 8 endpoints, the `PayoutBatchStatus`/`PayoutStatus` enums, and the + batch/payout/link/history DTO shapes (IRR digit strings, **masked** IBAN). Query params are **camelCase** + (`page`/`pageSize`/`status`/`periodStart`/`periodEnd`). +- **`dev/contracts/openapi/swagger.v1.json`** refreshed — the 7 payout paths are in the snapshot. + +## What is mocked (and how it becomes real) +- **`IBankTransferProvider`** (new) — the PAYA/SATNA rail. `MockBankTransferProvider` moves no money: it returns a + deterministic `transfer_reference` and settles every instruction `paid`, honouring the PAYA/SATNA method the + handler picked by `payout_satna_threshold_irr`. Config forces failures for testing (`Seams:BankTransfer:ForceFailure` + whole-batch, `Seams:BankTransfer:FailIban` one row). Make it real → a Jibit/Vandar/Sadad payout adapter with a + registered source settlement account + the async reconciliation callback (see reports/mocks-registry.md). +- **`INursePayoutStatus`** — b13 shipped the authoritative `NursePayoutLinkStatusService` (paid iff a link ties the + booking to a `paid` payout); the interim dispute-window derivation was deleted. **The b11 refund fork now forks on + the true paid-state** — no refund-side change needed. + +## Load-bearing rules (don't regress) +- **One payout per booking, ever** — the `booking_id` UNIQUE is unconditional (not soft-delete-filtered). +- **Eligibility ≠ completed** — needs `dispute_window_ends_at < now`, no active refund, not already linked. +- **Clawback netting recovers *whole* clawbacks up to earnings** (never a negative net, never a partial single row); + the recovery is a real `DEBIT nurse_payable / CREDIT nurse_clawback_receivable` posting + `recovered` status. +- **Process is idempotent** — forward-only `PayoutStatus` + a batch idempotency key + the ledger-exists guard. + +## Deferred (flagged, not built) +- The weekly **cron scheduler** — batches are admin-triggered; cadence in `nurse_payout_interval_days` (default 7). +- **On-demand / instant withdrawal**, **per-nurse payout frequency**, **automated clawback recovery beyond netting**. +- The **BNPL `settled_at` guard** — exposed as `require_bnpl_settlement_for_payout` (config, default off). diff --git a/dev/shared-working-context/frontend/STATUS.md b/dev/shared-working-context/frontend/STATUS.md index 8519b2c..9905ed7 100644 --- a/dev/shared-working-context/frontend/STATUS.md +++ b/dev/shared-working-context/frontend/STATUS.md @@ -12,6 +12,29 @@ for awareness. - **Requests filed:** frontend/requests/for-backend.md (yes/no) --> +## frontend-phase-6-b7 — Search & discovery (find a verified, same-gender nurse) — 2026-07-09 +- **Shipped:** the family discovery slice — `services/search` (types/keys/constants/apis/hooks + a shared + `filterParams.ts` C1↔C2 URL serializer) and screens **C1** `/search` (reused category grid + f3 region + picker + prominent same-gender facet + Toman price + live-count CTA; `useSearchFilters` colocated + controller with debounced price), **C2** `/search/results` (rating-sorted `NurseResultCard` list, all + four states incl. "relax filters" empty, load-more), **C3** `/search/nurse/[nurseId]` (TrustBadge + نظام + پرستاری badges, attribute chips, `ServicePriceRow` services, latest review, "درخواست رزرو" handoff). + Two shared tested components: `NurseResultCard`, `ServicePriceRow`. `/bookings/request` = f7 handoff stub. + i18n `search` (filled) + `booking` (seeded) in both locales. Reused f4 category grid, f5 TrustBadge, f3 + geo picker, f0 money util — none rebuilt. +- **Headline caching:** the **filter object IS the query key** — reverting to a prior filter set is a cache + hit with zero network (keepPreviousData avoids flashing); price input debounced. +- **Consumes:** dev/contracts/domains/search.md (b7) + b6 trust badge / b5 variant reads. +- **Mocked client-side:** `services/search` via `searchMockApi` (**USE_SEARCH_MOCK=true, primary**) — b7's + index row + b5/b6 reads don't yet expose nurse name/avatar/distance or an aggregated profile + (name/bio/specialties/services list/latest review). Real `searchClientApi` maps what exists; swap is one + line once REQ-012 lands. Recorded in the phase report (not mocks-registry — that's for backend DI seams). +- **Gate:** npm run check green · npm run test:ci green (165 tests, +8). `npm run build` compiles + types + clean; prerender fails only on the pre-existing f5 `/nurse/verification` "Missing .env variable!" (needs + env set — unrelated to f6). +- **Requests filed:** frontend/requests/for-backend.md — yes (REQ-012: search row name/avatar/distance + + `GET nurses/{id}/profile` aggregation). + ## frontend-phase-5-b6 — Nurse verification flow (trust engine) — 2026-07-09 - **Shipped:** `services/verification` domain (types/keys/constants/validation/apis[client+mock(primary)+seam]/ hooks/index) — ONE cached `status()` query drives B3+B6, every mutation invalidates it. The nurse diff --git a/dev/shared-working-context/frontend/requests/for-backend.md b/dev/shared-working-context/frontend/requests/for-backend.md index d2f70c4..2700261 100644 --- a/dev/shared-working-context/frontend/requests/for-backend.md +++ b/dev/shared-working-context/frontend/requests/for-backend.md @@ -150,3 +150,24 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a - **Also (minor):** the contract's `VerificationStepDto` has no `isRequired` — the client treats **every** seeded step as required (the "X از Y" meter Y = `steps.length`). Confirm that holds, or add `isRequired`. - **Status:** open + +## REQ-012 — Search result + nurse-profile enrichment for discovery (C2/C3) — filed by frontend-phase-6-b7 — 2026-07-09 +- **Need:** Two extra read surfaces the discovery UI renders but b7/b6/b5 don't yet expose: + 1. **On the `search/nurses` result row** (`NurseSearchResultDto`): the nurse's **display name** and + **avatar URL** (the C2 card's identity) and a **distance** value (km from the searched area) — b7's + index row today carries only ids + price/rating/gender/geo ids. Without the name/avatar the card falls + back to a generic label + initials; distance is simply hidden. + 2. **An aggregated public nurse-profile endpoint** for C3 — proposed `GET api/v1/nurses/{id}/profile` → + `{ nurseId, nurseName, avatarUrl, bio, yearsExperience, averageRating, totalReviews, + totalCompletedBookings, isVerified, inoMembership, attributeChips: string[] (specialty codes), + services: [{ variantId, displayName, priceIrr (string), priceUnit, sessionCount? }], + latestReview?: { rating, body, authorMasked, createdAt } }`. Today only the b6 public **trust badge** + (`nurses/{id}/trust_badge`, giving `isVerified` + `credentialTypes`) and the b5 single-variant read are + public — there is no name/bio/specialties/**full services list**/latest-review aggregation. +- **Why:** C2/C3 are the trust funnel — the family chooses a real, named, priced nurse here. The + `services/search` domain is **mock-primary** (`USE_SEARCH_MOCK = true`) precisely because these fields + aren't available; the real `searchClientApi` maps everything b7/b6 do provide and leaves the above blank. + When both land, the swap is a single config flip (no hook/component change). +- **Proposed shape:** enrich `NurseSearchResultDto` with `{ nurseName, avatarUrl, distanceKm? }`; add + `GET api/v1/nurses/{id}/profile` returning the object above. `price`/`priceIrr` stay IRR digit-strings. +- **Status:** open diff --git a/dev/shared-working-context/reports/backend-phase-13-report.md b/dev/shared-working-context/reports/backend-phase-13-report.md new file mode 100644 index 0000000..116d3dc --- /dev/null +++ b/dev/shared-working-context/reports/backend-phase-13-report.md @@ -0,0 +1,76 @@ +# Backend Phase 13 report — Weekly nurse payouts (mocked bank transfer) + +**Date:** 2026-07-09 · **Track:** backend · **Status:** complete, gate green. + +## What was built +- **`payouts` schema, 3 tables** (one migration `NursePayoutEngine`): + - `NursePayoutBatches` — weekly aggregation; `period_end`/`processing_date` holiday-shifted; `total_amount` / + `payout_count`; `status` (`draft|processing|partially_failed|completed|failed`); `initiated_by_admin_id` FK. + - `NursePayouts` — one per nurse per batch; DB CHECK `net = gross − clawback` + all ≥ 0; **encrypted + `iban_snapshot`** (EF converter) frozen from the verified primary account; `status` (`pending|submitted|paid|failed`, + forward-only); `transfer_reference`, `paid_at`, `failure_reason`. + - `NursePayoutBookingLinks` — **unconditional `UNIQUE(booking_id)`** (the one-payout-per-booking-ever guard); + nullable `session_id` for a future per-session model. +- **Domain:** `PayoutBatchStatus`/`PayoutStatus` + `*Transitions`; `LedgerPosting.NursePayout` (DEBIT nurse_payable / + CREDIT escrow_held) + `LedgerPosting.ClawbackRecovery` (DEBIT nurse_payable / CREDIT nurse_clawback_receivable); + `NurseClawback.Recover(payoutId, now)`. +- **Application:** `Features/Payouts/{Commands|Queries}` — `ComputeEligibleEarnings`, `GeneratePayoutBatch` + (build+link inline), `ExecutePayoutBatch`, `RetryFailedPayout`, `MarkPayoutFailed`, `GetBatchDetail`, + `ListPayoutBatches`, `GetNursePayoutHistory`; shared `PayoutSettlement` (ledger post + clawback netting). + `IPayoutRepository` on `IUnitOfWork`. +- **Infrastructure:** `PayoutRepository`, `PayoutsConfig/*`, `MockBankTransferProvider` (+ `BankTransferOptions`), + the authoritative `NursePayoutLinkStatusService` (swapped for the deleted interim `NursePayoutStatusService`), + `iban_snapshot` encryption wired in `ApplicationDbContext`, 2 new `platform_configs` seeds. +- **API:** `AdminPayoutsController` (admin, rate-limited) + `NursePayoutsController` (nurse, tenancy-scoped). + +## What is now testable and exactly how (per §7 of the phase) +Seed a few **completed** bookings via the admin flow: some with `dispute_window_ends_at` in the past (eligible), some +future (not yet), one disputed, one with a pending clawback; one nurse with a verified primary IBAN, one without. +1. **Eligibility preview** — `GET admin_payouts/eligible?periodStart=&periodEnd=` → only completed + dispute-window- + closed, unpaid bookings appear, grouped by nurse; future + disputed excluded; the no-IBAN nurse flagged + (`hasVerifiedPrimaryIban=false`). *(unit: `Preview_includes_only_closed_window_and_flags_missing_iban`)* +2. **Generate a batch** — `POST admin_payouts/batches` → a `draft` batch, one payout per eligible nurse; the nurse + with a pending clawback shows `clawbackAppliedIrr>0` and `net = gross − clawback`; `total_amount = Σ net`; + `iban_snapshot` populated (encrypted, served masked). *(unit: `Generate_materializes_one_payout_per_nurse_and_nets_clawback`, + `Generate_skips_nurse_without_verified_primary_iban_with_reason`)* +3. **Double-pay guard** — a second generate over the same window doesn't re-select the linked bookings. + *(unit: `Double_pay_guard_second_generate_does_not_reselect_linked_bookings`)* +4. **Holiday shift** — `period_end`/`processing_date` shift off a seeded bank-closed day. + *(unit: `Holiday_shifts_period_end_and_processing_date`)* +5. **Execute** — `POST admin_payouts/batches/{id}/process` → payouts go `paid` with a `transfer_reference`; the ledger + shows balanced `DEBIT nurse_payable / CREDIT escrow_held` per payout (the payable balance drops by the paid amount); + a netted clawback is marked `recovered` with `recovered_in_payout_id`. *(unit: + `Execute_posts_balanced_payout_ledger_and_drains_payable`, `Execute_recovers_clawback_and_posts_recovery_leg`)* +6. **Idempotency** — re-process → no second transfer / ledger group. *(unit: `Reprocess_is_idempotent_no_second_ledger_group`)* +7. **Failure / retry** — force a rail failure → `partially_failed`; `retry` (rail back to success) → `paid`, batch + `completed`. *(unit: `Partial_failure_then_retry_completes_the_batch`)* +8. **Nurse history** — `GET nurse_payouts/history` as the nurse → their payouts (masked IBAN, net, reference); + another nurse's are invisible. *(api: `NursePayoutsApiTests`)* + +API happy-path/401/400: `AdminPayoutsApiTests` (generate→process pays the nurse; 401 unauth; 400 bad period; list) +and `NursePayoutsApiTests` (401 unauth; own paid payout with masked IBAN). + +## What is mocked + how to make it real +- **`IBankTransferProvider`** (🟡) — PAYA/SATNA rail. Make real = a Jibit/Vandar/Sadad payout adapter with a + registered source settlement account, per-nurse verified Sheba, PAYA-vs-SATNA selection, batch caps/minimums, and + the async reconciliation callback that flips `submitted → paid/failed`. Config keys `Seams:BankTransfer:*`. +- **`INursePayoutStatus`** (🟢) — now the real link-based lookup (`NursePayoutLinkStatusService`). +- Reused mocks: `IHolidayCalendar`, `IFieldEncryptor`, `IDistributedLock`, `ICacheService`. + +## Contracts produced +- `dev/contracts/domains/payouts.md` (new) · `dev/contracts/openapi/swagger.v1.json` refreshed (7 payout paths). + +## Confirmed rules recorded (product/business/10-payouts.md §d1) +- Clawback netting recovers **whole** clawbacks up to a batch's earnings (never negative net, never a partial single + clawback); a clawback larger than a batch's earnings waits for a later batch. Recovery is a real ledger movement. +- A booking with an active refund is held out of payouts (the operational reading of "no open dispute"). +- `payout_satna_threshold_irr` picks PAYA vs SATNA; `require_bnpl_settlement_for_payout` (default off) gates BNPL. + +## Follow-ups (deferred) +- The weekly **cron scheduler** (PAYA-aligned) — entry point is `GeneratePayoutBatchCommand`; cadence in + `nurse_payout_interval_days`. On-demand/instant withdrawal; per-nurse payout frequency; automated clawback recovery + beyond next-batch netting; the BNPL `settled_at` timing guard (flag shipped, off). + +## Gate +`dotnet build Baya.sln` — 0 errors, 0 new code warnings (only pre-existing NU1510/NETSDK1057/NU1903). +`dotnet test Baya.sln` — 329 pass (223 foundation + 102 api + 4 identity), 0 fail. diff --git a/dev/shared-working-context/reports/frontend-phase-6-report.md b/dev/shared-working-context/reports/frontend-phase-6-report.md new file mode 100644 index 0000000..946a365 --- /dev/null +++ b/dev/shared-working-context/reports/frontend-phase-6-report.md @@ -0,0 +1,88 @@ +# Frontend Phase 6 — Search & discovery (C1/C2/C3) — report + +**Date:** 2026-07-09 · **Track:** frontend · **Depends on:** f4 (catalog/category grid), f5 +(TrustBadge), f3 (geo picker), f0 (money util, services pattern) · **Consumes:** b7 `search.md` + +b6 trust badge / b5 variant reads · **Unlocks:** f7 booking request. + +## What was built + +A vertical discovery slice — the trust funnel where a family picks a real, verified nurse. + +### `services/search/` (the domain, copies the f0/auth shape) +- **`types.ts`** — `NurseSearchFilters` (the cache key/URL shape), `NurseSearchResult` (C2 card row), + `NurseProfile` + `NurseProfileServiceRow` + `NurseReviewSnippet` (C3), the `SearchApi` seam. Derived + from the b7 contract; the fields b7 doesn't expose are documented inline + filed (REQ-012). +- **`keys.ts`** — `searchKeys.results(filters)` / `searchKeys.profile(id)` + `canonicalizeSearchFilters` + (stable key order, absent optionals omitted) — the filter-object-as-query-key caching contract. +- **`constants.ts`** — `USE_SEARCH_MOCK` (true, primary), stale/gc times, page size, debounce ms. +- **`filterParams.ts`** — the single C1↔C2 URL (de)serializer (snake_case, matching b7 params) so the + screen that writes the URL and the screen that reads it never drift. +- **`apis/`** — `mockApi.ts` (primary; real-shaped verified fixtures in `seed.ts`, reproduces b7 + filter + whole-city geography + rating-sort semantics), `clientApi.ts` (real b7/b6 mapping scaffold, + gaps left blank), `index.ts` (seam selection by `USE_SEARCH_MOCK`). +- **`hooks/`** — `useNurseSearch` (keepPreviousData, enabled on category+city), `useNurseProfile` + (enabled on id), `useDebouncedValue` (generic, used by the C1 controller). `index.ts` re-exports hooks. + +### Screens (all RTL/i18n/dark-mode, under the customer bottom-tab shell) +- **C1** `/search` — reused f4 category grid (selectable) + f3 `CascadingRegionSelect` (district + optional = whole city) + **prominent same-gender toggle** (خانم/آقا/فرقی ندارد) with a why-line + + intent-only date + Toman price range; a **live result count** drives the "مشاهده N پرستار" CTA into + C2. Fast-changing filter state in the colocated `useSearchFilters` controller (debounced price). +- **C2** `/search/results` — result count + rating sort control (one option; other sorts DEFERRED), + rating-sorted `NurseResultCard` list, **all four states** (skeleton / empty "relax filters" with + concrete suggestions / error-retry / populated), load-more. Filters live in the URL. +- **C3** `/search/nurse/[nurseId]` — avatar/name/rating, ✓ تاییدشده (reused TrustBadge) + نظام پرستاری + (rendered only when `inoMembership`), attribute chips (specialty codes → i18n + years-experience), + `ServicePriceRow` services list, latest-review snippet (+ "no reviews" empty), loading/not-found/error + states, and the **"درخواست رزرو"** CTA that hands off to `/bookings/request`. + +### Shared components (tested) +- **`NurseResultCard`** — presentational + memoized; avatar, name, reused verified badge, rating + + review count, optional distance chip, "from X تومان/unit" via `PriceDisplay`. +- **`ServicePriceRow`** — service name + `PriceDisplay` (money util + i18n unit label); reused by the + booking summary in f7+. + +### Other +- Added `star` + `tune` icons to the AppIcon registry. Routes: `SEARCH_RESULTS`, `SEARCH_NURSE`, + `BOOKING_REQUEST`. i18n: `search` filled + `booking` seeded, both locales in sync. + +## Now testable, and exactly how (§7 of the phase) +Run `npm run dev` (mock is primary — no backend needed; or point `NEXT_PUBLIC_API_URL` at a b7 server +and flip `USE_SEARCH_MOCK=false`, noting the REQ-012 gaps). +- **Discovery E2E:** Home → tap a category (e.g. مراقبت سالمند) → C1 preselects it → set city (تهران), + gender (خانم) → CTA shows a real count → tap → C2 lists only verified nurses, rating-sorted, each with + photo/initials, name, ✓ تاییدشده, rating + review count, distance, "from X تومان/ساعت". +- **Profile:** tap a card → C3 shows badges, attribute chips, services + Persian unit labels, latest + review → "درخواست رزرو" → `/bookings/request` echoing nurse + variant + gender intent. +- **Empty state:** search Mashhad/Isfahan/Shiraz (seeded empty) → "relax your filters" with suggestions. +- **Caching (headline):** React Query Devtools → filter set A → set B (one fetch) → **revert to A** → + instant, **zero** new requests. Type in the price field → one debounced request, not one per keystroke. +- **i18n/RTL:** flip fa↔en — all labels/badges/units/empty copy translate + mirror; dark mode holds. + +## Mocked behind the seam (how f-next swaps it) +`services/search` is **mock-primary** (`USE_SEARCH_MOCK = true`) because b7's `NurseSearchResultDto` +row omits the nurse **name/avatar/distance**, and there is **no aggregated public nurse-profile +endpoint** (only the b6 trust badge + b5 single-variant read). `searchMockApi` supplies real-shaped +fixtures so C1/C2/C3 fully demo. The real `searchClientApi` already maps everything b7/b6 provide and +leaves the missing fields blank; once **REQ-012** lands (enrich the search row + add +`GET nurses/{id}/profile`), the swap is flipping one flag — no hook/component change. (This is a +client-side mock; it is recorded here, not in `mocks-registry.md`, which tracks backend DI seams.) + +## Contract consumed / requests filed +- **Consumed (not edited):** `dev/contracts/domains/search.md` (b7) — `services/search/types.ts` derives + from it; b6 trust badge + b5 variant read for the profile scaffold. +- **Filed:** `frontend/requests/for-backend.md` **REQ-012** — search-row `nurseName`/`avatarUrl`/ + `distanceKm` + an aggregated `GET api/v1/nurses/{id}/profile`. + +## Follow-ups +- **f7 booking:** the "درخواست رزرو" handoff carries `nurse_id`, `variant_id`, `required_gender` + (the C1 same-gender intent → `required_caregiver_gender`/b8), `city_id`, `service_category_id`, `date` + as query params to `/bookings/request` (currently a DEFERRED stub). f7 builds the form + captures the + gender into the booking request. +- **C3 reviews tab:** DEFERRED → f13 (only the latest-review snippet ships now). +- **DEFERRED (per contract):** availability-window hard filter, sorts beyond rating, map/radius discovery. + +## Gate +`npm run check` green · `npm run test:ci` green (165 tests, +8). `npm run build` compiles and +type-checks clean; the only prerender failure is the **pre-existing** f5 `/nurse/verification` +"Missing .env variable!" (needs env set) — unrelated to this phase's routes. diff --git a/dev/shared-working-context/reports/mocks-registry.md b/dev/shared-working-context/reports/mocks-registry.md index 75c71e9..019594f 100644 --- a/dev/shared-working-context/reports/mocks-registry.md +++ b/dev/shared-working-context/reports/mocks-registry.md @@ -20,7 +20,7 @@ Status legend: 🔴 not built · 🟡 mocked (seam + fake impl in place) · 🟢 | `IBnplProvider` | backend-phase-12 | BNPL — `MockBnplProvider` drives the full state machine (eligible→settled→reverted), settle returns `order − commission%` | `Seams:Bnpl:{CommissionRate,SettlementInstant,CreditCeilingIrr,NotEligibleMobile,ForceFailure,ReverseProviderCommission}` | SnappPay/Digipay OAuth + verb set; encrypted creds in `payment_gateways.config_json` | 🟡 | | `IBnplProviderResolver` | backend-phase-12 | Per-`provider_code` selection — maps every known code to the one mock | _none_ | One concrete adapter per code; resolver returns the right one | 🟡 | | `ICurrencyNormalizer` | backend-phase-12 | Toman↔IRR — ×10 at the boundary | `Seams:Currency:TomanToIrrMultiplier` (default `10`) | Config-driven per provider boundary | 🟡 | -| `IBankTransferProvider` | backend-phase-13 | PAYA/SATNA payout — fake transfer ref | _tbd_ | Jibit/Vandar/Sadad payout; source account; PAYA vs SATNA | 🔴 | +| `IBankTransferProvider` | backend-phase-13 | PAYA/SATNA payout rail — `MockBankTransferProvider` (`Baya.Infrastructure.CrossCutting/Seams/`), **no external call, no money moves**: `SubmitPayoutBatchAsync(batchId, instructions, idempotencyKey)` returns a deterministic `externalBatchRef` + a per-instruction `transfer_reference` and settles every row `Paid` (collapsing the real `submitted → paid` reconciliation); it **honours** the PAYA/SATNA `method` the handler chose by the `payout_satna_threshold_irr` config and echoes it. A config switch forces deterministic failures so `partially_failed`/retry are testable: `ForceFailure` fails the whole batch, `FailIban` fails one destination. `GetPayoutStatusAsync` echoes `Paid`. Registered singleton in `AddCrossCuttingSeams` | `Seams:BankTransfer:ForceFailure` (default `false`), `Seams:BankTransfer:FailIban` (default empty) | 1) pick a transferor (Jibit/Vandar/Sadad payout API), add its client package to `Directory.Packages.props`; 2) add `Seams:BankTransfer:{ApiKey,BaseUrl,SourceSettlementAccount}`; 3) implement `SubmitPayoutBatchAsync` to register the batch against the registered **source settlement account** and route each transfer PAYA (batch, low-value) vs SATNA (real-time, above the threshold) to each nurse's **verified Sheba** (the b3 `matched_national_id` gate), honouring batch caps/minimums; 4) implement the async **reconciliation callback** that flips a payout `submitted → paid/failed` (the mock collapses this — the real rail is async); 5) swap the registration (config-selected) — the payout status machine + `nurse_payout_booking_links` UNIQUE remain the irreversible-transfer backstop; 6) test PAYA/SATNA selection, whole-batch + single-row failure → retry | 🟡 | | `IHolidayCalendar` | backend-phase-1 | Bank holidays — reads the seeded `ops.IranianHolidays` table; lookups cached (`HolidayCalendarService`, `Persistence/Services/Holidays/`); Iranian banking weekend = Friday | _none_ | Add a sync job/feed that maintains the (partly lunar-Hijri) calendar table; the read interface stays | 🟡 | | `IAnalyticsSink` | backend-phase-1 | Behavioural events — inserts an `ops.SystemEvents` row, fire-and-forget (`AnalyticsSink`, `Persistence/Services/Analytics/`) | _none_ | Pipe to a warehouse/stream (e.g. Kafka→ClickHouse); keep fire-and-forget semantics | 🟡 | | `IJobScheduler` (retention + booking expiry) | backend-phase-1 | Scheduling — in-process interval `BackgroundService`s: `PurgeOldReadNotifications` daily (`NotificationRetentionHostedService`, `Persistence/Services/Notifications/`) and **b8** `BookingRequestExpiryHostedService` (`Persistence/Services/Booking/`) running the idempotent booking-request expiry sweep every minute | _none_ | Swap to Hangfire/Quartz; register **both** jobs there; keep the purge predicate (`is_read=1 AND age>90d`) and the booking-expiry command | 🟡 | @@ -45,7 +45,7 @@ Status legend: 🔴 not built · 🟡 mocked (seam + fake impl in place) · 🟢 | `IMoadianClient` | backend-phase-11 | سامانه مودیان e-invoicing — `MockMoadianClient` (`Baya.Infrastructure.CrossCutting/Seams/`), **no external call**: `SubmitAsync` leaves a new invoice `moadian_status = pending` with `moadian_reference_number = null`; a config switch forces a deterministic `registered` result with a fake 22-digit reference so the reconciliation/registered path is testable. Registered singleton in `AddCrossCuttingSeams` | `Seams:Moadian:ForceRegistered` (default `false`) | 1) enroll the platform in سامانه مودیان (memory/economic code + signing certificate); 2) implement `SubmitAsync` to POST the معاملات/invoice (`صورتحساب`) to the مودیان API, sign the payload, map the 22-digit `reference_number`; 3) walk the async `pending → submitted → registered`/`failed` states via a reconciliation callback/poll (**cron deferred/manual today** — a job flips `moadian_status` + fills the ref); 4) swap the registration (config-selected) — the `IssueInvoice` handler is unchanged | 🟡 | | `IBnplProvider` | **backend-phase-12** (superset of the b11 revert-only stub) | BNPL provider — `MockBnplProvider` (`Baya.Infrastructure.CrossCutting/Seams/`), **no external call**, drives the full SnappPay-superset verb set `CheckEligibilityAsync`/`CreatePaymentTokenAsync`/`VerifyAsync`/`SettleAsync`/`GetStatusAsync`/`CancelAsync`/`RevertAsync`/`UpdateAsync` and the `eligible → token_issued → verified → settled → reverted/cancelled` machine. Eligibility is `eligible` unless the mobile = `NotEligibleMobile` (→`not_eligible`) or the order exceeds `CreditCeilingIrr` (→`ceiling_exceeded`); token/redirect are deterministic; **settle returns `settledAmountIrr = order − round(order × CommissionRate)` + the commission read from the response (never hardcoded) + a nullable `settledAt`** (null when `SettlementInstant=false`, modelling non-instant settlement); revert echoes a deterministic `external_revert_reference` + nullable `provider_commission_reversed_amount`. Selected per `provider_code` by **`IBnplProviderResolver`** (`MockBnplProviderResolver` → the one mock for every known code); the b11 refund `bnpl_revert` path still injects `IBnplProvider` directly. Registered singleton in `AddCrossCuttingSeams` | `Seams:Bnpl:CommissionRate` (default `0.10`), `Seams:Bnpl:SettlementInstant` (default `true`), `Seams:Bnpl:CreditCeilingIrr` (default `2000000000`), `Seams:Bnpl:NotEligibleMobile` (default `09120000099`), `Seams:Bnpl:ForceFailure` (default `false`), `Seams:Bnpl:ReverseProviderCommission` (default `false`) | 1) implement one concrete adapter per `provider_code` (**SnappPay** OAuth `api/online/v1/oauth/token` + `offer/v1/eligible` + `payment/v1/token\|verify\|settle\|revert\|cancel\|update\|status`, or **Digipay** UPG `tickets/business?type=13` + `purchases/verify` + `purchases/deliver?type=13` + `refunds`/`reverse`); 2) read credentials from the **encrypted** `payment_gateways.config_json`; 3) do Toman↔Rial via `ICurrencyNormalizer` at the adapter boundary; 4) read the **per-contract commission from the settle response**, never hardcode; 5) map the provider event shape into the callback so `HandleBnplCallback` dispatch is unchanged; 6) register per-code in `IBnplProviderResolver` (config-selected) — handlers unchanged. **Warn: do NOT use the unrelated Canadian `SnapPayInc/open-api-java-sdk`.** | 🟡 | | `ICurrencyNormalizer` | backend-phase-12 | Toman↔IRR at the provider boundary — `MockCurrencyNormalizer` (`Baya.Infrastructure.CrossCutting/Seams/`): `ToIrr(amount,"TOMAN")` = `amount × TomanToIrrMultiplier`, IRR passes through; `ToDisplayToman` divides back. **Conversion happens ONLY here, never internally.** Registered singleton in `AddCrossCuttingSeams` | `Seams:Currency:TomanToIrrMultiplier` (default `10`) | Read the multiplier (or a per-provider unit) from provider config; the interface stays — a currency redenomination is a config change | 🟡 | -| `INursePayoutStatus` | backend-phase-11 (interim; **b13** owns the real impl) | "Was the nurse already paid for this booking?" — `NursePayoutStatusService` (`Persistence/Services/Payments/`) derives it from the booking's `dispute_window_ends_at` close (the same gate b13 pays out on), with a `refund_assume_nurse_paid` config override. Not a mock of an external — a **temporary derivation** standing in for the b13 `nurse_payout_booking_links` lookup. Registered scoped in `AddPersistenceServices` | `refund_assume_nurse_paid` (`platform_configs`, default `false`) | In b13: implement `IsNursePaidForBookingAsync` as a real `nurse_payout_booking_links` join (a booking linked to a paid-out `nurse_payouts` batch ⇒ paid), swap the registration — the refund pre-payout/clawback fork is unchanged | 🟡 | +| `INursePayoutStatus` | backend-phase-11 (interim) → **backend-phase-13 (authoritative)** | "Was the nurse already paid for this booking?" — **b13 shipped the real `NursePayoutLinkStatusService`** (`Persistence/Services/Payments/`): a booking is paid iff a `nurse_payout_booking_links` row ties it to a `nurse_payouts` row in status `paid`. This **supersedes** the interim `NursePayoutStatusService` (dispute-window derivation, now deleted); the `refund_assume_nurse_paid` config override still forces the paid answer for ops/testing. Not a mock of an external — a real ledger-backed derivation. Registered scoped in `AddPersistenceServices`. The refund pre-payout/clawback fork is unchanged | `refund_assume_nurse_paid` (`platform_configs`, default `false`) | Nothing further — this is the real implementation. (A future on-demand-withdrawal model would extend the "paid?" definition, not replace it.) | 🟢 | > Exact config keys and file paths get filled in by the phase that builds each seam. Keep the > "Make it real →" column actionable enough that a developer can pick up any single row and ship it. diff --git a/product/business/10-payouts.md b/product/business/10-payouts.md index de0fdb2..da8d23c 100644 --- a/product/business/10-payouts.md +++ b/product/business/10-payouts.md @@ -20,6 +20,24 @@ - **MVP:** weekly batches; EVV + dispute-window gating; per-session accrual for engagements; `nurse_clawbacks` with next-batch netting and write-off; unique booking↔payout link; `iranian_holidays`-aware scheduling; verified-IBAN payouts with reconciliation references. - **DEFERRED:** on-demand / instant nurse withdrawal; per-nurse configurable payout frequency; automated clawback recovery beyond netting. +## (d1) Rules confirmed in build (backend b13) +These fill gaps the requirements left open; the payout engine was built to them: +- **Clawback netting recovers *whole* clawbacks up to a batch's earnings.** A `nurse_clawbacks` row is atomic — + it is recovered in full or not at all — so a batch nets the largest set of whole pending clawbacks (oldest first) + that fits within the nurse's earnings that week; `net_amount = gross_earnings − clawback_applied ≥ 0` (never + negative). A single clawback **larger than a batch's earnings** stays fully `pending` and recovers from a later, + larger batch (it is not partially recovered). The recovery is a real ledger movement (`DEBIT nurse_payable / + CREDIT nurse_clawback_receivable`), not just a status flag, so the derived balances reconcile. +- **A booking with an active (non-failed/-rejected) refund is held out of payout batches** — its money was (partly) + reversed, so paying its frozen `nurse_payout_amount` would overpay. It is excluded until resolved (this is the + operational reading of "no open dispute", since there is no separate dispute table yet). +- **PAYA vs SATNA** is chosen per payout by the `payout_satna_threshold_irr` config (SATNA for net amounts at/above + the threshold, else PAYA). +- **Optional BNPL settlement gate:** `require_bnpl_settlement_for_payout` (config, **default off**) — when on, a + BNPL-paid booking is payout-eligible only once its provider settlement (`settled_at`) is received. +- The weekly **cron trigger is DEFERRED** — batches are admin-triggered; the cadence lives in + `nurse_payout_interval_days` (default 7) for the future scheduler. + ## (d) Supporting database entities `nurse_payout_batches`, `nurse_payouts` (with `gross_earnings_irr`, `clawback_applied_irr`, `net_amount_irr`, `iban_snapshot`), `nurse_payout_booking_links` (unique per booking), **`nurse_clawbacks`**, `ledger_entries`, **`iranian_holidays`**, `bookings.dispute_window_ends_at`, `nurse_bank_accounts`. diff --git a/server/CLAUDE.md b/server/CLAUDE.md index 19acad1..69c0fb7 100644 --- a/server/CLAUDE.md +++ b/server/CLAUDE.md @@ -81,15 +81,15 @@ projects/assemblies, Clean-Architecture layers, and cross-layer dependencies. ``` src/ ├── Core/ -│ ├── Baya.Domain Entities (User, Role, UserSession, RoleNames…, Identity/ (NurseProfile, CustomerProfile, Patient, NurseBankAccount, CustomerAddress), Geography/ (Province, City, District, NurseServiceArea), Catalog/ (ServiceCategory, ServiceOptionGroup, ServiceOptionValue, NurseServiceVariant, NurseServiceVariantOption, PriceUnits), Verification/ (NurseVerification, VerificationStepType, VerificationStep, VerificationDocument, NurseCredential + VerificationStatus/VerificationStepStatus enums), Search/ (NurseSearchIndex — the denormalized search projection), Booking/ (BookingRequest — the money-free pre-payment intent + BookingRequestStatus/BookingRequestTransitions forward-only status guard + CaregiverGender codes; b9 adds Booking/BookingSession/BookingCareInstruction/VisitVerification/CancellationPolicy + their status/transition tables + BookingAmounts money split), Payments/ (b10 ledger/txn/webhook/gateway + LedgerPosting; b11 adds Refunds/ + Invoices/), Bnpl/ (b12 BnplTransaction + BnplStatus/BnplTransitions/BnplEligibilityStatus/BnplProviderCodes — the net-of-fee card-payment model), + Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts), BaseEntity, IEntity, ITimeModification, IAuditableEntity, IAuditable (audit-row marker) -│ └── Baya.Application Features/ (Commands & Queries; Identity area = auth + profiles/patients/nurse-bank-accounts; Geography/ServiceAreas/Addresses areas = geo hierarchy + nurse service areas + customer addresses; Catalog/Variants areas = admin catalog skeleton + nurse pricing variants; Verification area = the b6 nurse-verification pipeline (submit/status/uploads/automated runs + admin review/suspend/scan + public trust badge); Search area = the b7 discovery query + admin index-rebuild; Booking area = the b8 booking-request lifecycle (create/accept/reject/cancel + role-scoped inbox/detail + the expiry sweep command); Bookings area = the b9 booking engine (convert/detail/list/transition, care-instructions submit+gated read, EVV check-in/out + today's sessions + admin EVV queue, cancel booking/session, no-show sweep, cancellation-policy CRUD); Payments area = the b10 money core (initiate/webhook/confirm-post-ledger/nurse-payable-balance); Refunds + Invoices areas = the b11 reversal leg (create refund/write-off clawback/list/refund-status; issue invoice/get invoice); Bnpl area = the b12 provider-financed-installment checkout (eligibility/initiate/verify/settle/revert/callback/status + BookingConversion shared with b10); + Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts areas), Contracts/ (incl. Contracts/Common cross-cutting seams incl. IBankAccountOwnershipVerifier + IGeocoder + IVariantSnapshotSerializer + IShahkarVerifier + IIdentityKycProvider + ICredentialVerifier + the platform-signal facade contracts + Contracts/Search (INurseSearch read seam + ISearchIndexMaintainer write seam) + Contracts/Persistence per-domain repositories on IUnitOfWork incl. IVerificationRepository), Models/, pipeline behaviors (Common/ — validators auto-registered from this assembly; VerificationAggregator + IdentityNameMatch helpers) +│ ├── Baya.Domain Entities (User, Role, UserSession, RoleNames…, Identity/ (NurseProfile, CustomerProfile, Patient, NurseBankAccount, CustomerAddress), Geography/ (Province, City, District, NurseServiceArea), Catalog/ (ServiceCategory, ServiceOptionGroup, ServiceOptionValue, NurseServiceVariant, NurseServiceVariantOption, PriceUnits), Verification/ (NurseVerification, VerificationStepType, VerificationStep, VerificationDocument, NurseCredential + VerificationStatus/VerificationStepStatus enums), Search/ (NurseSearchIndex — the denormalized search projection), Booking/ (BookingRequest — the money-free pre-payment intent + BookingRequestStatus/BookingRequestTransitions forward-only status guard + CaregiverGender codes; b9 adds Booking/BookingSession/BookingCareInstruction/VisitVerification/CancellationPolicy + their status/transition tables + BookingAmounts money split), Payments/ (b10 ledger/txn/webhook/gateway + LedgerPosting; b11 adds Refunds/ + Invoices/), Bnpl/ (b12 BnplTransaction + BnplStatus/BnplTransitions/BnplEligibilityStatus/BnplProviderCodes — the net-of-fee card-payment model), Payouts/ (b13 NursePayoutBatch/NursePayout/NursePayoutBookingLink + PayoutBatchStatus/PayoutStatus/*Transitions — the weekly payout run), + Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts), BaseEntity, IEntity, ITimeModification, IAuditableEntity, IAuditable (audit-row marker) +│ └── Baya.Application Features/ (Commands & Queries; Identity area = auth + profiles/patients/nurse-bank-accounts; Geography/ServiceAreas/Addresses areas = geo hierarchy + nurse service areas + customer addresses; Catalog/Variants areas = admin catalog skeleton + nurse pricing variants; Verification area = the b6 nurse-verification pipeline (submit/status/uploads/automated runs + admin review/suspend/scan + public trust badge); Search area = the b7 discovery query + admin index-rebuild; Booking area = the b8 booking-request lifecycle (create/accept/reject/cancel + role-scoped inbox/detail + the expiry sweep command); Bookings area = the b9 booking engine (convert/detail/list/transition, care-instructions submit+gated read, EVV check-in/out + today's sessions + admin EVV queue, cancel booking/session, no-show sweep, cancellation-policy CRUD); Payments area = the b10 money core (initiate/webhook/confirm-post-ledger/nurse-payable-balance); Refunds + Invoices areas = the b11 reversal leg (create refund/write-off clawback/list/refund-status; issue invoice/get invoice); Bnpl area = the b12 provider-financed-installment checkout (eligibility/initiate/verify/settle/revert/callback/status + BookingConversion shared with b10); Payouts area = the b13 weekly payout engine (compute-eligible/generate-batch/process/retry/mark-failed + admin batch detail/list + nurse history; PayoutSettlement shared ledger+clawback-netting step); + Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts areas), Contracts/ (incl. Contracts/Common cross-cutting seams incl. IBankAccountOwnershipVerifier + IGeocoder + IVariantSnapshotSerializer + IShahkarVerifier + IIdentityKycProvider + ICredentialVerifier + the platform-signal facade contracts + Contracts/Search (INurseSearch read seam + ISearchIndexMaintainer write seam) + Contracts/Persistence per-domain repositories on IUnitOfWork incl. IVerificationRepository), Models/, pipeline behaviors (Common/ — validators auto-registered from this assembly; VerificationAggregator + IdentityNameMatch helpers) ├── Infrastructure/ │ ├── Baya.Infrastructure.Persistence ApplicationDbContext (+ encrypted-PII value converters & phone-hash sync), ValueConversion/, Repositories/, Configuration/ (per-area EF config incl. SearchConfig/ + BookingConfig/ — b8 BookingRequest + b9 bookings/sessions/care/EVV/cancellation-policy configs & seed), Repositories/ (incl. b9 BookingRepository + CancellationPolicyRepository), Migrations/, Interceptors/ (AuditFieldInterceptor — audit-fields + audit-log rows), Services/ (DB-backed platform-signal facades + notification-retention hosted service + Search/ = SearchIndexMaintainer + SqlNurseSearch + Booking/ = BookingRequestExpiryHostedService) │ ├── Baya.Infrastructure.Identity Jwt/, Identity/ (Managers, Stores, PermissionManager, Seed, CurrentUser/) -│ ├── Baya.Infrastructure.CrossCutting Serilog wiring + Seams/ (mock impls of the cross-cutting seams incl. LoggingSmsSender + MockBankAccountOwnershipVerifier + MockShahkarVerifier + MockIdentityKycProvider + MockCredentialVerifier + MockPaymentCaptureSimulator) + AddCrossCuttingSeams +│ ├── Baya.Infrastructure.CrossCutting Serilog wiring + Seams/ (mock impls of the cross-cutting seams incl. LoggingSmsSender + MockBankAccountOwnershipVerifier + MockShahkarVerifier + MockIdentityKycProvider + MockCredentialVerifier + MockPaymentCaptureSimulator + MockBankTransferProvider) + AddCrossCuttingSeams │ └── Baya.Infrastructure.Monitoring HealthChecks, OpenTelemetry, prometheus-net ├── API/ -│ ├── Baya.Web.Api Program.cs, Controllers/V1/ (Ping + Auth/Me phone-OTP surface + admin PlatformConfig/Holidays/Audit/SupportAlerts + current-user Notifications + public Geo + admin AdminGeo + nurse NurseServiceAreas + customer CustomerAddresses + public Catalog + admin AdminCatalog + nurse NurseVariants + nurse NurseVerification + admin AdminVerificationStepTypes/AdminVerifications + public Nurses (trust badge) + public Search + admin AdminSearch + customer/nurse BookingRequests + admin AdminBookingRequests + customer/nurse/admin Bookings + nurse/admin BookingSessions + admin AdminEvv + admin AdminCancellationPolicies + customer PaymentsController + public WebhooksController + admin AdminRefunds/AdminClawbacks/AdminInvoices + customer Refunds/Invoices + customer CheckoutBnpl + public WebhooksBnpl + admin AdminBnpl), appsettings*.json +│ ├── Baya.Web.Api Program.cs, Controllers/V1/ (Ping + Auth/Me phone-OTP surface + admin PlatformConfig/Holidays/Audit/SupportAlerts + current-user Notifications + public Geo + admin AdminGeo + nurse NurseServiceAreas + customer CustomerAddresses + public Catalog + admin AdminCatalog + nurse NurseVariants + nurse NurseVerification + admin AdminVerificationStepTypes/AdminVerifications + public Nurses (trust badge) + public Search + admin AdminSearch + customer/nurse BookingRequests + admin AdminBookingRequests + customer/nurse/admin Bookings + nurse/admin BookingSessions + admin AdminEvv + admin AdminCancellationPolicies + customer PaymentsController + public WebhooksController + admin AdminRefunds/AdminClawbacks/AdminInvoices + customer Refunds/Invoices + customer CheckoutBnpl + public WebhooksBnpl + admin AdminBnpl + admin AdminPayouts + nurse NursePayouts), appsettings*.json │ ├── Baya.WebFramework BaseController (incl. 401/403 OperationResult mapping), Filters/, Middlewares/, Swagger/, Routing/, ServiceConfiguration/ (rate limiting) │ └── Plugins/Baya.Web.Plugins.Grpc gRPC services + .proto models (User only) ├── Shared/Baya.SharedKernel Extensions + validation base @@ -392,6 +392,38 @@ helper (used by both the card `ConfirmPaymentAndPostLedger` and the BNPL settle) (`MockBnplProvider`/`MockBnplProviderResolver`/`MockCurrencyNormalizer`) in `CrossCutting/Seams/`, registered by `AddCrossCuttingSeams`. `bnpl_settlement_entries` (tranched settlement) is **DEFERRED — modeled-but-not-built**. +**Weekly nurse payouts (backend-phase-13).** A new **`payouts` schema** holds the money-out engine: three tables +— `NursePayoutBatches` (weekly aggregation, holiday-shifted `period_end`/`processing_date`), `NursePayouts` +(one row per nurse per batch; the `net = gross − clawback` split as a DB CHECK; **encrypted `iban_snapshot`** +frozen from the verified primary account) and `NursePayoutBookingLinks` (**`UNIQUE(booking_id)` unconditional** — +the structural one-payout-per-booking-ever guard). Entities in `Domain/Entities/Payouts/`; configs in +`Persistence/Configuration/PayoutsConfig/`; one migration (`NursePayoutEngine`). Features under +`Baya.Application/Features/Payouts/{Commands|Queries}/` (compute-eligible / generate-batch / process / retry / +mark-failed + admin batch-detail/list + nurse history), with the shared **`PayoutSettlement`** step (payout +ledger post + clawback netting); per-domain repo `IPayoutRepository` on `IUnitOfWork`; controllers +`AdminPayoutsController` (admin, rate-limited) / `NursePayoutsController` (nurse, tenancy-scoped). Load-bearing rules: +- **Payout eligibility ≠ completed.** A booking enters a batch only when `status='completed'` **AND** + `dispute_window_ends_at < now` **AND** it has no active refund **AND** it isn't already in a link row. There is + no `payout_released` boolean — paid-ness is derived from a `nurse_payout_booking_links` row + the ledger. +- **One payout per booking, forever.** `nurse_payout_booking_links.booking_id` is an **unconditional** UNIQUE + (not filtered on soft-delete); the "not already linked" filter is the fast first line, the UNIQUE the backstop. +- **The payout drains `nurse_payable`.** `ExecutePayoutBatch` posts `DEBIT nurse_payable / CREDIT escrow_held` for + the paid net (b10's `LedgerPosting.NursePayout`); a netted clawback posts `DEBIT nurse_payable / CREDIT + nurse_clawback_receivable` (`LedgerPosting.ClawbackRecovery`) and marks the `nurse_clawbacks` row `recovered` + (`recovered_in_payout_id` + `resolved_at`). Netting recovers **whole** pending clawbacks up to earnings (never a + negative net, never a partial single-clawback recovery). Forward-only `PayoutStatus` machine + the ledger-exists + guard + a batch idempotency key make a retried process never double-send an irreversible transfer. +- **Holiday-aware.** `period_end`/`processing_date` shift off `is_bank_closed` days via **`IHolidayCalendar`**; + retry refuses on a bank-closed day. **First-payout gate:** only a `is_primary=1 AND is_verified=1 AND + matched_national_id=1` account is paid; a nurse without one is skipped with a recorded reason. +- **`IBankTransferProvider`** (new seam, `Contracts/Payments`; mock `MockBankTransferProvider` in `CrossCutting/Seams/`, + config `Seams:BankTransfer`) is the mocked PAYA/SATNA rail — PAYA vs SATNA chosen by the + `payout_satna_threshold_irr` config; a config switch forces whole-batch/single-row failures. b13 also swaps the + `INursePayoutStatus` registration to the authoritative **`NursePayoutLinkStatusService`** (a booking is paid iff + linked to a `paid` payout), superseding the b11 dispute-window derivation. The weekly **cron trigger is DEFERRED** + (batches are admin-triggered; cadence in `nurse_payout_interval_days`); the BNPL `settled_at` guard is the + default-off `require_bnpl_settlement_for_payout` config flag. + **Keeping the Project map current.** When a change touches the architecture — adds, removes, or renames a project/assembly, a Clean-Architecture layer, or a major folder, or changes a cross-layer dependency — you **must** update this Project map (and the dependency rule above, if affected) in the diff --git a/server/src/API/Baya.Web.Api/Controllers/V1/AdminPayoutsController.cs b/server/src/API/Baya.Web.Api/Controllers/V1/AdminPayoutsController.cs new file mode 100644 index 0000000..a2a8426 --- /dev/null +++ b/server/src/API/Baya.Web.Api/Controllers/V1/AdminPayoutsController.cs @@ -0,0 +1,74 @@ +using System.ComponentModel.DataAnnotations; +using Asp.Versioning; +using Baya.Application.Features.Payouts.Commands.ExecutePayoutBatch; +using Baya.Application.Features.Payouts.Commands.GeneratePayoutBatch; +using Baya.Application.Features.Payouts.Commands.MarkPayoutFailed; +using Baya.Application.Features.Payouts.Commands.RetryFailedPayout; +using Baya.Application.Features.Payouts.Queries.ComputeEligibleEarnings; +using Baya.Application.Features.Payouts.Queries.GetBatchDetail; +using Baya.Application.Features.Payouts.Queries.ListPayoutBatches; +using Baya.Application.Models.Common; +using Baya.Application.Models.Payouts; +using Baya.Infrastructure.Identity.Identity.PermissionManager; +using Baya.WebFramework.Attributes; +using Baya.WebFramework.BaseController; +using Baya.WebFramework.ServiceConfiguration; +using Mediator; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; + +namespace Baya.Web.Api.Controllers.V1; + +/// +/// Admin payout console: preview eligible earnings, open a draft batch, submit it to the (mocked) PAYA/SATNA +/// rail, retry a failed payout, mark a reconciled bank rejection, and read batches. Generating and processing a +/// batch move real (mocked) money and are rate-limited as money endpoints. One payout per booking is guaranteed by +/// the nurse_payout_booking_links.booking_id UNIQUE; processing is the one irreversible step. +/// +[ApiVersion("1")] +[ApiController] +[Route("api/v{version:apiVersion}/admin_payouts")] +[Authorize(ConstantPolicies.DynamicPermission)] +[EnableRateLimiting(RateLimitingServiceExtension.SensitivePolicy)] +[Display(Description = "Admin payout batches: eligible preview, generate, process, retry, mark-failed, read")] +public sealed class AdminPayoutsController(ISender sender) : BaseController +{ + [HttpGet("eligible")] + [ProducesOkApiResponseType>] + public async Task Eligible([FromQuery] ComputeEligibleEarningsQuery query, CancellationToken cancellationToken) + => OperationResult(await sender.Send(query, cancellationToken)); + + [HttpPost("batches")] + [ProducesOkApiResponseType] + public async Task Generate(GeneratePayoutBatchCommand command, CancellationToken cancellationToken) + => OperationResult(await sender.Send(command, cancellationToken)); + + [HttpPost("batches/{id}/process")] + [ProducesOkApiResponseType] + public async Task Process(long id, CancellationToken cancellationToken) + => OperationResult(await sender.Send(new ExecutePayoutBatchCommand(id), cancellationToken)); + + [HttpGet("batches/{id}")] + [ProducesOkApiResponseType] + public async Task Get(long id, [FromQuery] int page = 1, [FromQuery] int pageSize = 50, CancellationToken cancellationToken = default) + => OperationResult(await sender.Send(new GetBatchDetailQuery(id, page, pageSize), cancellationToken)); + + [HttpGet("batches")] + [ProducesOkApiResponseType>] + public async Task List([FromQuery] ListPayoutBatchesQuery query, CancellationToken cancellationToken) + => OperationResult(await sender.Send(query, cancellationToken)); + + [HttpPost("{payoutId}/retry")] + [ProducesOkApiResponseType] + public async Task Retry(long payoutId, CancellationToken cancellationToken) + => OperationResult(await sender.Send(new RetryFailedPayoutCommand(payoutId), cancellationToken)); + + [HttpPost("{payoutId}/mark_failed")] + [ProducesOkApiResponseType] + public async Task MarkFailed(long payoutId, MarkPayoutFailedBody body, CancellationToken cancellationToken) + => OperationResult(await sender.Send(new MarkPayoutFailedCommand(payoutId, body.FailureReason), cancellationToken)); + + /// The mark-failed body (the payout id comes from the route). + public record MarkPayoutFailedBody(string FailureReason); +} diff --git a/server/src/API/Baya.Web.Api/Controllers/V1/NursePayoutsController.cs b/server/src/API/Baya.Web.Api/Controllers/V1/NursePayoutsController.cs new file mode 100644 index 0000000..43029ea --- /dev/null +++ b/server/src/API/Baya.Web.Api/Controllers/V1/NursePayoutsController.cs @@ -0,0 +1,28 @@ +using System.ComponentModel.DataAnnotations; +using Asp.Versioning; +using Baya.Application.Features.Payouts.Queries.GetNursePayoutHistory; +using Baya.Application.Models.Common; +using Baya.Application.Models.Payouts; +using Baya.WebFramework.Attributes; +using Baya.WebFramework.BaseController; +using Mediator; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Baya.Web.Api.Controllers.V1; + +/// The signed-in nurse's own payout history — tenancy-scoped to ICurrentUser (a nurse can never +/// read another nurse's payouts). Feeds f12's earnings screen: status, net, masked IBAN + transfer reference, and +/// any clawback applied. +[ApiVersion("1")] +[ApiController] +[Route("api/v{version:apiVersion}/nurse_payouts")] +[Authorize] +[Display(Description = "The signed-in nurse's payout history")] +public sealed class NursePayoutsController(ISender sender) : BaseController +{ + [HttpGet("history")] + [ProducesOkApiResponseType>] + public async Task History([FromQuery] GetNursePayoutHistoryQuery query, CancellationToken cancellationToken) + => OperationResult(await sender.Send(query, cancellationToken)); +} diff --git a/server/src/Core/Baya.Application/Contracts/Payments/IBankTransferProvider.cs b/server/src/Core/Baya.Application/Contracts/Payments/IBankTransferProvider.cs new file mode 100644 index 0000000..d301132 --- /dev/null +++ b/server/src/Core/Baya.Application/Contracts/Payments/IBankTransferProvider.cs @@ -0,0 +1,71 @@ +#nullable enable +namespace Baya.Application.Contracts.Payments; + +/// +/// The swappable PAYA/SATNA bank payout rail — the mocked stand-in for a real transferor (Jibit / Vandar / +/// Sadad payout API) that moves money out of the platform's registered source settlement account to each nurse's +/// verified Sheba. This is the one irreversible money-out step, so the seam carries an +/// and the whole submit is idempotent: a retried +/// for the same key never re-sends an already-paid instruction. +/// Handlers depend only on this contract; the concrete provider is a config-selected registration change, never +/// an if (mock) branch. Every amount crossing this seam is IRR long. +/// +public interface IBankTransferProvider +{ + /// Submits one per payout to the rail and returns a deterministic + /// externalBatchRef plus a per-instruction result carrying the bank track id + /// (transfer_reference) and status. The mock moves no money; a real transferor registers the batch + /// against the source account and routes each PAYA/SATNA transfer. + ValueTask SubmitPayoutBatchAsync( + long payoutBatchId, + IReadOnlyList instructions, + string idempotencyKey, + CancellationToken cancellationToken = default); + + /// The reconciliation read — echoes the batch's settled status (the real callback flips + /// submitted → paid/failed). + ValueTask GetPayoutStatusAsync(string externalBatchRef, CancellationToken cancellationToken = default); +} + +/// The settled state of a single transfer (or the batch echo). Forward-only on the payout row. +public enum BankTransferStatus +{ + /// The rail accepted the instruction (a track id was issued) but the transfer is not yet confirmed. + Submitted, + + /// The transfer is confirmed — an irreversible IBAN movement. + Paid, + + /// The rail rejected the transfer (closed day / insufficient provider balance / bad Sheba). + Failed +} + +/// The two Iranian interbank rails. Persisted/echoed as these stable codes; selection is by value +/// (SATNA for high-value rows above a config threshold, else PAYA). +public static class BankTransferMethod +{ + /// Batch ACH-style clearing — the default for ordinary-value payouts. + public const string Paya = "paya"; + + /// Real-time gross settlement — chosen for high-value rows above the SATNA threshold. + public const string Satna = "satna"; +} + +/// The nurse_payouts row this instruction settles. +/// The nurse's verified primary Sheba (the payout destination). +/// The net amount to transfer (IRR). +/// A code — PAYA or SATNA. +public sealed record PayoutInstruction(long PayoutId, string Iban, long AmountIrr, string Method); + +/// The rail's own batch reference, for reconciliation. +/// One result per submitted instruction. +public sealed record PayoutBatchSubmitResult(string ExternalBatchRef, IReadOnlyList Results); + +/// The payout this result belongs to. +/// The transfer outcome — drives the payout status machine. +/// The bank track id, when the rail accepted it; null on failure. +/// The rail the transfer took (the honoured ). +/// Why the rail rejected it, when is +/// . +public sealed record PayoutInstructionResult( + long PayoutId, BankTransferStatus Status, string? TransferReference, string Method, string? FailureReason); diff --git a/server/src/Core/Baya.Application/Contracts/Persistence/IPayoutRepository.cs b/server/src/Core/Baya.Application/Contracts/Persistence/IPayoutRepository.cs new file mode 100644 index 0000000..b1cab37 --- /dev/null +++ b/server/src/Core/Baya.Application/Contracts/Persistence/IPayoutRepository.cs @@ -0,0 +1,77 @@ +#nullable enable +using Baya.Application.Models.Common; +using Baya.Application.Models.Payouts; +using Baya.Domain.Entities.Payouts; +using Baya.Domain.Entities.Refunds; + +namespace Baya.Application.Contracts.Persistence; + +/// +/// The payouts aggregate — the weekly batch, its per-nurse payouts, and the anti-double-pay booking links. Reads +/// project to DTOs (AsNoTracking + .Select); writes load tracked rows. The payout ledger legs +/// are appended through (b10's helper) — this repo owns the +/// payout rows and the money facts a batch is built from. Money is IRR long. The +/// nurse_payout_booking_links.booking_id UNIQUE is the authoritative one-payout-per-booking backstop; the +/// eligibility predicate's "not already linked" filter is the fast first line. +/// +public interface IPayoutRepository +{ + // ---- eligibility (preview + build) ---- + + /// The payout-eligible, unpaid bookings for the window: status='completed' AND + /// dispute_window_ends_at < now AND no active refund AND not already in a link row (and, when + /// is set, its BNPL provider settlement is received). One row per + /// booking (nurse + this booking's payout portion) — grouped by nurse in the build handler. + Task> GetEligibleBookingsAsync( + DateOnly periodStart, DateOnly periodEnd, DateTime now, bool requireBnplSettlement, CancellationToken cancellationToken); + + /// The same eligibility set as a per-nurse preview (paginated), netting pending clawbacks and + /// flagging any nurse without a verified primary IBAN. + Task> GetEligiblePreviewAsync( + DateOnly periodStart, DateOnly periodEnd, DateTime now, bool requireBnplSettlement, int page, int pageSize, CancellationToken cancellationToken); + + /// The nurse's verified primary payout account (is_primary=1 AND is_verified=1 AND + /// matched_national_id=1) — the first-payout gate. Null when the nurse has none (the payout is skipped + /// with a recorded reason). The IBAN is decrypted by the EF converter on projection. + Task GetVerifiedPrimaryAccountAsync(long nurseId, CancellationToken cancellationToken); + + /// The nurse's display name (for the batch preview/detail), or null. + Task> GetNurseNamesAsync(IReadOnlyList nurseIds, CancellationToken cancellationToken); + + /// The sum of the nurse's pending clawbacks (IRR) — netted (capped at earnings) into a payout + /// at build time. + Task GetPendingClawbackSumAsync(long nurseId, CancellationToken cancellationToken); + + /// The nurse's pending clawbacks, oldest first, tracked — the execute step marks them + /// recovered (with recovered_in_payout_id + resolved_at) up to the payout's frozen + /// clawback_applied_irr. + Task> GetPendingClawbacksAsync(long nurseId, CancellationToken cancellationToken); + + // ---- writes ---- + + Task AddBatchAsync(NursePayoutBatch batch, CancellationToken cancellationToken); + + /// The tracked batch with its payouts + links — loaded by execute/retry to drive the transfers and + /// post the ledger. Null when absent. + Task GetTrackedBatchAsync(long batchId, CancellationToken cancellationToken); + + /// A single tracked payout (with its batch) — for retry/mark-failed. Null when absent. + Task GetTrackedPayoutAsync(long payoutId, CancellationToken cancellationToken); + + /// Whether a payout already has a posted ledger group — makes the execute ledger post idempotent so + /// a retried execute never double-posts. + Task LedgerGroupExistsForPayoutAsync(long payoutId, CancellationToken cancellationToken); + + // ---- reads (admin + nurse) ---- + + Task> ListBatchesAsync(string? status, int page, int pageSize, CancellationToken cancellationToken); + + Task GetBatchDetailAsync(long batchId, int page, int pageSize, CancellationToken cancellationToken); + + /// The nurse's own payouts (tenancy-scoped), most recent first, projected + paginated. Masked IBAN. + Task> GetNurseHistoryAsync(long nurseId, int page, int pageSize, CancellationToken cancellationToken); +} + +/// The verified primary account a payout snapshots — the account id and the decrypted IBAN (frozen into +/// the encrypted iban_snapshot at build time). +public record VerifiedPayoutAccount(long BankAccountId, string Iban); diff --git a/server/src/Core/Baya.Application/Contracts/Persistence/IUnitOfWork.cs b/server/src/Core/Baya.Application/Contracts/Persistence/IUnitOfWork.cs index ef12ba3..15c679e 100644 --- a/server/src/Core/Baya.Application/Contracts/Persistence/IUnitOfWork.cs +++ b/server/src/Core/Baya.Application/Contracts/Persistence/IUnitOfWork.cs @@ -22,6 +22,7 @@ public interface IUnitOfWork public IRefundRepository RefundRepository { get; } public IInvoiceRepository InvoiceRepository { get; } public IBnplRepository BnplRepository { get; } + public IPayoutRepository PayoutRepository { get; } Task CommitAsync(); ValueTask RollBackAsync(); } diff --git a/server/src/Core/Baya.Application/Features/Payouts/Commands/ExecutePayoutBatch/ExecutePayoutBatchCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Payouts/Commands/ExecutePayoutBatch/ExecutePayoutBatchCommand.Handler.cs new file mode 100644 index 0000000..fdcb90f --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Payouts/Commands/ExecutePayoutBatch/ExecutePayoutBatchCommand.Handler.cs @@ -0,0 +1,114 @@ +#nullable enable +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Configuration; +using Baya.Application.Contracts.Payments; +using Baya.Application.Contracts.Persistence; +using Baya.Application.Models.Common; +using Baya.Application.Models.Payouts; +using Baya.Domain.Entities.Payouts; +using Mediator; + +namespace Baya.Application.Features.Payouts.Commands.ExecutePayoutBatch; + +/// +/// The one irreversible money-out step. Under lock(payout:batch) it submits the batch's unpaid payouts to +/// the (PAYA/SATNA by the config threshold), then for each accepted transfer +/// posts the balanced payout ledger group (DEBIT nurse_payable / CREDIT escrow_held) via b10's helper and +/// nets recovered clawbacks (DEBIT nurse_payable / CREDIT nurse_clawback_receivable + marks the row +/// recovered). The forward-only payout status machine + the ledger-exists guard + the batch idempotency key +/// make a retried execute never double-send a transfer or double-post the ledger. +/// +internal sealed class ExecutePayoutBatchCommandHandler( + IUnitOfWork unitOfWork, + IDistributedLock distributedLock, + IBankTransferProvider bankTransfer, + IPlatformConfig platformConfig, + IDateTimeProvider dateTimeProvider) + : IRequestHandler> +{ + public async ValueTask> Handle( + ExecutePayoutBatchCommand request, CancellationToken cancellationToken) + { + var now = dateTimeProvider.UtcNow.UtcDateTime; + + await using var _ = await distributedLock.AcquireAsync("payout:batch", cancellationToken); + + var batch = await unitOfWork.PayoutRepository.GetTrackedBatchAsync(request.BatchId, cancellationToken); + if (batch is null) + return OperationResult.NotFoundResult("Payout batch not found."); + + // Idempotent: a fully-settled batch has nothing left to submit. + if (batch.Status == PayoutBatchStatus.Completed) + return OperationResult.SuccessResult(Summarize(batch)); + + if (batch.Status == PayoutBatchStatus.Failed) + return OperationResult.ConflictResult("This batch has already failed; open a new batch."); + + var satnaThreshold = (long)await platformConfig.GetConfig("payout_satna_threshold_irr", cancellationToken); + + if (batch.Status == PayoutBatchStatus.Draft) + batch.TransitionTo(PayoutBatchStatus.Processing, now); + + // Only unpaid payouts are (re)submitted — an already-paid row is skipped by the status machine, never + // re-sent. A zero-net payout (fully netted against clawback) has no transfer but still realizes recovery. + var unpaid = batch.Payouts.Where(p => p.Status != PayoutStatus.Paid).ToList(); + var transferable = unpaid.Where(p => p.Amount > 0).ToList(); + + var resultsByPayout = new Dictionary(); + if (transferable.Count > 0) + { + var instructions = transferable + .Select(p => PayoutSettlement.ToInstruction(p, satnaThreshold)) + .ToList(); + + var submit = await bankTransfer.SubmitPayoutBatchAsync( + batch.Id, instructions, idempotencyKey: $"payout-batch:{batch.Id}", cancellationToken); + resultsByPayout = submit.Results.ToDictionary(r => r.PayoutId); + } + + foreach (var payout in unpaid) + { + if (payout.Amount > 0) + { + var result = resultsByPayout.GetValueOrDefault(payout.Id); + if (result is null || result.Status == BankTransferStatus.Failed) + { + payout.MarkFailed(result?.FailureReason ?? "provider_declined"); + continue; + } + + payout.MarkSubmitted(result.TransferReference ?? $"payout:{batch.Id}:{payout.Id}"); + if (result.Status == BankTransferStatus.Paid) + payout.MarkPaid(now); + } + else + { + // Fully netted — no cash leaves, but the withheld earnings realize the clawback recovery now. + payout.MarkSubmitted($"netted:{batch.Id}:{payout.Id}"); + payout.MarkPaid(now); + } + + // Only a settled (paid) payout posts the ledger + nets clawbacks — a still-submitted row waits for the + // reconciliation callback (the real rail), a failed one does neither. + if (payout.Status == PayoutStatus.Paid) + { + await PayoutSettlement.PostPayoutLedgerAsync(unitOfWork, payout, now, cancellationToken); + await PayoutSettlement.RecoverClawbacksAsync(unitOfWork, payout, now, cancellationToken); + } + } + + batch.RecomputeSettlement(now); + + await unitOfWork.CommitAsync(); + + return OperationResult.SuccessResult(Summarize(batch)); + } + + private static ExecutePayoutBatchResult Summarize(NursePayoutBatch batch) + { + var paid = batch.Payouts.Where(p => p.Status == PayoutStatus.Paid).ToList(); + var failed = batch.Payouts.Count(p => p.Status == PayoutStatus.Failed); + var totalPaid = paid.Sum(p => p.Amount); + return new ExecutePayoutBatchResult(batch.Id, batch.Status, paid.Count, failed, totalPaid.ToString()); + } +} diff --git a/server/src/Core/Baya.Application/Features/Payouts/Commands/ExecutePayoutBatch/ExecutePayoutBatchCommand.Validator.cs b/server/src/Core/Baya.Application/Features/Payouts/Commands/ExecutePayoutBatch/ExecutePayoutBatchCommand.Validator.cs new file mode 100644 index 0000000..3767da1 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Payouts/Commands/ExecutePayoutBatch/ExecutePayoutBatchCommand.Validator.cs @@ -0,0 +1,11 @@ +using FluentValidation; + +namespace Baya.Application.Features.Payouts.Commands.ExecutePayoutBatch; + +public sealed class ExecutePayoutBatchCommandValidator : AbstractValidator +{ + public ExecutePayoutBatchCommandValidator() + { + RuleFor(x => x.BatchId).GreaterThan(0); + } +} diff --git a/server/src/Core/Baya.Application/Features/Payouts/Commands/ExecutePayoutBatch/ExecutePayoutBatchCommand.cs b/server/src/Core/Baya.Application/Features/Payouts/Commands/ExecutePayoutBatch/ExecutePayoutBatchCommand.cs new file mode 100644 index 0000000..f0f5f80 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Payouts/Commands/ExecutePayoutBatch/ExecutePayoutBatchCommand.cs @@ -0,0 +1,12 @@ +using Baya.Application.Models.Common; +using Baya.Application.Models.Payouts; +using Mediator; + +namespace Baya.Application.Features.Payouts.Commands.ExecutePayoutBatch; + +/// Submits a draft (or partially-failed) batch to the bank rail: transitions it to processing, +/// sends one instruction per unpaid payout (PAYA/SATNA by the config threshold), posts the balanced payout ledger +/// group out of nurse_payable, nets recovered clawbacks, and settles the batch completed or +/// partially_failed. Idempotent — a retried call never re-sends an already-paid transfer or re-posts the +/// ledger. +public record ExecutePayoutBatchCommand(long BatchId) : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Payouts/Commands/GeneratePayoutBatch/GeneratePayoutBatchCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Payouts/Commands/GeneratePayoutBatch/GeneratePayoutBatchCommand.Handler.cs new file mode 100644 index 0000000..51bbe2a --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Payouts/Commands/GeneratePayoutBatch/GeneratePayoutBatchCommand.Handler.cs @@ -0,0 +1,160 @@ +#nullable enable +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Configuration; +using Baya.Application.Contracts.Holidays; +using Baya.Application.Contracts.Payments; +using Baya.Application.Contracts.Persistence; +using Baya.Application.Models.Common; +using Baya.Application.Models.Payouts; +using Baya.Domain.Entities.Payouts; +using Mediator; +using Microsoft.EntityFrameworkCore; + +namespace Baya.Application.Features.Payouts.Commands.GeneratePayoutBatch; + +/// +/// Builds a weekly payout batch. Under lock(payout:batch) (so two runs can't grab the same bookings), it +/// holiday-shifts the period end + processing date, selects the eligible unpaid bookings, groups them per nurse, +/// and materializes payouts + booking links in one unit of work. The BuildNursePayouts and +/// LinkPayoutBookings steps from the phase are cohesive private steps here (mirroring b11's +/// CreateRefund). Netting caps clawbacks at whole recoverable amounts; a nurse without a verified primary +/// IBAN is skipped with a recorded reason, never silently dropped. The booking_id UNIQUE link is the +/// backstop that makes a re-run over an overlapping window unable to re-select an already-paid booking. +/// +internal sealed class GeneratePayoutBatchCommandHandler( + IUnitOfWork unitOfWork, + IDistributedLock distributedLock, + IHolidayCalendar holidays, + IPlatformConfig platformConfig, + IDateTimeProvider dateTimeProvider, + ICurrentUser currentUser) + : IRequestHandler> +{ + public async ValueTask> Handle( + GeneratePayoutBatchCommand request, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } adminId) + return OperationResult.UnauthorizedResult("Not authenticated."); + + var now = dateTimeProvider.UtcNow.UtcDateTime; + + await using var _ = await distributedLock.AcquireAsync("payout:batch", cancellationToken); + + // Holiday-aware shifting: a batch landing on a bank-closed Nowruz day must move to the next business day, + // or PAYA/SATNA fails. + var periodEnd = await holidays.NextBusinessDay(request.PeriodEnd, cancellationToken); + var processingDate = await holidays.NextBusinessDay(DateOnly.FromDateTime(now), cancellationToken); + var requireBnplSettlement = await platformConfig.GetConfig("require_bnpl_settlement_for_payout", cancellationToken); + + var eligible = await unitOfWork.PayoutRepository.GetEligibleBookingsAsync( + request.PeriodStart, periodEnd, now, requireBnplSettlement, cancellationToken); + if (eligible.Count == 0) + return OperationResult.FailureResult( + "period", "No payout-eligible bookings in this window."); + + var batch = new NursePayoutBatch + { + PeriodStart = request.PeriodStart, + PeriodEnd = periodEnd, + ProcessingDate = processingDate, + InitiatedByAdminId = adminId + }; + + var nurseIds = eligible.Select(e => e.NurseId).Distinct().ToList(); + var names = await unitOfWork.PayoutRepository.GetNurseNamesAsync(nurseIds, cancellationToken); + + var skipped = new List(); + long batchTotal = 0; + var payoutCount = 0; + + foreach (var group in eligible.GroupBy(e => e.NurseId).OrderBy(g => g.Key)) + { + var nurseId = group.Key; + var bookings = group.ToList(); + var gross = bookings.Sum(b => b.PayoutAmountIrr); + + // First-payout gate: only a verified primary IBAN may receive a transfer. No account → skip, recorded. + var account = await unitOfWork.PayoutRepository.GetVerifiedPrimaryAccountAsync(nurseId, cancellationToken); + if (account is null) + { + skipped.Add(new SkippedNurseDto( + nurseId, names.GetValueOrDefault(nurseId), gross.ToString(), "no_verified_primary_iban")); + continue; + } + + var clawbackApplied = await ComputeNettableClawbackAsync(nurseId, gross, cancellationToken); + var net = gross - clawbackApplied; + + var payout = new NursePayout + { + NurseId = nurseId, + BankAccountId = account.BankAccountId, + IbanSnapshot = account.Iban, // encrypted at rest by the EF converter on save + GrossEarningsIrr = gross, + ClawbackAppliedIrr = clawbackApplied, + NetAmountIrr = net, + Amount = net, + BookingCount = bookings.Count + }; + + foreach (var b in bookings) + payout.BookingLinks.Add(new NursePayoutBookingLink + { + BookingId = b.BookingId, + PayoutAmountIrr = b.PayoutAmountIrr + }); + + batch.Payouts.Add(payout); + batchTotal += net; + payoutCount++; + } + + if (payoutCount == 0) + return OperationResult.FailureResult( + "nurse", "No eligible nurse has a verified primary IBAN to be paid."); + + // total_amount = Σ net_amount_irr; payout_count = COUNT(payouts) — the batch invariant, frozen here. + batch.SetTotals(batchTotal, payoutCount); + + await unitOfWork.PayoutRepository.AddBatchAsync(batch, cancellationToken); + + try + { + await unitOfWork.CommitAsync(); + } + catch (DbUpdateException) + { + // The booking_id UNIQUE backstop: a booking was linked by a concurrent run despite the lock. Never + // double-pay — surface a conflict rather than aborting into a half-written batch. + await unitOfWork.RollBackAsync(); + return OperationResult.ConflictResult( + "Another payout run already claimed one of these bookings."); + } + + var detail = await unitOfWork.PayoutRepository.GetBatchDetailAsync( + batch.Id, page: 1, pageSize: Math.Max(payoutCount, 1), cancellationToken); + + return OperationResult.SuccessResult( + new GeneratePayoutBatchResult(detail!.Batch, detail.Payouts, skipped)); + } + + /// + /// The clawback netting: recovers whole pending clawbacks (oldest first) that fit within the nurse's + /// earnings this batch — never a negative net, never a partial recovery of a single clawback row. A clawback + /// larger than this batch's earnings stays fully pending and recovers from a later, larger batch. + /// + private async Task ComputeNettableClawbackAsync(long nurseId, long gross, CancellationToken cancellationToken) + { + var pending = await unitOfWork.PayoutRepository.GetPendingClawbacksAsync(nurseId, cancellationToken); + + long applied = 0; + foreach (var clawback in pending) + { + if (applied + clawback.AmountIrr > gross) + break; + applied += clawback.AmountIrr; + } + + return applied; + } +} diff --git a/server/src/Core/Baya.Application/Features/Payouts/Commands/GeneratePayoutBatch/GeneratePayoutBatchCommand.Validator.cs b/server/src/Core/Baya.Application/Features/Payouts/Commands/GeneratePayoutBatch/GeneratePayoutBatchCommand.Validator.cs new file mode 100644 index 0000000..c18434e --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Payouts/Commands/GeneratePayoutBatch/GeneratePayoutBatchCommand.Validator.cs @@ -0,0 +1,18 @@ +using FluentValidation; + +namespace Baya.Application.Features.Payouts.Commands.GeneratePayoutBatch; + +public sealed class GeneratePayoutBatchCommandValidator : AbstractValidator +{ + public GeneratePayoutBatchCommandValidator() + { + RuleFor(x => x.PeriodStart) + .LessThanOrEqualTo(x => x.PeriodEnd) + .WithMessage("period_start must be on or before period_end."); + + // A payout window must be closed — never batch a future period. + RuleFor(x => x.PeriodEnd) + .Must(pe => pe <= DateOnly.FromDateTime(DateTime.UtcNow.Date)) + .WithMessage("period_end cannot be in the future."); + } +} diff --git a/server/src/Core/Baya.Application/Features/Payouts/Commands/GeneratePayoutBatch/GeneratePayoutBatchCommand.cs b/server/src/Core/Baya.Application/Features/Payouts/Commands/GeneratePayoutBatch/GeneratePayoutBatchCommand.cs new file mode 100644 index 0000000..1f1bd1d --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Payouts/Commands/GeneratePayoutBatch/GeneratePayoutBatchCommand.cs @@ -0,0 +1,12 @@ +using Baya.Application.Models.Common; +using Baya.Application.Models.Payouts; +using Mediator; + +namespace Baya.Application.Features.Payouts.Commands.GeneratePayoutBatch; + +/// Opens a draft payout batch for a window: shifts the period end + processing date off bank-closed +/// days, selects the payout-eligible unpaid bookings, and materializes one payout per nurse (netting pending +/// clawbacks, snapshotting the verified primary IBAN, linking each booking under the UNIQUE guard). Returns the +/// draft batch + payouts for admin preview; no money moves until process. +public record GeneratePayoutBatchCommand(DateOnly PeriodStart, DateOnly PeriodEnd) + : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Payouts/Commands/MarkPayoutFailed/MarkPayoutFailedCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Payouts/Commands/MarkPayoutFailed/MarkPayoutFailedCommand.Handler.cs new file mode 100644 index 0000000..00423c3 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Payouts/Commands/MarkPayoutFailed/MarkPayoutFailedCommand.Handler.cs @@ -0,0 +1,41 @@ +#nullable enable +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Persistence; +using Baya.Application.Models.Common; +using Baya.Domain.Entities.Payouts; +using Mediator; + +namespace Baya.Application.Features.Payouts.Commands.MarkPayoutFailed; + +/// Records a reconciled bank rejection on a payout. No ledger movement — a rejected transfer means no +/// money left, so there is nothing to reverse. Re-settles the parent batch so its status reflects the failure. +internal sealed class MarkPayoutFailedCommandHandler( + IUnitOfWork unitOfWork, + IDateTimeProvider dateTimeProvider) + : IRequestHandler> +{ + public async ValueTask> Handle(MarkPayoutFailedCommand request, CancellationToken cancellationToken) + { + var now = dateTimeProvider.UtcNow.UtcDateTime; + + var stub = await unitOfWork.PayoutRepository.GetTrackedPayoutAsync(request.PayoutId, cancellationToken); + if (stub is null) + return OperationResult.NotFoundResult("Payout not found."); + + var batch = await unitOfWork.PayoutRepository.GetTrackedBatchAsync(stub.BatchId, cancellationToken); + var payout = batch!.Payouts.First(p => p.Id == request.PayoutId); + + // A paid payout is a confirmed, irreversible transfer — it can never be marked failed. + if (payout.Status == PayoutStatus.Paid) + return OperationResult.ConflictResult("A paid payout cannot be marked failed."); + // Idempotent. + if (payout.Status == PayoutStatus.Failed) + return OperationResult.SuccessResult(true); + + payout.MarkFailed(request.FailureReason); + batch.RecomputeSettlement(now); + + await unitOfWork.CommitAsync(); + return OperationResult.SuccessResult(true); + } +} diff --git a/server/src/Core/Baya.Application/Features/Payouts/Commands/MarkPayoutFailed/MarkPayoutFailedCommand.Validator.cs b/server/src/Core/Baya.Application/Features/Payouts/Commands/MarkPayoutFailed/MarkPayoutFailedCommand.Validator.cs new file mode 100644 index 0000000..b4ad98d --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Payouts/Commands/MarkPayoutFailed/MarkPayoutFailedCommand.Validator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace Baya.Application.Features.Payouts.Commands.MarkPayoutFailed; + +public sealed class MarkPayoutFailedCommandValidator : AbstractValidator +{ + public MarkPayoutFailedCommandValidator() + { + RuleFor(x => x.PayoutId).GreaterThan(0); + RuleFor(x => x.FailureReason).NotEmpty().MaximumLength(500); + } +} diff --git a/server/src/Core/Baya.Application/Features/Payouts/Commands/MarkPayoutFailed/MarkPayoutFailedCommand.cs b/server/src/Core/Baya.Application/Features/Payouts/Commands/MarkPayoutFailed/MarkPayoutFailedCommand.cs new file mode 100644 index 0000000..7bbdd81 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Payouts/Commands/MarkPayoutFailed/MarkPayoutFailedCommand.cs @@ -0,0 +1,8 @@ +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Payouts.Commands.MarkPayoutFailed; + +/// Records a reconciled bank rejection on a payout — sets failed with the reason. Posts no +/// ledger movement (no money left the platform). Used when the rail reports a transfer bounced after submit. +public record MarkPayoutFailedCommand(long PayoutId, string FailureReason) : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Payouts/Commands/RetryFailedPayout/RetryFailedPayoutCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Payouts/Commands/RetryFailedPayout/RetryFailedPayoutCommand.Handler.cs new file mode 100644 index 0000000..d07bbe8 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Payouts/Commands/RetryFailedPayout/RetryFailedPayoutCommand.Handler.cs @@ -0,0 +1,82 @@ +#nullable enable +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Configuration; +using Baya.Application.Contracts.Holidays; +using Baya.Application.Contracts.Payments; +using Baya.Application.Contracts.Persistence; +using Baya.Application.Models.Common; +using Baya.Domain.Entities.Payouts; +using Mediator; + +namespace Baya.Application.Features.Payouts.Commands.RetryFailedPayout; + +/// +/// Re-submits one failed payout. Holiday-aware — it refuses on a bank-closed day (PAYA/SATNA would fail). +/// On acceptance it drives the same settlement as the batch execute (ledger post + clawback netting, both +/// idempotent) and re-settles the parent batch (partially_failed → completed when it was the last failure). +/// +internal sealed class RetryFailedPayoutCommandHandler( + IUnitOfWork unitOfWork, + IDistributedLock distributedLock, + IBankTransferProvider bankTransfer, + IHolidayCalendar holidays, + IPlatformConfig platformConfig, + IDateTimeProvider dateTimeProvider) + : IRequestHandler> +{ + public async ValueTask> Handle(RetryFailedPayoutCommand request, CancellationToken cancellationToken) + { + var now = dateTimeProvider.UtcNow.UtcDateTime; + + await using var _ = await distributedLock.AcquireAsync("payout:batch", cancellationToken); + + var stub = await unitOfWork.PayoutRepository.GetTrackedPayoutAsync(request.PayoutId, cancellationToken); + if (stub is null) + return OperationResult.NotFoundResult("Payout not found."); + + // Load the full batch (tracked, with all payouts) so the retry can re-settle the batch status; EF identity + // resolution returns the same tracked payout instance. + var batch = await unitOfWork.PayoutRepository.GetTrackedBatchAsync(stub.BatchId, cancellationToken); + var payout = batch!.Payouts.First(p => p.Id == request.PayoutId); + + // Idempotent: an already-paid payout needs no retry. + if (payout.Status == PayoutStatus.Paid) + return OperationResult.SuccessResult(true); + if (payout.Status != PayoutStatus.Failed) + return OperationResult.ConflictResult("Only a failed payout can be retried."); + + // Holiday-aware: a real PAYA/SATNA transfer won't settle on a bank-closed day (or the Friday weekend). + var today = DateOnly.FromDateTime(now); + var nextOpen = await holidays.NextBusinessDay(today, cancellationToken); + if (nextOpen != today) + return OperationResult.FailureResult( + "processing_date", "Banks are closed today; retry on the next business day."); + + var satnaThreshold = (long)await platformConfig.GetConfig("payout_satna_threshold_irr", cancellationToken); + + var submit = await bankTransfer.SubmitPayoutBatchAsync( + batch.Id, [PayoutSettlement.ToInstruction(payout, satnaThreshold)], + idempotencyKey: $"payout:{payout.Id}:retry", cancellationToken); + + var result = submit.Results.FirstOrDefault(r => r.PayoutId == payout.Id); + if (result is null || result.Status == BankTransferStatus.Failed) + { + payout.MarkFailed(result?.FailureReason ?? "provider_declined"); + await unitOfWork.CommitAsync(); + return OperationResult.FailureResult("channel", "The bank rail declined the transfer again."); + } + + payout.MarkSubmitted(result.TransferReference ?? $"payout:{batch.Id}:{payout.Id}"); + if (result.Status == BankTransferStatus.Paid) + { + payout.MarkPaid(now); + await PayoutSettlement.PostPayoutLedgerAsync(unitOfWork, payout, now, cancellationToken); + await PayoutSettlement.RecoverClawbacksAsync(unitOfWork, payout, now, cancellationToken); + } + + batch.RecomputeSettlement(now); + await unitOfWork.CommitAsync(); + + return OperationResult.SuccessResult(true); + } +} diff --git a/server/src/Core/Baya.Application/Features/Payouts/Commands/RetryFailedPayout/RetryFailedPayoutCommand.Validator.cs b/server/src/Core/Baya.Application/Features/Payouts/Commands/RetryFailedPayout/RetryFailedPayoutCommand.Validator.cs new file mode 100644 index 0000000..add6828 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Payouts/Commands/RetryFailedPayout/RetryFailedPayoutCommand.Validator.cs @@ -0,0 +1,11 @@ +using FluentValidation; + +namespace Baya.Application.Features.Payouts.Commands.RetryFailedPayout; + +public sealed class RetryFailedPayoutCommandValidator : AbstractValidator +{ + public RetryFailedPayoutCommandValidator() + { + RuleFor(x => x.PayoutId).GreaterThan(0); + } +} diff --git a/server/src/Core/Baya.Application/Features/Payouts/Commands/RetryFailedPayout/RetryFailedPayoutCommand.cs b/server/src/Core/Baya.Application/Features/Payouts/Commands/RetryFailedPayout/RetryFailedPayoutCommand.cs new file mode 100644 index 0000000..4bff35c --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Payouts/Commands/RetryFailedPayout/RetryFailedPayoutCommand.cs @@ -0,0 +1,9 @@ +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Payouts.Commands.RetryFailedPayout; + +/// Re-submits a single failed payout to the bank rail (holiday-aware — never on a bank-closed +/// day). On success it posts the payout ledger + nets clawbacks like the first execute and re-settles the batch; +/// idempotent on the same key so a retried retry never double-sends. +public record RetryFailedPayoutCommand(long PayoutId) : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Payouts/PayoutSettlement.cs b/server/src/Core/Baya.Application/Features/Payouts/PayoutSettlement.cs new file mode 100644 index 0000000..552bfa3 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Payouts/PayoutSettlement.cs @@ -0,0 +1,58 @@ +#nullable enable +using Baya.Application.Contracts.Payments; +using Baya.Application.Contracts.Persistence; +using Baya.Domain.Entities.Payments; +using Baya.Domain.Entities.Payouts; + +namespace Baya.Application.Features.Payouts; + +/// +/// The shared money-settlement steps for a paid payout, used by both ExecutePayoutBatch and +/// RetryFailedPayout so the ledger posting + clawback netting live in exactly one place (mirroring b12's +/// extracted BookingConversion). Both operations are idempotent: the ledger-exists guard blocks a second +/// payout group, and a recovered clawback is never re-marked or re-posted. +/// +internal static class PayoutSettlement +{ + /// Builds the rail instruction — SATNA above the config threshold, else PAYA. The tracked payout's + /// is decrypted by the EF converter on load. + public static PayoutInstruction ToInstruction(NursePayout payout, long satnaThreshold) + => new(payout.Id, payout.IbanSnapshot, payout.Amount, + payout.Amount >= satnaThreshold ? BankTransferMethod.Satna : BankTransferMethod.Paya); + + /// DEBIT nurse_payable / CREDIT escrow_held for the paid net — skipped when the payout + /// is fully netted (net 0) or a group already exists (retry idempotency). + public static async Task PostPayoutLedgerAsync(IUnitOfWork unitOfWork, NursePayout payout, DateTime now, CancellationToken cancellationToken) + { + if (payout.Amount <= 0) + return; + if (await unitOfWork.PayoutRepository.LedgerGroupExistsForPayoutAsync(payout.Id, cancellationToken)) + return; + + var legs = LedgerPosting.NursePayout(payout.NurseId, payout.Amount, payout.Id, now); + await unitOfWork.PaymentRepository.AddLedgerEntriesAsync(legs, cancellationToken); + } + + /// Realizes the payout's frozen clawback_applied_irr: recovers whole pending clawbacks (oldest + /// first, matching the build's greedy cap) — marks each recovered + posts DEBIT nurse_payable / + /// CREDIT nurse_clawback_receivable. Idempotent: on a retry the clawbacks are already recovered. + public static async Task RecoverClawbacksAsync(IUnitOfWork unitOfWork, NursePayout payout, DateTime now, CancellationToken cancellationToken) + { + if (payout.ClawbackAppliedIrr <= 0) + return; + + var pending = await unitOfWork.PayoutRepository.GetPendingClawbacksAsync(payout.NurseId, cancellationToken); + var remaining = payout.ClawbackAppliedIrr; + + foreach (var clawback in pending) + { + if (clawback.AmountIrr > remaining) + break; + + clawback.Recover(payout.Id, now); + var legs = LedgerPosting.ClawbackRecovery(clawback.BookingId, clawback.NurseId, clawback.AmountIrr, clawback.Id, now); + await unitOfWork.PaymentRepository.AddLedgerEntriesAsync(legs, cancellationToken); + remaining -= clawback.AmountIrr; + } + } +} diff --git a/server/src/Core/Baya.Application/Features/Payouts/Queries/ComputeEligibleEarnings/ComputeEligibleEarningsQuery.Handler.cs b/server/src/Core/Baya.Application/Features/Payouts/Queries/ComputeEligibleEarnings/ComputeEligibleEarningsQuery.Handler.cs new file mode 100644 index 0000000..e66c916 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Payouts/Queries/ComputeEligibleEarnings/ComputeEligibleEarningsQuery.Handler.cs @@ -0,0 +1,35 @@ +#nullable enable +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Configuration; +using Baya.Application.Contracts.Holidays; +using Baya.Application.Contracts.Persistence; +using Baya.Application.Models.Common; +using Baya.Application.Models.Payouts; +using Mediator; + +namespace Baya.Application.Features.Payouts.Queries.ComputeEligibleEarnings; + +internal sealed class ComputeEligibleEarningsQueryHandler( + IUnitOfWork unitOfWork, + IHolidayCalendar holidays, + IPlatformConfig platformConfig, + IDateTimeProvider dateTimeProvider) + : IRequestHandler>> +{ + public async ValueTask>> Handle( + ComputeEligibleEarningsQuery request, CancellationToken cancellationToken) + { + var page = request.Page < 1 ? 1 : request.Page; + var pageSize = request.PageSize is < 1 or > 100 ? 20 : request.PageSize; + var now = dateTimeProvider.UtcNow.UtcDateTime; + + // Preview the exact set a generate would take — the period end is holiday-shifted the same way. + var periodEnd = await holidays.NextBusinessDay(request.PeriodEnd, cancellationToken); + var requireBnplSettlement = await platformConfig.GetConfig("require_bnpl_settlement_for_payout", cancellationToken); + + var result = await unitOfWork.PayoutRepository.GetEligiblePreviewAsync( + request.PeriodStart, periodEnd, now, requireBnplSettlement, page, pageSize, cancellationToken); + + return OperationResult>.SuccessResult(result); + } +} diff --git a/server/src/Core/Baya.Application/Features/Payouts/Queries/ComputeEligibleEarnings/ComputeEligibleEarningsQuery.Validator.cs b/server/src/Core/Baya.Application/Features/Payouts/Queries/ComputeEligibleEarnings/ComputeEligibleEarningsQuery.Validator.cs new file mode 100644 index 0000000..b5aba06 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Payouts/Queries/ComputeEligibleEarnings/ComputeEligibleEarningsQuery.Validator.cs @@ -0,0 +1,18 @@ +using FluentValidation; + +namespace Baya.Application.Features.Payouts.Queries.ComputeEligibleEarnings; + +public sealed class ComputeEligibleEarningsQueryValidator : AbstractValidator +{ + public ComputeEligibleEarningsQueryValidator() + { + RuleFor(x => x.PeriodStart) + .LessThanOrEqualTo(x => x.PeriodEnd) + .WithMessage("period_start must be on or before period_end."); + + // A payout window must be closed — never preview a future period. + RuleFor(x => x.PeriodEnd) + .Must(pe => pe <= DateOnly.FromDateTime(DateTime.UtcNow.Date)) + .WithMessage("period_end cannot be in the future."); + } +} diff --git a/server/src/Core/Baya.Application/Features/Payouts/Queries/ComputeEligibleEarnings/ComputeEligibleEarningsQuery.cs b/server/src/Core/Baya.Application/Features/Payouts/Queries/ComputeEligibleEarnings/ComputeEligibleEarningsQuery.cs new file mode 100644 index 0000000..f23c0f4 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Payouts/Queries/ComputeEligibleEarnings/ComputeEligibleEarningsQuery.cs @@ -0,0 +1,12 @@ +using Baya.Application.Models.Common; +using Baya.Application.Models.Payouts; +using Mediator; + +namespace Baya.Application.Features.Payouts.Queries.ComputeEligibleEarnings; + +/// Admin preview of the payout-eligible, unpaid earnings for a window, grouped by nurse — the dry-run +/// before generating a batch. Only completed bookings whose dispute window has closed and that aren't already paid +/// appear; each nurse's pending clawback is netted and a nurse without a verified primary IBAN is flagged. +/// Paginated. +public record ComputeEligibleEarningsQuery(DateOnly PeriodStart, DateOnly PeriodEnd, int Page = 1, int PageSize = 20) + : IRequest>>; diff --git a/server/src/Core/Baya.Application/Features/Payouts/Queries/GetBatchDetail/GetBatchDetailQuery.Handler.cs b/server/src/Core/Baya.Application/Features/Payouts/Queries/GetBatchDetail/GetBatchDetailQuery.Handler.cs new file mode 100644 index 0000000..9ee608b --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Payouts/Queries/GetBatchDetail/GetBatchDetailQuery.Handler.cs @@ -0,0 +1,23 @@ +#nullable enable +using Baya.Application.Contracts.Persistence; +using Baya.Application.Models.Common; +using Baya.Application.Models.Payouts; +using Mediator; + +namespace Baya.Application.Features.Payouts.Queries.GetBatchDetail; + +internal sealed class GetBatchDetailQueryHandler(IUnitOfWork unitOfWork) + : IRequestHandler> +{ + public async ValueTask> Handle( + GetBatchDetailQuery request, CancellationToken cancellationToken) + { + var page = request.Page < 1 ? 1 : request.Page; + var pageSize = request.PageSize is < 1 or > 200 ? 50 : request.PageSize; + + var detail = await unitOfWork.PayoutRepository.GetBatchDetailAsync(request.BatchId, page, pageSize, cancellationToken); + return detail is null + ? OperationResult.NotFoundResult("Payout batch not found.") + : OperationResult.SuccessResult(detail); + } +} diff --git a/server/src/Core/Baya.Application/Features/Payouts/Queries/GetBatchDetail/GetBatchDetailQuery.cs b/server/src/Core/Baya.Application/Features/Payouts/Queries/GetBatchDetail/GetBatchDetailQuery.cs new file mode 100644 index 0000000..bf87394 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Payouts/Queries/GetBatchDetail/GetBatchDetailQuery.cs @@ -0,0 +1,10 @@ +using Baya.Application.Models.Common; +using Baya.Application.Models.Payouts; +using Mediator; + +namespace Baya.Application.Features.Payouts.Queries.GetBatchDetail; + +/// Admin batch detail — the header plus its paginated payouts (status, net, masked IBAN + transfer +/// reference) and the bookings each payout covers. +public record GetBatchDetailQuery(long BatchId, int Page = 1, int PageSize = 50) + : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Payouts/Queries/GetNursePayoutHistory/GetNursePayoutHistoryQuery.Handler.cs b/server/src/Core/Baya.Application/Features/Payouts/Queries/GetNursePayoutHistory/GetNursePayoutHistoryQuery.Handler.cs new file mode 100644 index 0000000..a065761 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Payouts/Queries/GetNursePayoutHistory/GetNursePayoutHistoryQuery.Handler.cs @@ -0,0 +1,33 @@ +#nullable enable +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Persistence; +using Baya.Application.Models.Common; +using Baya.Application.Models.Payouts; +using Mediator; + +namespace Baya.Application.Features.Payouts.Queries.GetNursePayoutHistory; + +internal sealed class GetNursePayoutHistoryQueryHandler( + IUnitOfWork unitOfWork, + ICurrentUser currentUser) + : IRequestHandler>> +{ + public async ValueTask>> Handle( + GetNursePayoutHistoryQuery request, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return OperationResult>.UnauthorizedResult("Not authenticated."); + + var page = request.Page < 1 ? 1 : request.Page; + var pageSize = request.PageSize is < 1 or > 100 ? 20 : request.PageSize; + + // Tenancy: resolve the caller's own nurse profile — a caller who is not a nurse simply has no payouts. + var nurseId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken); + if (nurseId is not { } id) + return OperationResult>.SuccessResult( + new PagedResult([], 0, page, pageSize)); + + var result = await unitOfWork.PayoutRepository.GetNurseHistoryAsync(id, page, pageSize, cancellationToken); + return OperationResult>.SuccessResult(result); + } +} diff --git a/server/src/Core/Baya.Application/Features/Payouts/Queries/GetNursePayoutHistory/GetNursePayoutHistoryQuery.cs b/server/src/Core/Baya.Application/Features/Payouts/Queries/GetNursePayoutHistory/GetNursePayoutHistoryQuery.cs new file mode 100644 index 0000000..41dbf3e --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Payouts/Queries/GetNursePayoutHistory/GetNursePayoutHistoryQuery.cs @@ -0,0 +1,10 @@ +using Baya.Application.Models.Common; +using Baya.Application.Models.Payouts; +using Mediator; + +namespace Baya.Application.Features.Payouts.Queries.GetNursePayoutHistory; + +/// The signed-in nurse's own payout history (tenancy-scoped to ICurrentUser) — status, net, +/// masked IBAN + transfer reference, any clawback applied, and the batch window. Feeds f12's earnings screen. +public record GetNursePayoutHistoryQuery(int Page = 1, int PageSize = 20) + : IRequest>>; diff --git a/server/src/Core/Baya.Application/Features/Payouts/Queries/ListPayoutBatches/ListPayoutBatchesQuery.Handler.cs b/server/src/Core/Baya.Application/Features/Payouts/Queries/ListPayoutBatches/ListPayoutBatchesQuery.Handler.cs new file mode 100644 index 0000000..6dd456f --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Payouts/Queries/ListPayoutBatches/ListPayoutBatchesQuery.Handler.cs @@ -0,0 +1,21 @@ +#nullable enable +using Baya.Application.Contracts.Persistence; +using Baya.Application.Models.Common; +using Baya.Application.Models.Payouts; +using Mediator; + +namespace Baya.Application.Features.Payouts.Queries.ListPayoutBatches; + +internal sealed class ListPayoutBatchesQueryHandler(IUnitOfWork unitOfWork) + : IRequestHandler>> +{ + public async ValueTask>> Handle( + ListPayoutBatchesQuery request, CancellationToken cancellationToken) + { + var page = request.Page < 1 ? 1 : request.Page; + var pageSize = request.PageSize is < 1 or > 100 ? 20 : request.PageSize; + + var result = await unitOfWork.PayoutRepository.ListBatchesAsync(request.Status, page, pageSize, cancellationToken); + return OperationResult>.SuccessResult(result); + } +} diff --git a/server/src/Core/Baya.Application/Features/Payouts/Queries/ListPayoutBatches/ListPayoutBatchesQuery.cs b/server/src/Core/Baya.Application/Features/Payouts/Queries/ListPayoutBatches/ListPayoutBatchesQuery.cs new file mode 100644 index 0000000..9e2855d --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Payouts/Queries/ListPayoutBatches/ListPayoutBatchesQuery.cs @@ -0,0 +1,9 @@ +using Baya.Application.Models.Common; +using Baya.Application.Models.Payouts; +using Mediator; + +namespace Baya.Application.Features.Payouts.Queries.ListPayoutBatches; + +/// Admin reconciliation list of payout batches — projected + paginated, optional status filter. +public record ListPayoutBatchesQuery(string? Status = null, int Page = 1, int PageSize = 20) + : IRequest>>; diff --git a/server/src/Core/Baya.Application/Models/Payouts/PayoutProjections.cs b/server/src/Core/Baya.Application/Models/Payouts/PayoutProjections.cs new file mode 100644 index 0000000..927da3d --- /dev/null +++ b/server/src/Core/Baya.Application/Models/Payouts/PayoutProjections.cs @@ -0,0 +1,87 @@ +#nullable enable +namespace Baya.Application.Models.Payouts; + +/// One raw eligible booking the batch build consumes: which nurse earned it and this booking's payout +/// portion (IRR). Grouped by nurse in the handler to compute gross_earnings_irr. +public record EligibleBookingRow(long NurseId, long BookingId, long PayoutAmountIrr); + +/// The eligibility preview row — per-nurse earnings for the window, the pending clawback that would be +/// netted, the resulting net, and whether the nurse has a verified primary IBAN to receive it (a nurse without +/// one is flagged, not silently dropped). Money crosses the wire as digit strings. +public record EligibleNurseEarningsDto( + long NurseId, + string? NurseName, + int BookingCount, + string GrossEarningsIrr, + string ClawbackAppliedIrr, + string NetAmountIrr, + bool HasVerifiedPrimaryIban); + +/// A batch header — periods (holiday-shifted), totals, status, and reconciliation timestamps. +/// Money is a digit string. +public record PayoutBatchDto( + long Id, + DateOnly PeriodStart, + DateOnly PeriodEnd, + DateOnly ProcessingDate, + string TotalAmount, + int PayoutCount, + string Status, + int InitiatedByAdminId, + DateTime? ProcessedAt, + string? FailureNotes, + DateTimeOffset CreatedAt); + +/// The booking a payout covers — with the per-booking amount and (future) session id. Money is a digit +/// string. +public record PayoutBookingLinkDto(long BookingId, long? SessionId, string PayoutAmountIrr); + +/// One payout in a batch detail — the decomposed amounts, status, the masked IBAN + transfer +/// reference, and the bookings it covers. Money is a digit string. +public record PayoutDto( + long Id, + long NurseId, + string? NurseName, + string MaskedIban, + string GrossEarningsIrr, + string ClawbackAppliedIrr, + string NetAmountIrr, + string Amount, + int BookingCount, + string Status, + string? TransferReference, + DateTime? PaidAt, + string? FailureReason, + IReadOnlyList Bookings); + +/// A batch header plus its paginated payouts — the admin reconciliation detail view. +public record PayoutBatchDetailDto(PayoutBatchDto Batch, IReadOnlyList Payouts, int Total, int Page, int PageSize); + +/// A nurse skipped during a batch build, with the reason — never silently dropped (the common case is +/// "no verified primary IBAN"). Money is a digit string. +public record SkippedNurseDto(long NurseId, string? NurseName, string GrossEarningsIrr, string Reason); + +/// What GeneratePayoutBatchCommand returns — the draft batch, the materialized payouts for admin +/// preview, and the nurses skipped (with reasons). +public record GeneratePayoutBatchResult( + PayoutBatchDto Batch, + IReadOnlyList Payouts, + IReadOnlyList Skipped); + +/// What ExecutePayoutBatchCommand returns — the settled batch status and per-outcome counts. +public record ExecutePayoutBatchResult(long BatchId, string Status, int PaidCount, int FailedCount, string TotalPaid); + +/// A nurse's own payout-history row (tenancy-scoped) — status, net, the masked IBAN + reference, +/// the clawback that was applied, and the batch window it belongs to. Money crosses the wire as digit strings. +public record NursePayoutHistoryDto( + long Id, + long BatchId, + string Status, + string GrossEarningsIrr, + string ClawbackAppliedIrr, + string NetAmountIrr, + string MaskedIban, + string? TransferReference, + DateTime? PaidAt, + DateOnly PeriodStart, + DateOnly PeriodEnd); diff --git a/server/src/Core/Baya.Domain/Entities/Payments/LedgerPosting.cs b/server/src/Core/Baya.Domain/Entities/Payments/LedgerPosting.cs index 4a0ea21..37ee3b7 100644 --- a/server/src/Core/Baya.Domain/Entities/Payments/LedgerPosting.cs +++ b/server/src/Core/Baya.Domain/Entities/Payments/LedgerPosting.cs @@ -160,6 +160,58 @@ public static class LedgerPosting ]; } + /// + /// The payout group (b13): DEBIT nurse_payable / CREDIT escrow_held for the amount actually + /// transferred to the nurse, under one fresh . Draining the + /// nurse_payable accrual to a real bank transfer is the one irreversible money-out step; the balance + /// (the signed sum over nurse_payable legs) drops by exactly what was paid. Posted once per payout — + /// the payout status machine + the nurse_payout_booking_links UNIQUE make a retried execute a no-op. + /// The clawback netted into the payout is not a leg here: the receivable was already booked by b11 and + /// is cleared by marking the nurse_clawbacks row recovered (the net amount is simply lower). + /// + public static IReadOnlyList NursePayout( + long nurseId, + long amountIrr, + long payoutId, + DateTime createdAt) + { + if (amountIrr <= 0) + throw new InvalidOperationException("A payout ledger group requires a positive amount."); + + var group = Guid.NewGuid(); + return + [ + Leg(group, LedgerAccountType.NursePayable, LedgerDirection.Debit, amountIrr, nurseId, null, LedgerSourceRefType.NursePayout, payoutId, createdAt), + Leg(group, LedgerAccountType.EscrowHeld, LedgerDirection.Credit, amountIrr, null, null, LedgerSourceRefType.NursePayout, payoutId, createdAt) + ]; + } + + /// + /// The clawback recovery netting (b13): when a payout withholds a nurse's pending clawback, the + /// withheld earnings clear the receivable — DEBIT nurse_payable / CREDIT nurse_clawback_receivable for + /// the recovered amount, under one group. Together with the payout group (which debits nurse_payable + /// by the paid net), this drains the nurse's nurse_payable by the full gross and zeroes the receivable, + /// so the derived balances reconcile. Posted once per recovered clawback (the recovered status makes a + /// retry a no-op). + /// + public static IReadOnlyList ClawbackRecovery( + long bookingId, + long nurseId, + long amountIrr, + long clawbackId, + DateTime createdAt) + { + if (amountIrr <= 0) + throw new InvalidOperationException("A clawback-recovery group requires a positive amount."); + + var group = Guid.NewGuid(); + return + [ + Leg(group, LedgerAccountType.NursePayable, LedgerDirection.Debit, amountIrr, nurseId, bookingId, LedgerSourceRefType.Clawback, clawbackId, createdAt), + Leg(group, LedgerAccountType.NurseClawbackReceivable, LedgerDirection.Credit, amountIrr, nurseId, bookingId, LedgerSourceRefType.Clawback, clawbackId, createdAt) + ]; + } + /// /// The clawback write-off correction: DEBIT bad_debt / CREDIT nurse_clawback_receivable for /// the amount, when an admin declares the receivable uncollectable. A new balancing group — never an edit. @@ -181,7 +233,7 @@ public static class LedgerPosting private static LedgerEntry Leg( Guid group, string account, string direction, long amount, long? nurse, - long bookingId, string sourceType, long sourceId, DateTime createdAt) => new() + long? bookingId, string sourceType, long sourceId, DateTime createdAt) => new() { TransactionGroupId = group, AccountType = account, diff --git a/server/src/Core/Baya.Domain/Entities/Payouts/NursePayout.cs b/server/src/Core/Baya.Domain/Entities/Payouts/NursePayout.cs new file mode 100644 index 0000000..2898162 --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/Payouts/NursePayout.cs @@ -0,0 +1,93 @@ +#nullable enable +using Baya.Domain.Common; + +namespace Baya.Domain.Entities.Payouts; + +/// +/// One row per nurse per batch — the exact amount transferred, the frozen IBAN snapshot, and the bank transfer +/// reference for reconciliation. The nurse's earnings for the window are ; any +/// pending clawback the nurse owes back is netted into so the platform +/// never overpays a nurse with a receivable. +/// +/// Invariant: = − +/// ; all amounts ≥ 0; ≥ 0 (a clawback exceeding +/// earnings nets to zero this batch, the remainder staying pending for the next — never a negative +/// transfer). is what actually moves; it equals on success. +/// Money is IRR BIGINT, no floats. is encrypted at rest and frozen at build +/// time from the nurse's verified primary account. Paid-ness is derived from a +/// nurse_payout_booking_links row + the ledger movement — never a boolean flag. +/// +/// +public class NursePayout : BaseEntity +{ + public long BatchId { get; set; } + public NursePayoutBatch Batch { get; set; } = null!; + + public long NurseId { get; set; } + + /// The verified primary account paid (FK nurse_bank_accounts). + public long BankAccountId { get; set; } + + /// The account's IBAN, frozen at build time and encrypted at rest through the field encryptor. + public string IbanSnapshot { get; set; } = null!; + + /// Σ eligible booking payouts for the window (IRR). + public long GrossEarningsIrr { get; set; } + + /// Pending clawbacks netted this batch (IRR, ≥ 0, capped at ). + public long ClawbackAppliedIrr { get; set; } + + /// Derived: (IRR, ≥ 0). + public long NetAmountIrr { get; set; } + + /// Actually transferred net (IRR) — equals on success. + public long Amount { get; set; } + + public int BookingCount { get; set; } + + /// Guarded — a code, mutated only through the mark-* methods. + public string Status { get; private set; } = PayoutStatus.Pending; + + /// The bank track id (PAYA/SATNA), set at submit — kept for reconciliation. + public string? TransferReference { get; private set; } + + public DateTime? PaidAt { get; private set; } + + public string? FailureReason { get; private set; } + + public DateTimeOffset? DeletedAt { get; set; } + + public ICollection BookingLinks { get; set; } = new List(); + + public bool CanTransitionTo(string target) => PayoutStatusTransitions.CanTransition(Status, target); + + private void Transition(string target) + { + if (!PayoutStatusTransitions.CanTransition(Status, target)) + throw new InvalidOperationException($"Illegal payout transition {Status} → {target}."); + Status = target; + } + + /// Records the bank track id and transitions pending|failed → submitted. Clears any prior + /// failure so a retry starts clean. + public void MarkSubmitted(string transferReference) + { + Transition(PayoutStatus.Submitted); + TransferReference = transferReference; + FailureReason = null; + } + + /// Confirms the transfer and transitions submitted → paid — the irreversible movement. + public void MarkPaid(DateTime now) + { + Transition(PayoutStatus.Paid); + PaidAt = now; + } + + /// Records a rail rejection and transitions to failed (from pending or submitted). + public void MarkFailed(string reason) + { + Transition(PayoutStatus.Failed); + FailureReason = reason; + } +} diff --git a/server/src/Core/Baya.Domain/Entities/Payouts/NursePayoutBatch.cs b/server/src/Core/Baya.Domain/Entities/Payouts/NursePayoutBatch.cs new file mode 100644 index 0000000..1cf7636 --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/Payouts/NursePayoutBatch.cs @@ -0,0 +1,88 @@ +#nullable enable +using Baya.Domain.Common; + +namespace Baya.Domain.Entities.Payouts; + +/// +/// A weekly aggregation of amounts owed for completed, payout-eligible, unpaid bookings — the operational unit of +/// the payout run, matching the PAYA settlement cycle. An admin (later, a scheduled job) opens a batch; it +/// materializes one per nurse with earnings in the window, then submits them all to the +/// bank rail in one go. +/// +/// Holiday-aware: and are shifted off bank-closed days +/// (via IHolidayCalendar) to the next business day — a batch landing on a multi-day Nowruz closure would +/// otherwise fail, since PAYA/SATNA does not settle on closed days. Invariant: +/// = Σ(nurse_payouts.net_amount_irr) and = +/// COUNT(payouts) — set by the handler when the rows are materialized and asserted by a verified invariant. +/// Money is IRR BIGINT, no floats. +/// +/// +public class NursePayoutBatch : BaseEntity +{ + public DateOnly PeriodStart { get; set; } + + /// Window end — shifted off is_bank_closed days to the next business day. + public DateOnly PeriodEnd { get; set; } + + /// The date the transfers are submitted — shifted off is_bank_closed days. + public DateOnly ProcessingDate { get; set; } + + /// Σ(net_amount_irr) across this batch's payouts (IRR). Set at materialization. + public long TotalAmount { get; set; } + + public int PayoutCount { get; set; } + + /// Guarded — mutated only through so every write goes through the machine. + public string Status { get; private set; } = PayoutBatchStatus.Draft; + + /// The admin who initiated the run (FK users). A future cron sets its own service id. + public int InitiatedByAdminId { get; set; } + + public DateTime? ProcessedAt { get; private set; } + + public string? FailureNotes { get; private set; } + + public DateTimeOffset? DeletedAt { get; set; } + + public ICollection Payouts { get; set; } = new List(); + + public bool CanTransitionTo(string target) => PayoutBatchTransitions.CanTransition(Status, target); + + /// Applies a guarded status change and, on a settling edge, stamps . Reaching + /// an illegal edge is a programming error (callers pre-check), so it fails fast rather than corrupting state. + public void TransitionTo(string target, DateTime now, string? failureNotes = null) + { + if (!PayoutBatchTransitions.CanTransition(Status, target)) + throw new InvalidOperationException($"Illegal payout-batch transition {Status} → {target}."); + + Status = target; + if (target is PayoutBatchStatus.Completed or PayoutBatchStatus.PartiallyFailed or PayoutBatchStatus.Failed) + ProcessedAt = now; + if (failureNotes is not null) + FailureNotes = failureNotes; + } + + /// Freezes the batch totals when the payouts are materialized (the CHECK-mirroring invariant). + public void SetTotals(long totalAmount, int payoutCount) + { + TotalAmount = totalAmount; + PayoutCount = payoutCount; + } + + /// Re-derives the batch's terminal status from its payouts after an execute or a retry: all paid → + /// completed; some failed but some paid → partially_failed; all failed → failed. A no-op + /// when the resulting edge isn't allowed from the current status (e.g. already partially_failed with a + /// still-failed row). Requires the collection to be loaded. + public void RecomputeSettlement(DateTime now) + { + var anyFailed = Payouts.Any(p => p.Status == PayoutStatus.Failed); + var anyPaid = Payouts.Any(p => p.Status == PayoutStatus.Paid); + + var target = !anyFailed + ? PayoutBatchStatus.Completed + : anyPaid ? PayoutBatchStatus.PartiallyFailed : PayoutBatchStatus.Failed; + + if (CanTransitionTo(target)) + TransitionTo(target, now, target == PayoutBatchStatus.Failed ? "All payouts failed at the bank rail." : null); + } +} diff --git a/server/src/Core/Baya.Domain/Entities/Payouts/NursePayoutBookingLink.cs b/server/src/Core/Baya.Domain/Entities/Payouts/NursePayoutBookingLink.cs new file mode 100644 index 0000000..d5b3bb7 --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/Payouts/NursePayoutBookingLink.cs @@ -0,0 +1,32 @@ +#nullable enable +using Baya.Domain.Common; + +namespace Baya.Domain.Entities.Payouts; + +/// +/// The join from a payout to the specific booking it covers — and the platform's strongest correctness feature: +/// carries a UNIQUE index so a booking can be paid in exactly one payout +/// across all batches, ever. A duplicate insert is the already-paid signal (the handler catches it, treats +/// the booking as not-eligible, and continues) — the structural anti-double-pay guard, not just a pre-check. +/// +/// The UNIQUE is unconditional (not filtered on soft-delete): the link is a +/// permanent record of an irreversible transfer, so even a removed row must never re-open a booking for +/// re-payment. is nullable for a future per-session accrual model; today one link per +/// booking carries the whole booking payout. Money is IRR BIGINT. +/// +/// +public class NursePayoutBookingLink : BaseEntity +{ + public long PayoutId { get; set; } + + /// UNIQUE (unconditional) across every batch — the hard one-payout-per-booking guard. + public long BookingId { get; set; } + + /// Set only when paying a per-session accrual; null for whole-booking payment. + public long? SessionId { get; set; } + + /// The portion of this booking (or session) paid in this payout (IRR). + public long PayoutAmountIrr { get; set; } + + public DateTimeOffset? DeletedAt { get; set; } +} diff --git a/server/src/Core/Baya.Domain/Entities/Payouts/PayoutBatchStatus.cs b/server/src/Core/Baya.Domain/Entities/Payouts/PayoutBatchStatus.cs new file mode 100644 index 0000000..c4f6879 --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/Payouts/PayoutBatchStatus.cs @@ -0,0 +1,26 @@ +namespace Baya.Domain.Entities.Payouts; + +/// +/// The closed nurse_payout_batches.status code set. A batch opens in (materialized +/// but not yet submitted to the bank), moves to when the transfers are submitted, and +/// ends (all paid), (some rejected — retryable), or +/// (the whole submit failed). Persisted as these stable snake_case codes; the allowed edges +/// live in . +/// +public static class PayoutBatchStatus +{ + /// Materialized (payouts + links built) but not yet submitted — the admin preview state. + public const string Draft = "draft"; + + /// The bank submit is in flight / partially applied. + public const string Processing = "processing"; + + /// At least one payout was rejected by the rail; the rest paid. Retry the failed rows. + public const string PartiallyFailed = "partially_failed"; + + /// Every payout in the batch was paid. Terminal. + public const string Completed = "completed"; + + /// The whole submit failed (e.g. the rail was unreachable / a closed day). Terminal for the run. + public const string Failed = "failed"; +} diff --git a/server/src/Core/Baya.Domain/Entities/Payouts/PayoutBatchTransitions.cs b/server/src/Core/Baya.Domain/Entities/Payouts/PayoutBatchTransitions.cs new file mode 100644 index 0000000..e5c4670 --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/Payouts/PayoutBatchTransitions.cs @@ -0,0 +1,25 @@ +namespace Baya.Domain.Entities.Payouts; + +/// +/// The allowed-edge table for the machine. A batch opens in +/// , is submitted (), then settles +/// to a terminal outcome. is re-enterable: a retry that clears the +/// last failed payout flips it to . +/// +public static class PayoutBatchTransitions +{ + private static readonly IReadOnlyDictionary> Allowed = + new Dictionary> + { + [PayoutBatchStatus.Draft] = [PayoutBatchStatus.Processing, PayoutBatchStatus.Failed], + [PayoutBatchStatus.Processing] = + [PayoutBatchStatus.Completed, PayoutBatchStatus.PartiallyFailed, PayoutBatchStatus.Failed], + // A retry can clear the last failed payout and complete the batch. + [PayoutBatchStatus.PartiallyFailed] = [PayoutBatchStatus.Completed, PayoutBatchStatus.PartiallyFailed], + [PayoutBatchStatus.Completed] = [], + [PayoutBatchStatus.Failed] = [] + }; + + public static bool CanTransition(string from, string to) + => Allowed.TryGetValue(from, out var targets) && targets.Contains(to); +} diff --git a/server/src/Core/Baya.Domain/Entities/Payouts/PayoutStatus.cs b/server/src/Core/Baya.Domain/Entities/Payouts/PayoutStatus.cs new file mode 100644 index 0000000..093f1e3 --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/Payouts/PayoutStatus.cs @@ -0,0 +1,24 @@ +namespace Baya.Domain.Entities.Payouts; + +/// +/// The closed nurse_payouts.status code set — a forward-only lifecycle that (with the +/// nurse_payout_booking_links.booking_id UNIQUE and the batch lock) makes a retried execute never +/// double-send an irreversible transfer. A payout is materialized , moves to +/// when the bank accepts the instruction, and to once the transfer +/// track id is confirmed; a rail rejection lands it in , from which a retry re-submits. +/// Persisted as these stable snake_case codes; the allowed edges live in . +/// +public static class PayoutStatus +{ + /// Materialized into the draft batch but not yet submitted to the rail. + public const string Pending = "pending"; + + /// The bank accepted the transfer instruction (a PAYA/SATNA track id was issued). + public const string Submitted = "submitted"; + + /// The transfer is confirmed paid — an irreversible IBAN movement. Terminal on success. + public const string Paid = "paid"; + + /// The rail rejected the transfer; a retry re-submits the same instruction. + public const string Failed = "failed"; +} diff --git a/server/src/Core/Baya.Domain/Entities/Payouts/PayoutStatusTransitions.cs b/server/src/Core/Baya.Domain/Entities/Payouts/PayoutStatusTransitions.cs new file mode 100644 index 0000000..a053eb3 --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/Payouts/PayoutStatusTransitions.cs @@ -0,0 +1,25 @@ +namespace Baya.Domain.Entities.Payouts; + +/// +/// The forward-only allowed-edge table for the machine (mirrors +/// BnplTransitions/RefundTransitions). Every write goes through 's +/// cohesive mark-* methods, which assert the edge here — so a replayed execute that would re-drive an +/// already- row is rejected before it can re-send an irreversible transfer or +/// re-post the ledger. is the only re-enterable state (a retry re-submits). +/// +public static class PayoutStatusTransitions +{ + private static readonly IReadOnlyDictionary> Allowed = + new Dictionary> + { + [PayoutStatus.Pending] = [PayoutStatus.Submitted, PayoutStatus.Failed], + [PayoutStatus.Submitted] = [PayoutStatus.Paid, PayoutStatus.Failed], + // A rejected transfer can be re-submitted. + [PayoutStatus.Failed] = [PayoutStatus.Submitted], + // Terminal on success — no outgoing edge (paid is an irreversible transfer). + [PayoutStatus.Paid] = [] + }; + + public static bool CanTransition(string from, string to) + => Allowed.TryGetValue(from, out var targets) && targets.Contains(to); +} diff --git a/server/src/Core/Baya.Domain/Entities/Refunds/NurseClawback.cs b/server/src/Core/Baya.Domain/Entities/Refunds/NurseClawback.cs index e45a916..8a5c8b5 100644 --- a/server/src/Core/Baya.Domain/Entities/Refunds/NurseClawback.cs +++ b/server/src/Core/Baya.Domain/Entities/Refunds/NurseClawback.cs @@ -39,6 +39,18 @@ public class NurseClawback : BaseEntity public bool IsPending => Status == ClawbackStatus.Pending; + /// Netted out of a payout batch (b13): records the recovering payout + resolution. The balancing + /// DEBIT nurse_payable / CREDIT nurse_clawback_receivable posting is the payout handler's job; this + /// records the workflow outcome. Only a pending clawback can be recovered. + public void Recover(long payoutId, DateTime now) + { + if (Status != ClawbackStatus.Pending) + throw new InvalidOperationException($"Only a pending clawback can be recovered (was {Status})."); + Status = ClawbackStatus.Recovered; + RecoveredInPayoutId = payoutId; + ResolvedAt = now; + } + /// Admin declares the receivable uncollectable. The balancing bad_debt posting is the /// handler's job; this records the workflow outcome. public void WriteOff(string notes, DateTime now) diff --git a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/MockBankTransferProvider.cs b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/MockBankTransferProvider.cs new file mode 100644 index 0000000..f2d5baf --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/MockBankTransferProvider.cs @@ -0,0 +1,49 @@ +#nullable enable +using Baya.Application.Contracts.Payments; +using Microsoft.Extensions.Options; + +namespace Baya.Infrastructure.CrossCutting.Seams; + +/// +/// A deterministic, network-free mock for the PAYA/SATNA payout rail. It +/// moves no money: every instruction gets a deterministic transfer_reference and settles +/// (the mock collapses the real submitted → paid reconciliation +/// into one step). It honours the chosen by the handler (PAYA vs +/// SATNA by the config threshold) and echoes it back. A config switch forces a deterministic failure so the +/// partially_failed/retry paths are testable: fails every +/// instruction (→ whole-batch failure), and fails just that one +/// destination (→ partial failure). A real transferor (Jibit / Vandar / Sadad payout) replaces this registration +/// only — the source settlement account, per-nurse Sheba, and the reconciliation callback are its concern. +/// +public sealed class MockBankTransferProvider(IOptions options) : IBankTransferProvider +{ + private readonly BankTransferOptions _options = options.Value.BankTransfer; + + public ValueTask SubmitPayoutBatchAsync( + long payoutBatchId, + IReadOnlyList instructions, + string idempotencyKey, + CancellationToken cancellationToken = default) + { + var results = new List(instructions.Count); + foreach (var instruction in instructions) + { + var fail = _options.ForceFailure + || (!string.IsNullOrEmpty(_options.FailIban) + && string.Equals(instruction.Iban, _options.FailIban, StringComparison.Ordinal)); + + results.Add(fail + ? new PayoutInstructionResult(instruction.PayoutId, BankTransferStatus.Failed, null, instruction.Method, "provider_declined") + : new PayoutInstructionResult( + instruction.PayoutId, BankTransferStatus.Paid, + TransferReference: $"mock-payout-{payoutBatchId}-{instruction.PayoutId}-{idempotencyKey}", + instruction.Method, FailureReason: null)); + } + + return ValueTask.FromResult(new PayoutBatchSubmitResult( + ExternalBatchRef: $"mock-batch-{payoutBatchId}-{idempotencyKey}", results)); + } + + public ValueTask GetPayoutStatusAsync(string externalBatchRef, CancellationToken cancellationToken = default) + => ValueTask.FromResult(_options.ForceFailure ? BankTransferStatus.Failed : BankTransferStatus.Paid); +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/SeamOptions.cs b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/SeamOptions.cs index d512478..c2e3811 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/SeamOptions.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/SeamOptions.cs @@ -19,6 +19,24 @@ public sealed class SeamOptions public MoadianOptions Moadian { get; set; } = new(); public BnplOptions Bnpl { get; set; } = new(); public CurrencyOptions Currency { get; set; } = new(); + public BankTransferOptions BankTransfer { get; set; } = new(); +} + +/// +/// Tunes the mock IBankTransferProvider (b13 PAYA/SATNA payouts). By default every instruction settles +/// paid with a deterministic transfer reference and no money moves. Set to fail the +/// whole batch (→ failed) or to fail just one destination (→ partially_failed, +/// so the retry path is testable). The real transferor ignores these — the source settlement account, per-nurse +/// Sheba, and the reconciliation callback come from provider config. +/// +public sealed class BankTransferOptions +{ + /// When true, every payout instruction is rejected so the whole-batch-failure path is testable. + public bool ForceFailure { get; set; } + + /// A designated IBAN that is rejected while others succeed — exercises the partially_failed + /// batch outcome and the single-payout retry. + public string FailIban { get; set; } = string.Empty; } /// diff --git a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/ServiceConfiguration/ServiceCollectionExtension.cs b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/ServiceConfiguration/ServiceCollectionExtension.cs index 4522bc3..2b46ca3 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/ServiceConfiguration/ServiceCollectionExtension.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/ServiceConfiguration/ServiceCollectionExtension.cs @@ -73,6 +73,13 @@ public static class ServiceCollectionExtension services.AddSingleton(); services.AddSingleton(); + // Payout bank rail (backend-phase-13). The deterministic MockBankTransferProvider settles every PAYA/SATNA + // instruction paid with no money movement; a config switch forces whole-batch/single-row failures so the + // partially_failed + retry paths are testable. A real transferor (Jibit/Vandar/Sadad payout) with a + // registered source settlement account + reconciliation callback swaps in by a registration change only — + // the payout status machine + the nurse_payout_booking_links UNIQUE remain the irreversible-transfer backstop. + services.AddSingleton(); + return services; } } diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/ApplicationDbContext.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/ApplicationDbContext.cs index 69110fb..abf0ebb 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Persistence/ApplicationDbContext.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/ApplicationDbContext.cs @@ -165,5 +165,12 @@ public class ApplicationDbContext: IdentityDbContext g.ConfigJson).HasConversion(encrypted); }); + + // b13 payout snapshot: the nurse's IBAN is frozen onto each payout at build time and encrypted at rest + // through the same seam. Reads mask it to the last 4 digits — the plaintext IBAN is never serialized. + modelBuilder.Entity(builder => + { + builder.Property(p => p.IbanSnapshot).HasConversion(encrypted); + }); } } \ No newline at end of file diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/ConfigurationConfig/PlatformConfigConfig.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/ConfigurationConfig/PlatformConfigConfig.cs index a0f061b..80b3c33 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/ConfigurationConfig/PlatformConfigConfig.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/ConfigurationConfig/PlatformConfigConfig.cs @@ -49,6 +49,8 @@ internal sealed class PlatformConfigConfig : IEntityTypeConfiguration +/// nurse_payout_batches — the weekly aggregation, in the dedicated payouts schema. The +/// total_amount = Σ payouts / payout_count = COUNT(payouts) invariants are enforced by the handler +/// when the rows are materialized (a cross-row aggregate can't be a single-row DB CHECK); the periods are +/// holiday-shifted before insert. 1:N → nurse_payouts. +/// +internal sealed class NursePayoutBatchConfig : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("NursePayoutBatches", "payouts"); + + builder.Property(b => b.Status).HasMaxLength(30).IsRequired(); + builder.Property(b => b.FailureNotes).HasMaxLength(1000); + + builder.HasIndex(b => b.Status); + builder.HasIndex(b => b.ProcessingDate); + + builder.HasMany(b => b.Payouts).WithOne(p => p.Batch).HasForeignKey(p => p.BatchId).IsRequired(); + + builder.HasOne().WithMany().HasForeignKey(b => b.InitiatedByAdminId).IsRequired(); + + builder.HasQueryFilter(b => b.DeletedAt == null); + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/PayoutsConfig/NursePayoutBookingLinkConfig.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/PayoutsConfig/NursePayoutBookingLinkConfig.cs new file mode 100644 index 0000000..d3ce416 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/PayoutsConfig/NursePayoutBookingLinkConfig.cs @@ -0,0 +1,31 @@ +using Baya.Domain.Entities.Booking; +using Baya.Domain.Entities.Payouts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Baya.Infrastructure.Persistence.Configuration.PayoutsConfig; + +/// +/// nurse_payout_booking_links — the structural anti-double-pay guard. UNIQUE(booking_id) is +/// unconditional (no soft-delete filter) so a booking can be paid in exactly one payout across all batches, +/// ever — a duplicate insert is the already-paid signal the build handler catches. N:1 → nurse_payouts; +/// 1:1 → bookings (and, for a future per-session model, booking_sessions). +/// +internal sealed class NursePayoutBookingLinkConfig : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("NursePayoutBookingLinks", "payouts"); + + // The hard guard: one payout per booking, forever. Unconditional (not filtered on DeletedAt) so a removed + // link can never re-open a booking for a second, irreversible transfer. + builder.HasIndex(l => l.BookingId).IsUnique(); + builder.HasIndex(l => l.PayoutId); + + builder.HasOne().WithMany(p => p.BookingLinks).HasForeignKey(l => l.PayoutId).IsRequired(); + builder.HasOne().WithMany().HasForeignKey(l => l.BookingId).IsRequired(); + builder.HasOne().WithMany().HasForeignKey(l => l.SessionId).IsRequired(false); + + builder.HasQueryFilter(l => l.DeletedAt == null); + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/PayoutsConfig/NursePayoutConfig.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/PayoutsConfig/NursePayoutConfig.cs new file mode 100644 index 0000000..69d54b1 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/PayoutsConfig/NursePayoutConfig.cs @@ -0,0 +1,40 @@ +using Baya.Domain.Entities.Identity; +using Baya.Domain.Entities.Payouts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Baya.Infrastructure.Persistence.Configuration.PayoutsConfig; + +/// +/// nurse_payouts — one row per nurse per batch. The net = gross − clawback decomposition + all +/// amounts non-negative + net ≥ 0 (never a negative transfer) is a DB CHECK mirroring b9/b11. +/// iban_snapshot is encrypted at rest (converter wired in ApplicationDbContext) and frozen at build +/// time from the nurse's verified primary account. Paid-ness is derived from a link row + the ledger — there is +/// no boolean flag. N:1 → batch / nurse_profiles / nurse_bank_accounts; 1:N → links. +/// +internal sealed class NursePayoutConfig : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("NursePayouts", "payouts", t => t.HasCheckConstraint( + "CK_NursePayouts_NetSplit", + "[NetAmountIrr] = [GrossEarningsIrr] - [ClawbackAppliedIrr] " + + "AND [GrossEarningsIrr] >= 0 AND [ClawbackAppliedIrr] >= 0 AND [NetAmountIrr] >= 0 AND [Amount] >= 0")); + + builder.Property(p => p.IbanSnapshot).IsRequired(); + builder.Property(p => p.Status).HasMaxLength(20).IsRequired(); + builder.Property(p => p.TransferReference).HasMaxLength(200); + builder.Property(p => p.FailureReason).HasMaxLength(500); + + builder.HasIndex(p => p.BatchId); + builder.HasIndex(p => p.NurseId); + builder.HasIndex(p => p.Status); + + builder.HasMany(p => p.BookingLinks).WithOne().HasForeignKey(l => l.PayoutId).IsRequired(); + + builder.HasOne().WithMany().HasForeignKey(p => p.NurseId).IsRequired(); + builder.HasOne().WithMany().HasForeignKey(p => p.BankAccountId).IsRequired(); + + builder.HasQueryFilter(p => p.DeletedAt == null); + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260709000908_NursePayoutEngine.Designer.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260709000908_NursePayoutEngine.Designer.cs new file mode 100644 index 0000000..06b16d9 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260709000908_NursePayoutEngine.Designer.cs @@ -0,0 +1,5260 @@ +// +using System; +using Baya.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Baya.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260709000908_NursePayoutEngine")] + partial class NursePayoutEngine + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Baya.Domain.Entities.Analytics.SystemEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("OccurredAt") + .HasColumnType("datetimeoffset"); + + b.Property("PropsJson") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.HasIndex("OccurredAt"); + + b.HasIndex("UserId"); + + b.ToTable("SystemEvents", "ops"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Audit.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("ActorUserId") + .HasColumnType("int"); + + b.Property("ChangedFieldsJson") + .HasColumnType("nvarchar(max)"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("OccurredAt") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("ActorUserId"); + + b.HasIndex("OccurredAt"); + + b.HasIndex("EntityType", "EntityId"); + + b.ToTable("AuditLogs", "ops"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Bnpl.BnplTransaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BnplCommissionIrr") + .HasColumnType("bigint"); + + b.Property("CallbackPayloadJson") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("Currency") + .IsRequired() + .HasMaxLength(5) + .HasColumnType("nvarchar(5)"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("EligibilityStatus") + .HasMaxLength(30) + .HasColumnType("nvarchar(30)"); + + b.Property("ExternalPaymentToken") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ExternalTransactionId") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("InstallmentCount") + .HasColumnType("tinyint"); + + b.Property("MerchantOfRecord") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("OrderAmountIrr") + .HasColumnType("bigint"); + + b.Property("PaymentTransactionId") + .HasColumnType("bigint"); + + b.Property("ProviderCode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ProviderCommissionReversedAmount") + .HasColumnType("bigint"); + + b.Property("RefundChannel") + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("RevertTransactionId") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RevertedAmountIrr") + .HasColumnType("bigint"); + + b.Property("RevertedAt") + .HasColumnType("datetime2"); + + b.Property("SettledAmountIrr") + .HasColumnType("bigint"); + + b.Property("SettledAt") + .HasColumnType("datetime2"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("nvarchar(30)"); + + b.HasKey("Id"); + + b.HasIndex("ExternalPaymentToken") + .HasFilter("[ExternalPaymentToken] IS NOT NULL"); + + b.HasIndex("PaymentTransactionId") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("BnplTransactions", "payments", t => + { + t.HasCheckConstraint("CK_BnplTransactions_SettleSplit", "([SettledAmountIrr] IS NULL AND [BnplCommissionIrr] IS NULL) OR ([SettledAmountIrr] = [OrderAmountIrr] - [BnplCommissionIrr] AND [SettledAmountIrr] >= 0 AND [BnplCommissionIrr] >= 0)"); + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Booking.Booking", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AddressSnapshotJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("BalinyaarCommissionIrr") + .HasColumnType("bigint"); + + b.Property("BookingRequestId") + .HasColumnType("bigint"); + + b.Property("CancellationPolicyCode") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("CancellationReason") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("CancellationRefundPercentage") + .HasPrecision(5, 2) + .HasColumnType("decimal(5,2)"); + + b.Property("CancelledAt") + .HasColumnType("datetime2"); + + b.Property("CancelledBy") + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("CompletedAt") + .HasColumnType("datetime2"); + + b.Property("ConfirmedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("CustomerAddressId") + .HasColumnType("bigint"); + + b.Property("CustomerId") + .HasColumnType("bigint"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DisputeWindowEndsAt") + .HasColumnType("datetime2"); + + b.Property("GrossPriceIrr") + .HasColumnType("bigint"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseId") + .HasColumnType("bigint"); + + b.Property("NursePayoutAmount") + .HasColumnType("bigint"); + + b.Property("PartnerCenterId") + .HasColumnType("bigint"); + + b.Property("PatientId") + .HasColumnType("bigint"); + + b.Property("PlatformFeeRate") + .HasPrecision(5, 4) + .HasColumnType("decimal(5,4)"); + + b.Property("PspFeeAmount") + .HasColumnType("bigint"); + + b.Property("RefundableAmountIrr") + .HasColumnType("bigint"); + + b.Property("ScheduledDate") + .HasColumnType("date"); + + b.Property("ScheduledTimeEnd") + .HasColumnType("time"); + + b.Property("ScheduledTimeStart") + .HasColumnType("time"); + + b.Property("SessionCount") + .HasColumnType("smallint"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("nvarchar(30)"); + + b.Property("VariantId") + .HasColumnType("bigint"); + + b.Property("VariantSnapshotJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("BookingRequestId") + .IsUnique(); + + b.HasIndex("CustomerAddressId"); + + b.HasIndex("DisputeWindowEndsAt"); + + b.HasIndex("PatientId"); + + b.HasIndex("VariantId"); + + b.HasIndex("CustomerId", "Status"); + + b.HasIndex("NurseId", "Status"); + + b.ToTable("Bookings", "booking", t => + { + t.HasCheckConstraint("CK_Bookings_AmountSplit", "[GrossPriceIrr] = [BalinyaarCommissionIrr] + [NursePayoutAmount] AND [GrossPriceIrr] >= 0 AND [BalinyaarCommissionIrr] >= 0 AND [NursePayoutAmount] >= 0"); + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Booking.BookingCareInstruction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Allergies") + .HasColumnType("nvarchar(max)"); + + b.Property("BookingId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("CurrentConditions") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("EmergencyContactName") + .HasColumnType("nvarchar(max)"); + + b.Property("EmergencyContactPhone") + .HasColumnType("nvarchar(max)"); + + b.Property("Medications") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("SpecialInstructions") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("BookingId") + .IsUnique(); + + b.ToTable("BookingCareInstructions", "booking"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Booking.BookingRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("CustomerAddressId") + .HasColumnType("bigint"); + + b.Property("CustomerId") + .HasColumnType("bigint"); + + b.Property("CustomerNotes") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseId") + .HasColumnType("bigint"); + + b.Property("NurseRejectionReason") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("NurseResponseDeadlineAt") + .HasColumnType("datetime2"); + + b.Property("PatientId") + .HasColumnType("bigint"); + + b.Property("PaymentDeadlineAt") + .HasColumnType("datetime2"); + + b.Property("RequestedDate") + .HasColumnType("date"); + + b.Property("RequestedTimeEnd") + .HasColumnType("time"); + + b.Property("RequestedTimeStart") + .HasColumnType("time"); + + b.Property("RequiredCaregiverGender") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("VariantId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("CustomerAddressId"); + + b.HasIndex("PatientId"); + + b.HasIndex("VariantId"); + + b.HasIndex("CustomerId", "Status"); + + b.HasIndex("NurseId", "Status"); + + b.HasIndex("Status", "NurseResponseDeadlineAt"); + + b.HasIndex("Status", "PaymentDeadlineAt"); + + b.ToTable("BookingRequests", "booking"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Booking.BookingSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BookingId") + .HasColumnType("bigint"); + + b.Property("CancellationEventId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("PayoutEligibleAt") + .HasColumnType("datetime2"); + + b.Property("ScheduledDate") + .HasColumnType("date"); + + b.Property("ScheduledTimeEnd") + .HasColumnType("time"); + + b.Property("ScheduledTimeStart") + .HasColumnType("time"); + + b.Property("SessionIndex") + .HasColumnType("int"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("VisitPayoutAmount") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("BookingId", "SessionIndex"); + + b.HasIndex("Status", "ScheduledDate"); + + b.ToTable("BookingSessions", "booking"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Booking.CancellationPolicy", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AppliesTo") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("FeeAmountIrr") + .HasColumnType("bigint"); + + b.Property("FeeRate") + .HasPrecision(5, 4) + .HasColumnType("decimal(5,4)"); + + b.Property("HoursBeforeStartMax") + .HasColumnType("int"); + + b.Property("HoursBeforeStartMin") + .HasColumnType("int"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("RefundPercentage") + .HasPrecision(5, 2) + .HasColumnType("decimal(5,2)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("AppliesTo", "IsActive"); + + b.ToTable("CancellationPolicies", "booking"); + + b.HasData( + new + { + Id = 1L, + AppliesTo = "customer", + Code = "standard_24h", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + FeeAmountIrr = 0L, + HoursBeforeStartMin = 24, + IsActive = true, + RefundPercentage = 100m + }, + new + { + Id = 2L, + AppliesTo = "customer", + Code = "standard_inside_24h", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + FeeAmountIrr = 0L, + HoursBeforeStartMax = 24, + IsActive = true, + RefundPercentage = 50m + }, + new + { + Id = 3L, + AppliesTo = "nurse", + Code = "nurse_no_show", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + FeeAmountIrr = 0L, + FeeRate = 0m, + IsActive = true, + RefundPercentage = 100m + }, + new + { + Id = 4L, + AppliesTo = "admin", + Code = "admin_cancellation", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + FeeAmountIrr = 0L, + IsActive = true, + RefundPercentage = 100m + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Booking.VisitVerification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BookingSessionId") + .HasColumnType("bigint"); + + b.Property("CheckInAddressMatch") + .HasColumnType("bit"); + + b.Property("CheckInAt") + .HasColumnType("datetime2"); + + b.Property("CheckInDistanceMeters") + .HasPrecision(10, 2) + .HasColumnType("decimal(10,2)"); + + b.Property("CheckInLat") + .HasPrecision(9, 6) + .HasColumnType("decimal(9,6)"); + + b.Property("CheckInLng") + .HasPrecision(9, 6) + .HasColumnType("decimal(9,6)"); + + b.Property("CheckOutAt") + .HasColumnType("datetime2"); + + b.Property("CheckOutLat") + .HasPrecision(9, 6) + .HasColumnType("decimal(9,6)"); + + b.Property("CheckOutLng") + .HasPrecision(9, 6) + .HasColumnType("decimal(9,6)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("BookingSessionId") + .IsUnique(); + + b.HasIndex("CheckInAddressMatch"); + + b.ToTable("VisitVerifications", "booking"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.NurseServiceVariant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseId") + .HasColumnType("bigint"); + + b.Property("OptionSetHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Price") + .HasColumnType("bigint"); + + b.Property("PriceUnit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("ServiceCategoryId") + .HasColumnType("bigint"); + + b.Property("SessionCount") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ServiceCategoryId"); + + b.HasIndex("NurseId", "IsActive"); + + b.HasIndex("NurseId", "ServiceCategoryId", "OptionSetHash") + .IsUnique() + .HasDatabaseName("UX_NurseServiceVariants_Nurse_Category_OptionSet") + .HasFilter("[DeletedAt] IS NULL"); + + b.ToTable("NurseServiceVariants", "catalog"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.NurseServiceVariantOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("OptionGroupId") + .HasColumnType("bigint"); + + b.Property("OptionValueId") + .HasColumnType("bigint"); + + b.Property("VariantId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("OptionGroupId"); + + b.HasIndex("OptionValueId"); + + b.HasIndex("VariantId", "OptionGroupId") + .IsUnique() + .HasDatabaseName("UX_NurseServiceVariantOptions_Variant_Group"); + + b.ToTable("NurseServiceVariantOptions", "catalog"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DescriptionEn") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("DescriptionFa") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("IconKey") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("NameFa") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("IsActive", "SortOrder"); + + b.ToTable("ServiceCategories", "catalog"); + + b.HasData( + new + { + Id = 1L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Elderly Care", + NameFa = "مراقبت از سالمند", + SortOrder = 1 + }, + new + { + Id = 2L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Post-Surgery Recovery", + NameFa = "مراقبت پس از جراحی", + SortOrder = 2 + }, + new + { + Id = 3L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Infant Care", + NameFa = "مراقبت از نوزاد", + SortOrder = 3 + }, + new + { + Id = 4L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Chronic Illness Management", + NameFa = "مدیریت بیماری مزمن", + SortOrder = 4 + }, + new + { + Id = 5L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Companionship", + NameFa = "همراهی و مراقبت روزمره", + SortOrder = 5 + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceOptionGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("IsRequired") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("NameFa") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("ServiceCategoryId") + .HasColumnType("bigint"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("ServiceCategoryId", "SortOrder"); + + b.ToTable("ServiceOptionGroups", "catalog"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceOptionValue", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("NameFa") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("OptionGroupId") + .HasColumnType("bigint"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("OptionGroupId", "SortOrder"); + + b.ToTable("ServiceOptionValues", "catalog"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Configuration.PlatformConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DataType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("Value") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("PlatformConfigs", "ops"); + + b.HasData( + new + { + Id = 1L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "decimal", + Description = "Balinyaar commission rate on the booking gross (fraction).", + Key = "platform_fee_rate", + Value = "0.15" + }, + new + { + Id = 2L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "decimal", + Description = "VAT rate applied to the commission line only (fraction).", + Key = "vat_rate", + Value = "0.10" + }, + new + { + Id = 3L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Hours after check-out a booking can be disputed.", + Key = "dispute_window_hours", + Value = "72" + }, + new + { + Id = 4L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Minutes a family has to pay before a pending booking expires.", + Key = "booking_payment_deadline_minutes", + Value = "30" + }, + new + { + Id = 5L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Hours a nurse has to accept/decline a booking request.", + Key = "nurse_response_deadline_hours", + Value = "24" + }, + new + { + Id = 6L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Weekly payout cadence in days.", + Key = "nurse_payout_interval_days", + Value = "7" + }, + new + { + Id = 7L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Allowed EVV check-in distance from the care address.", + Key = "evv_location_tolerance_meters", + Value = "200" + }, + new + { + Id = 8L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "decimal", + Description = "A review at or below this rating raises a support alert.", + Key = "min_rating_for_support_alert", + Value = "2" + }, + new + { + Id = 9L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "string", + Description = "Who is merchant of record for BNPL orders (platform|nurse).", + Key = "bnpl_merchant_of_record", + Value = "platform" + }, + new + { + Id = 10L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "decimal", + Description = "BNPL provider commission rate (fraction).", + Key = "bnpl_provider_commission_rate", + Value = "0.07" + }, + new + { + Id = 11L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "string", + Description = "When BNPL settles funds to the platform (immediate|deferred).", + Key = "bnpl_settlement_timing", + Value = "immediate" + }, + new + { + Id = 12L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "json", + Description = "Tiered cancellation refund policy: refund_percent by hours before the visit.", + Key = "cancellation_tiers", + Value = "[{\"min_hours_before\":48,\"refund_percent\":100},{\"min_hours_before\":24,\"refund_percent\":50},{\"min_hours_before\":0,\"refund_percent\":0}]" + }, + new + { + Id = 13L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Seconds a phone must wait before another OTP can be requested.", + Key = "auth_otp_resend_seconds", + Value = "120" + }, + new + { + Id = 14L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Wrong-code attempts allowed before OTP verification is refused until a fresh code.", + Key = "auth_otp_max_attempts", + Value = "5" + }, + new + { + Id = 15L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Refresh-token session lifetime in days.", + Key = "auth_session_ttl_days", + Value = "30" + }, + new + { + Id = 16L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Hours between credential-expiry scans (the scheduled cron is deferred; the scan is admin-triggered today).", + Key = "verification_expiry_scan_cadence_hours", + Value = "24" + }, + new + { + Id = 17L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Minutes after a session's scheduled start with no EVV check-in before it is flagged a no-show.", + Key = "no_show_threshold_minutes", + Value = "60" + }, + new + { + Id = 18L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Hours between no-show sweeps (the scheduled cron is deferred; the sweep is admin-triggered today).", + Key = "no_show_scan_cadence_hours", + Value = "1" + }, + new + { + Id = 19L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "bool", + Description = "Whether a refund must link a support ticket (b11). Off until b15 ships the tickets table.", + Key = "refund_ticket_required", + Value = "false" + }, + new + { + Id = 20L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Business days shown as the customer BNPL refund ETA (b11).", + Key = "bnpl_refund_eta_business_days", + Value = "10" + }, + new + { + Id = 21L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "bool", + Description = "Ops/testing override that forces the post-payout clawback path for refunds (b11); b13 replaces the derivation.", + Key = "refund_assume_nurse_paid", + Value = "false" + }, + new + { + Id = 22L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "decimal", + Description = "IRR net-amount threshold above which a payout is routed via SATNA (real-time) instead of PAYA (batch) (b13).", + Key = "payout_satna_threshold_irr", + Value = "1000000000" + }, + new + { + Id = 23L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "bool", + Description = "When on, a BNPL-paid booking is payout-eligible only after its provider settlement is received (b13; default off — the DEFERRED settled_at guard).", + Key = "require_bnpl_settlement_for_payout", + Value = "false" + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.City", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("NameFa") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("ProvinceId") + .HasColumnType("bigint"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("ProvinceId", "SortOrder"); + + b.ToTable("Cities", "geo"); + + b.HasData( + new + { + Id = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Tehran", + NameFa = "تهران", + ProvinceId = 1L, + SortOrder = 1 + }, + new + { + Id = 102L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Karaj", + NameFa = "کرج", + ProvinceId = 2L, + SortOrder = 1 + }, + new + { + Id = 103L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Isfahan", + NameFa = "اصفهان", + ProvinceId = 3L, + SortOrder = 1 + }, + new + { + Id = 104L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Shiraz", + NameFa = "شیراز", + ProvinceId = 4L, + SortOrder = 1 + }, + new + { + Id = 105L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Mashhad", + NameFa = "مشهد", + ProvinceId = 5L, + SortOrder = 1 + }, + new + { + Id = 106L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Tabriz", + NameFa = "تبریز", + ProvinceId = 6L, + SortOrder = 1 + }, + new + { + Id = 107L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Urmia", + NameFa = "ارومیه", + ProvinceId = 7L, + SortOrder = 1 + }, + new + { + Id = 108L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Ahvaz", + NameFa = "اهواز", + ProvinceId = 8L, + SortOrder = 1 + }, + new + { + Id = 109L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Qom", + NameFa = "قم", + ProvinceId = 9L, + SortOrder = 1 + }, + new + { + Id = 110L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Kerman", + NameFa = "کرمان", + ProvinceId = 10L, + SortOrder = 1 + }, + new + { + Id = 111L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Rasht", + NameFa = "رشت", + ProvinceId = 11L, + SortOrder = 1 + }, + new + { + Id = 112L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Sari", + NameFa = "ساری", + ProvinceId = 12L, + SortOrder = 1 + }, + new + { + Id = 113L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Arak", + NameFa = "اراک", + ProvinceId = 13L, + SortOrder = 1 + }, + new + { + Id = 114L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Ardabil", + NameFa = "اردبیل", + ProvinceId = 14L, + SortOrder = 1 + }, + new + { + Id = 115L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Qazvin", + NameFa = "قزوین", + ProvinceId = 15L, + SortOrder = 1 + }, + new + { + Id = 116L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Kermanshah", + NameFa = "کرمانشاه", + ProvinceId = 16L, + SortOrder = 1 + }, + new + { + Id = 117L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Bojnord", + NameFa = "بجنورد", + ProvinceId = 17L, + SortOrder = 1 + }, + new + { + Id = 118L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Birjand", + NameFa = "بیرجند", + ProvinceId = 18L, + SortOrder = 1 + }, + new + { + Id = 119L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Hamadan", + NameFa = "همدان", + ProvinceId = 19L, + SortOrder = 1 + }, + new + { + Id = 120L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Sanandaj", + NameFa = "سنندج", + ProvinceId = 20L, + SortOrder = 1 + }, + new + { + Id = 121L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Khorramabad", + NameFa = "خرم‌آباد", + ProvinceId = 21L, + SortOrder = 1 + }, + new + { + Id = 122L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Gorgan", + NameFa = "گرگان", + ProvinceId = 22L, + SortOrder = 1 + }, + new + { + Id = 123L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Bandar Abbas", + NameFa = "بندرعباس", + ProvinceId = 23L, + SortOrder = 1 + }, + new + { + Id = 124L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Bushehr", + NameFa = "بوشهر", + ProvinceId = 24L, + SortOrder = 1 + }, + new + { + Id = 125L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Zanjan", + NameFa = "زنجان", + ProvinceId = 25L, + SortOrder = 1 + }, + new + { + Id = 126L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Semnan", + NameFa = "سمنان", + ProvinceId = 26L, + SortOrder = 1 + }, + new + { + Id = 127L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Yazd", + NameFa = "یزد", + ProvinceId = 27L, + SortOrder = 1 + }, + new + { + Id = 128L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Zahedan", + NameFa = "زاهدان", + ProvinceId = 28L, + SortOrder = 1 + }, + new + { + Id = 129L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Shahrekord", + NameFa = "شهرکرد", + ProvinceId = 29L, + SortOrder = 1 + }, + new + { + Id = 130L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Yasuj", + NameFa = "یاسوج", + ProvinceId = 30L, + SortOrder = 1 + }, + new + { + Id = 131L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Ilam", + NameFa = "ایلام", + ProvinceId = 31L, + SortOrder = 1 + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.District", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CityId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("NameFa") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("CityId", "SortOrder"); + + b.ToTable("Districts", "geo"); + + b.HasData( + new + { + Id = 1001L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 1", + NameFa = "منطقه ۱", + SortOrder = 1 + }, + new + { + Id = 1002L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 2", + NameFa = "منطقه ۲", + SortOrder = 2 + }, + new + { + Id = 1003L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 3", + NameFa = "منطقه ۳", + SortOrder = 3 + }, + new + { + Id = 1004L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 4", + NameFa = "منطقه ۴", + SortOrder = 4 + }, + new + { + Id = 1005L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 5", + NameFa = "منطقه ۵", + SortOrder = 5 + }, + new + { + Id = 1006L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 6", + NameFa = "منطقه ۶", + SortOrder = 6 + }, + new + { + Id = 1007L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 7", + NameFa = "منطقه ۷", + SortOrder = 7 + }, + new + { + Id = 1008L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 8", + NameFa = "منطقه ۸", + SortOrder = 8 + }, + new + { + Id = 1009L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 9", + NameFa = "منطقه ۹", + SortOrder = 9 + }, + new + { + Id = 1010L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 10", + NameFa = "منطقه ۱۰", + SortOrder = 10 + }, + new + { + Id = 1011L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 11", + NameFa = "منطقه ۱۱", + SortOrder = 11 + }, + new + { + Id = 1012L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 12", + NameFa = "منطقه ۱۲", + SortOrder = 12 + }, + new + { + Id = 1013L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 13", + NameFa = "منطقه ۱۳", + SortOrder = 13 + }, + new + { + Id = 1014L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 14", + NameFa = "منطقه ۱۴", + SortOrder = 14 + }, + new + { + Id = 1015L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 15", + NameFa = "منطقه ۱۵", + SortOrder = 15 + }, + new + { + Id = 1016L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 16", + NameFa = "منطقه ۱۶", + SortOrder = 16 + }, + new + { + Id = 1017L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 17", + NameFa = "منطقه ۱۷", + SortOrder = 17 + }, + new + { + Id = 1018L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 18", + NameFa = "منطقه ۱۸", + SortOrder = 18 + }, + new + { + Id = 1019L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 19", + NameFa = "منطقه ۱۹", + SortOrder = 19 + }, + new + { + Id = 1020L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 20", + NameFa = "منطقه ۲۰", + SortOrder = 20 + }, + new + { + Id = 1021L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 21", + NameFa = "منطقه ۲۱", + SortOrder = 21 + }, + new + { + Id = 1022L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 22", + NameFa = "منطقه ۲۲", + SortOrder = 22 + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.NurseServiceArea", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CityId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DistrictId") + .HasColumnType("bigint"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("CityId"); + + b.HasIndex("DistrictId"); + + b.HasIndex("NurseId", "CityId") + .IsUnique() + .HasDatabaseName("UX_NurseServiceAreas_Nurse_City_WholeCity") + .HasFilter("[DistrictId] IS NULL AND [DeletedAt] IS NULL"); + + b.HasIndex("NurseId", "CityId", "DistrictId") + .IsUnique() + .HasDatabaseName("UX_NurseServiceAreas_Nurse_City_District") + .HasFilter("[DistrictId] IS NOT NULL AND [DeletedAt] IS NULL"); + + b.ToTable("NurseServiceAreas", "geo"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.Province", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("NameFa") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("SortOrder"); + + b.ToTable("Provinces", "geo"); + + b.HasData( + new + { + Id = 1L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Tehran", + NameFa = "تهران", + SortOrder = 1 + }, + new + { + Id = 2L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Alborz", + NameFa = "البرز", + SortOrder = 2 + }, + new + { + Id = 3L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Isfahan", + NameFa = "اصفهان", + SortOrder = 3 + }, + new + { + Id = 4L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Fars", + NameFa = "فارس", + SortOrder = 4 + }, + new + { + Id = 5L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Razavi Khorasan", + NameFa = "خراسان رضوی", + SortOrder = 5 + }, + new + { + Id = 6L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "East Azerbaijan", + NameFa = "آذربایجان شرقی", + SortOrder = 6 + }, + new + { + Id = 7L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "West Azerbaijan", + NameFa = "آذربایجان غربی", + SortOrder = 7 + }, + new + { + Id = 8L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Khuzestan", + NameFa = "خوزستان", + SortOrder = 8 + }, + new + { + Id = 9L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Qom", + NameFa = "قم", + SortOrder = 9 + }, + new + { + Id = 10L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Kerman", + NameFa = "کرمان", + SortOrder = 10 + }, + new + { + Id = 11L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Gilan", + NameFa = "گیلان", + SortOrder = 11 + }, + new + { + Id = 12L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Mazandaran", + NameFa = "مازندران", + SortOrder = 12 + }, + new + { + Id = 13L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Markazi", + NameFa = "مرکزی", + SortOrder = 13 + }, + new + { + Id = 14L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Ardabil", + NameFa = "اردبیل", + SortOrder = 14 + }, + new + { + Id = 15L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Qazvin", + NameFa = "قزوین", + SortOrder = 15 + }, + new + { + Id = 16L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Kermanshah", + NameFa = "کرمانشاه", + SortOrder = 16 + }, + new + { + Id = 17L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "North Khorasan", + NameFa = "خراسان شمالی", + SortOrder = 17 + }, + new + { + Id = 18L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "South Khorasan", + NameFa = "خراسان جنوبی", + SortOrder = 18 + }, + new + { + Id = 19L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Hamadan", + NameFa = "همدان", + SortOrder = 19 + }, + new + { + Id = 20L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Kurdistan", + NameFa = "کردستان", + SortOrder = 20 + }, + new + { + Id = 21L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Lorestan", + NameFa = "لرستان", + SortOrder = 21 + }, + new + { + Id = 22L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Golestan", + NameFa = "گلستان", + SortOrder = 22 + }, + new + { + Id = 23L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Hormozgan", + NameFa = "هرمزگان", + SortOrder = 23 + }, + new + { + Id = 24L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Bushehr", + NameFa = "بوشهر", + SortOrder = 24 + }, + new + { + Id = 25L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Zanjan", + NameFa = "زنجان", + SortOrder = 25 + }, + new + { + Id = 26L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Semnan", + NameFa = "سمنان", + SortOrder = 26 + }, + new + { + Id = 27L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Yazd", + NameFa = "یزد", + SortOrder = 27 + }, + new + { + Id = 28L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Sistan and Baluchestan", + NameFa = "سیستان و بلوچستان", + SortOrder = 28 + }, + new + { + Id = 29L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Chaharmahal and Bakhtiari", + NameFa = "چهارمحال و بختیاری", + SortOrder = 29 + }, + new + { + Id = 30L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Kohgiluyeh and Boyer-Ahmad", + NameFa = "کهگیلویه و بویراحمد", + SortOrder = 30 + }, + new + { + Id = 31L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Ilam", + NameFa = "ایلام", + SortOrder = 31 + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Holidays.IranianHoliday", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("HolidayDate") + .HasColumnType("date"); + + b.Property("IsBankClosed") + .HasColumnType("bit"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NameFa") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("HolidayDate") + .IsUnique(); + + b.ToTable("IranianHolidays", "ops"); + + b.HasData( + new + { + Id = 1L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HolidayDate = new DateOnly(2026, 2, 11), + IsBankClosed = true, + NameFa = "پیروزی انقلاب اسلامی", + Type = "national" + }, + new + { + Id = 2L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HolidayDate = new DateOnly(2026, 3, 21), + IsBankClosed = true, + NameFa = "نوروز", + Type = "national" + }, + new + { + Id = 3L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HolidayDate = new DateOnly(2026, 3, 22), + IsBankClosed = true, + NameFa = "نوروز", + Type = "national" + }, + new + { + Id = 4L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HolidayDate = new DateOnly(2026, 3, 23), + IsBankClosed = true, + NameFa = "نوروز", + Type = "national" + }, + new + { + Id = 5L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HolidayDate = new DateOnly(2026, 3, 24), + IsBankClosed = true, + NameFa = "نوروز", + Type = "national" + }, + new + { + Id = 6L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HolidayDate = new DateOnly(2026, 4, 1), + IsBankClosed = true, + NameFa = "روز طبیعت (سیزده‌به‌در)", + Type = "official" + }, + new + { + Id = 7L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HolidayDate = new DateOnly(2026, 6, 26), + IsBankClosed = true, + NameFa = "عید سعید قربان", + Type = "religious" + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.CustomerAddress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AddressLine") + .HasColumnType("nvarchar(max)"); + + b.Property("CityId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("CustomerId") + .HasColumnType("bigint"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DistrictId") + .HasColumnType("bigint"); + + b.Property("IsPrimary") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("Latitude") + .HasPrecision(9, 6) + .HasColumnType("decimal(9,6)"); + + b.Property("Longitude") + .HasPrecision(9, 6) + .HasColumnType("decimal(9,6)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("PostalCode") + .HasColumnType("nvarchar(max)"); + + b.Property("RecipientName") + .HasColumnType("nvarchar(max)"); + + b.Property("RecipientPhone") + .HasColumnType("nvarchar(max)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("CityId"); + + b.HasIndex("CustomerId") + .IsUnique() + .HasDatabaseName("UX_CustomerAddresses_Customer_Primary") + .HasFilter("[IsPrimary] = 1 AND [DeletedAt] IS NULL"); + + b.HasIndex("DistrictId"); + + b.ToTable("CustomerAddresses", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.CustomerProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DefaultEmergencyContactName") + .HasColumnType("nvarchar(max)"); + + b.Property("DefaultEmergencyContactPhone") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("CustomerProfiles", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.NurseBankAccount", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountHolderFromBank") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("AccountHolderName") + .HasColumnType("nvarchar(max)"); + + b.Property("BankName") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("Iban") + .HasColumnType("nvarchar(max)"); + + b.Property("IbanHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("IsPrimary") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("IsVerified") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("MatchedNationalId") + .HasColumnType("bit"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseId") + .HasColumnType("bigint"); + + b.Property("OwnershipVendorRef") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("VerifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("VerifiedByAdminId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("IbanHash") + .IsUnique(); + + b.HasIndex("NurseId") + .IsUnique() + .HasDatabaseName("UX_NurseBankAccounts_NurseId_Primary") + .HasFilter("[IsPrimary] = 1"); + + b.HasIndex("VerifiedByAdminId"); + + b.ToTable("NurseBankAccounts", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.NurseProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AverageRating") + .ValueGeneratedOnAdd() + .HasPrecision(3, 2) + .HasColumnType("decimal(3,2)") + .HasDefaultValue(0m); + + b.Property("Bio") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("EducationField") + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("EducationLevel") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("IsAcceptingBookings") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("IsVerified") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("PartnerCenterId") + .HasColumnType("bigint"); + + b.Property("SpecializationsJson") + .HasColumnType("nvarchar(max)"); + + b.Property("TotalCompletedBookings") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.Property("TotalReviews") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.Property("UserId") + .HasColumnType("int"); + + b.Property("YearsOfExperience") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("NurseProfiles", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.Patient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BirthDate") + .HasColumnType("date"); + + b.Property("BloodType") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("CustomerId") + .HasColumnType("bigint"); + + b.Property("DisplayName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("FirstName") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Gender") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("InitialMedicalNotes") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("LastName") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId"); + + b.ToTable("Patients", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Invoices.Invoice", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BnplCommissionIrr") + .HasColumnType("bigint"); + + b.Property("BookingId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("GrossIrr") + .HasColumnType("bigint"); + + b.Property("InvoiceNumber") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("IssuedAt") + .HasColumnType("datetime2"); + + b.Property("IssuingEntityType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("MoadianReferenceNumber") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("MoadianStatus") + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("PartnerCenterId") + .HasColumnType("bigint"); + + b.Property("PdfStorageKey") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("PlatformCommissionIrr") + .HasColumnType("bigint"); + + b.Property("VatIrr") + .HasColumnType("bigint"); + + b.Property("VatRate") + .HasPrecision(5, 4) + .HasColumnType("decimal(5,4)"); + + b.HasKey("Id"); + + b.HasIndex("BookingId") + .IsUnique(); + + b.HasIndex("InvoiceNumber") + .IsUnique(); + + b.ToTable("Invoices", "payments"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Invoices.InvoiceNumberSequence", b => + { + b.Property("Id") + .HasColumnType("int"); + + b.Property("NextValue") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.ToTable("InvoiceNumberSequences", "payments"); + + b.HasData( + new + { + Id = 1, + NextValue = 1L + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Notifications.Notification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Body") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DataJson") + .HasColumnType("nvarchar(max)"); + + b.Property("IsRead") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("ReadAt") + .HasColumnType("datetimeoffset"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "IsRead", "CreatedAt"); + + b.ToTable("Notifications", "ops"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Payments.LedgerEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountType") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("AmountIrr") + .HasColumnType("bigint"); + + b.Property("BookingId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Direction") + .IsRequired() + .HasMaxLength(6) + .HasColumnType("nvarchar(6)"); + + b.Property("Memo") + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("NurseId") + .HasColumnType("bigint"); + + b.Property("SourceRefId") + .HasColumnType("bigint"); + + b.Property("SourceRefType") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("TransactionGroupId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("BookingId"); + + b.HasIndex("NurseId"); + + b.HasIndex("TransactionGroupId"); + + b.HasIndex("AccountType", "NurseId"); + + b.HasIndex("SourceRefType", "SourceRefId"); + + b.ToTable("LedgerEntries", "payments"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Payments.PaymentGateway", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConfigJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("Priority") + .HasColumnType("int"); + + b.Property("ProviderCode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("Type", "IsActive", "Priority"); + + b.ToTable("PaymentGateways", "payments"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Payments.PaymentTransaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Amount") + .HasColumnType("bigint"); + + b.Property("BookingId") + .HasColumnType("bigint"); + + b.Property("BookingRequestId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("Currency") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("nvarchar(3)"); + + b.Property("CustomerId") + .HasColumnType("bigint"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("GatewayId") + .HasColumnType("bigint"); + + b.Property("GatewayReferenceCode") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("GatewayResponseCode") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("GatewayResponseJson") + .HasColumnType("nvarchar(max)"); + + b.Property("GatewayTransactionId") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("IpAddress") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("IsInstallment") + .HasColumnType("bit"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("UserAgent") + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.HasKey("Id"); + + b.HasIndex("BookingId") + .IsUnique() + .HasFilter("[Status] = 'succeeded' AND [BookingId] IS NOT NULL"); + + b.HasIndex("CustomerId"); + + b.HasIndex("GatewayId"); + + b.HasIndex("GatewayReferenceCode") + .IsUnique() + .HasFilter("[GatewayReferenceCode] IS NOT NULL"); + + b.HasIndex("BookingId", "Status"); + + b.HasIndex("BookingRequestId", "Status"); + + b.ToTable("PaymentTransactions", "payments"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Payments.PaymentWebhookEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("ExternalEventId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("PayloadJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ProcessedAt") + .HasColumnType("datetime2"); + + b.Property("ProcessingStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("ProviderCode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ReceivedAt") + .HasColumnType("datetime2"); + + b.Property("RelatedPaymentTransactionId") + .HasColumnType("bigint"); + + b.Property("SignatureValid") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("ProviderCode", "ExternalEventId") + .IsUnique(); + + b.ToTable("PaymentWebhookEvents", "payments"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Payouts.NursePayout", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Amount") + .HasColumnType("bigint"); + + b.Property("BankAccountId") + .HasColumnType("bigint"); + + b.Property("BatchId") + .HasColumnType("bigint"); + + b.Property("BookingCount") + .HasColumnType("int"); + + b.Property("ClawbackAppliedIrr") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("FailureReason") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("GrossEarningsIrr") + .HasColumnType("bigint"); + + b.Property("IbanSnapshot") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NetAmountIrr") + .HasColumnType("bigint"); + + b.Property("NurseId") + .HasColumnType("bigint"); + + b.Property("PaidAt") + .HasColumnType("datetime2"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("TransferReference") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("BankAccountId"); + + b.HasIndex("BatchId"); + + b.HasIndex("NurseId"); + + b.HasIndex("Status"); + + b.ToTable("NursePayouts", "payouts", t => + { + t.HasCheckConstraint("CK_NursePayouts_NetSplit", "[NetAmountIrr] = [GrossEarningsIrr] - [ClawbackAppliedIrr] AND [GrossEarningsIrr] >= 0 AND [ClawbackAppliedIrr] >= 0 AND [NetAmountIrr] >= 0 AND [Amount] >= 0"); + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Payouts.NursePayoutBatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("FailureNotes") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("InitiatedByAdminId") + .HasColumnType("int"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("PayoutCount") + .HasColumnType("int"); + + b.Property("PeriodEnd") + .HasColumnType("date"); + + b.Property("PeriodStart") + .HasColumnType("date"); + + b.Property("ProcessedAt") + .HasColumnType("datetime2"); + + b.Property("ProcessingDate") + .HasColumnType("date"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("nvarchar(30)"); + + b.Property("TotalAmount") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("InitiatedByAdminId"); + + b.HasIndex("ProcessingDate"); + + b.HasIndex("Status"); + + b.ToTable("NursePayoutBatches", "payouts"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Payouts.NursePayoutBookingLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BookingId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("PayoutAmountIrr") + .HasColumnType("bigint"); + + b.Property("PayoutId") + .HasColumnType("bigint"); + + b.Property("SessionId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("BookingId") + .IsUnique(); + + b.HasIndex("PayoutId"); + + b.HasIndex("SessionId"); + + b.ToTable("NursePayoutBookingLinks", "payouts"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Refunds.NurseClawback", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AmountIrr") + .HasColumnType("bigint"); + + b.Property("BookingId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseId") + .HasColumnType("bigint"); + + b.Property("OriginalPayoutId") + .HasColumnType("bigint"); + + b.Property("RecoveredInPayoutId") + .HasColumnType("bigint"); + + b.Property("RefundId") + .HasColumnType("bigint"); + + b.Property("ResolutionNotes") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("ResolvedAt") + .HasColumnType("datetime2"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("nvarchar(30)"); + + b.HasKey("Id"); + + b.HasIndex("BookingId"); + + b.HasIndex("NurseId"); + + b.HasIndex("OriginalPayoutId"); + + b.HasIndex("RecoveredInPayoutId"); + + b.HasIndex("RefundId") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("NurseClawbacks", "payments"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Refunds.Refund", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AdminNotes") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("Amount") + .HasColumnType("bigint"); + + b.Property("ApprovedByAdminId") + .HasColumnType("int"); + + b.Property("BookingId") + .HasColumnType("bigint"); + + b.Property("CancellationPolicyCode") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ExpectedCustomerRefundEta") + .HasColumnType("date"); + + b.Property("ExternalRevertReference") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("GatewayRefundReference") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NursePayoutRefundedIrr") + .HasColumnType("bigint"); + + b.Property("PaymentTransactionId") + .HasColumnType("bigint"); + + b.Property("PlatformFeeRefundedIrr") + .HasColumnType("bigint"); + + b.Property("ProcessedAt") + .HasColumnType("datetime2"); + + b.Property("ReasonCategory") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ReasonNotes") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("RefundChannel") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("RefundPercentage") + .HasPrecision(6, 4) + .HasColumnType("decimal(6,4)"); + + b.Property("RefundPercentageApplied") + .HasPrecision(5, 2) + .HasColumnType("decimal(5,2)"); + + b.Property("RejectedReason") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("RequestedByCustomerId") + .HasColumnType("bigint"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("TicketId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("BookingId"); + + b.HasIndex("PaymentTransactionId"); + + b.HasIndex("RequestedByCustomerId"); + + b.HasIndex("Status"); + + b.HasIndex("TicketId"); + + b.ToTable("Refunds", "payments", t => + { + t.HasCheckConstraint("CK_Refunds_LegSplit", "[Amount] = [PlatformFeeRefundedIrr] + [NursePayoutRefundedIrr] AND [Amount] >= 0 AND [PlatformFeeRefundedIrr] >= 0 AND [NursePayoutRefundedIrr] >= 0"); + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Search.NurseSearchIndex", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AverageRating") + .HasPrecision(3, 2) + .HasColumnType("decimal(3,2)"); + + b.Property("CityId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DistrictId") + .HasColumnType("bigint"); + + b.Property("IsSearchable") + .HasColumnType("bit"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseGender") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("NurseId") + .HasColumnType("bigint"); + + b.Property("Price") + .HasColumnType("bigint"); + + b.Property("PriceUnit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("ServiceCategoryId") + .HasColumnType("bigint"); + + b.Property("TotalCompletedBookings") + .HasColumnType("int"); + + b.Property("TotalReviews") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("VariantId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("NurseId"); + + b.HasIndex("VariantId", "CityId") + .IsUnique() + .HasDatabaseName("UX_NurseSearchIndex_Variant_City_WholeCity") + .HasFilter("[DistrictId] IS NULL AND [DeletedAt] IS NULL"); + + b.HasIndex("VariantId", "CityId", "DistrictId") + .IsUnique() + .HasDatabaseName("UX_NurseSearchIndex_Variant_City_District") + .HasFilter("[DistrictId] IS NOT NULL AND [DeletedAt] IS NULL"); + + b.HasIndex("IsSearchable", "ServiceCategoryId", "CityId", "DistrictId") + .HasDatabaseName("IX_NurseSearchIndex_Search"); + + SqlServerIndexBuilderExtensions.IncludeProperties(b.HasIndex("IsSearchable", "ServiceCategoryId", "CityId", "DistrictId"), new[] { "Price", "NurseGender", "AverageRating", "TotalReviews", "NurseId", "VariantId" }); + + b.ToTable("NurseSearchIndices", "search"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.SupportAlerts.SupportAlert", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BookingId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("OwnerUserId") + .HasColumnType("int"); + + b.Property("ResolutionNote") + .HasColumnType("nvarchar(max)"); + + b.Property("ResolvedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ReviewId") + .HasColumnType("bigint"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.HasIndex("Status"); + + b.HasIndex("Type"); + + b.ToTable("SupportAlerts", "ops"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("Roles", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.RoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedClaim") + .HasColumnType("datetime2"); + + b.Property("RoleId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("RoleClaims", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("UserId"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("FamilyName") + .HasColumnType("nvarchar(max)"); + + b.Property("Gender") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("GeneratedCode") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("NationalId") + .HasColumnType("nvarchar(max)"); + + b.Property("NationalIdVerifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneHash") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("PhoneVerifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("ShahkarVerifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.HasIndex("PhoneHash") + .IsUnique() + .HasFilter("[PhoneHash] IS NOT NULL"); + + b.ToTable("Users", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserClaims", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("LoggedOn") + .HasColumnType("datetime2"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("UserLogins", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserRefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("IsValid") + .HasColumnType("bit"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserRefreshTokens", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserRole", b => + { + b.Property("UserId") + .HasColumnType("int"); + + b.Property("RoleId") + .HasColumnType("int"); + + b.Property("CreatedUserRoleDate") + .HasColumnType("datetime2"); + + b.Property("GrantedAt") + .HasColumnType("datetimeoffset"); + + b.Property("GrantedById") + .HasColumnType("int"); + + b.Property("RevokedAt") + .HasColumnType("datetimeoffset"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("GrantedById"); + + b.HasIndex("RoleId"); + + b.ToTable("UserRoles", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeviceInfo") + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("ExpiresAt") + .HasColumnType("datetimeoffset"); + + b.Property("IpAddress") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("IsRevoked") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("RefreshTokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("RevokedAt") + .HasColumnType("datetimeoffset"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("RefreshTokenHash") + .IsUnique(); + + b.HasIndex("UserId", "IsRevoked"); + + b.ToTable("UserSessions", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserToken", b => + { + b.Property("UserId") + .HasColumnType("int"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("GeneratedTime") + .HasColumnType("datetime2"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("UserTokens", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.NurseCredential", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("CredentialNumber") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("CredentialType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ExpiresAt") + .HasColumnType("date"); + + b.Property("HolderNameSnapshot") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("IssuedAt") + .HasColumnType("date"); + + b.Property("IssuingAuthority") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseId") + .HasColumnType("bigint"); + + b.Property("VerificationMethod") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("VerificationSource") + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("VerifiedByAdminId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("VerifiedByAdminId"); + + b.HasIndex("NurseId", "CredentialType"); + + b.ToTable("NurseCredentials", "verif"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.NurseVerification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ApprovedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("InternalNotes") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseId") + .HasColumnType("bigint"); + + b.Property("RejectedAt") + .HasColumnType("datetimeoffset"); + + b.Property("RejectionReason") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("ReviewedByAdminId") + .HasColumnType("int"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("SubmittedAt") + .HasColumnType("datetimeoffset"); + + b.Property("SuspendedAt") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("NurseId") + .IsUnique(); + + b.HasIndex("ReviewedByAdminId"); + + b.ToTable("NurseVerifications", "verif"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.VerificationDocument", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("FileSizeBytes") + .HasColumnType("bigint"); + + b.Property("IntegrityHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("ObjectStorageKey") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("OriginalFileName") + .HasMaxLength(260) + .HasColumnType("nvarchar(260)"); + + b.Property("StepId") + .HasColumnType("bigint"); + + b.Property("UploadedByUserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("StepId"); + + b.HasIndex("UploadedByUserId"); + + b.ToTable("VerificationDocuments", "verif"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.VerificationStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("ExpiresAt") + .HasColumnType("datetimeoffset"); + + b.Property("ExternalResponseJson") + .HasColumnType("nvarchar(max)"); + + b.Property("FailureReason") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("IsAutomated") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseVerificationId") + .HasColumnType("bigint"); + + b.Property("StartedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("StepTypeId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("StepTypeId"); + + b.HasIndex("NurseVerificationId", "StepTypeId") + .IsUnique() + .HasDatabaseName("UX_VerificationSteps_Verification_StepType"); + + b.ToTable("VerificationSteps", "verif"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.VerificationStepType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AutomationProvider") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("IsAutomated") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("IsRequired") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsActive", "SortOrder"); + + b.ToTable("VerificationStepTypes", "verif"); + + b.HasData( + new + { + Id = 1L, + AutomationProvider = "identity_kyc_vendor", + Code = "identity_kyc", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + Description = "National-ID validity, name match and photo/video liveness via an Iranian e-KYC vendor.", + DisplayName = "Identity Verification (KYC)", + IsActive = true, + IsAutomated = true, + IsRequired = true, + SortOrder = 1 + }, + new + { + Id = 2L, + AutomationProvider = "shahkar", + Code = "shahkar_match", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + Description = "Confirms the login SIM is registered to the nurse's own national ID (شاهکار).", + DisplayName = "Shahkar Phone Binding", + IsActive = true, + IsAutomated = true, + IsRequired = true, + SortOrder = 2 + }, + new + { + Id = 3L, + Code = "moh_competency_license", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + Description = "پروانه صلاحیت حرفه‌ای — the MoH-mandated in-home nursing licence (bundles the criminal-record screen). Manual today.", + DisplayName = "MoH Professional Competency License", + IsActive = true, + IsAutomated = false, + IsRequired = true, + SortOrder = 3 + }, + new + { + Id = 4L, + Code = "ino_membership", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + Description = "نظام پرستاری membership cross-check (ino.ir). Manual today.", + DisplayName = "Nursing Organization (INO) Membership", + IsActive = true, + IsAutomated = false, + IsRequired = true, + SortOrder = 4 + }, + new + { + Id = 5L, + Code = "criminal_record", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + Description = "عدم سوء پیشینه — consent-gated, nurse-uploaded, time-limited (reverts on expiry).", + DisplayName = "Criminal Record Certificate", + IsActive = true, + IsAutomated = false, + IsRequired = true, + SortOrder = 5 + }, + new + { + Id = 6L, + AutomationProvider = "sheba", + Code = "bank_account_verification", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + Description = "استعلام شبا — the payout IBAN owner's national ID must equal the verified nurse national ID.", + DisplayName = "Bank Account (IBAN) Ownership", + IsActive = true, + IsAutomated = true, + IsRequired = true, + SortOrder = 6 + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Analytics.SystemEvent", b => + { + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("UserId"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Audit.AuditLog", b => + { + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("ActorUserId"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Bnpl.BnplTransaction", b => + { + b.HasOne("Baya.Domain.Entities.Payments.PaymentTransaction", null) + .WithMany() + .HasForeignKey("PaymentTransactionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Booking.Booking", b => + { + b.HasOne("Baya.Domain.Entities.Booking.BookingRequest", null) + .WithOne() + .HasForeignKey("Baya.Domain.Entities.Booking.Booking", "BookingRequestId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Identity.CustomerAddress", null) + .WithMany() + .HasForeignKey("CustomerAddressId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Identity.CustomerProfile", null) + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", null) + .WithMany() + .HasForeignKey("NurseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Identity.Patient", null) + .WithMany() + .HasForeignKey("PatientId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Catalog.NurseServiceVariant", null) + .WithMany() + .HasForeignKey("VariantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Booking.BookingCareInstruction", b => + { + b.HasOne("Baya.Domain.Entities.Booking.Booking", "Booking") + .WithOne("CareInstructions") + .HasForeignKey("Baya.Domain.Entities.Booking.BookingCareInstruction", "BookingId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Booking"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Booking.BookingRequest", b => + { + b.HasOne("Baya.Domain.Entities.Identity.CustomerAddress", "CustomerAddress") + .WithMany() + .HasForeignKey("CustomerAddressId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Identity.CustomerProfile", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", "Nurse") + .WithMany() + .HasForeignKey("NurseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Identity.Patient", "Patient") + .WithMany() + .HasForeignKey("PatientId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Catalog.NurseServiceVariant", "Variant") + .WithMany() + .HasForeignKey("VariantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Customer"); + + b.Navigation("CustomerAddress"); + + b.Navigation("Nurse"); + + b.Navigation("Patient"); + + b.Navigation("Variant"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Booking.BookingSession", b => + { + b.HasOne("Baya.Domain.Entities.Booking.Booking", "Booking") + .WithMany("Sessions") + .HasForeignKey("BookingId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Booking"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Booking.VisitVerification", b => + { + b.HasOne("Baya.Domain.Entities.Booking.BookingSession", "Session") + .WithOne("Verification") + .HasForeignKey("Baya.Domain.Entities.Booking.VisitVerification", "BookingSessionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Session"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.NurseServiceVariant", b => + { + b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", "Nurse") + .WithMany() + .HasForeignKey("NurseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Catalog.ServiceCategory", "ServiceCategory") + .WithMany("Variants") + .HasForeignKey("ServiceCategoryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Nurse"); + + b.Navigation("ServiceCategory"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.NurseServiceVariantOption", b => + { + b.HasOne("Baya.Domain.Entities.Catalog.ServiceOptionGroup", "OptionGroup") + .WithMany() + .HasForeignKey("OptionGroupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Catalog.ServiceOptionValue", "OptionValue") + .WithMany() + .HasForeignKey("OptionValueId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Catalog.NurseServiceVariant", "Variant") + .WithMany("Options") + .HasForeignKey("VariantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("OptionGroup"); + + b.Navigation("OptionValue"); + + b.Navigation("Variant"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceOptionGroup", b => + { + b.HasOne("Baya.Domain.Entities.Catalog.ServiceCategory", "ServiceCategory") + .WithMany("OptionGroups") + .HasForeignKey("ServiceCategoryId"); + + b.Navigation("ServiceCategory"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceOptionValue", b => + { + b.HasOne("Baya.Domain.Entities.Catalog.ServiceOptionGroup", "OptionGroup") + .WithMany("Values") + .HasForeignKey("OptionGroupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("OptionGroup"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.City", b => + { + b.HasOne("Baya.Domain.Entities.Geography.Province", "Province") + .WithMany("Cities") + .HasForeignKey("ProvinceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Province"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.District", b => + { + b.HasOne("Baya.Domain.Entities.Geography.City", "City") + .WithMany("Districts") + .HasForeignKey("CityId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("City"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.NurseServiceArea", b => + { + b.HasOne("Baya.Domain.Entities.Geography.City", "City") + .WithMany() + .HasForeignKey("CityId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Geography.District", "District") + .WithMany() + .HasForeignKey("DistrictId"); + + b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", null) + .WithMany() + .HasForeignKey("NurseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("City"); + + b.Navigation("District"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.CustomerAddress", b => + { + b.HasOne("Baya.Domain.Entities.Geography.City", "City") + .WithMany() + .HasForeignKey("CityId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Identity.CustomerProfile", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Geography.District", "District") + .WithMany() + .HasForeignKey("DistrictId"); + + b.Navigation("City"); + + b.Navigation("Customer"); + + b.Navigation("District"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.CustomerProfile", b => + { + b.HasOne("Baya.Domain.Entities.User.User", "User") + .WithOne() + .HasForeignKey("Baya.Domain.Entities.Identity.CustomerProfile", "UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.NurseBankAccount", b => + { + b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", "Nurse") + .WithMany("BankAccounts") + .HasForeignKey("NurseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("VerifiedByAdminId"); + + b.Navigation("Nurse"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.NurseProfile", b => + { + b.HasOne("Baya.Domain.Entities.User.User", "User") + .WithOne() + .HasForeignKey("Baya.Domain.Entities.Identity.NurseProfile", "UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.Patient", b => + { + b.HasOne("Baya.Domain.Entities.Identity.CustomerProfile", "Customer") + .WithMany("Patients") + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Customer"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Invoices.Invoice", b => + { + b.HasOne("Baya.Domain.Entities.Booking.Booking", null) + .WithMany() + .HasForeignKey("BookingId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Notifications.Notification", b => + { + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Payments.LedgerEntry", b => + { + b.HasOne("Baya.Domain.Entities.Booking.Booking", null) + .WithMany() + .HasForeignKey("BookingId"); + + b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", null) + .WithMany() + .HasForeignKey("NurseId"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Payments.PaymentTransaction", b => + { + b.HasOne("Baya.Domain.Entities.Booking.Booking", null) + .WithMany() + .HasForeignKey("BookingId"); + + b.HasOne("Baya.Domain.Entities.Booking.BookingRequest", null) + .WithMany() + .HasForeignKey("BookingRequestId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Identity.CustomerProfile", null) + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Payments.PaymentGateway", null) + .WithMany() + .HasForeignKey("GatewayId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Payouts.NursePayout", b => + { + b.HasOne("Baya.Domain.Entities.Identity.NurseBankAccount", null) + .WithMany() + .HasForeignKey("BankAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Payouts.NursePayoutBatch", "Batch") + .WithMany("Payouts") + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", null) + .WithMany() + .HasForeignKey("NurseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Batch"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Payouts.NursePayoutBatch", b => + { + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("InitiatedByAdminId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Payouts.NursePayoutBookingLink", b => + { + b.HasOne("Baya.Domain.Entities.Booking.Booking", null) + .WithMany() + .HasForeignKey("BookingId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Payouts.NursePayout", null) + .WithMany("BookingLinks") + .HasForeignKey("PayoutId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Booking.BookingSession", null) + .WithMany() + .HasForeignKey("SessionId"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Refunds.NurseClawback", b => + { + b.HasOne("Baya.Domain.Entities.Booking.Booking", null) + .WithMany() + .HasForeignKey("BookingId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", null) + .WithMany() + .HasForeignKey("NurseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Refunds.Refund", null) + .WithMany() + .HasForeignKey("RefundId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Refunds.Refund", b => + { + b.HasOne("Baya.Domain.Entities.Booking.Booking", null) + .WithMany() + .HasForeignKey("BookingId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Payments.PaymentTransaction", null) + .WithMany() + .HasForeignKey("PaymentTransactionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Identity.CustomerProfile", null) + .WithMany() + .HasForeignKey("RequestedByCustomerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Search.NurseSearchIndex", b => + { + b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", null) + .WithMany() + .HasForeignKey("NurseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Catalog.NurseServiceVariant", "Variant") + .WithMany() + .HasForeignKey("VariantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Variant"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.SupportAlerts.SupportAlert", b => + { + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("OwnerUserId"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.RoleClaim", b => + { + b.HasOne("Baya.Domain.Entities.User.Role", "Role") + .WithMany("Claims") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserClaim", b => + { + b.HasOne("Baya.Domain.Entities.User.User", "User") + .WithMany("Claims") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserLogin", b => + { + b.HasOne("Baya.Domain.Entities.User.User", "User") + .WithMany("Logins") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserRefreshToken", b => + { + b.HasOne("Baya.Domain.Entities.User.User", "User") + .WithMany("UserRefreshTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserRole", b => + { + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("GrantedById"); + + b.HasOne("Baya.Domain.Entities.User.Role", "Role") + .WithMany("Users") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.User.User", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserSession", b => + { + b.HasOne("Baya.Domain.Entities.User.User", "User") + .WithMany("Sessions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserToken", b => + { + b.HasOne("Baya.Domain.Entities.User.User", "User") + .WithMany("Tokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.NurseCredential", b => + { + b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", null) + .WithMany() + .HasForeignKey("NurseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("VerifiedByAdminId"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.NurseVerification", b => + { + b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", "Nurse") + .WithMany() + .HasForeignKey("NurseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("ReviewedByAdminId"); + + b.Navigation("Nurse"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.VerificationDocument", b => + { + b.HasOne("Baya.Domain.Entities.Verification.VerificationStep", "Step") + .WithMany("Documents") + .HasForeignKey("StepId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("UploadedByUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Step"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.VerificationStep", b => + { + b.HasOne("Baya.Domain.Entities.Verification.NurseVerification", "NurseVerification") + .WithMany("Steps") + .HasForeignKey("NurseVerificationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Verification.VerificationStepType", "StepType") + .WithMany("Steps") + .HasForeignKey("StepTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("NurseVerification"); + + b.Navigation("StepType"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Booking.Booking", b => + { + b.Navigation("CareInstructions"); + + b.Navigation("Sessions"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Booking.BookingSession", b => + { + b.Navigation("Verification"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.NurseServiceVariant", b => + { + b.Navigation("Options"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceCategory", b => + { + b.Navigation("OptionGroups"); + + b.Navigation("Variants"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceOptionGroup", b => + { + b.Navigation("Values"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.City", b => + { + b.Navigation("Districts"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.Province", b => + { + b.Navigation("Cities"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.CustomerProfile", b => + { + b.Navigation("Patients"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.NurseProfile", b => + { + b.Navigation("BankAccounts"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Payouts.NursePayout", b => + { + b.Navigation("BookingLinks"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Payouts.NursePayoutBatch", b => + { + b.Navigation("Payouts"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.Role", b => + { + b.Navigation("Claims"); + + b.Navigation("Users"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.User", b => + { + b.Navigation("Claims"); + + b.Navigation("Logins"); + + b.Navigation("Sessions"); + + b.Navigation("Tokens"); + + b.Navigation("UserRefreshTokens"); + + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.NurseVerification", b => + { + b.Navigation("Steps"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.VerificationStep", b => + { + b.Navigation("Documents"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.VerificationStepType", b => + { + b.Navigation("Steps"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260709000908_NursePayoutEngine.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260709000908_NursePayoutEngine.cs new file mode 100644 index 0000000..1d076d5 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260709000908_NursePayoutEngine.cs @@ -0,0 +1,248 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional + +namespace Baya.Infrastructure.Persistence.Migrations +{ + /// + public partial class NursePayoutEngine : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "payouts"); + + migrationBuilder.CreateTable( + name: "NursePayoutBatches", + schema: "payouts", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + PeriodStart = table.Column(type: "date", nullable: false), + PeriodEnd = table.Column(type: "date", nullable: false), + ProcessingDate = table.Column(type: "date", nullable: false), + TotalAmount = table.Column(type: "bigint", nullable: false), + PayoutCount = table.Column(type: "int", nullable: false), + Status = table.Column(type: "nvarchar(30)", maxLength: 30, nullable: false), + InitiatedByAdminId = table.Column(type: "int", nullable: false), + ProcessedAt = table.Column(type: "datetime2", nullable: true), + FailureNotes = table.Column(type: "nvarchar(1000)", maxLength: 1000, nullable: true), + DeletedAt = table.Column(type: "datetimeoffset", nullable: true), + CreatedAt = table.Column(type: "datetimeoffset", nullable: false), + ModifiedAt = table.Column(type: "datetimeoffset", nullable: true), + CreatedById = table.Column(type: "int", nullable: true), + ModifiedById = table.Column(type: "int", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_NursePayoutBatches", x => x.Id); + table.ForeignKey( + name: "FK_NursePayoutBatches_Users_InitiatedByAdminId", + column: x => x.InitiatedByAdminId, + principalSchema: "usr", + principalTable: "Users", + principalColumn: "UserId", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "NursePayouts", + schema: "payouts", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + BatchId = table.Column(type: "bigint", nullable: false), + NurseId = table.Column(type: "bigint", nullable: false), + BankAccountId = table.Column(type: "bigint", nullable: false), + IbanSnapshot = table.Column(type: "nvarchar(max)", nullable: false), + GrossEarningsIrr = table.Column(type: "bigint", nullable: false), + ClawbackAppliedIrr = table.Column(type: "bigint", nullable: false), + NetAmountIrr = table.Column(type: "bigint", nullable: false), + Amount = table.Column(type: "bigint", nullable: false), + BookingCount = table.Column(type: "int", nullable: false), + Status = table.Column(type: "nvarchar(20)", maxLength: 20, nullable: false), + TransferReference = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: true), + PaidAt = table.Column(type: "datetime2", nullable: true), + FailureReason = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: true), + DeletedAt = table.Column(type: "datetimeoffset", nullable: true), + CreatedAt = table.Column(type: "datetimeoffset", nullable: false), + ModifiedAt = table.Column(type: "datetimeoffset", nullable: true), + CreatedById = table.Column(type: "int", nullable: true), + ModifiedById = table.Column(type: "int", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_NursePayouts", x => x.Id); + table.CheckConstraint("CK_NursePayouts_NetSplit", "[NetAmountIrr] = [GrossEarningsIrr] - [ClawbackAppliedIrr] AND [GrossEarningsIrr] >= 0 AND [ClawbackAppliedIrr] >= 0 AND [NetAmountIrr] >= 0 AND [Amount] >= 0"); + table.ForeignKey( + name: "FK_NursePayouts_NurseBankAccounts_BankAccountId", + column: x => x.BankAccountId, + principalSchema: "usr", + principalTable: "NurseBankAccounts", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_NursePayouts_NursePayoutBatches_BatchId", + column: x => x.BatchId, + principalSchema: "payouts", + principalTable: "NursePayoutBatches", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_NursePayouts_NurseProfiles_NurseId", + column: x => x.NurseId, + principalSchema: "usr", + principalTable: "NurseProfiles", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "NursePayoutBookingLinks", + schema: "payouts", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + PayoutId = table.Column(type: "bigint", nullable: false), + BookingId = table.Column(type: "bigint", nullable: false), + SessionId = table.Column(type: "bigint", nullable: true), + PayoutAmountIrr = table.Column(type: "bigint", nullable: false), + DeletedAt = table.Column(type: "datetimeoffset", nullable: true), + CreatedAt = table.Column(type: "datetimeoffset", nullable: false), + ModifiedAt = table.Column(type: "datetimeoffset", nullable: true), + CreatedById = table.Column(type: "int", nullable: true), + ModifiedById = table.Column(type: "int", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_NursePayoutBookingLinks", x => x.Id); + table.ForeignKey( + name: "FK_NursePayoutBookingLinks_BookingSessions_SessionId", + column: x => x.SessionId, + principalSchema: "booking", + principalTable: "BookingSessions", + principalColumn: "Id"); + table.ForeignKey( + name: "FK_NursePayoutBookingLinks_Bookings_BookingId", + column: x => x.BookingId, + principalSchema: "booking", + principalTable: "Bookings", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_NursePayoutBookingLinks_NursePayouts_PayoutId", + column: x => x.PayoutId, + principalSchema: "payouts", + principalTable: "NursePayouts", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.InsertData( + schema: "ops", + table: "PlatformConfigs", + columns: new[] { "Id", "CreatedAt", "CreatedById", "DataType", "Description", "Key", "ModifiedAt", "ModifiedById", "Value" }, + values: new object[,] + { + { 22L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, "decimal", "IRR net-amount threshold above which a payout is routed via SATNA (real-time) instead of PAYA (batch) (b13).", "payout_satna_threshold_irr", null, null, "1000000000" }, + { 23L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, "bool", "When on, a BNPL-paid booking is payout-eligible only after its provider settlement is received (b13; default off — the DEFERRED settled_at guard).", "require_bnpl_settlement_for_payout", null, null, "false" } + }); + + migrationBuilder.CreateIndex( + name: "IX_NursePayoutBatches_InitiatedByAdminId", + schema: "payouts", + table: "NursePayoutBatches", + column: "InitiatedByAdminId"); + + migrationBuilder.CreateIndex( + name: "IX_NursePayoutBatches_ProcessingDate", + schema: "payouts", + table: "NursePayoutBatches", + column: "ProcessingDate"); + + migrationBuilder.CreateIndex( + name: "IX_NursePayoutBatches_Status", + schema: "payouts", + table: "NursePayoutBatches", + column: "Status"); + + migrationBuilder.CreateIndex( + name: "IX_NursePayoutBookingLinks_BookingId", + schema: "payouts", + table: "NursePayoutBookingLinks", + column: "BookingId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_NursePayoutBookingLinks_PayoutId", + schema: "payouts", + table: "NursePayoutBookingLinks", + column: "PayoutId"); + + migrationBuilder.CreateIndex( + name: "IX_NursePayoutBookingLinks_SessionId", + schema: "payouts", + table: "NursePayoutBookingLinks", + column: "SessionId"); + + migrationBuilder.CreateIndex( + name: "IX_NursePayouts_BankAccountId", + schema: "payouts", + table: "NursePayouts", + column: "BankAccountId"); + + migrationBuilder.CreateIndex( + name: "IX_NursePayouts_BatchId", + schema: "payouts", + table: "NursePayouts", + column: "BatchId"); + + migrationBuilder.CreateIndex( + name: "IX_NursePayouts_NurseId", + schema: "payouts", + table: "NursePayouts", + column: "NurseId"); + + migrationBuilder.CreateIndex( + name: "IX_NursePayouts_Status", + schema: "payouts", + table: "NursePayouts", + column: "Status"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "NursePayoutBookingLinks", + schema: "payouts"); + + migrationBuilder.DropTable( + name: "NursePayouts", + schema: "payouts"); + + migrationBuilder.DropTable( + name: "NursePayoutBatches", + schema: "payouts"); + + migrationBuilder.DeleteData( + schema: "ops", + table: "PlatformConfigs", + keyColumn: "Id", + keyValue: 22L); + + migrationBuilder.DeleteData( + schema: "ops", + table: "PlatformConfigs", + keyColumn: "Id", + keyValue: 23L); + } + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/ApplicationDbContextModelSnapshot.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/ApplicationDbContextModelSnapshot.cs index 440c4ff..76c9a8b 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/ApplicationDbContextModelSnapshot.cs @@ -1291,6 +1291,24 @@ namespace Baya.Infrastructure.Persistence.Migrations Description = "Ops/testing override that forces the post-payout clawback path for refunds (b11); b13 replaces the derivation.", Key = "refund_assume_nurse_paid", Value = "false" + }, + new + { + Id = 22L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "decimal", + Description = "IRR net-amount threshold above which a payout is routed via SATNA (real-time) instead of PAYA (batch) (b13).", + Key = "payout_satna_threshold_irr", + Value = "1000000000" + }, + new + { + Id = 23L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "bool", + Description = "When on, a BNPL-paid booking is payout-eligible only after its provider settlement is received (b13; default off — the DEFERRED settled_at guard).", + Key = "require_bnpl_settlement_for_payout", + Value = "false" }); }); @@ -3186,6 +3204,200 @@ namespace Baya.Infrastructure.Persistence.Migrations b.ToTable("PaymentWebhookEvents", "payments"); }); + modelBuilder.Entity("Baya.Domain.Entities.Payouts.NursePayout", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Amount") + .HasColumnType("bigint"); + + b.Property("BankAccountId") + .HasColumnType("bigint"); + + b.Property("BatchId") + .HasColumnType("bigint"); + + b.Property("BookingCount") + .HasColumnType("int"); + + b.Property("ClawbackAppliedIrr") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("FailureReason") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("GrossEarningsIrr") + .HasColumnType("bigint"); + + b.Property("IbanSnapshot") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NetAmountIrr") + .HasColumnType("bigint"); + + b.Property("NurseId") + .HasColumnType("bigint"); + + b.Property("PaidAt") + .HasColumnType("datetime2"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("TransferReference") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("BankAccountId"); + + b.HasIndex("BatchId"); + + b.HasIndex("NurseId"); + + b.HasIndex("Status"); + + b.ToTable("NursePayouts", "payouts", t => + { + t.HasCheckConstraint("CK_NursePayouts_NetSplit", "[NetAmountIrr] = [GrossEarningsIrr] - [ClawbackAppliedIrr] AND [GrossEarningsIrr] >= 0 AND [ClawbackAppliedIrr] >= 0 AND [NetAmountIrr] >= 0 AND [Amount] >= 0"); + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Payouts.NursePayoutBatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("FailureNotes") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("InitiatedByAdminId") + .HasColumnType("int"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("PayoutCount") + .HasColumnType("int"); + + b.Property("PeriodEnd") + .HasColumnType("date"); + + b.Property("PeriodStart") + .HasColumnType("date"); + + b.Property("ProcessedAt") + .HasColumnType("datetime2"); + + b.Property("ProcessingDate") + .HasColumnType("date"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("nvarchar(30)"); + + b.Property("TotalAmount") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("InitiatedByAdminId"); + + b.HasIndex("ProcessingDate"); + + b.HasIndex("Status"); + + b.ToTable("NursePayoutBatches", "payouts"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Payouts.NursePayoutBookingLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BookingId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("PayoutAmountIrr") + .HasColumnType("bigint"); + + b.Property("PayoutId") + .HasColumnType("bigint"); + + b.Property("SessionId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("BookingId") + .IsUnique(); + + b.HasIndex("PayoutId"); + + b.HasIndex("SessionId"); + + b.ToTable("NursePayoutBookingLinks", "payouts"); + }); + modelBuilder.Entity("Baya.Domain.Entities.Refunds.NurseClawback", b => { b.Property("Id") @@ -4674,6 +4886,57 @@ namespace Baya.Infrastructure.Persistence.Migrations .IsRequired(); }); + modelBuilder.Entity("Baya.Domain.Entities.Payouts.NursePayout", b => + { + b.HasOne("Baya.Domain.Entities.Identity.NurseBankAccount", null) + .WithMany() + .HasForeignKey("BankAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Payouts.NursePayoutBatch", "Batch") + .WithMany("Payouts") + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", null) + .WithMany() + .HasForeignKey("NurseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Batch"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Payouts.NursePayoutBatch", b => + { + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("InitiatedByAdminId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Payouts.NursePayoutBookingLink", b => + { + b.HasOne("Baya.Domain.Entities.Booking.Booking", null) + .WithMany() + .HasForeignKey("BookingId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Payouts.NursePayout", null) + .WithMany("BookingLinks") + .HasForeignKey("PayoutId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Booking.BookingSession", null) + .WithMany() + .HasForeignKey("SessionId"); + }); + modelBuilder.Entity("Baya.Domain.Entities.Refunds.NurseClawback", b => { b.HasOne("Baya.Domain.Entities.Booking.Booking", null) @@ -4942,6 +5205,16 @@ namespace Baya.Infrastructure.Persistence.Migrations b.Navigation("BankAccounts"); }); + modelBuilder.Entity("Baya.Domain.Entities.Payouts.NursePayout", b => + { + b.Navigation("BookingLinks"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Payouts.NursePayoutBatch", b => + { + b.Navigation("Payouts"); + }); + modelBuilder.Entity("Baya.Domain.Entities.User.Role", b => { b.Navigation("Claims"); diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/Common/UnitOfWork.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/Common/UnitOfWork.cs index b27a0b7..c9d9dca 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/Common/UnitOfWork.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/Common/UnitOfWork.cs @@ -26,6 +26,7 @@ public class UnitOfWork : IUnitOfWork public IRefundRepository RefundRepository { get; } public IInvoiceRepository InvoiceRepository { get; } public IBnplRepository BnplRepository { get; } + public IPayoutRepository PayoutRepository { get; } public UnitOfWork(ApplicationDbContext db) { @@ -50,6 +51,7 @@ public class UnitOfWork : IUnitOfWork RefundRepository = new RefundRepository(_db); InvoiceRepository = new InvoiceRepository(_db); BnplRepository = new BnplRepository(_db); + PayoutRepository = new PayoutRepository(_db); } public Task CommitAsync() diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/PayoutRepository.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/PayoutRepository.cs new file mode 100644 index 0000000..972a388 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/PayoutRepository.cs @@ -0,0 +1,241 @@ +#nullable enable +using Baya.Application.Contracts.Persistence; +using Baya.Application.Models.Common; +using Baya.Application.Models.Payouts; +using Baya.Domain.Entities.Bnpl; +using Baya.Domain.Entities.Booking; +using Baya.Domain.Entities.Identity; +using Baya.Domain.Entities.Payments; +using Baya.Domain.Entities.Payouts; +using Baya.Domain.Entities.Refunds; +using Baya.Domain.Entities.User; +using Baya.Infrastructure.Persistence.Repositories.Common; +using Microsoft.EntityFrameworkCore; + +namespace Baya.Infrastructure.Persistence.Repositories; + +internal sealed class PayoutRepository : BaseAsyncRepository, IPayoutRepository +{ + public PayoutRepository(ApplicationDbContext dbContext) : base(dbContext) + { + } + + // Eligible = completed AND its dispute window closed by `now` (and by the period end, so period_end bounds the + // run) AND no active refund reversed its money AND not already paid in a link row. No lower period bound, so a + // booking that missed an earlier batch is still swept — it must eventually be paid. When + // requireBnplSettlement is set, a BNPL-paid booking is held until its provider settlement is received. + private IQueryable EligibleBookingsQuery(DateOnly periodEnd, DateTime now, bool requireBnplSettlement) + { + var windowEnd = periodEnd.ToDateTime(TimeOnly.MaxValue); + var query = from b in DbContext.Set().AsNoTracking() + where b.Status == BookingStatus.Completed + && b.DisputeWindowEndsAt != null + && b.DisputeWindowEndsAt < now + && b.DisputeWindowEndsAt <= windowEnd + && !DbContext.Set() + .Any(r => r.BookingId == b.Id && r.Status != RefundStatus.Failed && r.Status != RefundStatus.Rejected) + && !DbContext.Set().IgnoreQueryFilters() + .Any(l => l.BookingId == b.Id) + select b; + + if (requireBnplSettlement) + // Hold a BNPL-paid booking until its 1:1 bnpl_transaction reports a settlement (settled_at set). + query = query.Where(b => !DbContext.Set() + .Any(t => t.BookingId == b.Id && t.Status == PaymentTransactionStatus.Succeeded + && DbContext.Set().Any(bt => bt.PaymentTransactionId == t.Id && bt.SettledAt == null))); + + return query.Select(b => new EligibleBookingRow(b.NurseId, b.Id, b.NursePayoutAmount)); + } + + public async Task> GetEligibleBookingsAsync( + DateOnly periodStart, DateOnly periodEnd, DateTime now, bool requireBnplSettlement, CancellationToken cancellationToken) + => await EligibleBookingsQuery(periodEnd, now, requireBnplSettlement).ToListAsync(cancellationToken); + + public async Task> GetEligiblePreviewAsync( + DateOnly periodStart, DateOnly periodEnd, DateTime now, bool requireBnplSettlement, int page, int pageSize, CancellationToken cancellationToken) + { + var rows = await EligibleBookingsQuery(periodEnd, now, requireBnplSettlement).ToListAsync(cancellationToken); + + var groups = rows + .GroupBy(r => r.NurseId) + .Select(g => new { NurseId = g.Key, Gross = g.Sum(x => x.PayoutAmountIrr), Count = g.Count() }) + .OrderBy(x => x.NurseId) + .ToList(); + + var total = groups.Count; + var slice = groups.Skip((page - 1) * pageSize).Take(pageSize).ToList(); + var nurseIds = slice.Select(x => x.NurseId).ToList(); + + var names = await GetNurseNamesAsync(nurseIds, cancellationToken); + + var clawbacks = await DbContext.Set().AsNoTracking() + .Where(c => nurseIds.Contains(c.NurseId) && c.Status == ClawbackStatus.Pending) + .GroupBy(c => c.NurseId) + .Select(g => new { NurseId = g.Key, Sum = g.Sum(x => x.AmountIrr) }) + .ToDictionaryAsync(x => x.NurseId, x => x.Sum, cancellationToken); + + var verified = (await DbContext.Set().AsNoTracking() + .Where(a => nurseIds.Contains(a.NurseId) && a.IsPrimary && a.IsVerified && a.MatchedNationalId == true) + .Select(a => a.NurseId) + .ToListAsync(cancellationToken)) + .ToHashSet(); + + var items = slice.Select(x => + { + var clawback = Math.Min(x.Gross, clawbacks.GetValueOrDefault(x.NurseId)); + var net = x.Gross - clawback; + return new EligibleNurseEarningsDto( + x.NurseId, names.GetValueOrDefault(x.NurseId), x.Count, + x.Gross.ToString(), clawback.ToString(), net.ToString(), verified.Contains(x.NurseId)); + }).ToList(); + + return new PagedResult(items, total, page, pageSize); + } + + public Task GetVerifiedPrimaryAccountAsync(long nurseId, CancellationToken cancellationToken) + => DbContext.Set().AsNoTracking() + .Where(a => a.NurseId == nurseId && a.IsPrimary && a.IsVerified && a.MatchedNationalId == true) + .Select(a => new VerifiedPayoutAccount(a.Id, a.Iban)) + .FirstOrDefaultAsync(cancellationToken); + + public async Task> GetNurseNamesAsync(IReadOnlyList nurseIds, CancellationToken cancellationToken) + { + if (nurseIds.Count == 0) + return new Dictionary(); + + var rows = await (from n in DbContext.Set().AsNoTracking() + where nurseIds.Contains(n.Id) + join u in DbContext.Set() on n.UserId equals u.Id + select new { n.Id, u.Name, u.FamilyName }) + .ToListAsync(cancellationToken); + + return rows.ToDictionary(x => x.Id, x => $"{x.Name} {x.FamilyName}".Trim()); + } + + public async Task GetPendingClawbackSumAsync(long nurseId, CancellationToken cancellationToken) + => await DbContext.Set().AsNoTracking() + .Where(c => c.NurseId == nurseId && c.Status == ClawbackStatus.Pending) + .SumAsync(c => (long?)c.AmountIrr, cancellationToken) ?? 0; + + public async Task> GetPendingClawbacksAsync(long nurseId, CancellationToken cancellationToken) + => await DbContext.Set() + .Where(c => c.NurseId == nurseId && c.Status == ClawbackStatus.Pending) + .OrderBy(c => c.Id) + .ToListAsync(cancellationToken); + + public Task AddBatchAsync(NursePayoutBatch batch, CancellationToken cancellationToken) + => base.AddAsync(batch); + + public Task GetTrackedBatchAsync(long batchId, CancellationToken cancellationToken) + => DbContext.Set() + .Include(b => b.Payouts).ThenInclude(p => p.BookingLinks) + .FirstOrDefaultAsync(b => b.Id == batchId, cancellationToken); + + public Task GetTrackedPayoutAsync(long payoutId, CancellationToken cancellationToken) + => DbContext.Set() + .Include(p => p.Batch) + .FirstOrDefaultAsync(p => p.Id == payoutId, cancellationToken); + + public Task LedgerGroupExistsForPayoutAsync(long payoutId, CancellationToken cancellationToken) + => DbContext.Set().AsNoTracking() + .AnyAsync(l => l.SourceRefType == LedgerSourceRefType.NursePayout && l.SourceRefId == payoutId, cancellationToken); + + public async Task> ListBatchesAsync(string? status, int page, int pageSize, CancellationToken cancellationToken) + { + var query = DbContext.Set().AsNoTracking().AsQueryable(); + if (!string.IsNullOrWhiteSpace(status)) + query = query.Where(b => b.Status == status); + + var total = await query.CountAsync(cancellationToken); + var rows = await query + .OrderByDescending(b => b.Id) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .Select(b => new + { + b.Id, b.PeriodStart, b.PeriodEnd, b.ProcessingDate, b.TotalAmount, b.PayoutCount, + b.Status, b.InitiatedByAdminId, b.ProcessedAt, b.FailureNotes, b.CreatedAt + }) + .ToListAsync(cancellationToken); + + var items = rows.Select(b => new PayoutBatchDto( + b.Id, b.PeriodStart, b.PeriodEnd, b.ProcessingDate, b.TotalAmount.ToString(), b.PayoutCount, + b.Status, b.InitiatedByAdminId, b.ProcessedAt, b.FailureNotes, b.CreatedAt)) + .ToList(); + + return new PagedResult(items, total, page, pageSize); + } + + public async Task GetBatchDetailAsync(long batchId, int page, int pageSize, CancellationToken cancellationToken) + { + var header = await DbContext.Set().AsNoTracking() + .Where(b => b.Id == batchId) + .Select(b => new PayoutBatchDto( + b.Id, b.PeriodStart, b.PeriodEnd, b.ProcessingDate, b.TotalAmount.ToString(), b.PayoutCount, + b.Status, b.InitiatedByAdminId, b.ProcessedAt, b.FailureNotes, b.CreatedAt)) + .FirstOrDefaultAsync(cancellationToken); + + if (header is null) + return null; + + var payoutsQuery = DbContext.Set().AsNoTracking().Where(p => p.BatchId == batchId); + var total = await payoutsQuery.CountAsync(cancellationToken); + + var rows = await payoutsQuery + .OrderBy(p => p.Id) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .Select(p => new + { + p.Id, p.NurseId, p.IbanSnapshot, p.GrossEarningsIrr, p.ClawbackAppliedIrr, p.NetAmountIrr, + p.Amount, p.BookingCount, p.Status, p.TransferReference, p.PaidAt, p.FailureReason, + Links = p.BookingLinks.Select(l => new { l.BookingId, l.SessionId, l.PayoutAmountIrr }).ToList() + }) + .ToListAsync(cancellationToken); + + var names = await GetNurseNamesAsync(rows.Select(r => r.NurseId).Distinct().ToList(), cancellationToken); + + var payouts = rows.Select(p => new PayoutDto( + p.Id, p.NurseId, names.GetValueOrDefault(p.NurseId), MaskIban(p.IbanSnapshot), + p.GrossEarningsIrr.ToString(), p.ClawbackAppliedIrr.ToString(), p.NetAmountIrr.ToString(), + p.Amount.ToString(), p.BookingCount, p.Status, p.TransferReference, p.PaidAt, p.FailureReason, + p.Links.Select(l => new PayoutBookingLinkDto(l.BookingId, l.SessionId, l.PayoutAmountIrr.ToString())).ToList())) + .ToList(); + + return new PayoutBatchDetailDto(header, payouts, total, page, pageSize); + } + + public async Task> GetNurseHistoryAsync(long nurseId, int page, int pageSize, CancellationToken cancellationToken) + { + var query = from p in DbContext.Set().AsNoTracking() + where p.NurseId == nurseId + join b in DbContext.Set() on p.BatchId equals b.Id + orderby p.Id descending + select new + { + p.Id, p.BatchId, p.Status, p.GrossEarningsIrr, p.ClawbackAppliedIrr, p.NetAmountIrr, + p.IbanSnapshot, p.TransferReference, p.PaidAt, b.PeriodStart, b.PeriodEnd + }; + + var total = await query.CountAsync(cancellationToken); + var rows = await query.Skip((page - 1) * pageSize).Take(pageSize).ToListAsync(cancellationToken); + + var items = rows.Select(p => new NursePayoutHistoryDto( + p.Id, p.BatchId, p.Status, p.GrossEarningsIrr.ToString(), p.ClawbackAppliedIrr.ToString(), + p.NetAmountIrr.ToString(), MaskIban(p.IbanSnapshot), p.TransferReference, p.PaidAt, + p.PeriodStart, p.PeriodEnd)) + .ToList(); + + return new PagedResult(items, total, page, pageSize); + } + + // Show only the last 4 digits of the IBAN — the plaintext snapshot never leaves the server. + private static string MaskIban(string iban) + { + if (string.IsNullOrEmpty(iban)) + return string.Empty; + return iban.Length <= 4 + ? new string('•', iban.Length) + : $"{new string('•', iban.Length - 4)}{iban[^4..]}"; + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/ServiceConfiguration/ServiceCollectionExtensions.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/ServiceConfiguration/ServiceCollectionExtensions.cs index 63e125e..2f8d9b1 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Persistence/ServiceConfiguration/ServiceCollectionExtensions.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/ServiceConfiguration/ServiceCollectionExtensions.cs @@ -54,10 +54,10 @@ public static class ServiceCollectionExtensions // Supersedes the b0 log/no-op stub with the real in-app notifications write. services.AddScoped(); - // "Has the nurse been paid?" — the refund pre-payout/clawback fork (b11). DB-backed because b13's real - // impl reads nurse_payout_booking_links; until then it derives from the dispute-window close. b13 swaps - // this registration for the authoritative payout-link lookup. - services.AddScoped(); + // "Has the nurse been paid?" — the refund pre-payout/clawback fork (b11). b13 now owns the authoritative + // impl: a booking is paid iff a nurse_payout_booking_links row ties it to a `paid` nurse_payouts row. This + // supersedes the interim NursePayoutStatusService (dispute-window derivation); the refund fork is unchanged. + services.AddScoped(); // Retention job seam (mock = in-process interval runner; real Hangfire/Quartz deferred). services.AddHostedService(); diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Payments/NursePayoutLinkStatusService.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Payments/NursePayoutLinkStatusService.cs new file mode 100644 index 0000000..9789893 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Payments/NursePayoutLinkStatusService.cs @@ -0,0 +1,34 @@ +#nullable enable +using Baya.Application.Contracts.Configuration; +using Baya.Application.Contracts.Payments; +using Baya.Domain.Entities.Payouts; +using Microsoft.EntityFrameworkCore; + +namespace Baya.Infrastructure.Persistence.Services.Payments; + +/// +/// The authoritative b13 implementation of — it answers "was the nurse +/// already paid for this booking?" from the real payout ledger: a booking is paid iff a +/// nurse_payout_booking_links row ties it to a nurse_payouts row in status paid (a +/// confirmed, irreversible transfer). This supersedes the b11 interim NursePayoutStatusService that +/// derived it from the dispute-window close. The refund pre-payout/clawback fork is unchanged — it just now +/// forks on the true paid-state. The refund_assume_nurse_paid config switch still forces the paid answer +/// for ops/testing. +/// +internal sealed class NursePayoutLinkStatusService( + ApplicationDbContext dbContext, + IPlatformConfig platformConfig) : INursePayoutStatus +{ + public async ValueTask IsNursePaidForBookingAsync(long bookingId, CancellationToken cancellationToken = default) + { + if (await platformConfig.GetConfig("refund_assume_nurse_paid", cancellationToken)) + return true; + + return await (from l in dbContext.Set().AsNoTracking() + where l.BookingId == bookingId + join p in dbContext.Set() on l.PayoutId equals p.Id + where p.Status == PayoutStatus.Paid + select l.Id) + .AnyAsync(cancellationToken); + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Payments/NursePayoutStatusService.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Payments/NursePayoutStatusService.cs deleted file mode 100644 index 88e1f6b..0000000 --- a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Payments/NursePayoutStatusService.cs +++ /dev/null @@ -1,35 +0,0 @@ -#nullable enable -using Baya.Application.Contracts.Common; -using Baya.Application.Contracts.Configuration; -using Baya.Application.Contracts.Payments; -using Microsoft.EntityFrameworkCore; -using BookingEntity = Baya.Domain.Entities.Booking.Booking; - -namespace Baya.Infrastructure.Persistence.Services.Payments; - -/// -/// The interim implementation of until b13 ships nurse_payouts / -/// nurse_payout_booking_links. It derives "already paid?" from the booking's dispute-window close — the -/// exact gate b13 pays out on — so the pre-payout (clean reversal) path is the common one and the clawback path -/// is the fallback. A refund_assume_nurse_paid config switch forces the paid answer for ops/testing. b13 -/// swaps this registration for the authoritative payout-link lookup. -/// -internal sealed class NursePayoutStatusService( - ApplicationDbContext dbContext, - IDateTimeProvider dateTimeProvider, - IPlatformConfig platformConfig) : INursePayoutStatus -{ - public async ValueTask IsNursePaidForBookingAsync(long bookingId, CancellationToken cancellationToken = default) - { - if (await platformConfig.GetConfig("refund_assume_nurse_paid", cancellationToken)) - return true; - - var now = dateTimeProvider.UtcNow.UtcDateTime; - var windowEnd = await dbContext.Set().AsNoTracking() - .Where(b => b.Id == bookingId) - .Select(b => b.DisputeWindowEndsAt) - .FirstOrDefaultAsync(cancellationToken); - - return windowEnd is { } endsAt && endsAt <= now; - } -} diff --git a/server/src/Tests/Baya.Test.Api/AdminPayoutsApiTests.cs b/server/src/Tests/Baya.Test.Api/AdminPayoutsApiTests.cs new file mode 100644 index 0000000..9f79aa4 --- /dev/null +++ b/server/src/Tests/Baya.Test.Api/AdminPayoutsApiTests.cs @@ -0,0 +1,171 @@ +using System.Net; +using System.Net.Http.Json; +using Baya.Application.Contracts.Identity; +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 Baya.Domain.Entities.User; +using Baya.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using BookingEntity = Baya.Domain.Entities.Booking.Booking; + +namespace Baya.Test.Api; + +public class AdminPayoutsApiTests(BayaApiFactory factory) : IClassFixture +{ + [Fact] + public async Task Generate_Unauthenticated_Returns401() + { + var client = factory.CreateClient(); + var response = await client.PostAsJsonAsync("/api/v1/admin_payouts/batches", + new { periodStart = "2020-01-01", periodEnd = "2020-01-31" }); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task Generate_InvalidPeriod_Returns400() + { + var admin = factory.CreateClient(); + await AdminTestClient.AuthenticateAsync(factory, admin, "09131902401"); + + // period_start after period_end + var response = await admin.PostAsJsonAsync("/api/v1/admin_payouts/batches", + new { periodStart = "2026-06-30", periodEnd = "2026-06-01" }); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task Generate_then_process_pays_the_eligible_nurse() + { + var admin = factory.CreateClient(); + await AdminTestClient.AuthenticateAsync(factory, admin, "09131902402"); + await SeedEligibleNurseBookingAsync(factory, "09131902403"); + + var today = DateOnly.FromDateTime(DateTime.UtcNow).ToString("yyyy-MM-dd"); + var generate = await admin.PostAsJsonAsync("/api/v1/admin_payouts/batches", + new { periodStart = "2020-01-01", periodEnd = today }); + Assert.Equal(HttpStatusCode.OK, generate.StatusCode); + + var data = await AuthTestClient.ReadDataAsync(generate); + var batchId = data.GetProperty("batch").GetProperty("id").GetInt64(); + Assert.Equal("draft", data.GetProperty("batch").GetProperty("status").GetString()); + Assert.True(data.GetProperty("payouts").GetArrayLength() >= 1); + + var process = await admin.PostAsJsonAsync($"/api/v1/admin_payouts/batches/{batchId}/process", new { }); + Assert.Equal(HttpStatusCode.OK, process.StatusCode); + var processed = await AuthTestClient.ReadDataAsync(process); + Assert.Equal("completed", processed.GetProperty("status").GetString()); + Assert.True(processed.GetProperty("paidCount").GetInt32() >= 1); + } + + [Fact] + public async Task List_AsAdmin_ReturnsPagedEnvelope() + { + var admin = factory.CreateClient(); + await AdminTestClient.AuthenticateAsync(factory, admin, "09131902404"); + + var response = await admin.GetAsync("/api/v1/admin_payouts/batches?page=1&pageSize=20"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var data = await AuthTestClient.ReadDataAsync(response); + Assert.True(data.GetProperty("total").GetInt32() >= 0); + } + + /// Seeds a completed, dispute-window-closed booking for a nurse who has a verified primary IBAN, so + /// the batch has exactly one eligible payout. Returns the nurse's phone (usable to authenticate as the nurse). + internal static async Task SeedEligibleNurseBookingAsync(BayaApiFactory factory, string nursePhone) + { + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var userManager = scope.ServiceProvider.GetRequiredService(); + + var customerUser = new User { UserName = $"cust_{Guid.NewGuid():N}", PhoneNumber = $"0912{Random.Shared.Next(1000000, 9999999)}", IsActive = true }; + db.Users.Add(customerUser); + db.SaveChanges(); + var customer = new CustomerProfile { UserId = customerUser.Id }; + db.Set().Add(customer); + db.SaveChanges(); + + // The nurse must be a real Identity user so the same phone can authenticate for the history test. + var nurseUser = await userManager.GetUserByPhoneNumber(nursePhone); + if (nurseUser is null) + { + await userManager.CreateUser(new User { UserName = $"nurse_{Guid.NewGuid():N}", PhoneNumber = nursePhone, Gender = "female", Name = "زهرا", FamilyName = "احمدی" }); + nurseUser = await userManager.GetUserByPhoneNumber(nursePhone); + } + var nurse = new NurseProfile { UserId = nurseUser!.Id }; + nurse.MarkVerified(); + nurse.SetAcceptingBookings(true); + db.Set().Add(nurse); + db.SaveChanges(); + + db.Set().Add(new NurseBankAccount + { + NurseId = nurse.Id, BankName = "ملی", AccountHolderName = "زهرا", Iban = $"IR{Random.Shared.Next(100000, 999999)}0000000000000000", + IbanHash = $"hash-{nurse.Id}", IsPrimary = true, IsVerified = true, MatchedNationalId = true, + AccountHolderFromBank = "زهرا", OwnershipVendorRef = "mock" + }); + + var province = new Province { NameFa = "ت", NameEn = "T", SortOrder = 1, IsActive = true }; + db.Set().Add(province); + db.SaveChanges(); + var city = new City { ProvinceId = province.Id, NameFa = "ت", NameEn = "T", SortOrder = 1, IsActive = true }; + db.Set().Add(city); + var category = new ServiceCategory { NameFa = "س", NameEn = "E", SortOrder = 1, IsActive = true }; + db.Set().Add(category); + db.SaveChanges(); + + var patient = new Patient { CustomerId = customer.Id, DisplayName = "پ", FirstName = "ح", LastName = "ر", Gender = "male", IsActive = true }; + db.Set().Add(patient); + var address = new CustomerAddress + { + CustomerId = customer.Id, CityId = city.Id, Title = "خ", AddressLine = "خیابان", + PostalCode = "1111111111", RecipientName = "ع", RecipientPhone = customerUser.PhoneNumber, IsPrimary = true + }; + db.Set().Add(address); + db.SaveChanges(); + + var variant = new NurseServiceVariant + { + NurseId = nurse.Id, ServiceCategoryId = category.Id, Price = 10_000_000, PriceUnit = "per_day", + SessionCount = null, DisplayName = "مراقبت", OptionSetHash = $"hash-{Guid.NewGuid():N}", IsActive = true + }; + db.Set().Add(variant); + db.SaveChanges(); + + var request = new BookingRequest + { + CustomerId = customer.Id, NurseId = nurse.Id, PatientId = patient.Id, VariantId = variant.Id, + CustomerAddressId = address.Id, RequiredCaregiverGender = CaregiverGender.Any, + RequestedDate = new DateOnly(2020, 1, 1), RequestedTimeStart = new TimeOnly(9, 0), RequestedTimeEnd = new TimeOnly(13, 0), + CustomerNotes = "n", NurseResponseDeadlineAt = new DateTime(2030, 1, 1, 0, 0, 0, DateTimeKind.Utc) + }; + db.Set().Add(request); + db.SaveChanges(); + request.Accept(new DateTime(2030, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + db.SaveChanges(); + + var completedAt = new DateTime(2020, 1, 2, 0, 0, 0, DateTimeKind.Utc); + var booking = new BookingEntity + { + BookingRequestId = request.Id, CustomerId = customer.Id, NurseId = nurse.Id, PatientId = patient.Id, + VariantId = variant.Id, CustomerAddressId = address.Id, VariantSnapshotJson = "{}", AddressSnapshotJson = "{}", + GrossPriceIrr = 10_000_000, BalinyaarCommissionIrr = 1_500_000, PlatformFeeRate = 0.15m, + NursePayoutAmount = 8_500_000, SessionCount = 1, + ScheduledDate = new DateOnly(2020, 1, 1), ScheduledTimeStart = new TimeOnly(9, 0), ScheduledTimeEnd = new TimeOnly(13, 0) + }; + booking.TransitionTo(BookingStatus.Confirmed, completedAt); + booking.TransitionTo(BookingStatus.InProgress, completedAt); + booking.TransitionTo(BookingStatus.Completed, completedAt); + booking.SetDisputeWindow(new DateTime(2020, 1, 5, 0, 0, 0, DateTimeKind.Utc)); // long past + db.Set().Add(booking); + db.SaveChanges(); + + var legs = LedgerPosting.CardCapture(booking.Id, nurse.Id, 10_000_000, 1_500_000, 8_500_000, 0, completedAt); + db.Set().AddRange(legs); + db.SaveChanges(); + } +} diff --git a/server/src/Tests/Baya.Test.Api/NursePayoutsApiTests.cs b/server/src/Tests/Baya.Test.Api/NursePayoutsApiTests.cs new file mode 100644 index 0000000..fb7a322 --- /dev/null +++ b/server/src/Tests/Baya.Test.Api/NursePayoutsApiTests.cs @@ -0,0 +1,48 @@ +using System.Net; +using System.Net.Http.Json; + +namespace Baya.Test.Api; + +public class NursePayoutsApiTests(BayaApiFactory factory) : IClassFixture +{ + [Fact] + public async Task History_Unauthenticated_Returns401() + { + var client = factory.CreateClient(); + var response = await client.GetAsync("/api/v1/nurse_payouts/history"); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task History_AsNurse_ReturnsOwnPaidPayout_WithMaskedIban() + { + const string nursePhone = "09131903401"; + + // Pay the nurse through the real admin flow, then read the nurse's own history. + var admin = factory.CreateClient(); + await AdminTestClient.AuthenticateAsync(factory, admin, "09131903402"); + await AdminPayoutsApiTests.SeedEligibleNurseBookingAsync(factory, nursePhone); + + var today = DateOnly.FromDateTime(DateTime.UtcNow).ToString("yyyy-MM-dd"); + var generate = await admin.PostAsJsonAsync("/api/v1/admin_payouts/batches", + new { periodStart = "2020-01-01", periodEnd = today }); + Assert.Equal(HttpStatusCode.OK, generate.StatusCode); + var batchId = (await AuthTestClient.ReadDataAsync(generate)).GetProperty("batch").GetProperty("id").GetInt64(); + var process = await admin.PostAsJsonAsync($"/api/v1/admin_payouts/batches/{batchId}/process", new { }); + Assert.Equal(HttpStatusCode.OK, process.StatusCode); + + var nurse = factory.CreateClient(); + var tokens = await AuthTestClient.LoginAsync(factory, nurse, nursePhone); + AuthTestClient.UseBearer(nurse, tokens.GetProperty("accessToken").GetString()!); + + var response = await nurse.GetAsync("/api/v1/nurse_payouts/history?page=1&pageSize=20"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var data = await AuthTestClient.ReadDataAsync(response); + Assert.True(data.GetProperty("total").GetInt32() >= 1); + var item = data.GetProperty("items")[0]; + Assert.Equal("paid", item.GetProperty("status").GetString()); + Assert.Equal("8500000", item.GetProperty("netAmountIrr").GetString()); + Assert.Contains("•", item.GetProperty("maskedIban").GetString()!); + } +} diff --git a/server/src/Tests/Baya.Test.Foundation/Payouts/PayoutHandlerTests.cs b/server/src/Tests/Baya.Test.Foundation/Payouts/PayoutHandlerTests.cs new file mode 100644 index 0000000..b8ae518 --- /dev/null +++ b/server/src/Tests/Baya.Test.Foundation/Payouts/PayoutHandlerTests.cs @@ -0,0 +1,246 @@ +using Baya.Application.Features.Payouts.Commands.ExecutePayoutBatch; +using Baya.Application.Features.Payouts.Commands.GeneratePayoutBatch; +using Baya.Application.Features.Payouts.Commands.RetryFailedPayout; +using Baya.Application.Features.Payouts.Queries.ComputeEligibleEarnings; +using Baya.Application.Contracts.Holidays; +using Baya.Domain.Entities.Payments; +using Baya.Domain.Entities.Payouts; +using Baya.Domain.Entities.Refunds; +using Microsoft.EntityFrameworkCore; + +namespace Baya.Test.Foundation.Payouts; + +public class PayoutHandlerTests +{ + private static readonly DateTimeOffset Now = new(2026, 7, 1, 10, 0, 0, TimeSpan.Zero); + private static readonly DateTime Past = new(2026, 6, 15, 0, 0, 0, DateTimeKind.Utc); // dispute window closed + private static readonly DateTime Future = new(2026, 8, 1, 0, 0, 0, DateTimeKind.Utc); // still open + private static readonly DateOnly PeriodStart = new(2026, 6, 1); + private static readonly DateOnly PeriodEnd = new(2026, 6, 30); + + private static GeneratePayoutBatchCommand GenCmd => new(PeriodStart, PeriodEnd); + + private static GeneratePayoutBatchCommandHandler Gen(PayoutsTestHost host, IHolidayCalendar? holidays = null) + => new(host.UnitOfWork, host.Lock(), holidays ?? host.Holidays(), host.Config(), host.Clock(Now), host.AsAdmin()); + + private static ExecutePayoutBatchCommandHandler Exec(PayoutsTestHost host, Baya.Application.Contracts.Payments.IBankTransferProvider? bank = null) + => new(host.UnitOfWork, host.Lock(), bank ?? host.Bank(), host.Config(), host.Clock(Now)); + + private static ComputeEligibleEarningsQueryHandler Preview(PayoutsTestHost host) + => new(host.UnitOfWork, host.Holidays(), host.Config(), host.Clock(Now)); + + [Fact] + public async Task Preview_includes_only_closed_window_and_flags_missing_iban() + { + using var host = new PayoutsTestHost(); + var nurseA = host.SeedNurse(); + var nurseB = host.SeedNurse(verifiedIban: null); + host.SeedCompletedBooking(nurseA, Past); // eligible + host.SeedCompletedBooking(nurseA, Future); // future window — excluded + host.SeedCompletedBooking(nurseA, Past, disputed: true); // disputed — excluded + host.SeedCompletedBooking(nurseB, Past); // eligible earnings but no verified IBAN + + var result = await Preview(host).Handle(new ComputeEligibleEarningsQuery(PeriodStart, PeriodEnd), CancellationToken.None); + + Assert.True(result.IsSuccess); + var items = result.Result.Items; + Assert.Equal(2, items.Count); + + var a = items.Single(x => x.NurseId == nurseA); + Assert.Equal(1, a.BookingCount); // only the one closed-window, non-disputed booking + Assert.Equal("8500000", a.GrossEarningsIrr); + Assert.True(a.HasVerifiedPrimaryIban); + + var b = items.Single(x => x.NurseId == nurseB); + Assert.False(b.HasVerifiedPrimaryIban); + } + + [Fact] + public async Task Generate_materializes_one_payout_per_nurse_and_nets_clawback() + { + using var host = new PayoutsTestHost(); + var nurse = host.SeedNurse(); + host.SeedCompletedBooking(nurse, Past, gross: 10_000_000, commission: 1_500_000); // eligible, payout 8.5M + var prior = host.SeedCompletedBooking(nurse, Past); // gets a refund below → excluded from earnings + host.SeedPendingClawback(nurse, prior, 2_000_000); + + var result = await Gen(host).Handle(GenCmd, CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal(PayoutBatchStatus.Draft, result.Result.Batch.Status); + var payout = Assert.Single(result.Result.Payouts); + Assert.Equal("8500000", payout.GrossEarningsIrr); + Assert.Equal("2000000", payout.ClawbackAppliedIrr); + Assert.Equal("6500000", payout.NetAmountIrr); + Assert.Equal("6500000", payout.Amount); + Assert.Equal(1, payout.BookingCount); + Assert.Equal("6500000", result.Result.Batch.TotalAmount); + Assert.Equal(1, result.Result.Batch.PayoutCount); + Assert.EndsWith("0123", payout.MaskedIban); + Assert.Contains("•", payout.MaskedIban); + Assert.Empty(result.Result.Skipped); + } + + [Fact] + public async Task Generate_skips_nurse_without_verified_primary_iban_with_reason() + { + using var host = new PayoutsTestHost(); + var nurseOk = host.SeedNurse(); + var nurseNoIban = host.SeedNurse(verifiedIban: null); + host.SeedCompletedBooking(nurseOk, Past); + host.SeedCompletedBooking(nurseNoIban, Past); + + var result = await Gen(host).Handle(GenCmd, CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Single(result.Result.Payouts); + var skipped = Assert.Single(result.Result.Skipped); + Assert.Equal(nurseNoIban, skipped.NurseId); + Assert.Equal("no_verified_primary_iban", skipped.Reason); + } + + [Fact] + public async Task Double_pay_guard_second_generate_does_not_reselect_linked_bookings() + { + using var host = new PayoutsTestHost(); + var nurse = host.SeedNurse(); + host.SeedCompletedBooking(nurse, Past); + + var first = await Gen(host).Handle(GenCmd, CancellationToken.None); + Assert.True(first.IsSuccess); + + var second = await Gen(host).Handle(GenCmd, CancellationToken.None); + Assert.False(second.IsSuccess); // the booking_id UNIQUE link excludes it — nothing left to pay + } + + [Fact] + public async Task Execute_posts_balanced_payout_ledger_and_drains_payable() + { + using var host = new PayoutsTestHost(); + var nurse = host.SeedNurse(); + host.SeedCompletedBooking(nurse, Past, gross: 10_000_000, commission: 1_500_000); + + var gen = await Gen(host).Handle(GenCmd, CancellationToken.None); + var batchId = gen.Result.Batch.Id; + var before = host.NursePayableBalance(nurse); + + var exec = await Exec(host).Handle(new ExecutePayoutBatchCommand(batchId), CancellationToken.None); + + Assert.True(exec.IsSuccess); + Assert.Equal(PayoutBatchStatus.Completed, exec.Result.Status); + Assert.Equal(1, exec.Result.PaidCount); + + var after = host.NursePayableBalance(nurse); + Assert.Equal(8_500_000, before - after); + + var legs = PayoutLegs(host); + Assert.Equal(8_500_000, Leg(legs, LedgerAccountType.NursePayable, LedgerDirection.Debit)); + Assert.Equal(8_500_000, Leg(legs, LedgerAccountType.EscrowHeld, LedgerDirection.Credit)); + Assert.Equal( + legs.Where(l => l.Direction == LedgerDirection.Debit).Sum(l => l.AmountIrr), + legs.Where(l => l.Direction == LedgerDirection.Credit).Sum(l => l.AmountIrr)); + + var payout = host.Db.Set().AsNoTracking().Single(); + Assert.Equal(PayoutStatus.Paid, payout.Status); + Assert.NotNull(payout.TransferReference); + Assert.NotNull(payout.PaidAt); + } + + [Fact] + public async Task Execute_recovers_clawback_and_posts_recovery_leg() + { + using var host = new PayoutsTestHost(); + var nurse = host.SeedNurse(); + host.SeedCompletedBooking(nurse, Past, gross: 10_000_000, commission: 1_500_000); + var prior = host.SeedCompletedBooking(nurse, Past); + var clawbackId = host.SeedPendingClawback(nurse, prior, 2_000_000); + + var gen = await Gen(host).Handle(GenCmd, CancellationToken.None); + await Exec(host).Handle(new ExecutePayoutBatchCommand(gen.Result.Batch.Id), CancellationToken.None); + + var recovery = host.Db.Set().AsNoTracking() + .Where(l => l.SourceRefType == LedgerSourceRefType.Clawback && l.SourceRefId == clawbackId).ToList(); + Assert.Equal(2_000_000, Leg(recovery, LedgerAccountType.NursePayable, LedgerDirection.Debit)); + Assert.Equal(2_000_000, Leg(recovery, LedgerAccountType.NurseClawbackReceivable, LedgerDirection.Credit)); + + var clawback = host.Db.Set().AsNoTracking().Single(c => c.Id == clawbackId); + Assert.Equal(ClawbackStatus.Recovered, clawback.Status); + Assert.NotNull(clawback.RecoveredInPayoutId); + } + + [Fact] + public async Task Reprocess_is_idempotent_no_second_ledger_group() + { + using var host = new PayoutsTestHost(); + var nurse = host.SeedNurse(); + host.SeedCompletedBooking(nurse, Past); + + var gen = await Gen(host).Handle(GenCmd, CancellationToken.None); + var batchId = gen.Result.Batch.Id; + + await Exec(host).Handle(new ExecutePayoutBatchCommand(batchId), CancellationToken.None); + var countAfterFirst = PayoutLegs(host).Count; + + var second = await Exec(host).Handle(new ExecutePayoutBatchCommand(batchId), CancellationToken.None); + Assert.True(second.IsSuccess); + Assert.Equal(PayoutBatchStatus.Completed, second.Result.Status); + Assert.Equal(countAfterFirst, PayoutLegs(host).Count); + } + + [Fact] + public async Task Holiday_shifts_period_end_and_processing_date() + { + using var host = new PayoutsTestHost(); + var nurse = host.SeedNurse(); + host.SeedCompletedBooking(nurse, Past); + + var shiftedEnd = new DateOnly(2026, 7, 2); + var shiftedProcessing = new DateOnly(2026, 7, 4); + var holidays = host.Holidays( + (PeriodEnd, shiftedEnd), + (DateOnly.FromDateTime(Now.UtcDateTime), shiftedProcessing)); + + var result = await Gen(host, holidays).Handle(GenCmd, CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal(shiftedEnd, result.Result.Batch.PeriodEnd); + Assert.Equal(shiftedProcessing, result.Result.Batch.ProcessingDate); + } + + [Fact] + public async Task Partial_failure_then_retry_completes_the_batch() + { + using var host = new PayoutsTestHost(); + var nurseOk = host.SeedNurse("IR000000000000000000000001"); + var nurseFail = host.SeedNurse("IR000000000000000000000999"); + host.SeedCompletedBooking(nurseOk, Past); + host.SeedCompletedBooking(nurseFail, Past); + + var gen = await Gen(host).Handle(GenCmd, CancellationToken.None); + var batchId = gen.Result.Batch.Id; + + var failingBank = host.Bank(failIban: "IR000000000000000000000999"); + var exec = await Exec(host, failingBank).Handle(new ExecutePayoutBatchCommand(batchId), CancellationToken.None); + + Assert.Equal(PayoutBatchStatus.PartiallyFailed, exec.Result.Status); + Assert.Equal(1, exec.Result.PaidCount); + Assert.Equal(1, exec.Result.FailedCount); + + var failedPayout = host.Db.Set().AsNoTracking().Single(p => p.Status == PayoutStatus.Failed); + + var retry = new RetryFailedPayoutCommandHandler( + host.UnitOfWork, host.Lock(), host.Bank(), host.Holidays(), host.Config(), host.Clock(Now)); + var retried = await retry.Handle(new RetryFailedPayoutCommand(failedPayout.Id), CancellationToken.None); + + Assert.True(retried.IsSuccess); + var batch = await host.Db.Set().AsNoTracking().FirstAsync(b => b.Id == batchId); + Assert.Equal(PayoutBatchStatus.Completed, batch.Status); + } + + private static List PayoutLegs(PayoutsTestHost host) + => host.Db.Set().AsNoTracking() + .Where(l => l.SourceRefType == LedgerSourceRefType.NursePayout).ToList(); + + private static long Leg(IReadOnlyList legs, string account, string direction) + => legs.Where(l => l.AccountType == account && l.Direction == direction).Sum(l => l.AmountIrr); +} diff --git a/server/src/Tests/Baya.Test.Foundation/Payouts/PayoutsTestHost.cs b/server/src/Tests/Baya.Test.Foundation/Payouts/PayoutsTestHost.cs new file mode 100644 index 0000000..ff2a1e7 --- /dev/null +++ b/server/src/Tests/Baya.Test.Foundation/Payouts/PayoutsTestHost.cs @@ -0,0 +1,286 @@ +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Configuration; +using Baya.Application.Contracts.Holidays; +using Baya.Application.Contracts.Payments; +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 Baya.Domain.Entities.Payouts; +using Baya.Domain.Entities.Refunds; +using Baya.Domain.Entities.User; +using Baya.Infrastructure.CrossCutting.Seams; +using Baya.Infrastructure.Persistence; +using Baya.Infrastructure.Persistence.Repositories.Common; +using Baya.Tests.Setup.Setups; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using NSubstitute; +using BookingEntity = Baya.Domain.Entities.Booking.Booking; + +namespace Baya.Test.Foundation.Payouts; + +/// +/// A self-contained SQLite host exercising the real EF model (schema, the net-split CHECK, the booking_id UNIQUE +/// link, encrypted iban_snapshot, query filters) for the b13 payout engine. Seeds a customer + bookable nurses and +/// can create completed, dispute-window-closed bookings, verified primary bank accounts, and pending clawbacks so +/// a test can drive the real handlers against the real with substituted seams. +/// +public sealed class PayoutsTestHost : IDisposable +{ + private readonly SqliteConnection _connection; + public ApplicationDbContext Db { get; } + public UnitOfWork UnitOfWork { get; } + + public long CustomerId { get; } + public int AdminUserId { get; } + private readonly long _cityId; + private readonly long _categoryId; + private readonly long _patientId; + private readonly long _addressId; + private int _phoneSeq = 100; + + public PayoutsTestHost() + { + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + + var options = new DbContextOptionsBuilder().UseSqlite(_connection).Options; + Db = new ApplicationDbContext(options, TestFieldEncryptor.Instance); + Db.Database.EnsureCreated(); + UnitOfWork = new UnitOfWork(Db); + + var province = new Province { NameFa = "تهران", NameEn = "Tehran", SortOrder = 1, IsActive = true }; + Db.Set().Add(province); + Db.SaveChanges(); + var city = new City { ProvinceId = province.Id, NameFa = "تهران", NameEn = "Tehran", SortOrder = 1, IsActive = true }; + Db.Set().Add(city); + var category = new ServiceCategory { NameFa = "سالمند", NameEn = "Elderly", SortOrder = 1, IsActive = true }; + Db.Set().Add(category); + Db.SaveChanges(); + _cityId = city.Id; + _categoryId = category.Id; + + var adminUser = new User { UserName = "admin1", PhoneNumber = NextPhone(), Gender = "male", Name = "ادمین", FamilyName = "سیستم", IsActive = true }; + Db.Users.Add(adminUser); + Db.SaveChanges(); + AdminUserId = adminUser.Id; + + var customerUser = new User { UserName = "cust1", PhoneNumber = NextPhone(), Gender = "male", Name = "علی", FamilyName = "رضایی", IsActive = true }; + Db.Users.Add(customerUser); + Db.SaveChanges(); + var customer = new CustomerProfile { UserId = customerUser.Id }; + Db.Set().Add(customer); + Db.SaveChanges(); + CustomerId = customer.Id; + + var patient = new Patient { CustomerId = customer.Id, DisplayName = "پدر", FirstName = "حسن", LastName = "رضایی", Gender = "male", IsActive = true }; + Db.Set().Add(patient); + var address = new CustomerAddress + { + CustomerId = customer.Id, CityId = city.Id, Title = "خانه", AddressLine = "خیابان اول", + PostalCode = "1111111111", RecipientName = "علی", RecipientPhone = "09120000001", + Latitude = 35.6892m, Longitude = 51.3890m, IsPrimary = true + }; + Db.Set().Add(address); + Db.SaveChanges(); + _patientId = patient.Id; + _addressId = address.Id; + } + + private string NextPhone() => $"0912000{++_phoneSeq:0000}"; + + /// Seeds a bookable nurse. When is set, also seeds a verified primary + /// bank account (is_primary + is_verified + matched_national_id) with the given IBAN. + public long SeedNurse(string? verifiedIban = "IR000000000000000000000123") + { + var nurseUser = new User { UserName = $"nurse_{Guid.NewGuid():N}", PhoneNumber = NextPhone(), Gender = "female", Name = "زهرا", FamilyName = "احمدی", IsActive = true }; + Db.Users.Add(nurseUser); + Db.SaveChanges(); + var nurse = new NurseProfile { UserId = nurseUser.Id }; + nurse.MarkVerified(); + nurse.SetAcceptingBookings(true); + Db.Set().Add(nurse); + Db.SaveChanges(); + + if (verifiedIban is not null) + { + var account = new NurseBankAccount + { + NurseId = nurse.Id, BankName = "بانک ملی", AccountHolderName = "زهرا احمدی", + Iban = verifiedIban, IbanHash = $"hash-{nurse.Id}", IsPrimary = true, IsVerified = true, + MatchedNationalId = true, AccountHolderFromBank = "زهرا احمدی", OwnershipVendorRef = "mock" + }; + Db.Set().Add(account); + Db.SaveChanges(); + } + + return nurse.Id; + } + + /// Seeds a completed booking with the given dispute-window close. Amounts satisfy + /// gross = commission + payout. When is set the booking is moved to + /// disputed (payout-ineligible). + public long SeedCompletedBooking( + long nurseId, DateTime disputeWindowEndsAt, long gross = 10_000_000, long commission = 1_500_000, bool disputed = false) + { + var variant = new NurseServiceVariant + { + NurseId = nurseId, ServiceCategoryId = _categoryId, Price = gross, PriceUnit = "per_day", + SessionCount = null, DisplayName = "مراقبت روزانه", OptionSetHash = $"hash-{Guid.NewGuid():N}", IsActive = true + }; + Db.Set().Add(variant); + Db.SaveChanges(); + + var request = new BookingRequest + { + CustomerId = CustomerId, NurseId = nurseId, PatientId = _patientId, VariantId = variant.Id, + CustomerAddressId = _addressId, RequiredCaregiverGender = CaregiverGender.Any, + RequestedDate = new DateOnly(2026, 6, 1), RequestedTimeStart = new TimeOnly(9, 0), RequestedTimeEnd = new TimeOnly(13, 0), + CustomerNotes = "note", NurseResponseDeadlineAt = new DateTime(2030, 1, 1, 0, 0, 0, DateTimeKind.Utc) + }; + Db.Set().Add(request); + Db.SaveChanges(); + request.Accept(new DateTime(2030, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + Db.SaveChanges(); + + var confirmedAt = new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc); + var booking = new BookingEntity + { + BookingRequestId = request.Id, CustomerId = CustomerId, NurseId = nurseId, PatientId = _patientId, + VariantId = variant.Id, CustomerAddressId = _addressId, VariantSnapshotJson = "{}", AddressSnapshotJson = "{}", + GrossPriceIrr = gross, BalinyaarCommissionIrr = commission, PlatformFeeRate = 0.15m, + NursePayoutAmount = gross - commission, SessionCount = 1, + ScheduledDate = new DateOnly(2026, 6, 1), ScheduledTimeStart = new TimeOnly(9, 0), ScheduledTimeEnd = new TimeOnly(13, 0) + }; + booking.TransitionTo(BookingStatus.Confirmed, confirmedAt); + booking.TransitionTo(BookingStatus.InProgress, confirmedAt); + booking.TransitionTo(BookingStatus.Completed, confirmedAt); + booking.SetDisputeWindow(disputeWindowEndsAt); + if (disputed) + booking.TransitionTo(BookingStatus.Disputed, confirmedAt); + Db.Set().Add(booking); + Db.SaveChanges(); + + // The capture accrual so nurse_payable starts positive and the payout drains it to zero. + var legs = LedgerPosting.CardCapture(booking.Id, nurseId, gross, commission, gross - commission, 0, confirmedAt); + Db.Set().AddRange(legs); + Db.SaveChanges(); + + return booking.Id; + } + + /// Seeds a pending clawback the nurse owes back (opened by a prior post-payout refund in b11). + public long SeedPendingClawback(long nurseId, long bookingId, long amount) + { + // A minimal refund row to satisfy the clawback's required refund_id FK. + var refund = new Refund + { + PaymentTransactionId = SeedThrowawayTransaction(bookingId), BookingId = bookingId, RequestedByCustomerId = CustomerId, + Amount = amount, PlatformFeeRefundedIrr = 0, NursePayoutRefundedIrr = amount, RefundPercentage = 1m, + RefundChannel = RefundChannel.PspCard + }; + Db.Set().Add(refund); + Db.SaveChanges(); + + var clawback = new NurseClawback { NurseId = nurseId, BookingId = bookingId, RefundId = refund.Id, AmountIrr = amount }; + Db.Set().Add(clawback); + Db.SaveChanges(); + return clawback.Id; + } + + private long SeedThrowawayTransaction(long bookingId) + { + var gateway = new PaymentGateway { ProviderCode = "zarinpal", Type = PaymentGatewayType.Standard, DisplayName = "GW", ConfigJson = "{}", IsActive = true, Priority = 0 }; + Db.Set().Add(gateway); + Db.SaveChanges(); + var nurseBookingRequestId = Db.Set().Where(b => b.Id == bookingId).Select(b => b.BookingRequestId).First(); + var txn = new PaymentTransaction + { + BookingRequestId = nurseBookingRequestId, CustomerId = CustomerId, GatewayId = gateway.Id, + Amount = 10_000_000, GatewayReferenceCode = $"ref-{Guid.NewGuid():N}" + }; + txn.MarkSucceeded(bookingId, "ok", null); + Db.Set().Add(txn); + Db.SaveChanges(); + return txn.Id; + } + + public ICurrentUser AsAdmin(int? userId = null) + { + var u = Substitute.For(); + u.UserId.Returns(userId ?? AdminUserId); + u.Roles.Returns(new[] { RoleNames.Admin }); + return u; + } + + public IDateTimeProvider Clock(DateTimeOffset now) + { + var c = Substitute.For(); + c.UtcNow.Returns(now); + return c; + } + + /// Identity holiday calendar (no shift) unless maps a closed day to its + /// next business day. + public IHolidayCalendar Holidays(params (DateOnly Closed, DateOnly Next)[] shifts) + { + var h = Substitute.For(); + h.NextBusinessDay(Arg.Any(), Arg.Any()) + .Returns(ci => + { + var d = ci.Arg(); + foreach (var (closed, next) in shifts) + if (closed == d) return next; + return d; + }); + h.IsBankClosed(Arg.Any(), Arg.Any()) + .Returns(ci => shifts.Any(s => s.Closed == ci.Arg())); + return h; + } + + public IPlatformConfig Config(long satnaThresholdIrr = 1_000_000_000, bool requireBnplSettlement = false) + { + var cfg = Substitute.For(); + cfg.GetConfig("payout_satna_threshold_irr", Arg.Any()).Returns(satnaThresholdIrr); + cfg.GetConfig("require_bnpl_settlement_for_payout", Arg.Any()).Returns(requireBnplSettlement); + return cfg; + } + + public IBankTransferProvider Bank(bool forceFailure = false, string failIban = "") + => new MockBankTransferProvider(Options.Create(new SeamOptions + { + BankTransfer = new BankTransferOptions { ForceFailure = forceFailure, FailIban = failIban } + })); + + public IDistributedLock Lock() => new NoOpLock(); + + public IReadOnlyList LedgerFor(long? bookingId, long? nurseId = null) + => Db.Set().AsNoTracking() + .Where(l => (bookingId == null || l.BookingId == bookingId) && (nurseId == null || l.NurseId == nurseId)) + .OrderBy(l => l.Id).ToList(); + + public long NursePayableBalance(long nurseId) + => Db.Set().AsNoTracking() + .Where(l => l.AccountType == LedgerAccountType.NursePayable && l.NurseId == nurseId) + .Sum(l => l.Direction == LedgerDirection.Credit ? l.AmountIrr : -l.AmountIrr); + + public void Dispose() + { + Db.Dispose(); + _connection.Dispose(); + } + + private sealed class NoOpLock : IDistributedLock + { + public ValueTask AcquireAsync(string key, CancellationToken cancellationToken = default) + => ValueTask.FromResult(new Handle()); + + private sealed class Handle : IAsyncDisposable + { + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } + } +}