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. */}
+
+
+
+
+
+ {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