backend phase 13 & frontend phase 6

This commit is contained in:
hamid
2026-07-09 04:09:35 +03:30
parent dc64472631
commit de53f9d8a6
97 changed files with 11969 additions and 77 deletions
+13 -3
View File
@@ -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)
+61 -4
View File
@@ -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",
+61 -4
View File
@@ -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": "ورود به بلینیار",
@@ -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 (
<Suspense fallback={<AppLoading />}>
<BookingRequestDeferred />
</Suspense>
);
}
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 <PlaceholderScreen icon="bookings" title={t('request_title')} description={[t('deferred'), echo].join(' ')} />;
}
@@ -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 <ProfileSkeleton />;
if (isError) {
const notFound = error instanceof ApiError && error.status === 404;
return (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 1 }}>
{notFound ? t('profile_not_found_title') : t('profile_error_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}>
{notFound ? t('profile_not_found_body') : t('profile_error_body')}
</Typography>
<AppButton
variant="outlined"
color="primary"
onClick={() => (notFound ? router.push(`/${locale}${ROUTES.SEARCH}`) : refetch())}
sx={{ m: 0 }}
>
{notFound ? t('profile_not_found_cta') : t('retry')}
</AppButton>
</Paper>
);
}
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 (
<Stack sx={{ gap: 3 }}>
<ProfileHeader profile={profile} />
<AttributeChips profile={profile} />
<ServicesSection profile={profile} />
<LatestReview profile={profile} />
<AppButton
color="primary"
variant="contained"
size="large"
onClick={requestBooking}
startIcon="bookings"
sx={{ m: 0, py: 1.5 }}
>
{t('request_booking')}
</AppButton>
</Stack>
);
}
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 (
<Stack sx={{ gap: 1.5 }}>
<Stack direction="row" sx={{ gap: 2, alignItems: 'center' }}>
<Avatar
src={profile.avatarUrl ?? undefined}
sx={{ width: 72, height: 72, bgcolor: 'var(--bal-primary-soft)', color: 'var(--bal-primary)', fontWeight: 700, fontSize: 28 }}
>
{name.charAt(0)}
</Avatar>
<Stack sx={{ gap: 0.5 }}>
<Typography variant="h5" component="h1">
{name}
</Typography>
<Stack direction="row" sx={{ gap: 0.5, alignItems: 'center' }}>
<AppIcon icon="star" size={18} color="var(--bal-warning)" />
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{rating}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('reviews_count', { count: profile.totalReviews })}
</Typography>
</Stack>
</Stack>
</Stack>
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
<TrustBadge state="verified" />
{profile.inoMembership ? (
<Chip
icon={<AppIcon icon="license" size={16} color="var(--bal-primary)" />}
label={t('badge_ino')}
sx={{ backgroundColor: 'var(--bal-primary-soft)', color: 'var(--bal-primary)', fontWeight: 700 }}
/>
) : null}
</Stack>
{profile.bio ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{profile.bio}
</Typography>
) : null}
</Stack>
);
}
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 (
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
{chips.map((label) => (
<Chip key={label} label={label} variant="outlined" />
))}
</Stack>
);
}
function ServicesSection({ profile }: { profile: NurseProfile }) {
const t = useTranslations('search');
return (
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('services_title')}
</Typography>
{profile.services.length === 0 ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('services_empty')}
</Typography>
) : (
<Box>
{profile.services.map((service) => (
<ServicePriceRow
key={service.variantId}
displayName={service.displayName}
priceIrr={service.priceIrr}
priceUnit={service.priceUnit}
sessionCount={service.sessionCount}
/>
))}
</Box>
)}
</Stack>
);
}
function LatestReview({ profile }: { profile: NurseProfile }) {
const t = useTranslations('search');
const locale = useLocale();
const review = profile.latestReview;
return (
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('latest_review_title')}
</Typography>
{!review ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('no_reviews')}
</Typography>
) : (
<Paper elevation={0} sx={{ p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack direction="row" sx={{ gap: 0.5, alignItems: 'center', mb: 0.5 }}>
<AppIcon icon="star" size={16} color="var(--bal-warning)" />
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US').format(review.rating)}
</Typography>
<Divider orientation="vertical" flexItem sx={{ mx: 1 }} />
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{review.authorMasked} · {formatShamsiDate(review.createdAt, locale)}
</Typography>
</Stack>
<Typography variant="body2">{review.body}</Typography>
</Paper>
)}
</Stack>
);
}
function ProfileSkeleton() {
return (
<Stack sx={{ gap: 3 }}>
<Stack direction="row" sx={{ gap: 2, alignItems: 'center' }}>
<Skeleton variant="circular" width={72} height={72} />
<Stack sx={{ gap: 1, flexGrow: 1 }}>
<Skeleton variant="text" width="60%" height={32} />
<Skeleton variant="text" width="40%" />
</Stack>
</Stack>
<Skeleton variant="rounded" height={72} />
<Skeleton variant="rounded" height={120} />
<Skeleton variant="rounded" height={96} />
</Stack>
);
}
@@ -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 (
<Suspense fallback={<AppLoading />}>
<SearchDeferred />
<SearchFilterScreen />
</Suspense>
);
}
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 (
<PlaceholderScreen
icon="search"
title={t('title')}
description={[t('deferred'), echo].filter(Boolean).join(' ')}
/>
<Stack sx={{ gap: 3 }}>
<Box>
<Typography variant="h5" component="h1">
{t('title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('subtitle')}
</Typography>
</Box>
<CategorySelect selectedId={controller.categoryId} onSelect={controller.setCategoryId} />
<FilterSection title={t('section_location')}>
<CascadingRegionSelect value={controller.region} onChange={controller.setRegion} includeDistrict />
</FilterSection>
<FilterSection title={t('section_gender')} hint={t('gender_hint')}>
<ToggleButtonGroup
exclusive
fullWidth
color="primary"
value={controller.gender ?? 'any'}
onChange={(_event, value: NurseGender | 'any' | null) => {
if (value != null) controller.setGender(value === 'any' ? undefined : value);
}}
>
{GENDER_OPTIONS.map((option) => (
<ToggleButton key={option} value={option} sx={{ fontWeight: 700 }}>
{t(`gender_${option}`)}
</ToggleButton>
))}
</ToggleButtonGroup>
</FilterSection>
<FilterSection title={t('section_date')} hint={t('date_hint')}>
<TextField
type="date"
fullWidth
value={controller.dateIntent}
onChange={(event) => controller.setDateIntent(event.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
/>
</FilterSection>
<FilterSection title={t('section_price')} hint={t('price_hint')}>
<Stack direction="row" sx={{ gap: 2 }}>
<PriceField
label={t('price_min')}
value={controller.priceMinToman}
onChange={controller.setPriceMinToman}
adornment={t('toman')}
/>
<PriceField
label={t('price_max')}
value={controller.priceMaxToman}
onChange={controller.setPriceMaxToman}
adornment={t('toman')}
/>
</Stack>
</FilterSection>
<AppButton
color="primary"
variant="contained"
size="large"
disabled={!controller.isReady}
onClick={goToResults}
startIcon="search"
sx={{ m: 0, py: 1.5 }}
>
{ctaLabel}
</AppButton>
</Stack>
);
}
const FilterSection: FunctionComponent<{ title: string; hint?: string; children: ReactNode }> = ({
title,
hint,
children,
}) => (
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{title}
</Typography>
{hint ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{hint}
</Typography>
) : null}
{children}
</Stack>
);
const PriceField: FunctionComponent<{
label: string;
value: string;
onChange: (value: string) => void;
adornment: string;
}> = ({ label, value, onChange, adornment }) => (
<TextField
label={label}
value={value}
onChange={(event) => onChange(event.target.value)}
inputMode="numeric"
fullWidth
slotProps={{
input: { endAdornment: <InputAdornment position="end">{adornment}</InputAdornment> },
}}
/>
);
/** 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 (
<FilterSection title={t('section_category')}>
{isLoading ? (
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: 'repeat(2, 1fr)', sm: 'repeat(3, 1fr)' }, gap: 1.5 }}>
{[0, 1, 2, 3].map((key) => (
<Skeleton key={key} variant="rounded" height={116} sx={{ borderRadius: 2 }} />
))}
</Box>
) : isError ? (
<Paper elevation={0} sx={{ p: 3, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('categories_error')}
</Typography>
</Paper>
) : (
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: 'repeat(2, 1fr)', sm: 'repeat(3, 1fr)' }, gap: 1.5 }}>
{categories.map((category) => (
<CategoryTile
key={category.id}
label={pickCatalogName(category, locale)}
iconKey={category.iconKey}
selected={category.id === selectedId}
onClick={() => onSelect(category.id)}
/>
))}
</Box>
)}
</FilterSection>
);
};
@@ -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 (
<Suspense fallback={<AppLoading />}>
<ResultsScreen />
</Suspense>
);
}
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 (
<Stack sx={{ gap: 2 }}>
<Stack direction="row" sx={{ gap: 2, alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap' }}>
<Typography variant="h6" component="h1">
{isLoading ? t('results_loading_title') : t('results_count', { count: total })}
</Typography>
{/* Rating is the only MVP sort; rendered as a control with a single option. Other sorts DEFERRED. */}
<TextField select size="small" label={t('sort_label')} value="rating" sx={{ minWidth: 160 }}>
<MenuItem value="rating">{t('sort_rating')}</MenuItem>
</TextField>
</Stack>
{isLoading ? (
<Stack sx={{ gap: 1.5 }}>
{[0, 1, 2, 3].map((key) => (
<Skeleton key={key} variant="rounded" height={112} sx={{ borderRadius: 2 }} />
))}
</Stack>
) : isError ? (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}>
{t('results_error')}
</Typography>
<AppButton variant="outlined" color="primary" onClick={() => refetch()} sx={{ m: 0 }}>
{t('retry')}
</AppButton>
</Paper>
) : items.length === 0 ? (
<EmptyState onRelax={backToFilters} />
) : (
<Stack sx={{ gap: 1.5 }}>
{items.map((nurse) => (
<NurseResultCard key={`${nurse.nurseId}-${nurse.variantId}`} nurse={nurse} onSelect={openProfile} />
))}
{hasMore ? (
<AppButton
variant="outlined"
color="primary"
onClick={() => setPageSize((size) => size + SEARCH_PAGE_SIZE)}
disabled={isFetching}
sx={{ m: 0, alignSelf: 'center' }}
>
{t('load_more')}
</AppButton>
) : null}
</Stack>
)}
</Stack>
);
}
/** The "no nurses match → relax your filters" state with concrete, product-aligned suggestions. */
function EmptyState({ onRelax }: { onRelax: () => void }) {
const t = useTranslations('search');
return (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
<AppIcon icon="search" size={40} color="var(--bal-text-secondary)" />
<Typography variant="subtitle1" sx={{ fontWeight: 700, mt: 1 }}>
{t('empty_title')}
</Typography>
<Stack sx={{ gap: 0.5, mt: 1, mb: 2 }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('empty_suggest_gender')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('empty_suggest_district')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('empty_suggest_city')}
</Typography>
</Stack>
<AppButton variant="contained" color="primary" onClick={onRelax} startIcon="tune" sx={{ m: 0 }}>
{t('empty_cta')}
</AppButton>
</Paper>
);
}
@@ -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<number | null>(initialCategoryId ?? null);
const [region, setRegion] = useState<CascadingRegionValue>(EMPTY_REGION);
const [gender, setGender] = useState<NurseGender | undefined>(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,
};
}
@@ -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(
<ThemeProvider>
<NurseResultCard nurse={nurse} onSelect={onSelect} />
</ThemeProvider>,
);
return onSelect;
}
describe('<NurseResultCard/> 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(
<ThemeProvider>
<NurseResultCard nurse={NURSE} onSelect={jest.fn()} />
</ThemeProvider>,
);
expect(screen.getByText('distance_km')).toBeInTheDocument();
rerender(
<ThemeProvider>
<NurseResultCard nurse={{ ...NURSE, distanceKm: null }} onSelect={jest.fn()} />
</ThemeProvider>,
);
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);
});
});
@@ -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 (
<Paper
elevation={0}
onClick={() => 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 },
}}
>
<Avatar
src={nurse.avatarUrl ?? undefined}
sx={{ width: 56, height: 56, bgcolor: 'var(--bal-primary-soft)', color: 'var(--bal-primary)', fontWeight: 700 }}
>
{initial}
</Avatar>
<Stack sx={{ gap: 0.75, flexGrow: 1, minWidth: 0 }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{name}
</Typography>
<TrustBadge state="verified" />
</Stack>
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center', flexWrap: 'wrap' }}>
<Stack direction="row" sx={{ gap: 0.5, alignItems: 'center' }}>
<AppIcon icon="star" size={16} color="var(--bal-warning)" />
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{ratingText(nurse.averageRating, locale)}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('reviews_count', { count: nurse.totalReviews })}
</Typography>
</Stack>
{distance != null ? (
<Stack direction="row" sx={{ gap: 0.25, alignItems: 'center' }}>
<AppIcon icon="location" size={16} color="var(--bal-text-secondary)" />
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('distance_km', { km: distance })}
</Typography>
</Stack>
) : null}
</Stack>
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.5, flexWrap: 'wrap' }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('price_from')}
</Typography>
<PriceDisplay price={nurse.priceFromIrr} priceUnit={nurse.priceUnit} align="start" />
</Box>
</Stack>
</Paper>
);
};
export default memo(NurseResultCard);
@@ -0,0 +1,2 @@
export { default } from './NurseResultCard';
export type { NurseResultCardProps } from './NurseResultCard';
@@ -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<typeof ServicePriceRow>) {
return render(
<ThemeProvider>
<ServicePriceRow {...props} />
</ThemeProvider>,
);
}
describe('<ServicePriceRow/> 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();
});
});
@@ -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<ServicePriceRowProps> = ({
displayName,
priceIrr,
priceUnit,
sessionCount,
}) => (
<Stack
direction="row"
sx={{
gap: 2,
alignItems: 'center',
justifyContent: 'space-between',
py: 1.5,
borderBottom: '1px solid',
borderColor: 'divider',
}}
>
<Typography variant="body1" sx={{ fontWeight: 600, flexGrow: 1 }}>
{displayName}
</Typography>
<PriceDisplay price={priceIrr} priceUnit={priceUnit} sessionCount={sessionCount} align="start" />
</Stack>
);
export default ServicePriceRow;
@@ -0,0 +1,2 @@
export { default } from './ServicePriceRow';
export type { ServicePriceRowProps } from './ServicePriceRow';
@@ -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,
};
+6
View File
@@ -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';
+7 -1
View File
@@ -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',
@@ -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<Paginated<NurseSearchResult>> => {
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<ApiEnvelope<Paginated<NurseSearchResultDto>>>(
`${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<NurseProfile> => {
// 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<ApiEnvelope<TrustBadge>>(`${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',
};
},
};
+10
View File
@@ -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;
+130
View File
@@ -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<Paginated<NurseSearchResult>> => {
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<NurseProfile> => {
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,
};
},
};
+180
View File
@@ -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',
},
},
];
+24
View File
@@ -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;
@@ -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,
};
}
@@ -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<T>(value: T, delayMs: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delayMs);
return () => clearTimeout(timer);
}, [value, delayMs]);
return debounced;
}
@@ -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,
});
}
@@ -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,
});
}
+3
View File
@@ -0,0 +1,3 @@
export { useNurseSearch } from './hooks/useNurseSearch';
export { useNurseProfile } from './hooks/useNurseProfile';
export { useDebouncedValue } from './hooks/useDebouncedValue';
+37
View File
@@ -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<string, string | number> {
const canonical: Record<string, string | number> = {
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,
};
+129
View File
@@ -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<T>` 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<Paginated<NurseSearchResult>>;
/** The C3 nurse profile (identity + badges + services + latest review). */
getNurseProfile(nurseId: number): Promise<NurseProfile>;
}
+99
View File
@@ -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<EligibleNurseEarningsDto>`.
- **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<PayoutBatchDto>`.
### `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<NursePayoutHistoryDto>`.
- **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.
File diff suppressed because it is too large Load Diff
@@ -12,6 +12,25 @@ One block per completed backend phase. Newest at the top. Backend lane writes he
- **Notes for frontend:** <anything load-bearing>
-->
## 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/
@@ -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).
@@ -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
@@ -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
@@ -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.
@@ -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.
@@ -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.
+18
View File
@@ -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`.
+36 -4
View File
@@ -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
@@ -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;
/// <summary>
/// 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 <c>nurse_payout_booking_links.booking_id</c> UNIQUE; processing is the one irreversible step.
/// </summary>
[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<PagedResult<EligibleNurseEarningsDto>>]
public async Task<IActionResult> Eligible([FromQuery] ComputeEligibleEarningsQuery query, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(query, cancellationToken));
[HttpPost("batches")]
[ProducesOkApiResponseType<GeneratePayoutBatchResult>]
public async Task<IActionResult> Generate(GeneratePayoutBatchCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command, cancellationToken));
[HttpPost("batches/{id}/process")]
[ProducesOkApiResponseType<ExecutePayoutBatchResult>]
public async Task<IActionResult> Process(long id, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new ExecutePayoutBatchCommand(id), cancellationToken));
[HttpGet("batches/{id}")]
[ProducesOkApiResponseType<PayoutBatchDetailDto>]
public async Task<IActionResult> 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<PagedResult<PayoutBatchDto>>]
public async Task<IActionResult> List([FromQuery] ListPayoutBatchesQuery query, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(query, cancellationToken));
[HttpPost("{payoutId}/retry")]
[ProducesOkApiResponseType<bool>]
public async Task<IActionResult> Retry(long payoutId, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new RetryFailedPayoutCommand(payoutId), cancellationToken));
[HttpPost("{payoutId}/mark_failed")]
[ProducesOkApiResponseType<bool>]
public async Task<IActionResult> MarkFailed(long payoutId, MarkPayoutFailedBody body, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new MarkPayoutFailedCommand(payoutId, body.FailureReason), cancellationToken));
/// <summary>The mark-failed body (the payout id comes from the route).</summary>
public record MarkPayoutFailedBody(string FailureReason);
}
@@ -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;
/// <summary>The signed-in nurse's own payout history — tenancy-scoped to <c>ICurrentUser</c> (a nurse can never
/// read another nurse's payouts). Feeds f12's earnings screen: status, net, masked IBAN + transfer reference, and
/// any clawback applied.</summary>
[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<PagedResult<NursePayoutHistoryDto>>]
public async Task<IActionResult> History([FromQuery] GetNursePayoutHistoryQuery query, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(query, cancellationToken));
}
@@ -0,0 +1,71 @@
#nullable enable
namespace Baya.Application.Contracts.Payments;
/// <summary>
/// The swappable PAYA/SATNA <b>bank payout rail</b> — 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
/// <paramref name="idempotencyKey" /> and the whole submit is idempotent: a retried
/// <see cref="SubmitPayoutBatchAsync" /> for the same key never re-sends an already-<c>paid</c> instruction.
/// Handlers depend only on this contract; the concrete provider is a config-selected registration change, never
/// an <c>if (mock)</c> branch. <b>Every amount crossing this seam is IRR <c>long</c>.</b>
/// </summary>
public interface IBankTransferProvider
{
/// <summary>Submits one <see cref="PayoutInstruction" /> per payout to the rail and returns a deterministic
/// <c>externalBatchRef</c> plus a per-instruction result carrying the bank track id
/// (<c>transfer_reference</c>) and status. The mock moves no money; a real transferor registers the batch
/// against the source account and routes each PAYA/SATNA transfer.</summary>
ValueTask<PayoutBatchSubmitResult> SubmitPayoutBatchAsync(
long payoutBatchId,
IReadOnlyList<PayoutInstruction> instructions,
string idempotencyKey,
CancellationToken cancellationToken = default);
/// <summary>The reconciliation read — echoes the batch's settled status (the real callback flips
/// <c>submitted → paid/failed</c>).</summary>
ValueTask<BankTransferStatus> GetPayoutStatusAsync(string externalBatchRef, CancellationToken cancellationToken = default);
}
/// <summary>The settled state of a single transfer (or the batch echo). Forward-only on the payout row.</summary>
public enum BankTransferStatus
{
/// <summary>The rail accepted the instruction (a track id was issued) but the transfer is not yet confirmed.</summary>
Submitted,
/// <summary>The transfer is confirmed — an irreversible IBAN movement.</summary>
Paid,
/// <summary>The rail rejected the transfer (closed day / insufficient provider balance / bad Sheba).</summary>
Failed
}
/// <summary>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).</summary>
public static class BankTransferMethod
{
/// <summary>Batch ACH-style clearing — the default for ordinary-value payouts.</summary>
public const string Paya = "paya";
/// <summary>Real-time gross settlement — chosen for high-value rows above the SATNA threshold.</summary>
public const string Satna = "satna";
}
/// <param name="PayoutId">The <c>nurse_payouts</c> row this instruction settles.</param>
/// <param name="Iban">The nurse's verified primary Sheba (the payout destination).</param>
/// <param name="AmountIrr">The net amount to transfer (IRR).</param>
/// <param name="Method">A <see cref="BankTransferMethod" /> code — PAYA or SATNA.</param>
public sealed record PayoutInstruction(long PayoutId, string Iban, long AmountIrr, string Method);
/// <param name="ExternalBatchRef">The rail's own batch reference, for reconciliation.</param>
/// <param name="Results">One result per submitted instruction.</param>
public sealed record PayoutBatchSubmitResult(string ExternalBatchRef, IReadOnlyList<PayoutInstructionResult> Results);
/// <param name="PayoutId">The payout this result belongs to.</param>
/// <param name="Status">The transfer outcome — drives the payout status machine.</param>
/// <param name="TransferReference">The bank track id, when the rail accepted it; null on failure.</param>
/// <param name="Method">The rail the transfer took (the honoured <see cref="PayoutInstruction.Method" />).</param>
/// <param name="FailureReason">Why the rail rejected it, when <see cref="Status" /> is
/// <see cref="BankTransferStatus.Failed" />.</param>
public sealed record PayoutInstructionResult(
long PayoutId, BankTransferStatus Status, string? TransferReference, string Method, string? FailureReason);
@@ -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;
/// <summary>
/// The payouts aggregate — the weekly batch, its per-nurse payouts, and the anti-double-pay booking links. Reads
/// project to DTOs (<c>AsNoTracking</c> + <c>.Select</c>); writes load tracked rows. The payout <b>ledger legs</b>
/// are appended through <see cref="IPaymentRepository.AddLedgerEntriesAsync"/> (b10's helper) — this repo owns the
/// payout rows and the money facts a batch is built from. Money is IRR <c>long</c>. The
/// <c>nurse_payout_booking_links.booking_id</c> UNIQUE is the authoritative one-payout-per-booking backstop; the
/// eligibility predicate's "not already linked" filter is the fast first line.
/// </summary>
public interface IPayoutRepository
{
// ---- eligibility (preview + build) ----
/// <summary>The payout-eligible, unpaid bookings for the window: <c>status='completed'</c> AND
/// <c>dispute_window_ends_at &lt; now</c> AND no active refund AND not already in a link row (and, when
/// <paramref name="requireBnplSettlement"/> 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.</summary>
Task<IReadOnlyList<EligibleBookingRow>> GetEligibleBookingsAsync(
DateOnly periodStart, DateOnly periodEnd, DateTime now, bool requireBnplSettlement, CancellationToken cancellationToken);
/// <summary>The same eligibility set as a per-nurse preview (paginated), netting pending clawbacks and
/// flagging any nurse without a verified primary IBAN.</summary>
Task<PagedResult<EligibleNurseEarningsDto>> GetEligiblePreviewAsync(
DateOnly periodStart, DateOnly periodEnd, DateTime now, bool requireBnplSettlement, int page, int pageSize, CancellationToken cancellationToken);
/// <summary>The nurse's verified primary payout account (<c>is_primary=1 AND is_verified=1 AND
/// matched_national_id=1</c>) — 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.</summary>
Task<VerifiedPayoutAccount?> GetVerifiedPrimaryAccountAsync(long nurseId, CancellationToken cancellationToken);
/// <summary>The nurse's display name (for the batch preview/detail), or null.</summary>
Task<IReadOnlyDictionary<long, string>> GetNurseNamesAsync(IReadOnlyList<long> nurseIds, CancellationToken cancellationToken);
/// <summary>The sum of the nurse's <c>pending</c> clawbacks (IRR) — netted (capped at earnings) into a payout
/// at build time.</summary>
Task<long> GetPendingClawbackSumAsync(long nurseId, CancellationToken cancellationToken);
/// <summary>The nurse's <c>pending</c> clawbacks, oldest first, tracked — the execute step marks them
/// <c>recovered</c> (with <c>recovered_in_payout_id</c> + <c>resolved_at</c>) up to the payout's frozen
/// <c>clawback_applied_irr</c>.</summary>
Task<IReadOnlyList<NurseClawback>> GetPendingClawbacksAsync(long nurseId, CancellationToken cancellationToken);
// ---- writes ----
Task AddBatchAsync(NursePayoutBatch batch, CancellationToken cancellationToken);
/// <summary>The tracked batch with its payouts + links — loaded by execute/retry to drive the transfers and
/// post the ledger. Null when absent.</summary>
Task<NursePayoutBatch?> GetTrackedBatchAsync(long batchId, CancellationToken cancellationToken);
/// <summary>A single tracked payout (with its batch) — for retry/mark-failed. Null when absent.</summary>
Task<NursePayout?> GetTrackedPayoutAsync(long payoutId, CancellationToken cancellationToken);
/// <summary>Whether a payout already has a posted ledger group — makes the execute ledger post idempotent so
/// a retried execute never double-posts.</summary>
Task<bool> LedgerGroupExistsForPayoutAsync(long payoutId, CancellationToken cancellationToken);
// ---- reads (admin + nurse) ----
Task<PagedResult<PayoutBatchDto>> ListBatchesAsync(string? status, int page, int pageSize, CancellationToken cancellationToken);
Task<PayoutBatchDetailDto?> GetBatchDetailAsync(long batchId, int page, int pageSize, CancellationToken cancellationToken);
/// <summary>The nurse's own payouts (tenancy-scoped), most recent first, projected + paginated. Masked IBAN.</summary>
Task<PagedResult<NursePayoutHistoryDto>> GetNurseHistoryAsync(long nurseId, int page, int pageSize, CancellationToken cancellationToken);
}
/// <summary>The verified primary account a payout snapshots — the account id and the decrypted IBAN (frozen into
/// the encrypted <c>iban_snapshot</c> at build time).</summary>
public record VerifiedPayoutAccount(long BankAccountId, string Iban);
@@ -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();
}
@@ -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;
/// <summary>
/// The one irreversible money-out step. Under <c>lock(payout:batch)</c> it submits the batch's unpaid payouts to
/// the <see cref="IBankTransferProvider"/> (PAYA/SATNA by the config threshold), then for each accepted transfer
/// posts the balanced payout ledger group (<c>DEBIT nurse_payable / CREDIT escrow_held</c>) via b10's helper and
/// nets recovered clawbacks (<c>DEBIT nurse_payable / CREDIT nurse_clawback_receivable</c> + marks the row
/// <c>recovered</c>). 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.
/// </summary>
internal sealed class ExecutePayoutBatchCommandHandler(
IUnitOfWork unitOfWork,
IDistributedLock distributedLock,
IBankTransferProvider bankTransfer,
IPlatformConfig platformConfig,
IDateTimeProvider dateTimeProvider)
: IRequestHandler<ExecutePayoutBatchCommand, OperationResult<ExecutePayoutBatchResult>>
{
public async ValueTask<OperationResult<ExecutePayoutBatchResult>> 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<ExecutePayoutBatchResult>.NotFoundResult("Payout batch not found.");
// Idempotent: a fully-settled batch has nothing left to submit.
if (batch.Status == PayoutBatchStatus.Completed)
return OperationResult<ExecutePayoutBatchResult>.SuccessResult(Summarize(batch));
if (batch.Status == PayoutBatchStatus.Failed)
return OperationResult<ExecutePayoutBatchResult>.ConflictResult("This batch has already failed; open a new batch.");
var satnaThreshold = (long)await platformConfig.GetConfig<decimal>("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<long, PayoutInstructionResult>();
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<ExecutePayoutBatchResult>.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());
}
}
@@ -0,0 +1,11 @@
using FluentValidation;
namespace Baya.Application.Features.Payouts.Commands.ExecutePayoutBatch;
public sealed class ExecutePayoutBatchCommandValidator : AbstractValidator<ExecutePayoutBatchCommand>
{
public ExecutePayoutBatchCommandValidator()
{
RuleFor(x => x.BatchId).GreaterThan(0);
}
}
@@ -0,0 +1,12 @@
using Baya.Application.Models.Common;
using Baya.Application.Models.Payouts;
using Mediator;
namespace Baya.Application.Features.Payouts.Commands.ExecutePayoutBatch;
/// <summary>Submits a draft (or partially-failed) batch to the bank rail: transitions it to <c>processing</c>,
/// sends one instruction per unpaid payout (PAYA/SATNA by the config threshold), posts the balanced payout ledger
/// group out of <c>nurse_payable</c>, nets recovered clawbacks, and settles the batch <c>completed</c> or
/// <c>partially_failed</c>. Idempotent — a retried call never re-sends an already-paid transfer or re-posts the
/// ledger.</summary>
public record ExecutePayoutBatchCommand(long BatchId) : IRequest<OperationResult<ExecutePayoutBatchResult>>;
@@ -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;
/// <summary>
/// Builds a weekly payout batch. Under <c>lock(payout:batch)</c> (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 <b>BuildNursePayouts</b> and
/// <b>LinkPayoutBookings</b> steps from the phase are cohesive private steps here (mirroring b11's
/// <c>CreateRefund</c>). Netting caps clawbacks at whole recoverable amounts; a nurse without a verified primary
/// IBAN is skipped with a recorded reason, never silently dropped. The <c>booking_id</c> UNIQUE link is the
/// backstop that makes a re-run over an overlapping window unable to re-select an already-paid booking.
/// </summary>
internal sealed class GeneratePayoutBatchCommandHandler(
IUnitOfWork unitOfWork,
IDistributedLock distributedLock,
IHolidayCalendar holidays,
IPlatformConfig platformConfig,
IDateTimeProvider dateTimeProvider,
ICurrentUser currentUser)
: IRequestHandler<GeneratePayoutBatchCommand, OperationResult<GeneratePayoutBatchResult>>
{
public async ValueTask<OperationResult<GeneratePayoutBatchResult>> Handle(
GeneratePayoutBatchCommand request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } adminId)
return OperationResult<GeneratePayoutBatchResult>.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<bool>("require_bnpl_settlement_for_payout", cancellationToken);
var eligible = await unitOfWork.PayoutRepository.GetEligibleBookingsAsync(
request.PeriodStart, periodEnd, now, requireBnplSettlement, cancellationToken);
if (eligible.Count == 0)
return OperationResult<GeneratePayoutBatchResult>.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<SkippedNurseDto>();
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<GeneratePayoutBatchResult>.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<GeneratePayoutBatchResult>.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<GeneratePayoutBatchResult>.SuccessResult(
new GeneratePayoutBatchResult(detail!.Batch, detail.Payouts, skipped));
}
/// <summary>
/// The clawback netting: recovers whole <c>pending</c> 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 <c>pending</c> and recovers from a later, larger batch.
/// </summary>
private async Task<long> 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;
}
}
@@ -0,0 +1,18 @@
using FluentValidation;
namespace Baya.Application.Features.Payouts.Commands.GeneratePayoutBatch;
public sealed class GeneratePayoutBatchCommandValidator : AbstractValidator<GeneratePayoutBatchCommand>
{
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.");
}
}
@@ -0,0 +1,12 @@
using Baya.Application.Models.Common;
using Baya.Application.Models.Payouts;
using Mediator;
namespace Baya.Application.Features.Payouts.Commands.GeneratePayoutBatch;
/// <summary>Opens a <c>draft</c> 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 <c>process</c>.</summary>
public record GeneratePayoutBatchCommand(DateOnly PeriodStart, DateOnly PeriodEnd)
: IRequest<OperationResult<GeneratePayoutBatchResult>>;
@@ -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;
/// <summary>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.</summary>
internal sealed class MarkPayoutFailedCommandHandler(
IUnitOfWork unitOfWork,
IDateTimeProvider dateTimeProvider)
: IRequestHandler<MarkPayoutFailedCommand, OperationResult<bool>>
{
public async ValueTask<OperationResult<bool>> 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<bool>.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<bool>.ConflictResult("A paid payout cannot be marked failed.");
// Idempotent.
if (payout.Status == PayoutStatus.Failed)
return OperationResult<bool>.SuccessResult(true);
payout.MarkFailed(request.FailureReason);
batch.RecomputeSettlement(now);
await unitOfWork.CommitAsync();
return OperationResult<bool>.SuccessResult(true);
}
}
@@ -0,0 +1,12 @@
using FluentValidation;
namespace Baya.Application.Features.Payouts.Commands.MarkPayoutFailed;
public sealed class MarkPayoutFailedCommandValidator : AbstractValidator<MarkPayoutFailedCommand>
{
public MarkPayoutFailedCommandValidator()
{
RuleFor(x => x.PayoutId).GreaterThan(0);
RuleFor(x => x.FailureReason).NotEmpty().MaximumLength(500);
}
}
@@ -0,0 +1,8 @@
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Payouts.Commands.MarkPayoutFailed;
/// <summary>Records a reconciled bank rejection on a payout — sets <c>failed</c> with the reason. Posts <b>no</b>
/// ledger movement (no money left the platform). Used when the rail reports a transfer bounced after submit.</summary>
public record MarkPayoutFailedCommand(long PayoutId, string FailureReason) : IRequest<OperationResult<bool>>;
@@ -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;
/// <summary>
/// Re-submits one <c>failed</c> 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 (<c>partially_failed → completed</c> when it was the last failure).
/// </summary>
internal sealed class RetryFailedPayoutCommandHandler(
IUnitOfWork unitOfWork,
IDistributedLock distributedLock,
IBankTransferProvider bankTransfer,
IHolidayCalendar holidays,
IPlatformConfig platformConfig,
IDateTimeProvider dateTimeProvider)
: IRequestHandler<RetryFailedPayoutCommand, OperationResult<bool>>
{
public async ValueTask<OperationResult<bool>> 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<bool>.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<bool>.SuccessResult(true);
if (payout.Status != PayoutStatus.Failed)
return OperationResult<bool>.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<bool>.FailureResult(
"processing_date", "Banks are closed today; retry on the next business day.");
var satnaThreshold = (long)await platformConfig.GetConfig<decimal>("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<bool>.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<bool>.SuccessResult(true);
}
}
@@ -0,0 +1,11 @@
using FluentValidation;
namespace Baya.Application.Features.Payouts.Commands.RetryFailedPayout;
public sealed class RetryFailedPayoutCommandValidator : AbstractValidator<RetryFailedPayoutCommand>
{
public RetryFailedPayoutCommandValidator()
{
RuleFor(x => x.PayoutId).GreaterThan(0);
}
}
@@ -0,0 +1,9 @@
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Payouts.Commands.RetryFailedPayout;
/// <summary>Re-submits a single <c>failed</c> 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.</summary>
public record RetryFailedPayoutCommand(long PayoutId) : IRequest<OperationResult<bool>>;
@@ -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;
/// <summary>
/// The shared money-settlement steps for a paid payout, used by both <c>ExecutePayoutBatch</c> and
/// <c>RetryFailedPayout</c> so the ledger posting + clawback netting live in exactly one place (mirroring b12's
/// extracted <c>BookingConversion</c>). Both operations are idempotent: the ledger-exists guard blocks a second
/// payout group, and a <c>recovered</c> clawback is never re-marked or re-posted.
/// </summary>
internal static class PayoutSettlement
{
/// <summary>Builds the rail instruction — SATNA above the config threshold, else PAYA. The tracked payout's
/// <see cref="NursePayout.IbanSnapshot"/> is decrypted by the EF converter on load.</summary>
public static PayoutInstruction ToInstruction(NursePayout payout, long satnaThreshold)
=> new(payout.Id, payout.IbanSnapshot, payout.Amount,
payout.Amount >= satnaThreshold ? BankTransferMethod.Satna : BankTransferMethod.Paya);
/// <summary>DEBIT <c>nurse_payable</c> / CREDIT <c>escrow_held</c> for the paid net — skipped when the payout
/// is fully netted (net 0) or a group already exists (retry idempotency).</summary>
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);
}
/// <summary>Realizes the payout's frozen <c>clawback_applied_irr</c>: recovers whole pending clawbacks (oldest
/// first, matching the build's greedy cap) — marks each <c>recovered</c> + posts DEBIT <c>nurse_payable</c> /
/// CREDIT <c>nurse_clawback_receivable</c>. Idempotent: on a retry the clawbacks are already <c>recovered</c>.</summary>
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;
}
}
}
@@ -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<ComputeEligibleEarningsQuery, OperationResult<PagedResult<EligibleNurseEarningsDto>>>
{
public async ValueTask<OperationResult<PagedResult<EligibleNurseEarningsDto>>> 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<bool>("require_bnpl_settlement_for_payout", cancellationToken);
var result = await unitOfWork.PayoutRepository.GetEligiblePreviewAsync(
request.PeriodStart, periodEnd, now, requireBnplSettlement, page, pageSize, cancellationToken);
return OperationResult<PagedResult<EligibleNurseEarningsDto>>.SuccessResult(result);
}
}
@@ -0,0 +1,18 @@
using FluentValidation;
namespace Baya.Application.Features.Payouts.Queries.ComputeEligibleEarnings;
public sealed class ComputeEligibleEarningsQueryValidator : AbstractValidator<ComputeEligibleEarningsQuery>
{
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.");
}
}
@@ -0,0 +1,12 @@
using Baya.Application.Models.Common;
using Baya.Application.Models.Payouts;
using Mediator;
namespace Baya.Application.Features.Payouts.Queries.ComputeEligibleEarnings;
/// <summary>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.</summary>
public record ComputeEligibleEarningsQuery(DateOnly PeriodStart, DateOnly PeriodEnd, int Page = 1, int PageSize = 20)
: IRequest<OperationResult<PagedResult<EligibleNurseEarningsDto>>>;
@@ -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<GetBatchDetailQuery, OperationResult<PayoutBatchDetailDto>>
{
public async ValueTask<OperationResult<PayoutBatchDetailDto>> 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<PayoutBatchDetailDto>.NotFoundResult("Payout batch not found.")
: OperationResult<PayoutBatchDetailDto>.SuccessResult(detail);
}
}
@@ -0,0 +1,10 @@
using Baya.Application.Models.Common;
using Baya.Application.Models.Payouts;
using Mediator;
namespace Baya.Application.Features.Payouts.Queries.GetBatchDetail;
/// <summary>Admin batch detail — the header plus its paginated payouts (status, net, masked IBAN + transfer
/// reference) and the bookings each payout covers.</summary>
public record GetBatchDetailQuery(long BatchId, int Page = 1, int PageSize = 50)
: IRequest<OperationResult<PayoutBatchDetailDto>>;
@@ -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<GetNursePayoutHistoryQuery, OperationResult<PagedResult<NursePayoutHistoryDto>>>
{
public async ValueTask<OperationResult<PagedResult<NursePayoutHistoryDto>>> Handle(
GetNursePayoutHistoryQuery request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return OperationResult<PagedResult<NursePayoutHistoryDto>>.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<PagedResult<NursePayoutHistoryDto>>.SuccessResult(
new PagedResult<NursePayoutHistoryDto>([], 0, page, pageSize));
var result = await unitOfWork.PayoutRepository.GetNurseHistoryAsync(id, page, pageSize, cancellationToken);
return OperationResult<PagedResult<NursePayoutHistoryDto>>.SuccessResult(result);
}
}
@@ -0,0 +1,10 @@
using Baya.Application.Models.Common;
using Baya.Application.Models.Payouts;
using Mediator;
namespace Baya.Application.Features.Payouts.Queries.GetNursePayoutHistory;
/// <summary>The signed-in nurse's own payout history (tenancy-scoped to <c>ICurrentUser</c>) — status, net,
/// masked IBAN + transfer reference, any clawback applied, and the batch window. Feeds f12's earnings screen.</summary>
public record GetNursePayoutHistoryQuery(int Page = 1, int PageSize = 20)
: IRequest<OperationResult<PagedResult<NursePayoutHistoryDto>>>;
@@ -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<ListPayoutBatchesQuery, OperationResult<PagedResult<PayoutBatchDto>>>
{
public async ValueTask<OperationResult<PagedResult<PayoutBatchDto>>> 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<PagedResult<PayoutBatchDto>>.SuccessResult(result);
}
}
@@ -0,0 +1,9 @@
using Baya.Application.Models.Common;
using Baya.Application.Models.Payouts;
using Mediator;
namespace Baya.Application.Features.Payouts.Queries.ListPayoutBatches;
/// <summary>Admin reconciliation list of payout batches — projected + paginated, optional status filter.</summary>
public record ListPayoutBatchesQuery(string? Status = null, int Page = 1, int PageSize = 20)
: IRequest<OperationResult<PagedResult<PayoutBatchDto>>>;
@@ -0,0 +1,87 @@
#nullable enable
namespace Baya.Application.Models.Payouts;
/// <summary>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 <c>gross_earnings_irr</c>.</summary>
public record EligibleBookingRow(long NurseId, long BookingId, long PayoutAmountIrr);
/// <summary>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 <b>flagged</b>, not silently dropped). Money crosses the wire as digit strings.</summary>
public record EligibleNurseEarningsDto(
long NurseId,
string? NurseName,
int BookingCount,
string GrossEarningsIrr,
string ClawbackAppliedIrr,
string NetAmountIrr,
bool HasVerifiedPrimaryIban);
/// <summary>A batch header — periods (holiday-shifted), totals, status, and reconciliation timestamps.
/// Money is a digit string.</summary>
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);
/// <summary>The booking a payout covers — with the per-booking amount and (future) session id. Money is a digit
/// string.</summary>
public record PayoutBookingLinkDto(long BookingId, long? SessionId, string PayoutAmountIrr);
/// <summary>One payout in a batch detail — the decomposed amounts, status, the <b>masked</b> IBAN + transfer
/// reference, and the bookings it covers. Money is a digit string.</summary>
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<PayoutBookingLinkDto> Bookings);
/// <summary>A batch header plus its paginated payouts — the admin reconciliation detail view.</summary>
public record PayoutBatchDetailDto(PayoutBatchDto Batch, IReadOnlyList<PayoutDto> Payouts, int Total, int Page, int PageSize);
/// <summary>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.</summary>
public record SkippedNurseDto(long NurseId, string? NurseName, string GrossEarningsIrr, string Reason);
/// <summary>What <c>GeneratePayoutBatchCommand</c> returns — the draft batch, the materialized payouts for admin
/// preview, and the nurses skipped (with reasons).</summary>
public record GeneratePayoutBatchResult(
PayoutBatchDto Batch,
IReadOnlyList<PayoutDto> Payouts,
IReadOnlyList<SkippedNurseDto> Skipped);
/// <summary>What <c>ExecutePayoutBatchCommand</c> returns — the settled batch status and per-outcome counts.</summary>
public record ExecutePayoutBatchResult(long BatchId, string Status, int PaidCount, int FailedCount, string TotalPaid);
/// <summary>A nurse's own payout-history row (tenancy-scoped) — status, net, the <b>masked</b> IBAN + reference,
/// the clawback that was applied, and the batch window it belongs to. Money crosses the wire as digit strings.</summary>
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);
@@ -160,6 +160,58 @@ public static class LedgerPosting
];
}
/// <summary>
/// The <b>payout group</b> (b13): <c>DEBIT nurse_payable / CREDIT escrow_held</c> for the amount actually
/// transferred to the nurse, under one fresh <see cref="LedgerEntry.TransactionGroupId"/>. Draining the
/// <c>nurse_payable</c> accrual to a real bank transfer is the one irreversible money-out step; the balance
/// (the signed sum over <c>nurse_payable</c> legs) drops by exactly what was paid. Posted once per payout —
/// the payout status machine + the <c>nurse_payout_booking_links</c> UNIQUE make a retried execute a no-op.
/// The clawback netted into the payout is <b>not</b> a leg here: the receivable was already booked by b11 and
/// is cleared by marking the <c>nurse_clawbacks</c> row <c>recovered</c> (the net amount is simply lower).
/// </summary>
public static IReadOnlyList<LedgerEntry> 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)
];
}
/// <summary>
/// The clawback <b>recovery</b> netting (b13): when a payout withholds a nurse's <c>pending</c> clawback, the
/// withheld earnings clear the receivable — <c>DEBIT nurse_payable / CREDIT nurse_clawback_receivable</c> for
/// the recovered amount, under one group. Together with the payout group (which debits <c>nurse_payable</c>
/// by the paid net), this drains the nurse's <c>nurse_payable</c> by the full gross and zeroes the receivable,
/// so the derived balances reconcile. Posted once per recovered clawback (the <c>recovered</c> status makes a
/// retry a no-op).
/// </summary>
public static IReadOnlyList<LedgerEntry> 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)
];
}
/// <summary>
/// The clawback <b>write-off</b> correction: <c>DEBIT bad_debt / CREDIT nurse_clawback_receivable</c> 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,
@@ -0,0 +1,93 @@
#nullable enable
using Baya.Domain.Common;
namespace Baya.Domain.Entities.Payouts;
/// <summary>
/// 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 <see cref="GrossEarningsIrr"/>; any
/// <c>pending</c> clawback the nurse owes back is netted into <see cref="ClawbackAppliedIrr"/> so the platform
/// never overpays a nurse with a receivable.
/// <para>
/// <b>Invariant:</b> <see cref="NetAmountIrr"/> = <see cref="GrossEarningsIrr"/>
/// <see cref="ClawbackAppliedIrr"/>; all amounts ≥ 0; <see cref="NetAmountIrr"/> ≥ 0 (a clawback exceeding
/// earnings nets to <b>zero this batch</b>, the remainder staying <c>pending</c> for the next — never a negative
/// transfer). <see cref="Amount"/> is what actually moves; it equals <see cref="NetAmountIrr"/> on success.
/// Money is IRR <c>BIGINT</c>, no floats. <see cref="IbanSnapshot"/> is encrypted at rest and frozen at build
/// time from the nurse's verified primary account. Paid-ness is derived from a
/// <c>nurse_payout_booking_links</c> row + the ledger movement — never a boolean flag.
/// </para>
/// </summary>
public class NursePayout : BaseEntity<long>
{
public long BatchId { get; set; }
public NursePayoutBatch Batch { get; set; } = null!;
public long NurseId { get; set; }
/// <summary>The verified primary account paid (FK <c>nurse_bank_accounts</c>).</summary>
public long BankAccountId { get; set; }
/// <summary>The account's IBAN, frozen at build time and <b>encrypted at rest</b> through the field encryptor.</summary>
public string IbanSnapshot { get; set; } = null!;
/// <summary>Σ eligible booking payouts for the window (IRR).</summary>
public long GrossEarningsIrr { get; set; }
/// <summary>Pending clawbacks netted this batch (IRR, ≥ 0, capped at <see cref="GrossEarningsIrr"/>).</summary>
public long ClawbackAppliedIrr { get; set; }
/// <summary>Derived: <see cref="GrossEarningsIrr"/> <see cref="ClawbackAppliedIrr"/> (IRR, ≥ 0).</summary>
public long NetAmountIrr { get; set; }
/// <summary>Actually transferred net (IRR) — equals <see cref="NetAmountIrr"/> on success.</summary>
public long Amount { get; set; }
public int BookingCount { get; set; }
/// <summary>Guarded — a <see cref="PayoutStatus"/> code, mutated only through the mark-* methods.</summary>
public string Status { get; private set; } = PayoutStatus.Pending;
/// <summary>The bank track id (PAYA/SATNA), set at submit — kept for reconciliation.</summary>
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<NursePayoutBookingLink> BookingLinks { get; set; } = new List<NursePayoutBookingLink>();
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;
}
/// <summary>Records the bank track id and transitions <c>pending|failed → submitted</c>. Clears any prior
/// failure so a retry starts clean.</summary>
public void MarkSubmitted(string transferReference)
{
Transition(PayoutStatus.Submitted);
TransferReference = transferReference;
FailureReason = null;
}
/// <summary>Confirms the transfer and transitions <c>submitted → paid</c> — the irreversible movement.</summary>
public void MarkPaid(DateTime now)
{
Transition(PayoutStatus.Paid);
PaidAt = now;
}
/// <summary>Records a rail rejection and transitions to <c>failed</c> (from pending or submitted).</summary>
public void MarkFailed(string reason)
{
Transition(PayoutStatus.Failed);
FailureReason = reason;
}
}
@@ -0,0 +1,88 @@
#nullable enable
using Baya.Domain.Common;
namespace Baya.Domain.Entities.Payouts;
/// <summary>
/// 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 <see cref="NursePayout"/> per nurse with earnings in the window, then submits them all to the
/// bank rail in one go.
/// <para>
/// <b>Holiday-aware:</b> <see cref="PeriodEnd"/> and <see cref="ProcessingDate"/> are shifted off bank-closed days
/// (via <c>IHolidayCalendar</c>) 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. <b>Invariant:</b>
/// <see cref="TotalAmount"/> = Σ(<c>nurse_payouts.net_amount_irr</c>) and <see cref="PayoutCount"/> =
/// COUNT(payouts) — set by the handler when the rows are materialized and asserted by a verified invariant.
/// Money is IRR <c>BIGINT</c>, no floats.
/// </para>
/// </summary>
public class NursePayoutBatch : BaseEntity<long>
{
public DateOnly PeriodStart { get; set; }
/// <summary>Window end — shifted off <c>is_bank_closed</c> days to the next business day.</summary>
public DateOnly PeriodEnd { get; set; }
/// <summary>The date the transfers are submitted — shifted off <c>is_bank_closed</c> days.</summary>
public DateOnly ProcessingDate { get; set; }
/// <summary>Σ(<c>net_amount_irr</c>) across this batch's payouts (IRR). Set at materialization.</summary>
public long TotalAmount { get; set; }
public int PayoutCount { get; set; }
/// <summary>Guarded — mutated only through <see cref="TransitionTo"/> so every write goes through the machine.</summary>
public string Status { get; private set; } = PayoutBatchStatus.Draft;
/// <summary>The admin who initiated the run (FK <c>users</c>). A future cron sets its own service id.</summary>
public int InitiatedByAdminId { get; set; }
public DateTime? ProcessedAt { get; private set; }
public string? FailureNotes { get; private set; }
public DateTimeOffset? DeletedAt { get; set; }
public ICollection<NursePayout> Payouts { get; set; } = new List<NursePayout>();
public bool CanTransitionTo(string target) => PayoutBatchTransitions.CanTransition(Status, target);
/// <summary>Applies a guarded status change and, on a settling edge, stamps <see cref="ProcessedAt"/>. Reaching
/// an illegal edge is a programming error (callers pre-check), so it fails fast rather than corrupting state.</summary>
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;
}
/// <summary>Freezes the batch totals when the payouts are materialized (the CHECK-mirroring invariant).</summary>
public void SetTotals(long totalAmount, int payoutCount)
{
TotalAmount = totalAmount;
PayoutCount = payoutCount;
}
/// <summary>Re-derives the batch's terminal status from its payouts after an execute or a retry: all paid →
/// <c>completed</c>; some failed but some paid → <c>partially_failed</c>; all failed → <c>failed</c>. A no-op
/// when the resulting edge isn't allowed from the current status (e.g. already <c>partially_failed</c> with a
/// still-failed row). Requires the <see cref="Payouts"/> collection to be loaded.</summary>
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);
}
}
@@ -0,0 +1,32 @@
#nullable enable
using Baya.Domain.Common;
namespace Baya.Domain.Entities.Payouts;
/// <summary>
/// The join from a payout to the specific booking it covers — and the platform's strongest correctness feature:
/// <see cref="BookingId"/> carries a <b>UNIQUE</b> index so a booking can be paid in <b>exactly one</b> payout
/// across all batches, ever. A duplicate insert <i>is</i> 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.
/// <para>
/// The <see cref="BookingId"/> UNIQUE is <b>unconditional</b> (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. <see cref="SessionId"/> is nullable for a future per-session accrual model; today one link per
/// booking carries the whole booking payout. Money is IRR <c>BIGINT</c>.
/// </para>
/// </summary>
public class NursePayoutBookingLink : BaseEntity<long>
{
public long PayoutId { get; set; }
/// <summary><b>UNIQUE</b> (unconditional) across every batch — the hard one-payout-per-booking guard.</summary>
public long BookingId { get; set; }
/// <summary>Set only when paying a per-session accrual; null for whole-booking payment.</summary>
public long? SessionId { get; set; }
/// <summary>The portion of this booking (or session) paid in this payout (IRR).</summary>
public long PayoutAmountIrr { get; set; }
public DateTimeOffset? DeletedAt { get; set; }
}
@@ -0,0 +1,26 @@
namespace Baya.Domain.Entities.Payouts;
/// <summary>
/// The closed <c>nurse_payout_batches.status</c> code set. A batch opens in <see cref="Draft"/> (materialized
/// but not yet submitted to the bank), moves to <see cref="Processing"/> when the transfers are submitted, and
/// ends <see cref="Completed"/> (all paid), <see cref="PartiallyFailed"/> (some rejected — retryable), or
/// <see cref="Failed"/> (the whole submit failed). Persisted as these stable snake_case codes; the allowed edges
/// live in <see cref="PayoutBatchTransitions"/>.
/// </summary>
public static class PayoutBatchStatus
{
/// <summary>Materialized (payouts + links built) but not yet submitted — the admin preview state.</summary>
public const string Draft = "draft";
/// <summary>The bank submit is in flight / partially applied.</summary>
public const string Processing = "processing";
/// <summary>At least one payout was rejected by the rail; the rest paid. Retry the failed rows.</summary>
public const string PartiallyFailed = "partially_failed";
/// <summary>Every payout in the batch was paid. Terminal.</summary>
public const string Completed = "completed";
/// <summary>The whole submit failed (e.g. the rail was unreachable / a closed day). Terminal for the run.</summary>
public const string Failed = "failed";
}
@@ -0,0 +1,25 @@
namespace Baya.Domain.Entities.Payouts;
/// <summary>
/// The allowed-edge table for the <see cref="PayoutBatchStatus"/> machine. A batch opens in
/// <see cref="PayoutBatchStatus.Draft"/>, is submitted (<see cref="PayoutBatchStatus.Processing"/>), then settles
/// to a terminal outcome. <see cref="PayoutBatchStatus.PartiallyFailed"/> is re-enterable: a retry that clears the
/// last failed payout flips it to <see cref="PayoutBatchStatus.Completed"/>.
/// </summary>
public static class PayoutBatchTransitions
{
private static readonly IReadOnlyDictionary<string, IReadOnlyCollection<string>> Allowed =
new Dictionary<string, IReadOnlyCollection<string>>
{
[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);
}
@@ -0,0 +1,24 @@
namespace Baya.Domain.Entities.Payouts;
/// <summary>
/// The closed <c>nurse_payouts.status</c> code set — a <b>forward-only</b> lifecycle that (with the
/// <c>nurse_payout_booking_links.booking_id</c> UNIQUE and the batch lock) makes a retried execute never
/// double-send an irreversible transfer. A payout is materialized <see cref="Pending"/>, moves to
/// <see cref="Submitted"/> when the bank accepts the instruction, and to <see cref="Paid"/> once the transfer
/// track id is confirmed; a rail rejection lands it in <see cref="Failed"/>, from which a retry re-submits.
/// Persisted as these stable snake_case codes; the allowed edges live in <see cref="PayoutStatusTransitions"/>.
/// </summary>
public static class PayoutStatus
{
/// <summary>Materialized into the draft batch but not yet submitted to the rail.</summary>
public const string Pending = "pending";
/// <summary>The bank accepted the transfer instruction (a PAYA/SATNA track id was issued).</summary>
public const string Submitted = "submitted";
/// <summary>The transfer is confirmed paid — an irreversible IBAN movement. Terminal on success.</summary>
public const string Paid = "paid";
/// <summary>The rail rejected the transfer; a retry re-submits the same instruction.</summary>
public const string Failed = "failed";
}
@@ -0,0 +1,25 @@
namespace Baya.Domain.Entities.Payouts;
/// <summary>
/// The forward-only allowed-edge table for the <see cref="PayoutStatus"/> machine (mirrors
/// <c>BnplTransitions</c>/<c>RefundTransitions</c>). Every write goes through <see cref="NursePayout"/>'s
/// cohesive mark-* methods, which assert the edge here — so a replayed execute that would re-drive an
/// already-<see cref="PayoutStatus.Paid"/> row is rejected before it can re-send an irreversible transfer or
/// re-post the ledger. <see cref="PayoutStatus.Failed"/> is the only re-enterable state (a retry re-submits).
/// </summary>
public static class PayoutStatusTransitions
{
private static readonly IReadOnlyDictionary<string, IReadOnlyCollection<string>> Allowed =
new Dictionary<string, IReadOnlyCollection<string>>
{
[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);
}
@@ -39,6 +39,18 @@ public class NurseClawback : BaseEntity<long>
public bool IsPending => Status == ClawbackStatus.Pending;
/// <summary>Netted out of a payout batch (b13): records the recovering payout + resolution. The balancing
/// <c>DEBIT nurse_payable / CREDIT nurse_clawback_receivable</c> posting is the payout handler's job; this
/// records the workflow outcome. Only a <c>pending</c> clawback can be recovered.</summary>
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;
}
/// <summary>Admin declares the receivable uncollectable. The balancing <c>bad_debt</c> posting is the
/// handler's job; this records the workflow outcome.</summary>
public void WriteOff(string notes, DateTime now)
@@ -0,0 +1,49 @@
#nullable enable
using Baya.Application.Contracts.Payments;
using Microsoft.Extensions.Options;
namespace Baya.Infrastructure.CrossCutting.Seams;
/// <summary>
/// A deterministic, network-free mock <see cref="IBankTransferProvider" /> for the PAYA/SATNA payout rail. It
/// moves <b>no money</b>: every instruction gets a deterministic <c>transfer_reference</c> and settles
/// <see cref="BankTransferStatus.Paid" /> (the mock collapses the real <c>submitted → paid</c> reconciliation
/// into one step). It <b>honours</b> the <see cref="PayoutInstruction.Method" /> chosen by the handler (PAYA vs
/// SATNA by the config threshold) and echoes it back. A config switch forces a deterministic failure so the
/// <c>partially_failed</c>/retry paths are testable: <see cref="BankTransferOptions.ForceFailure" /> fails every
/// instruction (→ whole-batch failure), and <see cref="BankTransferOptions.FailIban" /> 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.
/// </summary>
public sealed class MockBankTransferProvider(IOptions<SeamOptions> options) : IBankTransferProvider
{
private readonly BankTransferOptions _options = options.Value.BankTransfer;
public ValueTask<PayoutBatchSubmitResult> SubmitPayoutBatchAsync(
long payoutBatchId,
IReadOnlyList<PayoutInstruction> instructions,
string idempotencyKey,
CancellationToken cancellationToken = default)
{
var results = new List<PayoutInstructionResult>(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<BankTransferStatus> GetPayoutStatusAsync(string externalBatchRef, CancellationToken cancellationToken = default)
=> ValueTask.FromResult(_options.ForceFailure ? BankTransferStatus.Failed : BankTransferStatus.Paid);
}
@@ -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();
}
/// <summary>
/// Tunes the mock <c>IBankTransferProvider</c> (b13 PAYA/SATNA payouts). By default every instruction settles
/// paid with a deterministic transfer reference and no money moves. Set <see cref="ForceFailure"/> to fail the
/// whole batch (→ <c>failed</c>) or <see cref="FailIban"/> to fail just one destination (→ <c>partially_failed</c>,
/// 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.
/// </summary>
public sealed class BankTransferOptions
{
/// <summary>When true, every payout instruction is rejected so the whole-batch-failure path is testable.</summary>
public bool ForceFailure { get; set; }
/// <summary>A designated IBAN that is rejected while others succeed — exercises the <c>partially_failed</c>
/// batch outcome and the single-payout retry.</summary>
public string FailIban { get; set; } = string.Empty;
}
/// <summary>
@@ -73,6 +73,13 @@ public static class ServiceCollectionExtension
services.AddSingleton<IBnplProviderResolver, MockBnplProviderResolver>();
services.AddSingleton<ICurrencyNormalizer, MockCurrencyNormalizer>();
// 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<IBankTransferProvider, MockBankTransferProvider>();
return services;
}
}
@@ -165,5 +165,12 @@ public class ApplicationDbContext: IdentityDbContext<User, Role, int, UserClaim,
{
builder.Property(g => 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<Baya.Domain.Entities.Payouts.NursePayout>(builder =>
{
builder.Property(p => p.IbanSnapshot).HasConversion(encrypted);
});
}
}
@@ -49,6 +49,8 @@ internal sealed class PlatformConfigConfig : IEntityTypeConfiguration<PlatformCo
(19, "refund_ticket_required", "false", ConfigDataType.Bool, "Whether a refund must link a support ticket (b11). Off until b15 ships the tickets table."),
(20, "bnpl_refund_eta_business_days", "10", ConfigDataType.Int, "Business days shown as the customer BNPL refund ETA (b11)."),
(21, "refund_assume_nurse_paid", "false", ConfigDataType.Bool, "Ops/testing override that forces the post-payout clawback path for refunds (b11); b13 replaces the derivation."),
(22, "payout_satna_threshold_irr", "1000000000", ConfigDataType.Decimal, "IRR net-amount threshold above which a payout is routed via SATNA (real-time) instead of PAYA (batch) (b13)."),
(23, "require_bnpl_settlement_for_payout", "false", ConfigDataType.Bool, "When on, a BNPL-paid booking is payout-eligible only after its provider settlement is received (b13; default off — the DEFERRED settled_at guard)."),
];
return rows
@@ -0,0 +1,32 @@
using Baya.Domain.Entities.Payouts;
using Baya.Domain.Entities.User;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Baya.Infrastructure.Persistence.Configuration.PayoutsConfig;
/// <summary>
/// <c>nurse_payout_batches</c> — the weekly aggregation, in the dedicated <c>payouts</c> schema. The
/// <c>total_amount = Σ payouts</c> / <c>payout_count = COUNT(payouts)</c> 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 → <c>nurse_payouts</c>.
/// </summary>
internal sealed class NursePayoutBatchConfig : IEntityTypeConfiguration<NursePayoutBatch>
{
public void Configure(EntityTypeBuilder<NursePayoutBatch> 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<User>().WithMany().HasForeignKey(b => b.InitiatedByAdminId).IsRequired();
builder.HasQueryFilter(b => b.DeletedAt == null);
}
}
@@ -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;
/// <summary>
/// <c>nurse_payout_booking_links</c> — the structural anti-double-pay guard. <c>UNIQUE(booking_id)</c> is
/// <b>unconditional</b> (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 → <c>nurse_payouts</c>;
/// 1:1 → <c>bookings</c> (and, for a future per-session model, <c>booking_sessions</c>).
/// </summary>
internal sealed class NursePayoutBookingLinkConfig : IEntityTypeConfiguration<NursePayoutBookingLink>
{
public void Configure(EntityTypeBuilder<NursePayoutBookingLink> 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<NursePayout>().WithMany(p => p.BookingLinks).HasForeignKey(l => l.PayoutId).IsRequired();
builder.HasOne<Booking>().WithMany().HasForeignKey(l => l.BookingId).IsRequired();
builder.HasOne<BookingSession>().WithMany().HasForeignKey(l => l.SessionId).IsRequired(false);
builder.HasQueryFilter(l => l.DeletedAt == null);
}
}
@@ -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;
/// <summary>
/// <c>nurse_payouts</c> — one row per nurse per batch. The <c>net = gross clawback</c> decomposition + all
/// amounts non-negative + <c>net ≥ 0</c> (never a negative transfer) is a DB CHECK mirroring b9/b11.
/// <c>iban_snapshot</c> is encrypted at rest (converter wired in <c>ApplicationDbContext</c>) 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.
/// </summary>
internal sealed class NursePayoutConfig : IEntityTypeConfiguration<NursePayout>
{
public void Configure(EntityTypeBuilder<NursePayout> 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<NurseProfile>().WithMany().HasForeignKey(p => p.NurseId).IsRequired();
builder.HasOne<NurseBankAccount>().WithMany().HasForeignKey(p => p.BankAccountId).IsRequired();
builder.HasQueryFilter(p => p.DeletedAt == null);
}
}
@@ -0,0 +1,248 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace Baya.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class NursePayoutEngine : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.EnsureSchema(
name: "payouts");
migrationBuilder.CreateTable(
name: "NursePayoutBatches",
schema: "payouts",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
PeriodStart = table.Column<DateOnly>(type: "date", nullable: false),
PeriodEnd = table.Column<DateOnly>(type: "date", nullable: false),
ProcessingDate = table.Column<DateOnly>(type: "date", nullable: false),
TotalAmount = table.Column<long>(type: "bigint", nullable: false),
PayoutCount = table.Column<int>(type: "int", nullable: false),
Status = table.Column<string>(type: "nvarchar(30)", maxLength: 30, nullable: false),
InitiatedByAdminId = table.Column<int>(type: "int", nullable: false),
ProcessedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
FailureNotes = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: true),
DeletedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
ModifiedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedById = table.Column<int>(type: "int", nullable: true),
ModifiedById = table.Column<int>(type: "int", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_NursePayoutBatches", x => x.Id);
table.ForeignKey(
name: "FK_NursePayoutBatches_Users_InitiatedByAdminId",
column: x => x.InitiatedByAdminId,
principalSchema: "usr",
principalTable: "Users",
principalColumn: "UserId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "NursePayouts",
schema: "payouts",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
BatchId = table.Column<long>(type: "bigint", nullable: false),
NurseId = table.Column<long>(type: "bigint", nullable: false),
BankAccountId = table.Column<long>(type: "bigint", nullable: false),
IbanSnapshot = table.Column<string>(type: "nvarchar(max)", nullable: false),
GrossEarningsIrr = table.Column<long>(type: "bigint", nullable: false),
ClawbackAppliedIrr = table.Column<long>(type: "bigint", nullable: false),
NetAmountIrr = table.Column<long>(type: "bigint", nullable: false),
Amount = table.Column<long>(type: "bigint", nullable: false),
BookingCount = table.Column<int>(type: "int", nullable: false),
Status = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
TransferReference = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
PaidAt = table.Column<DateTime>(type: "datetime2", nullable: true),
FailureReason = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
DeletedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
ModifiedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedById = table.Column<int>(type: "int", nullable: true),
ModifiedById = table.Column<int>(type: "int", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_NursePayouts", x => x.Id);
table.CheckConstraint("CK_NursePayouts_NetSplit", "[NetAmountIrr] = [GrossEarningsIrr] - [ClawbackAppliedIrr] AND [GrossEarningsIrr] >= 0 AND [ClawbackAppliedIrr] >= 0 AND [NetAmountIrr] >= 0 AND [Amount] >= 0");
table.ForeignKey(
name: "FK_NursePayouts_NurseBankAccounts_BankAccountId",
column: x => x.BankAccountId,
principalSchema: "usr",
principalTable: "NurseBankAccounts",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_NursePayouts_NursePayoutBatches_BatchId",
column: x => x.BatchId,
principalSchema: "payouts",
principalTable: "NursePayoutBatches",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_NursePayouts_NurseProfiles_NurseId",
column: x => x.NurseId,
principalSchema: "usr",
principalTable: "NurseProfiles",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "NursePayoutBookingLinks",
schema: "payouts",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
PayoutId = table.Column<long>(type: "bigint", nullable: false),
BookingId = table.Column<long>(type: "bigint", nullable: false),
SessionId = table.Column<long>(type: "bigint", nullable: true),
PayoutAmountIrr = table.Column<long>(type: "bigint", nullable: false),
DeletedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
ModifiedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedById = table.Column<int>(type: "int", nullable: true),
ModifiedById = table.Column<int>(type: "int", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_NursePayoutBookingLinks", x => x.Id);
table.ForeignKey(
name: "FK_NursePayoutBookingLinks_BookingSessions_SessionId",
column: x => x.SessionId,
principalSchema: "booking",
principalTable: "BookingSessions",
principalColumn: "Id");
table.ForeignKey(
name: "FK_NursePayoutBookingLinks_Bookings_BookingId",
column: x => x.BookingId,
principalSchema: "booking",
principalTable: "Bookings",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_NursePayoutBookingLinks_NursePayouts_PayoutId",
column: x => x.PayoutId,
principalSchema: "payouts",
principalTable: "NursePayouts",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.InsertData(
schema: "ops",
table: "PlatformConfigs",
columns: new[] { "Id", "CreatedAt", "CreatedById", "DataType", "Description", "Key", "ModifiedAt", "ModifiedById", "Value" },
values: new object[,]
{
{ 22L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, "decimal", "IRR net-amount threshold above which a payout is routed via SATNA (real-time) instead of PAYA (batch) (b13).", "payout_satna_threshold_irr", null, null, "1000000000" },
{ 23L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, "bool", "When on, a BNPL-paid booking is payout-eligible only after its provider settlement is received (b13; default off — the DEFERRED settled_at guard).", "require_bnpl_settlement_for_payout", null, null, "false" }
});
migrationBuilder.CreateIndex(
name: "IX_NursePayoutBatches_InitiatedByAdminId",
schema: "payouts",
table: "NursePayoutBatches",
column: "InitiatedByAdminId");
migrationBuilder.CreateIndex(
name: "IX_NursePayoutBatches_ProcessingDate",
schema: "payouts",
table: "NursePayoutBatches",
column: "ProcessingDate");
migrationBuilder.CreateIndex(
name: "IX_NursePayoutBatches_Status",
schema: "payouts",
table: "NursePayoutBatches",
column: "Status");
migrationBuilder.CreateIndex(
name: "IX_NursePayoutBookingLinks_BookingId",
schema: "payouts",
table: "NursePayoutBookingLinks",
column: "BookingId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_NursePayoutBookingLinks_PayoutId",
schema: "payouts",
table: "NursePayoutBookingLinks",
column: "PayoutId");
migrationBuilder.CreateIndex(
name: "IX_NursePayoutBookingLinks_SessionId",
schema: "payouts",
table: "NursePayoutBookingLinks",
column: "SessionId");
migrationBuilder.CreateIndex(
name: "IX_NursePayouts_BankAccountId",
schema: "payouts",
table: "NursePayouts",
column: "BankAccountId");
migrationBuilder.CreateIndex(
name: "IX_NursePayouts_BatchId",
schema: "payouts",
table: "NursePayouts",
column: "BatchId");
migrationBuilder.CreateIndex(
name: "IX_NursePayouts_NurseId",
schema: "payouts",
table: "NursePayouts",
column: "NurseId");
migrationBuilder.CreateIndex(
name: "IX_NursePayouts_Status",
schema: "payouts",
table: "NursePayouts",
column: "Status");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "NursePayoutBookingLinks",
schema: "payouts");
migrationBuilder.DropTable(
name: "NursePayouts",
schema: "payouts");
migrationBuilder.DropTable(
name: "NursePayoutBatches",
schema: "payouts");
migrationBuilder.DeleteData(
schema: "ops",
table: "PlatformConfigs",
keyColumn: "Id",
keyValue: 22L);
migrationBuilder.DeleteData(
schema: "ops",
table: "PlatformConfigs",
keyColumn: "Id",
keyValue: 23L);
}
}
}
@@ -1291,6 +1291,24 @@ namespace Baya.Infrastructure.Persistence.Migrations
Description = "Ops/testing override that forces the post-payout clawback path for refunds (b11); b13 replaces the derivation.",
Key = "refund_assume_nurse_paid",
Value = "false"
},
new
{
Id = 22L,
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
DataType = "decimal",
Description = "IRR net-amount threshold above which a payout is routed via SATNA (real-time) instead of PAYA (batch) (b13).",
Key = "payout_satna_threshold_irr",
Value = "1000000000"
},
new
{
Id = 23L,
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
DataType = "bool",
Description = "When on, a BNPL-paid booking is payout-eligible only after its provider settlement is received (b13; default off — the DEFERRED settled_at guard).",
Key = "require_bnpl_settlement_for_payout",
Value = "false"
});
});
@@ -3186,6 +3204,200 @@ namespace Baya.Infrastructure.Persistence.Migrations
b.ToTable("PaymentWebhookEvents", "payments");
});
modelBuilder.Entity("Baya.Domain.Entities.Payouts.NursePayout", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<long>("Amount")
.HasColumnType("bigint");
b.Property<long>("BankAccountId")
.HasColumnType("bigint");
b.Property<long>("BatchId")
.HasColumnType("bigint");
b.Property<int>("BookingCount")
.HasColumnType("int");
b.Property<long>("ClawbackAppliedIrr")
.HasColumnType("bigint");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("CreatedById")
.HasColumnType("int");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("datetimeoffset");
b.Property<string>("FailureReason")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<long>("GrossEarningsIrr")
.HasColumnType("bigint");
b.Property<string>("IbanSnapshot")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<DateTimeOffset?>("ModifiedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ModifiedById")
.HasColumnType("int");
b.Property<long>("NetAmountIrr")
.HasColumnType("bigint");
b.Property<long>("NurseId")
.HasColumnType("bigint");
b.Property<DateTime?>("PaidAt")
.HasColumnType("datetime2");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<string>("TransferReference")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.HasKey("Id");
b.HasIndex("BankAccountId");
b.HasIndex("BatchId");
b.HasIndex("NurseId");
b.HasIndex("Status");
b.ToTable("NursePayouts", "payouts", t =>
{
t.HasCheckConstraint("CK_NursePayouts_NetSplit", "[NetAmountIrr] = [GrossEarningsIrr] - [ClawbackAppliedIrr] AND [GrossEarningsIrr] >= 0 AND [ClawbackAppliedIrr] >= 0 AND [NetAmountIrr] >= 0 AND [Amount] >= 0");
});
});
modelBuilder.Entity("Baya.Domain.Entities.Payouts.NursePayoutBatch", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("CreatedById")
.HasColumnType("int");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("datetimeoffset");
b.Property<string>("FailureNotes")
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<int>("InitiatedByAdminId")
.HasColumnType("int");
b.Property<DateTimeOffset?>("ModifiedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ModifiedById")
.HasColumnType("int");
b.Property<int>("PayoutCount")
.HasColumnType("int");
b.Property<DateOnly>("PeriodEnd")
.HasColumnType("date");
b.Property<DateOnly>("PeriodStart")
.HasColumnType("date");
b.Property<DateTime?>("ProcessedAt")
.HasColumnType("datetime2");
b.Property<DateOnly>("ProcessingDate")
.HasColumnType("date");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("nvarchar(30)");
b.Property<long>("TotalAmount")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("InitiatedByAdminId");
b.HasIndex("ProcessingDate");
b.HasIndex("Status");
b.ToTable("NursePayoutBatches", "payouts");
});
modelBuilder.Entity("Baya.Domain.Entities.Payouts.NursePayoutBookingLink", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<long>("BookingId")
.HasColumnType("bigint");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("CreatedById")
.HasColumnType("int");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("datetimeoffset");
b.Property<DateTimeOffset?>("ModifiedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ModifiedById")
.HasColumnType("int");
b.Property<long>("PayoutAmountIrr")
.HasColumnType("bigint");
b.Property<long>("PayoutId")
.HasColumnType("bigint");
b.Property<long?>("SessionId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("BookingId")
.IsUnique();
b.HasIndex("PayoutId");
b.HasIndex("SessionId");
b.ToTable("NursePayoutBookingLinks", "payouts");
});
modelBuilder.Entity("Baya.Domain.Entities.Refunds.NurseClawback", b =>
{
b.Property<long>("Id")
@@ -4674,6 +4886,57 @@ namespace Baya.Infrastructure.Persistence.Migrations
.IsRequired();
});
modelBuilder.Entity("Baya.Domain.Entities.Payouts.NursePayout", b =>
{
b.HasOne("Baya.Domain.Entities.Identity.NurseBankAccount", null)
.WithMany()
.HasForeignKey("BankAccountId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Baya.Domain.Entities.Payouts.NursePayoutBatch", "Batch")
.WithMany("Payouts")
.HasForeignKey("BatchId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", null)
.WithMany()
.HasForeignKey("NurseId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Batch");
});
modelBuilder.Entity("Baya.Domain.Entities.Payouts.NursePayoutBatch", b =>
{
b.HasOne("Baya.Domain.Entities.User.User", null)
.WithMany()
.HasForeignKey("InitiatedByAdminId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("Baya.Domain.Entities.Payouts.NursePayoutBookingLink", b =>
{
b.HasOne("Baya.Domain.Entities.Booking.Booking", null)
.WithMany()
.HasForeignKey("BookingId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Baya.Domain.Entities.Payouts.NursePayout", null)
.WithMany("BookingLinks")
.HasForeignKey("PayoutId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Baya.Domain.Entities.Booking.BookingSession", null)
.WithMany()
.HasForeignKey("SessionId");
});
modelBuilder.Entity("Baya.Domain.Entities.Refunds.NurseClawback", b =>
{
b.HasOne("Baya.Domain.Entities.Booking.Booking", null)
@@ -4942,6 +5205,16 @@ namespace Baya.Infrastructure.Persistence.Migrations
b.Navigation("BankAccounts");
});
modelBuilder.Entity("Baya.Domain.Entities.Payouts.NursePayout", b =>
{
b.Navigation("BookingLinks");
});
modelBuilder.Entity("Baya.Domain.Entities.Payouts.NursePayoutBatch", b =>
{
b.Navigation("Payouts");
});
modelBuilder.Entity("Baya.Domain.Entities.User.Role", b =>
{
b.Navigation("Claims");
@@ -26,6 +26,7 @@ public class UnitOfWork : IUnitOfWork
public IRefundRepository RefundRepository { get; }
public IInvoiceRepository InvoiceRepository { get; }
public IBnplRepository BnplRepository { get; }
public IPayoutRepository PayoutRepository { get; }
public UnitOfWork(ApplicationDbContext db)
{
@@ -50,6 +51,7 @@ public class UnitOfWork : IUnitOfWork
RefundRepository = new RefundRepository(_db);
InvoiceRepository = new InvoiceRepository(_db);
BnplRepository = new BnplRepository(_db);
PayoutRepository = new PayoutRepository(_db);
}
public Task CommitAsync()
@@ -0,0 +1,241 @@
#nullable enable
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Application.Models.Payouts;
using Baya.Domain.Entities.Bnpl;
using Baya.Domain.Entities.Booking;
using Baya.Domain.Entities.Identity;
using Baya.Domain.Entities.Payments;
using Baya.Domain.Entities.Payouts;
using Baya.Domain.Entities.Refunds;
using Baya.Domain.Entities.User;
using Baya.Infrastructure.Persistence.Repositories.Common;
using Microsoft.EntityFrameworkCore;
namespace Baya.Infrastructure.Persistence.Repositories;
internal sealed class PayoutRepository : BaseAsyncRepository<NursePayoutBatch>, IPayoutRepository
{
public PayoutRepository(ApplicationDbContext dbContext) : base(dbContext)
{
}
// Eligible = completed AND its dispute window closed by `now` (and by the period end, so period_end bounds the
// run) AND no active refund reversed its money AND not already paid in a link row. No lower period bound, so a
// booking that missed an earlier batch is still swept — it must eventually be paid. When
// requireBnplSettlement is set, a BNPL-paid booking is held until its provider settlement is received.
private IQueryable<EligibleBookingRow> EligibleBookingsQuery(DateOnly periodEnd, DateTime now, bool requireBnplSettlement)
{
var windowEnd = periodEnd.ToDateTime(TimeOnly.MaxValue);
var query = from b in DbContext.Set<Booking>().AsNoTracking()
where b.Status == BookingStatus.Completed
&& b.DisputeWindowEndsAt != null
&& b.DisputeWindowEndsAt < now
&& b.DisputeWindowEndsAt <= windowEnd
&& !DbContext.Set<Refund>()
.Any(r => r.BookingId == b.Id && r.Status != RefundStatus.Failed && r.Status != RefundStatus.Rejected)
&& !DbContext.Set<NursePayoutBookingLink>().IgnoreQueryFilters()
.Any(l => l.BookingId == b.Id)
select b;
if (requireBnplSettlement)
// Hold a BNPL-paid booking until its 1:1 bnpl_transaction reports a settlement (settled_at set).
query = query.Where(b => !DbContext.Set<PaymentTransaction>()
.Any(t => t.BookingId == b.Id && t.Status == PaymentTransactionStatus.Succeeded
&& DbContext.Set<BnplTransaction>().Any(bt => bt.PaymentTransactionId == t.Id && bt.SettledAt == null)));
return query.Select(b => new EligibleBookingRow(b.NurseId, b.Id, b.NursePayoutAmount));
}
public async Task<IReadOnlyList<EligibleBookingRow>> GetEligibleBookingsAsync(
DateOnly periodStart, DateOnly periodEnd, DateTime now, bool requireBnplSettlement, CancellationToken cancellationToken)
=> await EligibleBookingsQuery(periodEnd, now, requireBnplSettlement).ToListAsync(cancellationToken);
public async Task<PagedResult<EligibleNurseEarningsDto>> GetEligiblePreviewAsync(
DateOnly periodStart, DateOnly periodEnd, DateTime now, bool requireBnplSettlement, int page, int pageSize, CancellationToken cancellationToken)
{
var rows = await EligibleBookingsQuery(periodEnd, now, requireBnplSettlement).ToListAsync(cancellationToken);
var groups = rows
.GroupBy(r => r.NurseId)
.Select(g => new { NurseId = g.Key, Gross = g.Sum(x => x.PayoutAmountIrr), Count = g.Count() })
.OrderBy(x => x.NurseId)
.ToList();
var total = groups.Count;
var slice = groups.Skip((page - 1) * pageSize).Take(pageSize).ToList();
var nurseIds = slice.Select(x => x.NurseId).ToList();
var names = await GetNurseNamesAsync(nurseIds, cancellationToken);
var clawbacks = await DbContext.Set<NurseClawback>().AsNoTracking()
.Where(c => nurseIds.Contains(c.NurseId) && c.Status == ClawbackStatus.Pending)
.GroupBy(c => c.NurseId)
.Select(g => new { NurseId = g.Key, Sum = g.Sum(x => x.AmountIrr) })
.ToDictionaryAsync(x => x.NurseId, x => x.Sum, cancellationToken);
var verified = (await DbContext.Set<NurseBankAccount>().AsNoTracking()
.Where(a => nurseIds.Contains(a.NurseId) && a.IsPrimary && a.IsVerified && a.MatchedNationalId == true)
.Select(a => a.NurseId)
.ToListAsync(cancellationToken))
.ToHashSet();
var items = slice.Select(x =>
{
var clawback = Math.Min(x.Gross, clawbacks.GetValueOrDefault(x.NurseId));
var net = x.Gross - clawback;
return new EligibleNurseEarningsDto(
x.NurseId, names.GetValueOrDefault(x.NurseId), x.Count,
x.Gross.ToString(), clawback.ToString(), net.ToString(), verified.Contains(x.NurseId));
}).ToList();
return new PagedResult<EligibleNurseEarningsDto>(items, total, page, pageSize);
}
public Task<VerifiedPayoutAccount?> GetVerifiedPrimaryAccountAsync(long nurseId, CancellationToken cancellationToken)
=> DbContext.Set<NurseBankAccount>().AsNoTracking()
.Where(a => a.NurseId == nurseId && a.IsPrimary && a.IsVerified && a.MatchedNationalId == true)
.Select(a => new VerifiedPayoutAccount(a.Id, a.Iban))
.FirstOrDefaultAsync(cancellationToken);
public async Task<IReadOnlyDictionary<long, string>> GetNurseNamesAsync(IReadOnlyList<long> nurseIds, CancellationToken cancellationToken)
{
if (nurseIds.Count == 0)
return new Dictionary<long, string>();
var rows = await (from n in DbContext.Set<NurseProfile>().AsNoTracking()
where nurseIds.Contains(n.Id)
join u in DbContext.Set<User>() on n.UserId equals u.Id
select new { n.Id, u.Name, u.FamilyName })
.ToListAsync(cancellationToken);
return rows.ToDictionary(x => x.Id, x => $"{x.Name} {x.FamilyName}".Trim());
}
public async Task<long> GetPendingClawbackSumAsync(long nurseId, CancellationToken cancellationToken)
=> await DbContext.Set<NurseClawback>().AsNoTracking()
.Where(c => c.NurseId == nurseId && c.Status == ClawbackStatus.Pending)
.SumAsync(c => (long?)c.AmountIrr, cancellationToken) ?? 0;
public async Task<IReadOnlyList<NurseClawback>> GetPendingClawbacksAsync(long nurseId, CancellationToken cancellationToken)
=> await DbContext.Set<NurseClawback>()
.Where(c => c.NurseId == nurseId && c.Status == ClawbackStatus.Pending)
.OrderBy(c => c.Id)
.ToListAsync(cancellationToken);
public Task AddBatchAsync(NursePayoutBatch batch, CancellationToken cancellationToken)
=> base.AddAsync(batch);
public Task<NursePayoutBatch?> GetTrackedBatchAsync(long batchId, CancellationToken cancellationToken)
=> DbContext.Set<NursePayoutBatch>()
.Include(b => b.Payouts).ThenInclude(p => p.BookingLinks)
.FirstOrDefaultAsync(b => b.Id == batchId, cancellationToken);
public Task<NursePayout?> GetTrackedPayoutAsync(long payoutId, CancellationToken cancellationToken)
=> DbContext.Set<NursePayout>()
.Include(p => p.Batch)
.FirstOrDefaultAsync(p => p.Id == payoutId, cancellationToken);
public Task<bool> LedgerGroupExistsForPayoutAsync(long payoutId, CancellationToken cancellationToken)
=> DbContext.Set<LedgerEntry>().AsNoTracking()
.AnyAsync(l => l.SourceRefType == LedgerSourceRefType.NursePayout && l.SourceRefId == payoutId, cancellationToken);
public async Task<PagedResult<PayoutBatchDto>> ListBatchesAsync(string? status, int page, int pageSize, CancellationToken cancellationToken)
{
var query = DbContext.Set<NursePayoutBatch>().AsNoTracking().AsQueryable();
if (!string.IsNullOrWhiteSpace(status))
query = query.Where(b => b.Status == status);
var total = await query.CountAsync(cancellationToken);
var rows = await query
.OrderByDescending(b => b.Id)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(b => new
{
b.Id, b.PeriodStart, b.PeriodEnd, b.ProcessingDate, b.TotalAmount, b.PayoutCount,
b.Status, b.InitiatedByAdminId, b.ProcessedAt, b.FailureNotes, b.CreatedAt
})
.ToListAsync(cancellationToken);
var items = rows.Select(b => new PayoutBatchDto(
b.Id, b.PeriodStart, b.PeriodEnd, b.ProcessingDate, b.TotalAmount.ToString(), b.PayoutCount,
b.Status, b.InitiatedByAdminId, b.ProcessedAt, b.FailureNotes, b.CreatedAt))
.ToList();
return new PagedResult<PayoutBatchDto>(items, total, page, pageSize);
}
public async Task<PayoutBatchDetailDto?> GetBatchDetailAsync(long batchId, int page, int pageSize, CancellationToken cancellationToken)
{
var header = await DbContext.Set<NursePayoutBatch>().AsNoTracking()
.Where(b => b.Id == batchId)
.Select(b => new PayoutBatchDto(
b.Id, b.PeriodStart, b.PeriodEnd, b.ProcessingDate, b.TotalAmount.ToString(), b.PayoutCount,
b.Status, b.InitiatedByAdminId, b.ProcessedAt, b.FailureNotes, b.CreatedAt))
.FirstOrDefaultAsync(cancellationToken);
if (header is null)
return null;
var payoutsQuery = DbContext.Set<NursePayout>().AsNoTracking().Where(p => p.BatchId == batchId);
var total = await payoutsQuery.CountAsync(cancellationToken);
var rows = await payoutsQuery
.OrderBy(p => p.Id)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(p => new
{
p.Id, p.NurseId, p.IbanSnapshot, p.GrossEarningsIrr, p.ClawbackAppliedIrr, p.NetAmountIrr,
p.Amount, p.BookingCount, p.Status, p.TransferReference, p.PaidAt, p.FailureReason,
Links = p.BookingLinks.Select(l => new { l.BookingId, l.SessionId, l.PayoutAmountIrr }).ToList()
})
.ToListAsync(cancellationToken);
var names = await GetNurseNamesAsync(rows.Select(r => r.NurseId).Distinct().ToList(), cancellationToken);
var payouts = rows.Select(p => new PayoutDto(
p.Id, p.NurseId, names.GetValueOrDefault(p.NurseId), MaskIban(p.IbanSnapshot),
p.GrossEarningsIrr.ToString(), p.ClawbackAppliedIrr.ToString(), p.NetAmountIrr.ToString(),
p.Amount.ToString(), p.BookingCount, p.Status, p.TransferReference, p.PaidAt, p.FailureReason,
p.Links.Select(l => new PayoutBookingLinkDto(l.BookingId, l.SessionId, l.PayoutAmountIrr.ToString())).ToList()))
.ToList();
return new PayoutBatchDetailDto(header, payouts, total, page, pageSize);
}
public async Task<PagedResult<NursePayoutHistoryDto>> GetNurseHistoryAsync(long nurseId, int page, int pageSize, CancellationToken cancellationToken)
{
var query = from p in DbContext.Set<NursePayout>().AsNoTracking()
where p.NurseId == nurseId
join b in DbContext.Set<NursePayoutBatch>() on p.BatchId equals b.Id
orderby p.Id descending
select new
{
p.Id, p.BatchId, p.Status, p.GrossEarningsIrr, p.ClawbackAppliedIrr, p.NetAmountIrr,
p.IbanSnapshot, p.TransferReference, p.PaidAt, b.PeriodStart, b.PeriodEnd
};
var total = await query.CountAsync(cancellationToken);
var rows = await query.Skip((page - 1) * pageSize).Take(pageSize).ToListAsync(cancellationToken);
var items = rows.Select(p => new NursePayoutHistoryDto(
p.Id, p.BatchId, p.Status, p.GrossEarningsIrr.ToString(), p.ClawbackAppliedIrr.ToString(),
p.NetAmountIrr.ToString(), MaskIban(p.IbanSnapshot), p.TransferReference, p.PaidAt,
p.PeriodStart, p.PeriodEnd))
.ToList();
return new PagedResult<NursePayoutHistoryDto>(items, total, page, pageSize);
}
// Show only the last 4 digits of the IBAN — the plaintext snapshot never leaves the server.
private static string MaskIban(string iban)
{
if (string.IsNullOrEmpty(iban))
return string.Empty;
return iban.Length <= 4
? new string('•', iban.Length)
: $"{new string('•', iban.Length - 4)}{iban[^4..]}";
}
}
@@ -54,10 +54,10 @@ public static class ServiceCollectionExtensions
// Supersedes the b0 log/no-op stub with the real in-app notifications write.
services.AddScoped<INotificationDispatcher, InAppNotificationDispatcher>();
// "Has the nurse been paid?" — the refund pre-payout/clawback fork (b11). DB-backed because b13's real
// impl reads nurse_payout_booking_links; until then it derives from the dispute-window close. b13 swaps
// this registration for the authoritative payout-link lookup.
services.AddScoped<INursePayoutStatus, NursePayoutStatusService>();
// "Has the nurse been paid?" — the refund pre-payout/clawback fork (b11). b13 now owns the authoritative
// impl: a booking is paid iff a nurse_payout_booking_links row ties it to a `paid` nurse_payouts row. This
// supersedes the interim NursePayoutStatusService (dispute-window derivation); the refund fork is unchanged.
services.AddScoped<INursePayoutStatus, NursePayoutLinkStatusService>();
// Retention job seam (mock = in-process interval runner; real Hangfire/Quartz deferred).
services.AddHostedService<NotificationRetentionHostedService>();
@@ -0,0 +1,34 @@
#nullable enable
using Baya.Application.Contracts.Configuration;
using Baya.Application.Contracts.Payments;
using Baya.Domain.Entities.Payouts;
using Microsoft.EntityFrameworkCore;
namespace Baya.Infrastructure.Persistence.Services.Payments;
/// <summary>
/// The <b>authoritative</b> b13 implementation of <see cref="INursePayoutStatus"/> — it answers "was the nurse
/// already paid for this booking?" from the real payout ledger: a booking is paid iff a
/// <c>nurse_payout_booking_links</c> row ties it to a <c>nurse_payouts</c> row in status <c>paid</c> (a
/// confirmed, irreversible transfer). This supersedes the b11 interim <c>NursePayoutStatusService</c> that
/// derived it from the dispute-window close. The refund pre-payout/clawback fork is unchanged — it just now
/// forks on the true paid-state. The <c>refund_assume_nurse_paid</c> config switch still forces the paid answer
/// for ops/testing.
/// </summary>
internal sealed class NursePayoutLinkStatusService(
ApplicationDbContext dbContext,
IPlatformConfig platformConfig) : INursePayoutStatus
{
public async ValueTask<bool> IsNursePaidForBookingAsync(long bookingId, CancellationToken cancellationToken = default)
{
if (await platformConfig.GetConfig<bool>("refund_assume_nurse_paid", cancellationToken))
return true;
return await (from l in dbContext.Set<NursePayoutBookingLink>().AsNoTracking()
where l.BookingId == bookingId
join p in dbContext.Set<NursePayout>() on l.PayoutId equals p.Id
where p.Status == PayoutStatus.Paid
select l.Id)
.AnyAsync(cancellationToken);
}
}
@@ -1,35 +0,0 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Configuration;
using Baya.Application.Contracts.Payments;
using Microsoft.EntityFrameworkCore;
using BookingEntity = Baya.Domain.Entities.Booking.Booking;
namespace Baya.Infrastructure.Persistence.Services.Payments;
/// <summary>
/// The interim implementation of <see cref="INursePayoutStatus"/> until b13 ships <c>nurse_payouts</c> /
/// <c>nurse_payout_booking_links</c>. It derives "already paid?" from the booking's dispute-window close — the
/// exact gate b13 pays out on — so the pre-payout (clean reversal) path is the common one and the clawback path
/// is the fallback. A <c>refund_assume_nurse_paid</c> config switch forces the paid answer for ops/testing. b13
/// swaps this registration for the authoritative payout-link lookup.
/// </summary>
internal sealed class NursePayoutStatusService(
ApplicationDbContext dbContext,
IDateTimeProvider dateTimeProvider,
IPlatformConfig platformConfig) : INursePayoutStatus
{
public async ValueTask<bool> IsNursePaidForBookingAsync(long bookingId, CancellationToken cancellationToken = default)
{
if (await platformConfig.GetConfig<bool>("refund_assume_nurse_paid", cancellationToken))
return true;
var now = dateTimeProvider.UtcNow.UtcDateTime;
var windowEnd = await dbContext.Set<BookingEntity>().AsNoTracking()
.Where(b => b.Id == bookingId)
.Select(b => b.DisputeWindowEndsAt)
.FirstOrDefaultAsync(cancellationToken);
return windowEnd is { } endsAt && endsAt <= now;
}
}
@@ -0,0 +1,171 @@
using System.Net;
using System.Net.Http.Json;
using Baya.Application.Contracts.Identity;
using Baya.Domain.Entities.Booking;
using Baya.Domain.Entities.Catalog;
using Baya.Domain.Entities.Geography;
using Baya.Domain.Entities.Identity;
using Baya.Domain.Entities.Payments;
using Baya.Domain.Entities.User;
using Baya.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using BookingEntity = Baya.Domain.Entities.Booking.Booking;
namespace Baya.Test.Api;
public class AdminPayoutsApiTests(BayaApiFactory factory) : IClassFixture<BayaApiFactory>
{
[Fact]
public async Task Generate_Unauthenticated_Returns401()
{
var client = factory.CreateClient();
var response = await client.PostAsJsonAsync("/api/v1/admin_payouts/batches",
new { periodStart = "2020-01-01", periodEnd = "2020-01-31" });
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task Generate_InvalidPeriod_Returns400()
{
var admin = factory.CreateClient();
await AdminTestClient.AuthenticateAsync(factory, admin, "09131902401");
// period_start after period_end
var response = await admin.PostAsJsonAsync("/api/v1/admin_payouts/batches",
new { periodStart = "2026-06-30", periodEnd = "2026-06-01" });
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task Generate_then_process_pays_the_eligible_nurse()
{
var admin = factory.CreateClient();
await AdminTestClient.AuthenticateAsync(factory, admin, "09131902402");
await SeedEligibleNurseBookingAsync(factory, "09131902403");
var today = DateOnly.FromDateTime(DateTime.UtcNow).ToString("yyyy-MM-dd");
var generate = await admin.PostAsJsonAsync("/api/v1/admin_payouts/batches",
new { periodStart = "2020-01-01", periodEnd = today });
Assert.Equal(HttpStatusCode.OK, generate.StatusCode);
var data = await AuthTestClient.ReadDataAsync(generate);
var batchId = data.GetProperty("batch").GetProperty("id").GetInt64();
Assert.Equal("draft", data.GetProperty("batch").GetProperty("status").GetString());
Assert.True(data.GetProperty("payouts").GetArrayLength() >= 1);
var process = await admin.PostAsJsonAsync($"/api/v1/admin_payouts/batches/{batchId}/process", new { });
Assert.Equal(HttpStatusCode.OK, process.StatusCode);
var processed = await AuthTestClient.ReadDataAsync(process);
Assert.Equal("completed", processed.GetProperty("status").GetString());
Assert.True(processed.GetProperty("paidCount").GetInt32() >= 1);
}
[Fact]
public async Task List_AsAdmin_ReturnsPagedEnvelope()
{
var admin = factory.CreateClient();
await AdminTestClient.AuthenticateAsync(factory, admin, "09131902404");
var response = await admin.GetAsync("/api/v1/admin_payouts/batches?page=1&pageSize=20");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var data = await AuthTestClient.ReadDataAsync(response);
Assert.True(data.GetProperty("total").GetInt32() >= 0);
}
/// <summary>Seeds a completed, dispute-window-closed booking for a nurse who has a verified primary IBAN, so
/// the batch has exactly one eligible payout. Returns the nurse's phone (usable to authenticate as the nurse).</summary>
internal static async Task SeedEligibleNurseBookingAsync(BayaApiFactory factory, string nursePhone)
{
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var userManager = scope.ServiceProvider.GetRequiredService<IAppUserManager>();
var customerUser = new User { UserName = $"cust_{Guid.NewGuid():N}", PhoneNumber = $"0912{Random.Shared.Next(1000000, 9999999)}", IsActive = true };
db.Users.Add(customerUser);
db.SaveChanges();
var customer = new CustomerProfile { UserId = customerUser.Id };
db.Set<CustomerProfile>().Add(customer);
db.SaveChanges();
// The nurse must be a real Identity user so the same phone can authenticate for the history test.
var nurseUser = await userManager.GetUserByPhoneNumber(nursePhone);
if (nurseUser is null)
{
await userManager.CreateUser(new User { UserName = $"nurse_{Guid.NewGuid():N}", PhoneNumber = nursePhone, Gender = "female", Name = "زهرا", FamilyName = "احمدی" });
nurseUser = await userManager.GetUserByPhoneNumber(nursePhone);
}
var nurse = new NurseProfile { UserId = nurseUser!.Id };
nurse.MarkVerified();
nurse.SetAcceptingBookings(true);
db.Set<NurseProfile>().Add(nurse);
db.SaveChanges();
db.Set<NurseBankAccount>().Add(new NurseBankAccount
{
NurseId = nurse.Id, BankName = "ملی", AccountHolderName = "زهرا", Iban = $"IR{Random.Shared.Next(100000, 999999)}0000000000000000",
IbanHash = $"hash-{nurse.Id}", IsPrimary = true, IsVerified = true, MatchedNationalId = true,
AccountHolderFromBank = "زهرا", OwnershipVendorRef = "mock"
});
var province = new Province { NameFa = "ت", NameEn = "T", SortOrder = 1, IsActive = true };
db.Set<Province>().Add(province);
db.SaveChanges();
var city = new City { ProvinceId = province.Id, NameFa = "ت", NameEn = "T", SortOrder = 1, IsActive = true };
db.Set<City>().Add(city);
var category = new ServiceCategory { NameFa = "س", NameEn = "E", SortOrder = 1, IsActive = true };
db.Set<ServiceCategory>().Add(category);
db.SaveChanges();
var patient = new Patient { CustomerId = customer.Id, DisplayName = "پ", FirstName = "ح", LastName = "ر", Gender = "male", IsActive = true };
db.Set<Patient>().Add(patient);
var address = new CustomerAddress
{
CustomerId = customer.Id, CityId = city.Id, Title = "خ", AddressLine = "خیابان",
PostalCode = "1111111111", RecipientName = "ع", RecipientPhone = customerUser.PhoneNumber, IsPrimary = true
};
db.Set<CustomerAddress>().Add(address);
db.SaveChanges();
var variant = new NurseServiceVariant
{
NurseId = nurse.Id, ServiceCategoryId = category.Id, Price = 10_000_000, PriceUnit = "per_day",
SessionCount = null, DisplayName = "مراقبت", OptionSetHash = $"hash-{Guid.NewGuid():N}", IsActive = true
};
db.Set<NurseServiceVariant>().Add(variant);
db.SaveChanges();
var request = new BookingRequest
{
CustomerId = customer.Id, NurseId = nurse.Id, PatientId = patient.Id, VariantId = variant.Id,
CustomerAddressId = address.Id, RequiredCaregiverGender = CaregiverGender.Any,
RequestedDate = new DateOnly(2020, 1, 1), RequestedTimeStart = new TimeOnly(9, 0), RequestedTimeEnd = new TimeOnly(13, 0),
CustomerNotes = "n", NurseResponseDeadlineAt = new DateTime(2030, 1, 1, 0, 0, 0, DateTimeKind.Utc)
};
db.Set<BookingRequest>().Add(request);
db.SaveChanges();
request.Accept(new DateTime(2030, 1, 1, 0, 0, 0, DateTimeKind.Utc));
db.SaveChanges();
var completedAt = new DateTime(2020, 1, 2, 0, 0, 0, DateTimeKind.Utc);
var booking = new BookingEntity
{
BookingRequestId = request.Id, CustomerId = customer.Id, NurseId = nurse.Id, PatientId = patient.Id,
VariantId = variant.Id, CustomerAddressId = address.Id, VariantSnapshotJson = "{}", AddressSnapshotJson = "{}",
GrossPriceIrr = 10_000_000, BalinyaarCommissionIrr = 1_500_000, PlatformFeeRate = 0.15m,
NursePayoutAmount = 8_500_000, SessionCount = 1,
ScheduledDate = new DateOnly(2020, 1, 1), ScheduledTimeStart = new TimeOnly(9, 0), ScheduledTimeEnd = new TimeOnly(13, 0)
};
booking.TransitionTo(BookingStatus.Confirmed, completedAt);
booking.TransitionTo(BookingStatus.InProgress, completedAt);
booking.TransitionTo(BookingStatus.Completed, completedAt);
booking.SetDisputeWindow(new DateTime(2020, 1, 5, 0, 0, 0, DateTimeKind.Utc)); // long past
db.Set<BookingEntity>().Add(booking);
db.SaveChanges();
var legs = LedgerPosting.CardCapture(booking.Id, nurse.Id, 10_000_000, 1_500_000, 8_500_000, 0, completedAt);
db.Set<LedgerEntry>().AddRange(legs);
db.SaveChanges();
}
}
@@ -0,0 +1,48 @@
using System.Net;
using System.Net.Http.Json;
namespace Baya.Test.Api;
public class NursePayoutsApiTests(BayaApiFactory factory) : IClassFixture<BayaApiFactory>
{
[Fact]
public async Task History_Unauthenticated_Returns401()
{
var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/nurse_payouts/history");
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task History_AsNurse_ReturnsOwnPaidPayout_WithMaskedIban()
{
const string nursePhone = "09131903401";
// Pay the nurse through the real admin flow, then read the nurse's own history.
var admin = factory.CreateClient();
await AdminTestClient.AuthenticateAsync(factory, admin, "09131903402");
await AdminPayoutsApiTests.SeedEligibleNurseBookingAsync(factory, nursePhone);
var today = DateOnly.FromDateTime(DateTime.UtcNow).ToString("yyyy-MM-dd");
var generate = await admin.PostAsJsonAsync("/api/v1/admin_payouts/batches",
new { periodStart = "2020-01-01", periodEnd = today });
Assert.Equal(HttpStatusCode.OK, generate.StatusCode);
var batchId = (await AuthTestClient.ReadDataAsync(generate)).GetProperty("batch").GetProperty("id").GetInt64();
var process = await admin.PostAsJsonAsync($"/api/v1/admin_payouts/batches/{batchId}/process", new { });
Assert.Equal(HttpStatusCode.OK, process.StatusCode);
var nurse = factory.CreateClient();
var tokens = await AuthTestClient.LoginAsync(factory, nurse, nursePhone);
AuthTestClient.UseBearer(nurse, tokens.GetProperty("accessToken").GetString()!);
var response = await nurse.GetAsync("/api/v1/nurse_payouts/history?page=1&pageSize=20");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var data = await AuthTestClient.ReadDataAsync(response);
Assert.True(data.GetProperty("total").GetInt32() >= 1);
var item = data.GetProperty("items")[0];
Assert.Equal("paid", item.GetProperty("status").GetString());
Assert.Equal("8500000", item.GetProperty("netAmountIrr").GetString());
Assert.Contains("•", item.GetProperty("maskedIban").GetString()!);
}
}
@@ -0,0 +1,246 @@
using Baya.Application.Features.Payouts.Commands.ExecutePayoutBatch;
using Baya.Application.Features.Payouts.Commands.GeneratePayoutBatch;
using Baya.Application.Features.Payouts.Commands.RetryFailedPayout;
using Baya.Application.Features.Payouts.Queries.ComputeEligibleEarnings;
using Baya.Application.Contracts.Holidays;
using Baya.Domain.Entities.Payments;
using Baya.Domain.Entities.Payouts;
using Baya.Domain.Entities.Refunds;
using Microsoft.EntityFrameworkCore;
namespace Baya.Test.Foundation.Payouts;
public class PayoutHandlerTests
{
private static readonly DateTimeOffset Now = new(2026, 7, 1, 10, 0, 0, TimeSpan.Zero);
private static readonly DateTime Past = new(2026, 6, 15, 0, 0, 0, DateTimeKind.Utc); // dispute window closed
private static readonly DateTime Future = new(2026, 8, 1, 0, 0, 0, DateTimeKind.Utc); // still open
private static readonly DateOnly PeriodStart = new(2026, 6, 1);
private static readonly DateOnly PeriodEnd = new(2026, 6, 30);
private static GeneratePayoutBatchCommand GenCmd => new(PeriodStart, PeriodEnd);
private static GeneratePayoutBatchCommandHandler Gen(PayoutsTestHost host, IHolidayCalendar? holidays = null)
=> new(host.UnitOfWork, host.Lock(), holidays ?? host.Holidays(), host.Config(), host.Clock(Now), host.AsAdmin());
private static ExecutePayoutBatchCommandHandler Exec(PayoutsTestHost host, Baya.Application.Contracts.Payments.IBankTransferProvider? bank = null)
=> new(host.UnitOfWork, host.Lock(), bank ?? host.Bank(), host.Config(), host.Clock(Now));
private static ComputeEligibleEarningsQueryHandler Preview(PayoutsTestHost host)
=> new(host.UnitOfWork, host.Holidays(), host.Config(), host.Clock(Now));
[Fact]
public async Task Preview_includes_only_closed_window_and_flags_missing_iban()
{
using var host = new PayoutsTestHost();
var nurseA = host.SeedNurse();
var nurseB = host.SeedNurse(verifiedIban: null);
host.SeedCompletedBooking(nurseA, Past); // eligible
host.SeedCompletedBooking(nurseA, Future); // future window — excluded
host.SeedCompletedBooking(nurseA, Past, disputed: true); // disputed — excluded
host.SeedCompletedBooking(nurseB, Past); // eligible earnings but no verified IBAN
var result = await Preview(host).Handle(new ComputeEligibleEarningsQuery(PeriodStart, PeriodEnd), CancellationToken.None);
Assert.True(result.IsSuccess);
var items = result.Result.Items;
Assert.Equal(2, items.Count);
var a = items.Single(x => x.NurseId == nurseA);
Assert.Equal(1, a.BookingCount); // only the one closed-window, non-disputed booking
Assert.Equal("8500000", a.GrossEarningsIrr);
Assert.True(a.HasVerifiedPrimaryIban);
var b = items.Single(x => x.NurseId == nurseB);
Assert.False(b.HasVerifiedPrimaryIban);
}
[Fact]
public async Task Generate_materializes_one_payout_per_nurse_and_nets_clawback()
{
using var host = new PayoutsTestHost();
var nurse = host.SeedNurse();
host.SeedCompletedBooking(nurse, Past, gross: 10_000_000, commission: 1_500_000); // eligible, payout 8.5M
var prior = host.SeedCompletedBooking(nurse, Past); // gets a refund below → excluded from earnings
host.SeedPendingClawback(nurse, prior, 2_000_000);
var result = await Gen(host).Handle(GenCmd, CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.Equal(PayoutBatchStatus.Draft, result.Result.Batch.Status);
var payout = Assert.Single(result.Result.Payouts);
Assert.Equal("8500000", payout.GrossEarningsIrr);
Assert.Equal("2000000", payout.ClawbackAppliedIrr);
Assert.Equal("6500000", payout.NetAmountIrr);
Assert.Equal("6500000", payout.Amount);
Assert.Equal(1, payout.BookingCount);
Assert.Equal("6500000", result.Result.Batch.TotalAmount);
Assert.Equal(1, result.Result.Batch.PayoutCount);
Assert.EndsWith("0123", payout.MaskedIban);
Assert.Contains("•", payout.MaskedIban);
Assert.Empty(result.Result.Skipped);
}
[Fact]
public async Task Generate_skips_nurse_without_verified_primary_iban_with_reason()
{
using var host = new PayoutsTestHost();
var nurseOk = host.SeedNurse();
var nurseNoIban = host.SeedNurse(verifiedIban: null);
host.SeedCompletedBooking(nurseOk, Past);
host.SeedCompletedBooking(nurseNoIban, Past);
var result = await Gen(host).Handle(GenCmd, CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.Single(result.Result.Payouts);
var skipped = Assert.Single(result.Result.Skipped);
Assert.Equal(nurseNoIban, skipped.NurseId);
Assert.Equal("no_verified_primary_iban", skipped.Reason);
}
[Fact]
public async Task Double_pay_guard_second_generate_does_not_reselect_linked_bookings()
{
using var host = new PayoutsTestHost();
var nurse = host.SeedNurse();
host.SeedCompletedBooking(nurse, Past);
var first = await Gen(host).Handle(GenCmd, CancellationToken.None);
Assert.True(first.IsSuccess);
var second = await Gen(host).Handle(GenCmd, CancellationToken.None);
Assert.False(second.IsSuccess); // the booking_id UNIQUE link excludes it — nothing left to pay
}
[Fact]
public async Task Execute_posts_balanced_payout_ledger_and_drains_payable()
{
using var host = new PayoutsTestHost();
var nurse = host.SeedNurse();
host.SeedCompletedBooking(nurse, Past, gross: 10_000_000, commission: 1_500_000);
var gen = await Gen(host).Handle(GenCmd, CancellationToken.None);
var batchId = gen.Result.Batch.Id;
var before = host.NursePayableBalance(nurse);
var exec = await Exec(host).Handle(new ExecutePayoutBatchCommand(batchId), CancellationToken.None);
Assert.True(exec.IsSuccess);
Assert.Equal(PayoutBatchStatus.Completed, exec.Result.Status);
Assert.Equal(1, exec.Result.PaidCount);
var after = host.NursePayableBalance(nurse);
Assert.Equal(8_500_000, before - after);
var legs = PayoutLegs(host);
Assert.Equal(8_500_000, Leg(legs, LedgerAccountType.NursePayable, LedgerDirection.Debit));
Assert.Equal(8_500_000, Leg(legs, LedgerAccountType.EscrowHeld, LedgerDirection.Credit));
Assert.Equal(
legs.Where(l => l.Direction == LedgerDirection.Debit).Sum(l => l.AmountIrr),
legs.Where(l => l.Direction == LedgerDirection.Credit).Sum(l => l.AmountIrr));
var payout = host.Db.Set<NursePayout>().AsNoTracking().Single();
Assert.Equal(PayoutStatus.Paid, payout.Status);
Assert.NotNull(payout.TransferReference);
Assert.NotNull(payout.PaidAt);
}
[Fact]
public async Task Execute_recovers_clawback_and_posts_recovery_leg()
{
using var host = new PayoutsTestHost();
var nurse = host.SeedNurse();
host.SeedCompletedBooking(nurse, Past, gross: 10_000_000, commission: 1_500_000);
var prior = host.SeedCompletedBooking(nurse, Past);
var clawbackId = host.SeedPendingClawback(nurse, prior, 2_000_000);
var gen = await Gen(host).Handle(GenCmd, CancellationToken.None);
await Exec(host).Handle(new ExecutePayoutBatchCommand(gen.Result.Batch.Id), CancellationToken.None);
var recovery = host.Db.Set<LedgerEntry>().AsNoTracking()
.Where(l => l.SourceRefType == LedgerSourceRefType.Clawback && l.SourceRefId == clawbackId).ToList();
Assert.Equal(2_000_000, Leg(recovery, LedgerAccountType.NursePayable, LedgerDirection.Debit));
Assert.Equal(2_000_000, Leg(recovery, LedgerAccountType.NurseClawbackReceivable, LedgerDirection.Credit));
var clawback = host.Db.Set<NurseClawback>().AsNoTracking().Single(c => c.Id == clawbackId);
Assert.Equal(ClawbackStatus.Recovered, clawback.Status);
Assert.NotNull(clawback.RecoveredInPayoutId);
}
[Fact]
public async Task Reprocess_is_idempotent_no_second_ledger_group()
{
using var host = new PayoutsTestHost();
var nurse = host.SeedNurse();
host.SeedCompletedBooking(nurse, Past);
var gen = await Gen(host).Handle(GenCmd, CancellationToken.None);
var batchId = gen.Result.Batch.Id;
await Exec(host).Handle(new ExecutePayoutBatchCommand(batchId), CancellationToken.None);
var countAfterFirst = PayoutLegs(host).Count;
var second = await Exec(host).Handle(new ExecutePayoutBatchCommand(batchId), CancellationToken.None);
Assert.True(second.IsSuccess);
Assert.Equal(PayoutBatchStatus.Completed, second.Result.Status);
Assert.Equal(countAfterFirst, PayoutLegs(host).Count);
}
[Fact]
public async Task Holiday_shifts_period_end_and_processing_date()
{
using var host = new PayoutsTestHost();
var nurse = host.SeedNurse();
host.SeedCompletedBooking(nurse, Past);
var shiftedEnd = new DateOnly(2026, 7, 2);
var shiftedProcessing = new DateOnly(2026, 7, 4);
var holidays = host.Holidays(
(PeriodEnd, shiftedEnd),
(DateOnly.FromDateTime(Now.UtcDateTime), shiftedProcessing));
var result = await Gen(host, holidays).Handle(GenCmd, CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.Equal(shiftedEnd, result.Result.Batch.PeriodEnd);
Assert.Equal(shiftedProcessing, result.Result.Batch.ProcessingDate);
}
[Fact]
public async Task Partial_failure_then_retry_completes_the_batch()
{
using var host = new PayoutsTestHost();
var nurseOk = host.SeedNurse("IR000000000000000000000001");
var nurseFail = host.SeedNurse("IR000000000000000000000999");
host.SeedCompletedBooking(nurseOk, Past);
host.SeedCompletedBooking(nurseFail, Past);
var gen = await Gen(host).Handle(GenCmd, CancellationToken.None);
var batchId = gen.Result.Batch.Id;
var failingBank = host.Bank(failIban: "IR000000000000000000000999");
var exec = await Exec(host, failingBank).Handle(new ExecutePayoutBatchCommand(batchId), CancellationToken.None);
Assert.Equal(PayoutBatchStatus.PartiallyFailed, exec.Result.Status);
Assert.Equal(1, exec.Result.PaidCount);
Assert.Equal(1, exec.Result.FailedCount);
var failedPayout = host.Db.Set<NursePayout>().AsNoTracking().Single(p => p.Status == PayoutStatus.Failed);
var retry = new RetryFailedPayoutCommandHandler(
host.UnitOfWork, host.Lock(), host.Bank(), host.Holidays(), host.Config(), host.Clock(Now));
var retried = await retry.Handle(new RetryFailedPayoutCommand(failedPayout.Id), CancellationToken.None);
Assert.True(retried.IsSuccess);
var batch = await host.Db.Set<NursePayoutBatch>().AsNoTracking().FirstAsync(b => b.Id == batchId);
Assert.Equal(PayoutBatchStatus.Completed, batch.Status);
}
private static List<LedgerEntry> PayoutLegs(PayoutsTestHost host)
=> host.Db.Set<LedgerEntry>().AsNoTracking()
.Where(l => l.SourceRefType == LedgerSourceRefType.NursePayout).ToList();
private static long Leg(IReadOnlyList<LedgerEntry> legs, string account, string direction)
=> legs.Where(l => l.AccountType == account && l.Direction == direction).Sum(l => l.AmountIrr);
}
@@ -0,0 +1,286 @@
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Configuration;
using Baya.Application.Contracts.Holidays;
using Baya.Application.Contracts.Payments;
using Baya.Domain.Entities.Booking;
using Baya.Domain.Entities.Catalog;
using Baya.Domain.Entities.Geography;
using Baya.Domain.Entities.Identity;
using Baya.Domain.Entities.Payments;
using Baya.Domain.Entities.Payouts;
using Baya.Domain.Entities.Refunds;
using Baya.Domain.Entities.User;
using Baya.Infrastructure.CrossCutting.Seams;
using Baya.Infrastructure.Persistence;
using Baya.Infrastructure.Persistence.Repositories.Common;
using Baya.Tests.Setup.Setups;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using NSubstitute;
using BookingEntity = Baya.Domain.Entities.Booking.Booking;
namespace Baya.Test.Foundation.Payouts;
/// <summary>
/// A self-contained SQLite host exercising the real EF model (schema, the net-split CHECK, the booking_id UNIQUE
/// link, encrypted iban_snapshot, query filters) for the b13 payout engine. Seeds a customer + bookable nurses and
/// can create completed, dispute-window-closed bookings, verified primary bank accounts, and pending clawbacks so
/// a test can drive the real handlers against the real <see cref="UnitOfWork"/> with substituted seams.
/// </summary>
public sealed class PayoutsTestHost : IDisposable
{
private readonly SqliteConnection _connection;
public ApplicationDbContext Db { get; }
public UnitOfWork UnitOfWork { get; }
public long CustomerId { get; }
public int AdminUserId { get; }
private readonly long _cityId;
private readonly long _categoryId;
private readonly long _patientId;
private readonly long _addressId;
private int _phoneSeq = 100;
public PayoutsTestHost()
{
_connection = new SqliteConnection("DataSource=:memory:");
_connection.Open();
var options = new DbContextOptionsBuilder<ApplicationDbContext>().UseSqlite(_connection).Options;
Db = new ApplicationDbContext(options, TestFieldEncryptor.Instance);
Db.Database.EnsureCreated();
UnitOfWork = new UnitOfWork(Db);
var province = new Province { NameFa = "تهران", NameEn = "Tehran", SortOrder = 1, IsActive = true };
Db.Set<Province>().Add(province);
Db.SaveChanges();
var city = new City { ProvinceId = province.Id, NameFa = "تهران", NameEn = "Tehran", SortOrder = 1, IsActive = true };
Db.Set<City>().Add(city);
var category = new ServiceCategory { NameFa = "سالمند", NameEn = "Elderly", SortOrder = 1, IsActive = true };
Db.Set<ServiceCategory>().Add(category);
Db.SaveChanges();
_cityId = city.Id;
_categoryId = category.Id;
var adminUser = new User { UserName = "admin1", PhoneNumber = NextPhone(), Gender = "male", Name = "ادمین", FamilyName = "سیستم", IsActive = true };
Db.Users.Add(adminUser);
Db.SaveChanges();
AdminUserId = adminUser.Id;
var customerUser = new User { UserName = "cust1", PhoneNumber = NextPhone(), Gender = "male", Name = "علی", FamilyName = "رضایی", IsActive = true };
Db.Users.Add(customerUser);
Db.SaveChanges();
var customer = new CustomerProfile { UserId = customerUser.Id };
Db.Set<CustomerProfile>().Add(customer);
Db.SaveChanges();
CustomerId = customer.Id;
var patient = new Patient { CustomerId = customer.Id, DisplayName = "پدر", FirstName = "حسن", LastName = "رضایی", Gender = "male", IsActive = true };
Db.Set<Patient>().Add(patient);
var address = new CustomerAddress
{
CustomerId = customer.Id, CityId = city.Id, Title = "خانه", AddressLine = "خیابان اول",
PostalCode = "1111111111", RecipientName = "علی", RecipientPhone = "09120000001",
Latitude = 35.6892m, Longitude = 51.3890m, IsPrimary = true
};
Db.Set<CustomerAddress>().Add(address);
Db.SaveChanges();
_patientId = patient.Id;
_addressId = address.Id;
}
private string NextPhone() => $"0912000{++_phoneSeq:0000}";
/// <summary>Seeds a bookable nurse. When <paramref name="verifiedIban"/> is set, also seeds a verified primary
/// bank account (is_primary + is_verified + matched_national_id) with the given IBAN.</summary>
public long SeedNurse(string? verifiedIban = "IR000000000000000000000123")
{
var nurseUser = new User { UserName = $"nurse_{Guid.NewGuid():N}", PhoneNumber = NextPhone(), Gender = "female", Name = "زهرا", FamilyName = "احمدی", IsActive = true };
Db.Users.Add(nurseUser);
Db.SaveChanges();
var nurse = new NurseProfile { UserId = nurseUser.Id };
nurse.MarkVerified();
nurse.SetAcceptingBookings(true);
Db.Set<NurseProfile>().Add(nurse);
Db.SaveChanges();
if (verifiedIban is not null)
{
var account = new NurseBankAccount
{
NurseId = nurse.Id, BankName = "بانک ملی", AccountHolderName = "زهرا احمدی",
Iban = verifiedIban, IbanHash = $"hash-{nurse.Id}", IsPrimary = true, IsVerified = true,
MatchedNationalId = true, AccountHolderFromBank = "زهرا احمدی", OwnershipVendorRef = "mock"
};
Db.Set<NurseBankAccount>().Add(account);
Db.SaveChanges();
}
return nurse.Id;
}
/// <summary>Seeds a completed booking with the given dispute-window close. Amounts satisfy
/// <c>gross = commission + payout</c>. When <paramref name="disputed"/> is set the booking is moved to
/// <c>disputed</c> (payout-ineligible).</summary>
public long SeedCompletedBooking(
long nurseId, DateTime disputeWindowEndsAt, long gross = 10_000_000, long commission = 1_500_000, bool disputed = false)
{
var variant = new NurseServiceVariant
{
NurseId = nurseId, ServiceCategoryId = _categoryId, Price = gross, PriceUnit = "per_day",
SessionCount = null, DisplayName = "مراقبت روزانه", OptionSetHash = $"hash-{Guid.NewGuid():N}", IsActive = true
};
Db.Set<NurseServiceVariant>().Add(variant);
Db.SaveChanges();
var request = new BookingRequest
{
CustomerId = CustomerId, NurseId = nurseId, PatientId = _patientId, VariantId = variant.Id,
CustomerAddressId = _addressId, RequiredCaregiverGender = CaregiverGender.Any,
RequestedDate = new DateOnly(2026, 6, 1), RequestedTimeStart = new TimeOnly(9, 0), RequestedTimeEnd = new TimeOnly(13, 0),
CustomerNotes = "note", NurseResponseDeadlineAt = new DateTime(2030, 1, 1, 0, 0, 0, DateTimeKind.Utc)
};
Db.Set<BookingRequest>().Add(request);
Db.SaveChanges();
request.Accept(new DateTime(2030, 1, 1, 0, 0, 0, DateTimeKind.Utc));
Db.SaveChanges();
var confirmedAt = new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc);
var booking = new BookingEntity
{
BookingRequestId = request.Id, CustomerId = CustomerId, NurseId = nurseId, PatientId = _patientId,
VariantId = variant.Id, CustomerAddressId = _addressId, VariantSnapshotJson = "{}", AddressSnapshotJson = "{}",
GrossPriceIrr = gross, BalinyaarCommissionIrr = commission, PlatformFeeRate = 0.15m,
NursePayoutAmount = gross - commission, SessionCount = 1,
ScheduledDate = new DateOnly(2026, 6, 1), ScheduledTimeStart = new TimeOnly(9, 0), ScheduledTimeEnd = new TimeOnly(13, 0)
};
booking.TransitionTo(BookingStatus.Confirmed, confirmedAt);
booking.TransitionTo(BookingStatus.InProgress, confirmedAt);
booking.TransitionTo(BookingStatus.Completed, confirmedAt);
booking.SetDisputeWindow(disputeWindowEndsAt);
if (disputed)
booking.TransitionTo(BookingStatus.Disputed, confirmedAt);
Db.Set<BookingEntity>().Add(booking);
Db.SaveChanges();
// The capture accrual so nurse_payable starts positive and the payout drains it to zero.
var legs = LedgerPosting.CardCapture(booking.Id, nurseId, gross, commission, gross - commission, 0, confirmedAt);
Db.Set<LedgerEntry>().AddRange(legs);
Db.SaveChanges();
return booking.Id;
}
/// <summary>Seeds a pending clawback the nurse owes back (opened by a prior post-payout refund in b11).</summary>
public long SeedPendingClawback(long nurseId, long bookingId, long amount)
{
// A minimal refund row to satisfy the clawback's required refund_id FK.
var refund = new Refund
{
PaymentTransactionId = SeedThrowawayTransaction(bookingId), BookingId = bookingId, RequestedByCustomerId = CustomerId,
Amount = amount, PlatformFeeRefundedIrr = 0, NursePayoutRefundedIrr = amount, RefundPercentage = 1m,
RefundChannel = RefundChannel.PspCard
};
Db.Set<Refund>().Add(refund);
Db.SaveChanges();
var clawback = new NurseClawback { NurseId = nurseId, BookingId = bookingId, RefundId = refund.Id, AmountIrr = amount };
Db.Set<NurseClawback>().Add(clawback);
Db.SaveChanges();
return clawback.Id;
}
private long SeedThrowawayTransaction(long bookingId)
{
var gateway = new PaymentGateway { ProviderCode = "zarinpal", Type = PaymentGatewayType.Standard, DisplayName = "GW", ConfigJson = "{}", IsActive = true, Priority = 0 };
Db.Set<PaymentGateway>().Add(gateway);
Db.SaveChanges();
var nurseBookingRequestId = Db.Set<BookingEntity>().Where(b => b.Id == bookingId).Select(b => b.BookingRequestId).First();
var txn = new PaymentTransaction
{
BookingRequestId = nurseBookingRequestId, CustomerId = CustomerId, GatewayId = gateway.Id,
Amount = 10_000_000, GatewayReferenceCode = $"ref-{Guid.NewGuid():N}"
};
txn.MarkSucceeded(bookingId, "ok", null);
Db.Set<PaymentTransaction>().Add(txn);
Db.SaveChanges();
return txn.Id;
}
public ICurrentUser AsAdmin(int? userId = null)
{
var u = Substitute.For<ICurrentUser>();
u.UserId.Returns(userId ?? AdminUserId);
u.Roles.Returns(new[] { RoleNames.Admin });
return u;
}
public IDateTimeProvider Clock(DateTimeOffset now)
{
var c = Substitute.For<IDateTimeProvider>();
c.UtcNow.Returns(now);
return c;
}
/// <summary>Identity holiday calendar (no shift) unless <paramref name="shifts"/> maps a closed day to its
/// next business day.</summary>
public IHolidayCalendar Holidays(params (DateOnly Closed, DateOnly Next)[] shifts)
{
var h = Substitute.For<IHolidayCalendar>();
h.NextBusinessDay(Arg.Any<DateOnly>(), Arg.Any<CancellationToken>())
.Returns(ci =>
{
var d = ci.Arg<DateOnly>();
foreach (var (closed, next) in shifts)
if (closed == d) return next;
return d;
});
h.IsBankClosed(Arg.Any<DateOnly>(), Arg.Any<CancellationToken>())
.Returns(ci => shifts.Any(s => s.Closed == ci.Arg<DateOnly>()));
return h;
}
public IPlatformConfig Config(long satnaThresholdIrr = 1_000_000_000, bool requireBnplSettlement = false)
{
var cfg = Substitute.For<IPlatformConfig>();
cfg.GetConfig<decimal>("payout_satna_threshold_irr", Arg.Any<CancellationToken>()).Returns(satnaThresholdIrr);
cfg.GetConfig<bool>("require_bnpl_settlement_for_payout", Arg.Any<CancellationToken>()).Returns(requireBnplSettlement);
return cfg;
}
public IBankTransferProvider Bank(bool forceFailure = false, string failIban = "")
=> new MockBankTransferProvider(Options.Create(new SeamOptions
{
BankTransfer = new BankTransferOptions { ForceFailure = forceFailure, FailIban = failIban }
}));
public IDistributedLock Lock() => new NoOpLock();
public IReadOnlyList<LedgerEntry> LedgerFor(long? bookingId, long? nurseId = null)
=> Db.Set<LedgerEntry>().AsNoTracking()
.Where(l => (bookingId == null || l.BookingId == bookingId) && (nurseId == null || l.NurseId == nurseId))
.OrderBy(l => l.Id).ToList();
public long NursePayableBalance(long nurseId)
=> Db.Set<LedgerEntry>().AsNoTracking()
.Where(l => l.AccountType == LedgerAccountType.NursePayable && l.NurseId == nurseId)
.Sum(l => l.Direction == LedgerDirection.Credit ? l.AmountIrr : -l.AmountIrr);
public void Dispose()
{
Db.Dispose();
_connection.Dispose();
}
private sealed class NoOpLock : IDistributedLock
{
public ValueTask<IAsyncDisposable> AcquireAsync(string key, CancellationToken cancellationToken = default)
=> ValueTask.FromResult<IAsyncDisposable>(new Handle());
private sealed class Handle : IAsyncDisposable
{
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}
}