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 │ │ ├── (customer)/ # Customer (family) app — mobile-first, bottom-tab nav; no URL segment
│ │ │ ├── layout.tsx # 'use client' — wraps CustomerLayout │ │ │ ├── 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) │ │ │ ├── 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) │ │ │ ├── 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) │ │ │ ├── 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) │ │ │ ├── addresses/page.tsx # /addresses — F3 address book (cascading region dropdowns + map-pin picker, set-primary)
│ │ │ ├── wallet/page.tsx # /wallet │ │ │ ├── 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) │ ├── 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) │ ├── 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) │ ├── 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) │ ├── 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 │ └── auth/ # Auth-flow composites: LoginFlow, PhoneStep, OtpStep, RoleRouter, SelectRole, AuthCard, BrandMark, AuthSplash, useCountdown
├── i18n/ ├── i18n/
@@ -215,6 +223,7 @@ client/
│ ├── addresses/ # F3 customer address book CRUD + set-primary (single-primary invariant; invalidate-on-mutation) │ ├── 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) │ ├── 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 │ ├── 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 │ ├── 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}/ │ └── {domain}/
│ ├── types.ts # Request/response types + the domain's Api interface (the seam) │ ├── 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) - `'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) - `'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) - `'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 - `'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) - `'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" "saved_toast": "Changes saved"
}, },
"search": { "search": {
"title": "Search", "title": "Find a nurse",
"deferred": "Search and results arrive in the next phase.", "subtitle": "Only verified, background-checked nurses appear here.",
"query_echo": "You searched for “{query}”.", "section_category": "Care category",
"category_echo": "Filtered to the selected service 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": { "auth": {
"customer_title": "Sign in to Balinyaar", "customer_title": "Sign in to Balinyaar",
+61 -4
View File
@@ -310,10 +310,67 @@
"saved_toast": "تغییرات ذخیره شد" "saved_toast": "تغییرات ذخیره شد"
}, },
"search": { "search": {
"title": "جستجو", "title": "یافتن پرستار",
"deferred": "جستجو و نتایج در فاز بعدی اضافه می‌شود.", "subtitle": "فقط پرستاران تاییدشده و دارای صلاحیت اینجا نمایش داده می‌شوند.",
"query_echo": "جستجوی شما: «{query}».", "section_category": "دستهٔ مراقبت",
"category_echo": "محدود به دستهٔ خدمت انتخاب‌شده." "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": { "auth": {
"customer_title": "ورود به بلینیار", "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'; 'use client';
import { Suspense } from 'react'; import { Suspense, type FunctionComponent, type ReactNode } from 'react';
import { useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import { useTranslations } from 'next-intl'; import { useLocale, useTranslations } from 'next-intl';
import { AppLoading, PlaceholderScreen } from '@/components'; 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 * C1 — Search & filter (جستجو و فیلتر): the discovery entry screen. Pick a care category (reusing the
* navigate here carrying a `q` / `category_id`; f6 builds the actual results, filters, and nurse * f4 catalog grid), a city (reusing the f3 cascading region picker; district optional = whole city),
* cards. This placeholder just acknowledges the intent so the Home CTAs don't dead-end. `useSearchParams` * the **prominent same-gender facet**, and an optional Toman price range; a live result count drives the
* needs a Suspense boundary under static rendering. * "مشاهده 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() { export default function SearchPage() {
return ( return (
<Suspense fallback={<AppLoading />}> <Suspense fallback={<AppLoading />}>
<SearchDeferred /> <SearchFilterScreen />
</Suspense> </Suspense>
); );
} }
function SearchDeferred() { const GENDER_OPTIONS: readonly (NurseGender | 'any')[] = ['female', 'male', 'any'];
function SearchFilterScreen() {
const t = useTranslations('search'); const t = useTranslations('search');
const router = useRouter();
const locale = useLocale();
const params = useSearchParams(); const params = useSearchParams();
const query = params.get('q');
const categoryId = params.get('category_id'); const initialCategoryRaw = Number(params.get('category_id'));
const echo = query ? t('query_echo', { query }) : categoryId ? t('category_echo') : undefined; 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 ( return (
<PlaceholderScreen <Stack sx={{ gap: 3 }}>
icon="search" <Box>
title={t('title')} <Typography variant="h5" component="h1">
description={[t('deferred'), echo].filter(Boolean).join(' ')} {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 IdentityIcon from '@mui/icons-material/BadgeOutlined';
import LicenseIcon from '@mui/icons-material/WorkspacePremiumOutlined'; import LicenseIcon from '@mui/icons-material/WorkspacePremiumOutlined';
import PublishIcon from '@mui/icons-material/RocketLaunchOutlined'; 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 * List of all available Icon names
@@ -123,4 +126,6 @@ export const ICONS /* Note: Setting type disables property autocomplete :( was -
identity: IdentityIcon, identity: IdentityIcon,
license: LicenseIcon, license: LicenseIcon,
publish: PublishIcon, publish: PublishIcon,
star: StarIcon,
tune: TuneIcon,
}; };
+6
View File
@@ -17,6 +17,8 @@ import PriceDisplay from './PriceDisplay';
import VariantCard from './VariantCard'; import VariantCard from './VariantCard';
import TrustBadge from './TrustBadge'; import TrustBadge from './TrustBadge';
import DocumentUpload from './DocumentUpload'; import DocumentUpload from './DocumentUpload';
import NurseResultCard from './NurseResultCard';
import ServicePriceRow from './ServicePriceRow';
export { export {
UserInfo, UserInfo,
@@ -36,6 +38,8 @@ export {
VariantCard, VariantCard,
TrustBadge, TrustBadge,
DocumentUpload, DocumentUpload,
NurseResultCard,
ServicePriceRow,
}; };
export type { PlaceholderScreenProps } from './PlaceholderScreen'; export type { PlaceholderScreenProps } from './PlaceholderScreen';
export type { OtpInputProps } from './OtpInput'; export type { OtpInputProps } from './OtpInput';
@@ -53,3 +57,5 @@ export type { PriceDisplayProps } from './PriceDisplay';
export type { VariantCardProps } from './VariantCard'; export type { VariantCardProps } from './VariantCard';
export type { TrustBadgeProps } from './TrustBadge'; export type { TrustBadgeProps } from './TrustBadge';
export type { DocumentUploadProps, UploadedDocInfo } from './DocumentUpload'; 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: '/', HOME: '/',
// First-login "who is care for?" flow (A3→A4); re-enterable from the patient list. // First-login "who is care for?" flow (A3→A4); re-enterable from the patient list.
ONBOARDING: '/onboarding', 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', 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', BOOKINGS: '/bookings',
// Booking-request handoff target (f7 owns the form) — C3's "درخواست رزرو" lands here with intent.
BOOKING_REQUEST: '/bookings/request',
PATIENTS: '/patients', PATIENTS: '/patients',
// Address book — cascading region dropdowns + map-pin picker; reached from the profile hub. // Address book — cascading region dropdowns + map-pin picker; reached from the profile hub.
ADDRESSES: '/addresses', 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> - **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 ## backend-phase-12 — BNPL: provider-financed installments (mocked) — 2026-07-09
- **Shipped:** `payments.BnplTransactions` (1:1 with `payment_transaction`, `UNIQUE(payment_transaction_id)`, - **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/ 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) - **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 ## 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]/ - **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 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** - **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`. seeded step as required (the "X از Y" meter Y = `steps.length`). Confirm that holds, or add `isRequired`.
- **Status:** open - **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` | 🟡 | | `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 | 🟡 | | `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 | 🟡 | | `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 | 🟡 | | `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 | 🟡 | | `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 | 🟡 | | `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 | 🟡 | | `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`.** | 🟡 | | `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 | 🟡 | | `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 > 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. > "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. - **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. - **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 ## (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`. `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/ src/
├── Core/ ├── 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.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); + 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.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/ ├── 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.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.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 │ └── Baya.Infrastructure.Monitoring HealthChecks, OpenTelemetry, prometheus-net
├── API/ ├── 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) │ ├── 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) │ └── Plugins/Baya.Web.Plugins.Grpc gRPC services + .proto models (User only)
├── Shared/Baya.SharedKernel Extensions + validation base ├── 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 (`MockBnplProvider`/`MockBnplProviderResolver`/`MockCurrencyNormalizer`) in `CrossCutting/Seams/`, registered by
`AddCrossCuttingSeams`. `bnpl_settlement_entries` (tranched settlement) is **DEFERRED — modeled-but-not-built**. `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 **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 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 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 IRefundRepository RefundRepository { get; }
public IInvoiceRepository InvoiceRepository { get; } public IInvoiceRepository InvoiceRepository { get; }
public IBnplRepository BnplRepository { get; } public IBnplRepository BnplRepository { get; }
public IPayoutRepository PayoutRepository { get; }
Task CommitAsync(); Task CommitAsync();
ValueTask RollBackAsync(); 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> /// <summary>
/// The clawback <b>write-off</b> correction: <c>DEBIT bad_debt / CREDIT nurse_clawback_receivable</c> for /// 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. /// 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( private static LedgerEntry Leg(
Guid group, string account, string direction, long amount, long? nurse, 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, TransactionGroupId = group,
AccountType = account, 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; 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 /// <summary>Admin declares the receivable uncollectable. The balancing <c>bad_debt</c> posting is the
/// handler's job; this records the workflow outcome.</summary> /// handler's job; this records the workflow outcome.</summary>
public void WriteOff(string notes, DateTime now) 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 MoadianOptions Moadian { get; set; } = new();
public BnplOptions Bnpl { get; set; } = new(); public BnplOptions Bnpl { get; set; } = new();
public CurrencyOptions Currency { 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> /// <summary>
@@ -73,6 +73,13 @@ public static class ServiceCollectionExtension
services.AddSingleton<IBnplProviderResolver, MockBnplProviderResolver>(); services.AddSingleton<IBnplProviderResolver, MockBnplProviderResolver>();
services.AddSingleton<ICurrencyNormalizer, MockCurrencyNormalizer>(); 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; return services;
} }
} }
@@ -165,5 +165,12 @@ public class ApplicationDbContext: IdentityDbContext<User, Role, int, UserClaim,
{ {
builder.Property(g => g.ConfigJson).HasConversion(encrypted); 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."), (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)."), (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."), (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 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.", Description = "Ops/testing override that forces the post-payout clawback path for refunds (b11); b13 replaces the derivation.",
Key = "refund_assume_nurse_paid", Key = "refund_assume_nurse_paid",
Value = "false" 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"); 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 => modelBuilder.Entity("Baya.Domain.Entities.Refunds.NurseClawback", b =>
{ {
b.Property<long>("Id") b.Property<long>("Id")
@@ -4674,6 +4886,57 @@ namespace Baya.Infrastructure.Persistence.Migrations
.IsRequired(); .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 => modelBuilder.Entity("Baya.Domain.Entities.Refunds.NurseClawback", b =>
{ {
b.HasOne("Baya.Domain.Entities.Booking.Booking", null) b.HasOne("Baya.Domain.Entities.Booking.Booking", null)
@@ -4942,6 +5205,16 @@ namespace Baya.Infrastructure.Persistence.Migrations
b.Navigation("BankAccounts"); 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 => modelBuilder.Entity("Baya.Domain.Entities.User.Role", b =>
{ {
b.Navigation("Claims"); b.Navigation("Claims");
@@ -26,6 +26,7 @@ public class UnitOfWork : IUnitOfWork
public IRefundRepository RefundRepository { get; } public IRefundRepository RefundRepository { get; }
public IInvoiceRepository InvoiceRepository { get; } public IInvoiceRepository InvoiceRepository { get; }
public IBnplRepository BnplRepository { get; } public IBnplRepository BnplRepository { get; }
public IPayoutRepository PayoutRepository { get; }
public UnitOfWork(ApplicationDbContext db) public UnitOfWork(ApplicationDbContext db)
{ {
@@ -50,6 +51,7 @@ public class UnitOfWork : IUnitOfWork
RefundRepository = new RefundRepository(_db); RefundRepository = new RefundRepository(_db);
InvoiceRepository = new InvoiceRepository(_db); InvoiceRepository = new InvoiceRepository(_db);
BnplRepository = new BnplRepository(_db); BnplRepository = new BnplRepository(_db);
PayoutRepository = new PayoutRepository(_db);
} }
public Task CommitAsync() 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. // Supersedes the b0 log/no-op stub with the real in-app notifications write.
services.AddScoped<INotificationDispatcher, InAppNotificationDispatcher>(); services.AddScoped<INotificationDispatcher, InAppNotificationDispatcher>();
// "Has the nurse been paid?" — the refund pre-payout/clawback fork (b11). DB-backed because b13's real // "Has the nurse been paid?" — the refund pre-payout/clawback fork (b11). b13 now owns the authoritative
// impl reads nurse_payout_booking_links; until then it derives from the dispute-window close. b13 swaps // impl: a booking is paid iff a nurse_payout_booking_links row ties it to a `paid` nurse_payouts row. This
// this registration for the authoritative payout-link lookup. // supersedes the interim NursePayoutStatusService (dispute-window derivation); the refund fork is unchanged.
services.AddScoped<INursePayoutStatus, NursePayoutStatusService>(); services.AddScoped<INursePayoutStatus, NursePayoutLinkStatusService>();
// Retention job seam (mock = in-process interval runner; real Hangfire/Quartz deferred). // Retention job seam (mock = in-process interval runner; real Hangfire/Quartz deferred).
services.AddHostedService<NotificationRetentionHostedService>(); 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;
}
}
}