frontend phase 5 & backend phase 12

This commit is contained in:
hamid
2026-07-09 03:05:14 +03:30
parent 465f75c29e
commit dc64472631
98 changed files with 11847 additions and 136 deletions
+13 -3
View File
@@ -130,10 +130,16 @@ client/
│ │ │ ├── layout.tsx # 'use client' — wraps NurseLayout
│ │ │ ├── page.tsx # /nurse (dashboard)
│ │ │ ├── profile/page.tsx # /nurse/profile — B7 profile bootstrap (avatar+bio+years; unverified placeholder)
│ │ │ ├── services/ # /nurse/services — B7 services half: offerings list ↔ variant builder (page.tsx switches mode; MyServicesList + VariantBuilder co-located)
│ │ │ ├── services/ # /nurse/services — B7 services half: offerings list ↔ variant builder (page.tsx switches mode; MyServicesList + VariantBuilder + PublishGate co-located; PublishGate is the f5 verification-gated go-live)
│ │ │ ├── coverage/page.tsx # /nurse/coverage — F3 coverage-area editor (whole-city/district areas, dup-blocked)
│ │ │ ├── bank/page.tsx # /nurse/bank — payout IBAN + ownership states (pending/verified/mismatch)
│ │ │ ├── verification/page.tsx # /nurse/verification
│ │ │ ├── bank/page.tsx # /nurse/bank — payout IBAN + ownership states (pending/verified/mismatch); the f5 bank_account_verification step deep-links here
│ │ │ ├── verification/ # /nurse/verification — f5 trust flow: ONE cached VerificationStatus query, four views
│ │ │ │ ├── page.tsx # B3 hub — "X از Y" meter + data-driven checklist (StatusChip rows) + single continue CTA + not_started/approved states; dev-only mock admin-decision sim
│ │ │ │ ├── identity/page.tsx # B4 — national-ID (checksum) + card/selfie local capture → automated KYC + chained Shahkar
│ │ │ │ ├── credentials/page.tsx # B5 — INO number + specialty chips + a DocumentUpload per manual step (data-driven) → in_review
│ │ │ │ ├── review/page.tsx # B6 — under-review (same status query, condensed mini-checklist)
│ │ │ │ ├── VerificationChecklist.tsx # B3 body: meter + step rows (co-located, page-only)
│ │ │ │ └── verificationSteps.ts # step→label/chip/route helpers + synthetic mobile step (keeps rendering data-driven)
│ │ │ └── visits/page.tsx # /nurse/visits (EVV)
│ │ └── admin/ # Admin/backoffice (/admin/…) — desktop sidebar shell
│ │ ├── layout.tsx # 'use client' — wraps AdminLayout
@@ -158,6 +164,8 @@ client/
│ ├── CategoryTile/ # f4 tappable service-category tile (icon+label; `selected` state for the builder) — Home grid + builder step 1 (tested)
│ ├── PriceDisplay/ # f4 price renderer: money-util Toman + i18n unit label + unit-aware estimated total (never a total from price alone) (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)
│ ├── DocumentUpload/ # f5 reusable doc uploader: client type/size validation, progress %, success/retry, re-upload on reject; server-metadata truth (local-capture mode too) (tested)
│ ├── geography/ # F3 geo composites: CascadingRegionSelect, AddressMapPicker (map-pin stand-in), AddressForm, AddressCard (each tested)
│ └── auth/ # Auth-flow composites: LoginFlow, PhoneStep, OtpStep, RoleRouter, SelectRole, AuthCard, BrandMark, AuthSplash, useCountdown
├── i18n/
@@ -207,6 +215,7 @@ client/
│ ├── addresses/ # F3 customer address book CRUD + set-primary (single-primary invariant; invalidate-on-mutation)
│ ├── serviceAreas/ # F3 nurse coverage areas add/remove (areaExists dup-guard; districtId=null = whole city)
│ ├── catalog/ # F4 catalog skeleton + nurse pricing variants (b5). Reference data (categories, category option groups) cached session-long like geography (Infinity staleTime); myVariants invalidated on mutation. useServiceCategories/useCategoryOptionGroups/useMyVariants/useCreateVariant/useUpdateVariant/useSetVariantActive; seam+mock+client; names.ts locale-label helper
│ ├── verification/ # F5 nurse trust flow (b6). ONE cached status() query drives B3+B6; every mutation invalidates it. useVerificationStatus/useStartVerification/useSubmitIdentity/useRunBankVerification/useUploadVerificationDocument/useSubmitCredentials/useNurseTrustBadge; seam+mock(primary)+client; validation.ts (national-ID checksum); types export ownBadgeState/publicBadgeState/isApproved
│ └── {domain}/
│ ├── types.ts # Request/response types + the domain's Api interface (the seam)
│ ├── keys.ts # React Query key factory (hierarchical)
@@ -300,6 +309,7 @@ async function MyServerComponent() {
- `'catalog'`**shared** catalog vocabulary: the five `price_unit` labels + count nouns + the estimated-total label (read by `PriceDisplay`; f6 reuses it customer-side)
- `'services'` — the f4 nurse Services & prices surface (offerings list, the variant builder steps/fields/validation, the duplicate-listing warning, deactivate confirm)
- `'search'` — the f4 deferred `/search` placeholder (title + "arrives next phase" + query/category echo); f6 fills it out
- `'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)
**Namespace conventions for the phases to come** (seed each when its feature lands, in both locale
+125
View File
@@ -343,5 +343,130 @@
"role_nurse": "Nurse",
"role_nurse_desc": "Offer nursing services",
"continue": "Continue"
},
"verification": {
"title": "Verification",
"subtitle": "Complete these steps to activate your profile and start receiving bookings.",
"retry": "Try again",
"load_error": "Couldn't load your verification status.",
"start_title": "Start verification",
"start_body": "Trust is the whole of Balinyaar. Completing these steps lets families choose you with confidence.",
"start_cta": "Start verification",
"starting": "Preparing…",
"progress_title": "Verification progress",
"progress_count": "{passed} of {total}",
"blocking_summary": "Your profile isn't visible or bookable until every step is complete.",
"continue": "Continue",
"continue_step": "Continue step {n}",
"approved_title": "Verification complete",
"approved_body": "Everything checks out. You can now publish your profile and services.",
"approved_cta": "Publish services",
"step_mobile_verified": "Mobile number",
"step_mobile_verified_desc": "Verified when you signed in.",
"step_identity_kyc": "Identity (civil registry)",
"step_identity_kyc_desc": "Automatic civil-registry check from your national ID + a liveness selfie.",
"step_shahkar_match": "Shahkar match",
"step_shahkar_match_desc": "Automatic check that your mobile number matches your national ID.",
"step_moh_competency_license": "Competency license (MoH)",
"step_moh_competency_license_desc": "Upload the document; reviewed manually by our team.",
"step_ino_membership": "Nursing-council membership",
"step_ino_membership_desc": "Upload your nursing-council card; reviewed manually.",
"step_criminal_record": "Criminal-record clearance",
"step_criminal_record_desc": "Upload the certificate; reviewed manually. Has an expiry date.",
"step_bank_account_verification": "Bank-account verification",
"step_bank_account_verification_desc": "Automatic IBAN-ownership check against your national ID.",
"status_passed": "Verified",
"status_in_review": "Under review",
"status_pending": "Pending",
"status_failed": "Rejected",
"status_expired": "Expired",
"status_next": "Next",
"auto_query_note": "Automatic check — no document upload needed.",
"row_go": "Complete this step",
"row_fix": "Fix and resubmit",
"reason_shared_sim": "This SIM doesn't appear to be registered in your name. Please try again with a SIM registered to you.",
"reason_kyc_no_match": "Your identity details didn't match the civil registry.",
"reason_national_id_mismatch": "The national ID didn't match.",
"reason_blurry_scan": "The document image wasn't clear. Please upload a sharper copy.",
"journey_identity": "Identity",
"journey_credentials": "Credentials",
"journey_review": "Review",
"identity_title": "Verify identity",
"identity_subtitle": "Your national ID, a photo of your ID card, and a liveness selfie.",
"national_id_label": "National ID",
"national_id_hint": "Enter your 10-digit national ID.",
"national_id_invalid": "Enter a valid national ID.",
"card_label": "National-ID card image",
"card_hint": "Photograph your ID card in good light, clearly readable.",
"card_recommended": "Uploading the ID card image is recommended.",
"selfie_label": "Liveness selfie",
"selfie_hint": "Take a selfie for face verification.",
"auto_registry_note": "An automatic civil-registry check is performed.",
"error_national_id_mismatch": "The national ID didn't match the civil registry. Please check and try again.",
"error_shared_sim": "This SIM doesn't appear to be registered in your name. Please try again with a SIM registered to you.",
"error_shahkar_mismatch": "The mobile-to-national-ID match failed. Please check and try again.",
"identity_submit": "Submit & verify",
"identity_submitting": "Verifying…",
"identity_submitted": "Identity submitted — verification started",
"back_to_checklist": "Back to checklist",
"credentials_title": "Professional credentials",
"credentials_subtitle": "Your documents are reviewed by our team after upload.",
"credentials_needs_start": "Start verification from the status checklist first.",
"ino_number_label": "Nursing-council number",
"ino_number_hint": "Enter your nursing-council membership number.",
"ino_number_required": "Enter your nursing-council number.",
"doc_uploaded": "Document uploaded",
"education_label": "Education certificate",
"education_hint": "Optional — an image of your latest qualification.",
"specialties_label": "Specialties",
"specialties_hint": "Choose or add your areas of specialty.",
"specialty_elderly": "Elderly care",
"specialty_icu": "ICU",
"specialty_pediatric": "Pediatric",
"specialty_post_surgery": "Post-surgery",
"specialty_wound_care": "Wound care",
"specialty_add_placeholder": "Another specialty",
"specialty_add": "Add",
"registry_details_label": "Credential details (optional)",
"issuing_authority_label": "Issuing authority",
"issued_at_label": "Issue date",
"expires_at_label": "Expiry date",
"manual_review_note": "These documents are reviewed manually by our team — not an instant approval.",
"credentials_submit": "Submit credentials",
"credentials_submitting": "Submitting…",
"credentials_submitted": "Credentials submitted — under review",
"credentials_error": "Couldn't submit your credentials. Check the inputs and try again.",
"review_title": "Under review",
"review_body": "Your documents were submitted and are being reviewed by our team.",
"review_eta": "This usually takes 2448 hours.",
"review_approved_title": "Verification complete",
"review_approved_body": "Every step is verified. You can now publish your profile.",
"review_summary_title": "Status summary",
"review_view_status": "View status",
"badge_verified": "Verified",
"badge_unverified": "Not verified",
"badge_expired": "Expired",
"publish_ready_title": "Ready to publish",
"publish_ready_body": "Your verification is complete. Your services are visible and bookable.",
"publish_blocked_title": "Complete verification to publish",
"publish_blocked_body": "Until verification is complete, your services won't appear in search and can't be booked.",
"publish_cta": "Publish profile",
"publish_complete_verification": "Complete verification",
"publish_done": "Your profile has been published",
"mock_admin_title": "Simulate admin review (demo)",
"mock_admin_approve": "Approve all steps",
"mock_admin_reject": "Reject a document",
"upload_choose": "Choose file",
"upload_capture": "Take photo",
"upload_size_hint": "JPG, PNG or PDF — up to {size} MB.",
"upload_uploading": "Uploading…",
"upload_success": "Uploaded",
"upload_change": "Change",
"upload_error": "Upload failed.",
"upload_bad_type": "That file type isn't allowed. Use JPG, PNG or PDF.",
"upload_too_large": "The file must be smaller than {size} MB.",
"upload_retry": "Try again",
"upload_rejected": "This document was rejected",
"upload_reupload": "Upload again"
}
}
+125
View File
@@ -343,5 +343,130 @@
"role_nurse": "پرستار",
"role_nurse_desc": "برای ارائه خدمات پرستاری",
"continue": "ادامه"
},
"verification": {
"title": "احراز هویت",
"subtitle": "برای فعال‌شدن پروفایل و دریافت رزرو، این مراحل را کامل کنید.",
"retry": "تلاش مجدد",
"load_error": "بارگذاری وضعیت احراز هویت ممکن نشد.",
"start_title": "احراز هویت را آغاز کنید",
"start_body": "اعتماد، بنیان بلینیار است. با تکمیل این مراحل، خانواده‌ها با اطمینان شما را انتخاب می‌کنند.",
"start_cta": "شروع احراز هویت",
"starting": "در حال آماده‌سازی…",
"progress_title": "پیشرفت احراز هویت",
"progress_count": "{passed} از {total}",
"blocking_summary": "تا تکمیل همهٔ مراحل، پروفایل شما قابل نمایش و رزرو نیست.",
"continue": "ادامه",
"continue_step": "ادامه مرحلهٔ {n}",
"approved_title": "احراز هویت تکمیل شد",
"approved_body": "همه‌چیز تأیید شد. اکنون می‌توانید پروفایل و خدمات خود را منتشر کنید.",
"approved_cta": "انتشار خدمات",
"step_mobile_verified": "شماره موبایل",
"step_mobile_verified_desc": "با ورود شما تأیید شد.",
"step_identity_kyc": "احراز هویت (ثبت احوال)",
"step_identity_kyc_desc": "استعلام خودکار از ثبت احوال با کد ملی و تصویر زنده.",
"step_shahkar_match": "تطبیق شاهکار",
"step_shahkar_match_desc": "استعلام خودکار تطبیق شماره موبایل با کد ملی.",
"step_moh_competency_license": "پروانه صلاحیت (وزارت بهداشت)",
"step_moh_competency_license_desc": "آپلود مدرک؛ بررسی دستی توسط کارشناس.",
"step_ino_membership": "عضویت نظام پرستاری",
"step_ino_membership_desc": "آپلود کارت نظام پرستاری؛ بررسی دستی.",
"step_criminal_record": "گواهی عدم سوءپیشینه",
"step_criminal_record_desc": "آپلود گواهی؛ بررسی دستی. دارای تاریخ انقضا.",
"step_bank_account_verification": "تأیید حساب بانکی",
"step_bank_account_verification_desc": "استعلام خودکار مالکیت شبا با کد ملی.",
"status_passed": "تاییدشده",
"status_in_review": "در حال بررسی",
"status_pending": "در انتظار",
"status_failed": "رد شد",
"status_expired": "منقضی شده",
"status_next": "بعدی",
"auto_query_note": "استعلام خودکار — بدون نیاز به بارگذاری مدرک.",
"row_go": "تکمیل این مرحله",
"row_fix": "رفع مشکل و ارسال دوباره",
"reason_shared_sim": "به نظر می‌رسد این سیم‌کارت به نام شما نیست. لطفاً با سیم‌کارتی که به نام خودتان است دوباره تلاش کنید.",
"reason_kyc_no_match": "اطلاعات هویتی با ثبت احوال مطابقت نداشت.",
"reason_national_id_mismatch": "کد ملی مطابقت نداشت.",
"reason_blurry_scan": "تصویر مدرک واضح نبود. لطفاً نسخهٔ خواناتری بارگذاری کنید.",
"journey_identity": "هویت",
"journey_credentials": "مدارک",
"journey_review": "بررسی",
"identity_title": "تأیید هویت",
"identity_subtitle": "کد ملی، تصویر کارت ملی و یک سلفی زنده.",
"national_id_label": "کد ملی",
"national_id_hint": "کد ملی ۱۰ رقمی خود را وارد کنید.",
"national_id_invalid": "کد ملی معتبر وارد کنید.",
"card_label": "تصویر کارت ملی",
"card_hint": "کارت ملی را در نور کافی و خوانا عکس بگیرید.",
"card_recommended": "بارگذاری تصویر کارت ملی توصیه می‌شود.",
"selfie_label": "سلفی زنده",
"selfie_hint": "برای تشخیص چهره، سلفی بگیرید.",
"auto_registry_note": "استعلام خودکار از ثبت احوال انجام می‌شود.",
"error_national_id_mismatch": "کد ملی با ثبت احوال مطابقت نداشت. لطفاً بررسی و دوباره تلاش کنید.",
"error_shared_sim": "به نظر می‌رسد این سیم‌کارت به نام شما نیست. لطفاً با سیم‌کارتی که به نام خودتان است دوباره تلاش کنید.",
"error_shahkar_mismatch": "تطبیق شماره موبایل و کد ملی ناموفق بود. لطفاً بررسی و دوباره تلاش کنید.",
"identity_submit": "ثبت و استعلام",
"identity_submitting": "در حال استعلام…",
"identity_submitted": "هویت ثبت شد — استعلام آغاز شد",
"back_to_checklist": "بازگشت به فهرست",
"credentials_title": "مدارک حرفه‌ای",
"credentials_subtitle": "مدارک شما پس از بارگذاری، توسط کارشناس بررسی می‌شود.",
"credentials_needs_start": "ابتدا احراز هویت را از فهرست وضعیت آغاز کنید.",
"ino_number_label": "شماره نظام پرستاری",
"ino_number_hint": "شماره عضویت نظام پرستاری خود را وارد کنید.",
"ino_number_required": "شماره نظام پرستاری را وارد کنید.",
"doc_uploaded": "مدرک بارگذاری شد",
"education_label": "مدرک تحصیلی",
"education_hint": "اختیاری — تصویر آخرین مدرک تحصیلی.",
"specialties_label": "تخصص‌ها",
"specialties_hint": "حوزه‌های تخصص خود را انتخاب یا اضافه کنید.",
"specialty_elderly": "سالمندان",
"specialty_icu": "آی‌سی‌یو",
"specialty_pediatric": "کودکان",
"specialty_post_surgery": "پس از جراحی",
"specialty_wound_care": "زخم و پانسمان",
"specialty_add_placeholder": "تخصص دیگر",
"specialty_add": "افزودن",
"registry_details_label": "جزئیات مدرک (اختیاری)",
"issuing_authority_label": "مرجع صادرکننده",
"issued_at_label": "تاریخ صدور",
"expires_at_label": "تاریخ انقضا",
"manual_review_note": "این مدارک به‌صورت دستی توسط کارشناس بررسی می‌شوند؛ تأیید فوری نیست.",
"credentials_submit": "ثبت مدارک",
"credentials_submitting": "در حال ثبت…",
"credentials_submitted": "مدارک ثبت شد — در حال بررسی",
"credentials_error": "ثبت مدارک ممکن نشد. ورودی‌ها را بررسی کرده و دوباره تلاش کنید.",
"review_title": "در حال بررسی",
"review_body": "مدارک شما ثبت شد و در حال بررسی توسط کارشناس است.",
"review_eta": "معمولاً ۲۴ تا ۴۸ ساعت زمان می‌برد.",
"review_approved_title": "احراز هویت تکمیل شد",
"review_approved_body": "همهٔ مراحل تأیید شد. اکنون می‌توانید پروفایل خود را منتشر کنید.",
"review_summary_title": "خلاصهٔ وضعیت",
"review_view_status": "مشاهده وضعیت",
"badge_verified": "تاییدشده",
"badge_unverified": "احراز نشده",
"badge_expired": "منقضی شده",
"publish_ready_title": "آمادهٔ انتشار",
"publish_ready_body": "احراز هویت شما کامل است. خدمات شما قابل نمایش و رزرو هستند.",
"publish_blocked_title": "برای انتشار، احراز هویت را کامل کنید",
"publish_blocked_body": "تا تکمیل احراز هویت، خدمات شما در جستجو نمایش داده نمی‌شود و قابل رزرو نیست.",
"publish_cta": "انتشار پروفایل",
"publish_complete_verification": "تکمیل احراز هویت",
"publish_done": "پروفایل شما منتشر شد",
"mock_admin_title": "شبیه‌سازی بررسی مدیر (نمایشی)",
"mock_admin_approve": "تأیید همهٔ مراحل",
"mock_admin_reject": "رد یک مدرک",
"upload_choose": "انتخاب فایل",
"upload_capture": "گرفتن عکس",
"upload_size_hint": "JPG، PNG یا PDF — حداکثر {size} مگابایت.",
"upload_uploading": "در حال بارگذاری…",
"upload_success": "بارگذاری شد",
"upload_change": "تغییر",
"upload_error": "بارگذاری ناموفق بود.",
"upload_bad_type": "نوع فایل مجاز نیست. از JPG، PNG یا PDF استفاده کنید.",
"upload_too_large": "حجم فایل باید کمتر از {size} مگابایت باشد.",
"upload_retry": "تلاش مجدد",
"upload_rejected": "این مدرک رد شد",
"upload_reupload": "بارگذاری دوباره"
}
}
@@ -3,10 +3,12 @@ import { ChangeEvent, FunctionComponent, useRef, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack';
import { Avatar, Box, Paper, Stack, TextField, Typography } from '@mui/material';
import { AppButton, AppIcon, AppLoading } from '@/components';
import { AppButton, AppIcon, AppLoading, TrustBadge } from '@/components';
import { ROUTES } from '@/constants';
import { useNurseProfile, useUpsertNurseProfile, useUploadAvatar } from '@/services/profiles';
import type { NurseProfile } from '@/services/profiles/types';
import { useVerificationStatus } from '@/services/verification';
import { ownBadgeState } from '@/services/verification/types';
const MAX_YEARS = 80;
@@ -24,6 +26,8 @@ const NurseProfileForm: FunctionComponent<{ initial: NurseProfile | null }> = ({
const { enqueueSnackbar } = useSnackbar();
const upsert = useUpsertNurseProfile();
const uploadAvatar = useUploadAvatar();
const { data: verificationStatus } = useVerificationStatus();
const badgeState = ownBadgeState(verificationStatus);
const fileInputRef = useRef<HTMLInputElement>(null);
const [avatarUrl, setAvatarUrl] = useState<string | null>(initial?.avatarUrl ?? null);
@@ -63,41 +67,47 @@ const NurseProfileForm: FunctionComponent<{ initial: NurseProfile | null }> = ({
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 560 }}>
<Box>
<Typography variant="h5" component="h1">
{t('title')}
</Typography>
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center', flexWrap: 'wrap' }}>
<Typography variant="h5" component="h1">
{t('title')}
</Typography>
{/* The public trust signal on the nurse's own profile — the same badge f6 reuses in search. */}
<TrustBadge state={badgeState} />
</Stack>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('subtitle')}
</Typography>
</Box>
{/* Not bookable until verification (f5) — a neutral placeholder, not the real banner. */}
<Paper
elevation={0}
sx={{ p: 2, borderRadius: 2, border: '1px solid', borderColor: 'divider', borderInlineStartWidth: 4, borderInlineStartColor: 'var(--bal-warning)' }}
>
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'flex-start' }}>
<AppIcon icon="warning" size={24} color="var(--bal-warning)" />
<Stack sx={{ gap: 1, flexGrow: 1 }}>
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('unverified_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('unverified_body')}
</Typography>
</Box>
<AppButton
color="primary"
variant="outlined"
to={`/${locale}${ROUTES.NURSE_VERIFICATION}`}
sx={{ m: 0, alignSelf: 'flex-start' }}
>
{t('unverified_cta')}
</AppButton>
{/* Blocked-until-verified banner — shown until the aggregate is approved (incl. the expired state). */}
{badgeState !== 'verified' ? (
<Paper
elevation={0}
sx={{ p: 2, borderRadius: 2, border: '1px solid', borderColor: 'divider', borderInlineStartWidth: 4, borderInlineStartColor: 'var(--bal-warning)' }}
>
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'flex-start' }}>
<AppIcon icon="warning" size={24} color="var(--bal-warning)" />
<Stack sx={{ gap: 1, flexGrow: 1 }}>
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('unverified_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('unverified_body')}
</Typography>
</Box>
<AppButton
color="primary"
variant="outlined"
to={`/${locale}${ROUTES.NURSE_VERIFICATION}`}
sx={{ m: 0, alignSelf: 'flex-start' }}
>
{t('unverified_cta')}
</AppButton>
</Stack>
</Stack>
</Stack>
</Paper>
</Paper>
) : null}
<Stack direction="row" sx={{ gap: 2, alignItems: 'center' }}>
<Avatar src={avatarUrl ?? undefined} sx={{ width: 72, height: 72, bgcolor: 'var(--bal-primary-soft)' }}>
@@ -16,6 +16,7 @@ import {
import { AppButton, AppIcon, VariantCard } from '@/components';
import { useMyVariants, useSetVariantActive } from '@/services/catalog';
import type { NurseServiceVariant } from '@/services/catalog/types';
import PublishGate from './PublishGate';
interface MyServicesListProps {
onAdd: () => void;
@@ -86,6 +87,8 @@ const MyServicesList: FunctionComponent<MyServicesListProps> = ({ onAdd, onEdit
) : null}
</Stack>
<PublishGate />
{isLoading ? (
<Stack sx={{ gap: 1.5 }}>
{[0, 1].map((key) => (
@@ -0,0 +1,84 @@
'use client';
import { FunctionComponent } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack';
import { Paper, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon } from '@/components';
import { ROUTES } from '@/constants';
import { useVerificationStatus } from '@/services/verification';
import { isApproved } from '@/services/verification/types';
/**
* The go-live gate for the nurse's services (the f4 publish stub, wired to verification here). A nurse
* is **not bookable and cannot publish until verified**: when the aggregate isn't `approved` the publish
* CTA is **disabled** with a blocked-until-verified explanation that links to the B3 checklist; once
* approved it enables. Mirrors the server's guarded `is_verified` flip — the UI never implies a nurse is
* live before verification completes. Reads the shared `VerificationStatus` query (cached across the app).
*/
const PublishGate: FunctionComponent = () => {
const t = useTranslations('verification');
const locale = useLocale();
const { enqueueSnackbar } = useSnackbar();
const { data: status, isLoading } = useVerificationStatus();
if (isLoading) return null;
const approved = isApproved(status);
return (
<Paper
elevation={0}
sx={{
p: 2,
borderRadius: 2,
border: '1px solid',
borderColor: 'divider',
borderInlineStartWidth: 4,
borderInlineStartColor: approved ? 'var(--bal-success)' : 'var(--bal-warning)',
display: 'flex',
flexDirection: 'column',
gap: 1,
}}
>
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'flex-start' }}>
<AppIcon
icon={approved ? 'verified' : 'warning'}
size={24}
color={approved ? 'var(--bal-success)' : 'var(--bal-warning)'}
/>
<Stack sx={{ gap: 0.5, flexGrow: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{approved ? t('publish_ready_title') : t('publish_blocked_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{approved ? t('publish_ready_body') : t('publish_blocked_body')}
</Typography>
</Stack>
</Stack>
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
<AppButton
color="primary"
variant="contained"
startIcon="publish"
disabled={!approved}
onClick={() => enqueueSnackbar(t('publish_done'), { variant: 'success' })}
sx={{ m: 0 }}
>
{t('publish_cta')}
</AppButton>
{!approved ? (
<AppButton
variant="outlined"
color="primary"
to={`/${locale}${ROUTES.NURSE_VERIFICATION}`}
sx={{ m: 0 }}
>
{t('publish_complete_verification')}
</AppButton>
) : null}
</Stack>
</Paper>
);
};
export default PublishGate;
@@ -0,0 +1,138 @@
'use client';
import { FunctionComponent } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { Box, LinearProgress, Paper, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon, StatusChip } from '@/components';
import type { VerificationStatus, VerificationStep } from '@/services/verification/types';
import {
displaySteps,
progressCounts,
routeForStep,
stepDescriptionKey,
stepLabelKey,
stepStatusChip,
} from './verificationSteps';
interface VerificationChecklistProps {
status: VerificationStatus;
}
/**
* The B3 checklist body: the "X از Y" progress meter + the data-driven, ordered step rows (reusing the
* shared `StatusChip`). Rows are rendered from `displaySteps(status)` — a new step type appears without a
* code change. Failed/expired rows surface their reason and a re-submit path; the first actionable
* automated/manual step gets an inline "go" link to its screen.
*/
const VerificationChecklist: FunctionComponent<VerificationChecklistProps> = ({ status }) => {
const { passed, total } = progressCounts(status);
const steps = displaySteps(status);
const firstActionableId = steps.find(
(step) => step.status !== 'passed' && step.status !== 'in_review' && routeForStep(step.code) !== null,
)?.id;
return (
<Stack sx={{ gap: 2 }}>
<ProgressMeter passed={passed} total={total} />
<Stack sx={{ gap: 1 }}>
{steps.map((step) => (
<StepRow key={step.code} step={step} highlighted={step.id === firstActionableId} />
))}
</Stack>
</Stack>
);
};
const ProgressMeter: FunctionComponent<{ passed: number; total: number }> = ({ passed, total }) => {
const t = useTranslations('verification');
const locale = useLocale();
const percent = total === 0 ? 0 : (passed / total) * 100;
const format = (value: number) => new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US').format(value);
return (
<Paper elevation={0} sx={{ p: 2, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'baseline', mb: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('progress_title')}
</Typography>
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: 'var(--bal-primary)' }}>
{t('progress_count', { passed: format(passed), total: format(total) })}
</Typography>
</Stack>
<LinearProgress variant="determinate" value={percent} sx={{ height: 8, borderRadius: 1 }} />
</Paper>
);
};
const StepRow: FunctionComponent<{ step: VerificationStep; highlighted: boolean }> = ({ step, highlighted }) => {
const t = useTranslations('verification');
const locale = useLocale();
const chip = stepStatusChip(step.status);
const labelKey = stepLabelKey(step.code);
const descKey = stepDescriptionKey(step.code);
const label = t.has(labelKey) ? t(labelKey) : step.displayName;
const description = t.has(descKey) ? t(descKey) : '';
const route = routeForStep(step.code);
const showReason = step.status === 'failed' || step.status === 'expired';
const reason = step.failureReason
? t.has(`reason_${step.failureReason}`)
? t(`reason_${step.failureReason}`)
: step.failureReason
: null;
// Only the genuinely automated checks may advertise "استعلام خودکار" — the honesty constraint.
const showAutoNote = step.isAutomated && step.status === 'not_started';
return (
<Paper
elevation={0}
sx={{
p: 2,
borderRadius: 2,
border: '1px solid',
borderColor: highlighted ? 'var(--bal-primary)' : 'divider',
display: 'flex',
flexDirection: 'column',
gap: 1,
}}
>
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center' }}>
<AppIcon icon={step.isAutomated ? 'verified' : 'document'} size={22} color="var(--bal-text-secondary)" />
<Stack sx={{ gap: 0.25, flexGrow: 1, minWidth: 0 }}>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{label}
</Typography>
{description ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{description}
</Typography>
) : null}
</Stack>
<StatusChip status={chip.kind} label={t(chip.labelKey)} sx={{ flexShrink: 0 }} />
</Stack>
{showReason && reason ? (
<Typography variant="body2" sx={{ color: 'var(--bal-error)' }}>
{reason}
</Typography>
) : null}
{showAutoNote ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('auto_query_note')}
</Typography>
) : null}
{route && (highlighted || step.status === 'failed' || step.status === 'expired') ? (
<AppButton
variant="text"
color="primary"
endIcon="edit"
to={`/${locale}${route}`}
sx={{ m: 0, alignSelf: 'flex-start' }}
>
{step.status === 'failed' || step.status === 'expired' ? t('row_fix') : t('row_go')}
</AppButton>
) : null}
</Paper>
);
};
export default VerificationChecklist;
@@ -0,0 +1,266 @@
'use client';
import { useMemo, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation';
import { useSnackbar } from 'notistack';
import { Box, Chip, Paper, Stack, TextField, Typography } from '@mui/material';
import { AppButton, AppIcon, AppLoading, DocumentUpload, StepperHeader } from '@/components';
import type { UploadedDocInfo } from '@/components';
import { ROUTES } from '@/constants';
import {
useSubmitCredentials,
useUploadVerificationDocument,
useVerificationStatus,
} from '@/services/verification';
import { SPECIALTY_PRESETS } from '@/services/verification/types';
import type { VerificationStep } from '@/services/verification/types';
import { stepDescriptionKey, stepLabelKey } from '../verificationSteps';
const MANUAL_CREDENTIAL_CODES = ['moh_competency_license', 'ino_membership', 'criminal_record'];
/**
* B5 — professional credentials. Renders a `DocumentUpload` for **each manual credential step in the
* status** (data-driven — a new manual step renders without a code change); each upload moves its step to
* `in_review` (manual admin review — copy never claims an automated authority check). Collects the INO
* number + specialty chips + optional registry fields, persisted on submit. Lands on B6 (under review).
*/
export default function CredentialsSubmitPage() {
const t = useTranslations('verification');
const locale = useLocale();
const router = useRouter();
const { enqueueSnackbar } = useSnackbar();
const { data: status, isLoading } = useVerificationStatus();
const uploadDocument = useUploadVerificationDocument();
const submitCredentials = useSubmitCredentials();
const [inoNumber, setInoNumber] = useState('');
const [inoError, setInoError] = useState(false);
const [specialties, setSpecialties] = useState<string[]>([]);
const [customSpecialty, setCustomSpecialty] = useState('');
const [issuingAuthority, setIssuingAuthority] = useState('');
const [issuedAt, setIssuedAt] = useState('');
const [expiresAt, setExpiresAt] = useState('');
const [uploadedSteps, setUploadedSteps] = useState<Record<number, boolean>>({});
const manualSteps = useMemo(
() => (status?.steps ?? []).filter((step) => MANUAL_CREDENTIAL_CODES.includes(step.code)),
[status],
);
const toggleSpecialty = (value: string) =>
setSpecialties((prev) => (prev.includes(value) ? prev.filter((item) => item !== value) : [...prev, value]));
const addCustomSpecialty = () => {
const value = customSpecialty.trim();
if (value && !specialties.includes(value)) setSpecialties((prev) => [...prev, value]);
setCustomSpecialty('');
};
const uploadToStep = (step: VerificationStep) => async (file: File, onProgress: (percent: number) => void) => {
const doc = await uploadDocument.mutateAsync({ stepId: step.id, file, onProgress });
return { name: doc.originalFileName ?? file.name, sizeBytes: doc.fileSizeBytes } satisfies UploadedDocInfo;
};
const handleSubmit = () => {
const inoValid = inoNumber.trim().length > 0;
setInoError(!inoValid);
if (!inoValid) return;
submitCredentials.mutate(
{
inoNumber: inoNumber.trim(),
specialties,
issuingAuthority: issuingAuthority.trim() || undefined,
issuedAt: issuedAt || undefined,
expiresAt: expiresAt || undefined,
},
{
onSuccess: () => {
enqueueSnackbar(t('credentials_submitted'), { variant: 'success' });
router.push(`/${locale}${ROUTES.NURSE_VERIFICATION_REVIEW}`);
},
onError: () => enqueueSnackbar(t('credentials_error'), { variant: 'error' }),
},
);
};
if (isLoading) return <AppLoading />;
if (!status || manualSteps.length === 0) {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, maxWidth: 560 }}>
<Typography variant="h5" component="h1">
{t('credentials_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('credentials_needs_start')}
</Typography>
<AppButton color="primary" variant="contained" to={`/${locale}${ROUTES.NURSE_VERIFICATION}`} sx={{ m: 0, alignSelf: 'flex-start' }}>
{t('back_to_checklist')}
</AppButton>
</Box>
);
}
const anyUploaded = Object.values(uploadedSteps).some(Boolean);
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 560 }}>
<Box>
<Typography variant="h5" component="h1">
{t('credentials_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('credentials_subtitle')}
</Typography>
</Box>
<Box sx={{ overflowX: 'auto' }}>
<StepperHeader steps={[t('journey_identity'), t('journey_credentials'), t('journey_review')]} activeStep={1} />
</Box>
<TextField
label={t('ino_number_label')}
value={inoNumber}
onChange={(event) => {
setInoNumber(event.target.value);
if (inoError) setInoError(false);
}}
error={inoError}
helperText={inoError ? t('ino_number_required') : t('ino_number_hint')}
slotProps={{ htmlInput: { dir: 'ltr', style: { textAlign: 'start' } } }}
fullWidth
/>
{/* One uploader per manual credential step — data-driven from the status. */}
<Stack sx={{ gap: 2 }}>
{manualSteps.map((step) => (
<DocumentUpload
key={step.code}
label={t.has(stepLabelKey(step.code)) ? t(stepLabelKey(step.code)) : step.displayName}
hint={t.has(stepDescriptionKey(step.code)) ? t(stepDescriptionKey(step.code)) : undefined}
onUpload={uploadToStep(step)}
onUploaded={() => setUploadedSteps((prev) => ({ ...prev, [step.id]: true }))}
rejected={step.status === 'failed'}
rejectionReason={step.failureReason ?? undefined}
existingDoc={step.status === 'in_review' ? { name: t('doc_uploaded') } : null}
/>
))}
</Stack>
{/* Supplementary education certificate — a local attachment (no dedicated step). */}
<DocumentUpload label={t('education_label')} hint={t('education_hint')} onUpload={async (file) => ({ name: file.name })} />
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('specialties_label')}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('specialties_hint')}
</Typography>
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
{SPECIALTY_PRESETS.map((preset) => {
const selected = specialties.includes(preset);
return (
<Chip
key={preset}
label={t.has(`specialty_${preset}`) ? t(`specialty_${preset}`) : preset}
onClick={() => toggleSpecialty(preset)}
sx={{
fontWeight: 600,
backgroundColor: selected ? 'var(--bal-primary)' : 'var(--bal-primary-soft)',
color: selected ? 'var(--bal-primary-contrast)' : 'var(--bal-primary)',
}}
/>
);
})}
{specialties
.filter((value) => !SPECIALTY_PRESETS.includes(value))
.map((value) => (
<Chip
key={value}
label={value}
onDelete={() => toggleSpecialty(value)}
sx={{ fontWeight: 600, backgroundColor: 'var(--bal-primary)', color: 'var(--bal-primary-contrast)' }}
/>
))}
</Stack>
<Stack direction="row" sx={{ gap: 1, alignItems: 'flex-start' }}>
<TextField
size="small"
placeholder={t('specialty_add_placeholder')}
value={customSpecialty}
onChange={(event) => setCustomSpecialty(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
addCustomSpecialty();
}
}}
/>
<AppButton variant="outlined" color="primary" startIcon="add" onClick={addCustomSpecialty} sx={{ m: 0 }}>
{t('specialty_add')}
</AppButton>
</Stack>
</Stack>
{/* Optional registry details the admin cross-checks — issue/expiry are stored UTC (Shamsi shown elsewhere). */}
<Stack sx={{ gap: 1.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('registry_details_label')}
</Typography>
<TextField
label={t('issuing_authority_label')}
value={issuingAuthority}
onChange={(event) => setIssuingAuthority(event.target.value)}
fullWidth
/>
<Stack direction="row" sx={{ gap: 1.5, flexWrap: 'wrap' }}>
<TextField
label={t('issued_at_label')}
type="date"
value={issuedAt}
onChange={(event) => setIssuedAt(event.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
sx={{ flex: 1, minWidth: 160 }}
/>
<TextField
label={t('expires_at_label')}
type="date"
value={expiresAt}
onChange={(event) => setExpiresAt(event.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
sx={{ flex: 1, minWidth: 160 }}
/>
</Stack>
</Stack>
<Paper
elevation={0}
sx={{ p: 1.5, borderRadius: 2, bgcolor: 'var(--bal-primary-soft)', display: 'flex', gap: 1, alignItems: 'center' }}
>
<AppIcon icon="info" size={18} color="var(--bal-primary)" />
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('manual_review_note')}
</Typography>
</Paper>
<Stack direction="row" sx={{ gap: 1 }}>
<AppButton
color="primary"
variant="contained"
startIcon="license"
onClick={handleSubmit}
disabled={submitCredentials.isPending || !anyUploaded}
sx={{ m: 0 }}
>
{submitCredentials.isPending ? t('credentials_submitting') : t('credentials_submit')}
</AppButton>
<AppButton variant="text" color="primary" to={`/${locale}${ROUTES.NURSE_VERIFICATION}`} sx={{ m: 0 }}>
{t('back_to_checklist')}
</AppButton>
</Stack>
</Box>
);
}
@@ -0,0 +1,156 @@
'use client';
import { useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation';
import { useSnackbar } from 'notistack';
import { Box, Paper, Stack, TextField, Typography } from '@mui/material';
import { AppAlert, AppButton, AppIcon, DocumentUpload, StepperHeader } from '@/components';
import { ROUTES } from '@/constants';
import { toEnglishDigits } from '@/utils';
import { useSubmitIdentity } from '@/services/verification';
import { isValidNationalId } from '@/services/verification/validation';
import { ACCEPTED_IMAGE_TYPES, NATIONAL_ID_LENGTH } from '@/services/verification/constants';
import type { SubmitIdentityResult } from '@/services/verification/hooks/useSubmitIdentity';
type SubmitError = { key: 'national_id_mismatch' | 'shared_sim' | 'shahkar_mismatch' } | null;
/**
* B4 — identity submission. Collects the national id (10-digit + checksum), a national-ID card image,
* and a liveness selfie, then runs the automated civil-registry KYC + the chained Shahkar match. The
* card/selfie are **local captures** feeding the automated check (identity stores no document server-side),
* so `DocumentUpload` runs in local mode here. The auto-query note is honest — this check is performed.
* The shared-SIM Shahkar failure surfaces as a clear, non-accusatory message; a national-ID mismatch on
* its own step.
*/
export default function IdentitySubmitPage() {
const t = useTranslations('verification');
const locale = useLocale();
const router = useRouter();
const { enqueueSnackbar } = useSnackbar();
const submitIdentity = useSubmitIdentity();
const [nationalId, setNationalId] = useState('');
const [idError, setIdError] = useState(false);
const [cardCaptured, setCardCaptured] = useState(false);
const [selfieCaptured, setSelfieCaptured] = useState(false);
const [submitError, setSubmitError] = useState<SubmitError>(null);
// Local capture: the card/selfie feed the automated KYC (no stored document) — resolve immediately.
const captureLocally = async (file: File) => ({ name: file.name });
const canSubmit = isValidNationalId(nationalId) && selfieCaptured && !submitIdentity.isPending;
const handleSubmit = () => {
const idValid = isValidNationalId(nationalId);
setIdError(!idValid);
setSubmitError(null);
if (!idValid || !selfieCaptured) return;
submitIdentity.mutate(
{ nationalId, livenessCaptured: selfieCaptured },
{
onSuccess: (result: SubmitIdentityResult) => {
if (result.identity.stepStatus === 'failed') {
setSubmitError({ key: 'national_id_mismatch' });
return;
}
if (result.shahkar?.stepStatus === 'failed') {
setSubmitError({ key: result.shahkar.failureReason === 'shared_sim' ? 'shared_sim' : 'shahkar_mismatch' });
return;
}
enqueueSnackbar(t('identity_submitted'), { variant: 'success' });
router.push(`/${locale}${ROUTES.NURSE_VERIFICATION}`);
},
},
);
};
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 560 }}>
<Box>
<Typography variant="h5" component="h1">
{t('identity_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('identity_subtitle')}
</Typography>
</Box>
<Box sx={{ overflowX: 'auto' }}>
<StepperHeader
steps={[t('journey_identity'), t('journey_credentials'), t('journey_review')]}
activeStep={0}
/>
</Box>
<TextField
label={t('national_id_label')}
value={nationalId}
onChange={(event) => {
setNationalId(toEnglishDigits(event.target.value).replace(/\D/g, '').slice(0, NATIONAL_ID_LENGTH));
if (idError) setIdError(false);
}}
error={idError}
helperText={idError ? t('national_id_invalid') : t('national_id_hint')}
slotProps={{ htmlInput: { dir: 'ltr', inputMode: 'numeric', style: { textAlign: 'start', letterSpacing: 2 } } }}
fullWidth
/>
<DocumentUpload
label={t('card_label')}
hint={t('card_hint')}
accept={ACCEPTED_IMAGE_TYPES}
capture="environment"
onUpload={captureLocally}
onUploaded={() => setCardCaptured(true)}
/>
<DocumentUpload
label={t('selfie_label')}
hint={t('selfie_hint')}
accept={ACCEPTED_IMAGE_TYPES}
capture="user"
onUpload={captureLocally}
onUploaded={() => setSelfieCaptured(true)}
/>
<Paper
elevation={0}
sx={{ p: 1.5, borderRadius: 2, bgcolor: 'var(--bal-primary-soft)', display: 'flex', gap: 1, alignItems: 'center' }}
>
<AppIcon icon="info" size={18} color="var(--bal-primary)" />
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('auto_registry_note')}
</Typography>
</Paper>
{submitError ? (
<AppAlert severity={submitError.key === 'shared_sim' ? 'warning' : 'error'} variant="outlined">
{t(`error_${submitError.key}`)}
</AppAlert>
) : null}
{!cardCaptured ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('card_recommended')}
</Typography>
) : null}
<Stack direction="row" sx={{ gap: 1 }}>
<AppButton
color="primary"
variant="contained"
startIcon="identity"
onClick={handleSubmit}
disabled={!canSubmit}
sx={{ m: 0 }}
>
{submitIdentity.isPending ? t('identity_submitting') : t('identity_submit')}
</AppButton>
<AppButton variant="text" color="primary" to={`/${locale}${ROUTES.NURSE_VERIFICATION}`} sx={{ m: 0 }}>
{t('back_to_checklist')}
</AppButton>
</Stack>
</Box>
);
}
@@ -1,8 +1,237 @@
import { getTranslations } from 'next-intl/server';
import { PlaceholderScreen } from '@/components';
'use client';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation';
import { useQueryClient } from '@tanstack/react-query';
import { Box, Paper, Skeleton, Stack, Typography } from '@mui/material';
import { AppAlert, AppButton, AppIcon } from '@/components';
import { ROUTES } from '@/constants';
import { useStartVerification, useVerificationStatus } from '@/services/verification';
import { verificationKeys } from '@/services/verification/keys';
import { USE_VERIFICATION_MOCK } from '@/services/verification/constants';
import { __mockApproveAll, __mockRejectStep } from '@/services/verification/apis/mockApi';
import VerificationChecklist from './VerificationChecklist';
import { nextActionRoute, nextActionableIndex } from './verificationSteps';
export default async function NurseVerificationPage() {
const t = await getTranslations('nav');
const tShell = await getTranslations('shell');
return <PlaceholderScreen icon="verification" title={t('verification')} description={tShell('placeholder_body')} />;
/**
* B3 — the verification status hub. The canonical view of the single cached `VerificationStatus` query
* (B6 is a focused second view of the same data, never a separate fetch). Renders the loading skeleton,
* error, the `not_started` start-CTA, the in-progress checklist + a single "continue" CTA that routes to
* the next actionable step, and the terminal `approved` state (link to publish). The mock-only admin
* simulation lets a human observe the verified flip while the b6 admin queue is deferred (f15).
*/
export default function NurseVerificationPage() {
const t = useTranslations('verification');
const locale = useLocale();
const router = useRouter();
const queryClient = useQueryClient();
const { data: status, isLoading, isError, refetch } = useVerificationStatus();
const startVerification = useStartVerification();
const go = (route: string) => router.push(`/${locale}${route}`);
const handleContinue = () => {
if (!status || status.status === 'not_started') {
startVerification.mutate(undefined, {
onSuccess: () => go(ROUTES.NURSE_VERIFICATION_IDENTITY),
});
return;
}
const route = nextActionRoute(status);
if (route) go(route);
};
const refreshStatus = () => queryClient.invalidateQueries({ queryKey: verificationKeys.status() });
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 620 }}>
<Box>
<Typography variant="h5" component="h1">
{t('title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('subtitle')}
</Typography>
</Box>
{isLoading ? (
<Stack sx={{ gap: 1.5 }}>
<Skeleton variant="rounded" height={72} sx={{ borderRadius: 2 }} />
{[0, 1, 2, 3].map((key) => (
<Skeleton key={key} variant="rounded" height={64} sx={{ borderRadius: 2 }} />
))}
</Stack>
) : isError ? (
<AppAlert
severity="error"
action={
<AppButton variant="text" color="inherit" onClick={() => refetch()} sx={{ m: 0 }}>
{t('retry')}
</AppButton>
}
>
{t('load_error')}
</AppAlert>
) : !status || status.status === 'not_started' ? (
<NotStarted onStart={handleContinue} pending={startVerification.isPending} />
) : status.status === 'approved' ? (
<Approved onPublish={() => go(ROUTES.NURSE_SERVICES)} />
) : (
<>
<VerificationChecklist status={status} />
<BlockingSummary hasBlocking={status.blockingSteps.length > 0} />
<ContinueCta status={status} onContinue={handleContinue} />
</>
)}
{/* Dev-only: stand in for the deferred (f15) admin review queue so a human can watch the flip. */}
{USE_VERIFICATION_MOCK && status && status.status !== 'not_started' ? (
<MockAdminControls
onApprove={() => {
__mockApproveAll();
refreshStatus();
}}
onReject={() => {
__mockRejectStep('moh_competency_license', 'blurry_scan');
refreshStatus();
}}
/>
) : null}
</Box>
);
}
function NotStarted({ onStart, pending }: { onStart: () => void; pending: boolean }) {
const t = useTranslations('verification');
return (
<Paper
elevation={0}
sx={{
p: 4,
textAlign: 'center',
border: '1px dashed',
borderColor: 'divider',
borderRadius: 2,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 1.5,
}}
>
<AppIcon icon="verification" size={44} color="var(--bal-primary)" />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('start_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary', maxWidth: 440 }}>
{t('start_body')}
</Typography>
<AppButton
color="primary"
variant="contained"
startIcon="verification"
onClick={onStart}
disabled={pending}
sx={{ mt: 1 }}
>
{pending ? t('starting') : t('start_cta')}
</AppButton>
</Paper>
);
}
function Approved({ onPublish }: { onPublish: () => void }) {
const t = useTranslations('verification');
return (
<Paper
elevation={0}
sx={{
p: 3,
borderRadius: 2,
border: '1px solid',
borderColor: 'divider',
borderInlineStartWidth: 4,
borderInlineStartColor: 'var(--bal-success)',
display: 'flex',
flexDirection: 'column',
gap: 1.5,
}}
>
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center' }}>
<AppIcon icon="verified" size={28} color="var(--bal-success)" />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('approved_title')}
</Typography>
</Stack>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('approved_body')}
</Typography>
<AppButton
color="primary"
variant="contained"
startIcon="publish"
onClick={onPublish}
sx={{ m: 0, alignSelf: 'flex-start' }}
>
{t('approved_cta')}
</AppButton>
</Paper>
);
}
function BlockingSummary({ hasBlocking }: { hasBlocking: boolean }) {
const t = useTranslations('verification');
if (!hasBlocking) return null;
return (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('blocking_summary')}
</Typography>
);
}
function ContinueCta({
status,
onContinue,
}: {
status: Parameters<typeof nextActionRoute>[0];
onContinue: () => void;
}) {
const t = useTranslations('verification');
const index = nextActionableIndex(status);
const route = nextActionRoute(status);
if (route == null) return null;
return (
<AppButton color="primary" variant="contained" onClick={onContinue} sx={{ m: 0, alignSelf: 'flex-start' }}>
{index != null ? t('continue_step', { n: index }) : t('continue')}
</AppButton>
);
}
function MockAdminControls({ onApprove, onReject }: { onApprove: () => void; onReject: () => void }) {
const t = useTranslations('verification');
return (
<Paper
elevation={0}
sx={{
p: 2,
borderRadius: 2,
border: '1px dashed',
borderColor: 'divider',
display: 'flex',
flexDirection: 'column',
gap: 1,
}}
>
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 700 }}>
{t('mock_admin_title')}
</Typography>
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
<AppButton variant="outlined" color="primary" onClick={onApprove} sx={{ m: 0 }}>
{t('mock_admin_approve')}
</AppButton>
<AppButton variant="outlined" color="error" onClick={onReject} sx={{ m: 0 }}>
{t('mock_admin_reject')}
</AppButton>
</Stack>
</Paper>
);
}
@@ -0,0 +1,92 @@
'use client';
import { useLocale, useTranslations } from 'next-intl';
import { Box, Paper, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon, AppLoading, StatusChip, StepperHeader } from '@/components';
import { ROUTES } from '@/constants';
import { useVerificationStatus } from '@/services/verification';
import { displaySteps, stepLabelKey, stepStatusChip } from '../verificationSteps';
/**
* B6 — under review. A focused view of the **same cached `VerificationStatus`** B3 reads (one query, two
* views — never a second fetch). Shows the waiting message + the 2448h expectation + a condensed
* mini-checklist (reusing the shared `StatusChip`) of what is passed vs in-review vs pending. "مشاهده
* وضعیت" returns to the canonical B3 hub.
*/
export default function UnderReviewPage() {
const t = useTranslations('verification');
const locale = useLocale();
const { data: status, isLoading } = useVerificationStatus();
if (isLoading) return <AppLoading />;
const steps = displaySteps(status);
const isApproved = status?.status === 'approved';
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 560 }}>
<Box sx={{ overflowX: 'auto' }}>
<StepperHeader steps={[t('journey_identity'), t('journey_credentials'), t('journey_review')]} activeStep={2} />
</Box>
<Paper
elevation={0}
sx={{
p: 3,
borderRadius: 2,
border: '1px solid',
borderColor: 'divider',
borderInlineStartWidth: 4,
borderInlineStartColor: isApproved ? 'var(--bal-success)' : 'var(--bal-warning)',
display: 'flex',
flexDirection: 'column',
gap: 1,
}}
>
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center' }}>
<AppIcon icon={isApproved ? 'verified' : 'pending'} size={28} color={isApproved ? 'var(--bal-success)' : 'var(--bal-warning)'} />
<Typography variant="h6" component="h1">
{isApproved ? t('review_approved_title') : t('review_title')}
</Typography>
</Stack>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{isApproved ? t('review_approved_body') : t('review_body')}
</Typography>
{!isApproved ? (
<Typography variant="body2" sx={{ fontWeight: 600, color: 'var(--bal-warning)' }}>
{t('review_eta')}
</Typography>
) : null}
</Paper>
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('review_summary_title')}
</Typography>
{steps.map((step) => {
const chip = stepStatusChip(step.status);
const label = t.has(stepLabelKey(step.code)) ? t(stepLabelKey(step.code)) : step.displayName;
return (
<Stack
key={step.code}
direction="row"
sx={{ gap: 1, alignItems: 'center', justifyContent: 'space-between', py: 0.5 }}
>
<Typography variant="body2">{label}</Typography>
<StatusChip status={chip.kind} label={t(chip.labelKey)} sx={{ flexShrink: 0 }} />
</Stack>
);
})}
</Stack>
<AppButton
color="primary"
variant="contained"
startIcon="verification"
to={`/${locale}${ROUTES.NURSE_VERIFICATION}`}
sx={{ m: 0, alignSelf: 'flex-start' }}
>
{t('review_view_status')}
</AppButton>
</Box>
);
}
@@ -0,0 +1,117 @@
import type { StatusKind } from '@/components';
import { ROUTES } from '@/constants';
import type { VerificationStatus, VerificationStep, VerificationStepStatus } from '@/services/verification/types';
/**
* Screen helpers shared across the verification subtree (B3/B4/B5/B6). Keeps the rendering **data-driven**:
* the screens iterate `steps[]` and map each `code`/`status` to a label + chip via these helpers — a new
* step type in the response renders without a code change. Labels are i18n keys off the `code`/`status`,
* never derived from the enum string (honesty + localisation rule).
*/
/**
* A display-only "mobile verified" step prepended to the checklist. It is **not** a server step — it is
* satisfied at login (f1 phone-OTP), so it always renders `passed` and never blocks. Id 0 keeps it out
* of the server-id space.
*/
export const MOBILE_STEP: VerificationStep = {
id: 0,
code: 'mobile_verified',
displayName: 'mobile_verified',
status: 'passed',
isAutomated: true,
expiresAt: null,
failureReason: null,
};
/** The full ordered checklist as shown: the synthetic mobile step, then the server's seeded steps. */
export function displaySteps(status: VerificationStatus | undefined): VerificationStep[] {
return status ? [MOBILE_STEP, ...status.steps] : [MOBILE_STEP];
}
/** "X از Y" meter counts — X = passed steps, Y = total (all seeded steps are required). */
export function progressCounts(status: VerificationStatus | undefined): { passed: number; total: number } {
const steps = displaySteps(status);
return { passed: steps.filter((step) => step.status === 'passed').length, total: steps.length };
}
/** The i18n label key for a step, keyed off its stable `code`. */
export function stepLabelKey(code: string): string {
return `step_${code}`;
}
/** The i18n one-line description key for a step (what it verifies / why it is manual vs automatic). */
export function stepDescriptionKey(code: string): string {
return `step_${code}_desc`;
}
interface StepChip {
kind: StatusKind;
labelKey: string;
}
/**
* The chip kind + label key for a per-step status. Encodes the wireframe legend: green = passed,
* amber = pending/in-review/expired, grey = not-started/next, red = failed. Expired is amber but keeps
* its own label so copy stays honest ("expired", not "pending").
*/
export function stepStatusChip(status: VerificationStepStatus): StepChip {
switch (status) {
case 'passed':
return { kind: 'verified', labelKey: 'status_passed' };
case 'in_review':
return { kind: 'pending', labelKey: 'status_in_review' };
case 'pending':
return { kind: 'pending', labelKey: 'status_pending' };
case 'failed':
return { kind: 'rejected', labelKey: 'status_failed' };
case 'expired':
return { kind: 'pending', labelKey: 'status_expired' };
case 'not_started':
default:
return { kind: 'neutral', labelKey: 'status_next' };
}
}
/** Which submission screen owns a given step code (drives the row CTA + the "continue" router). */
export function routeForStep(code: string): string | null {
switch (code) {
case 'identity_kyc':
case 'shahkar_match':
return ROUTES.NURSE_VERIFICATION_IDENTITY;
case 'moh_competency_license':
case 'ino_membership':
case 'criminal_record':
return ROUTES.NURSE_VERIFICATION_CREDENTIALS;
case 'bank_account_verification':
return ROUTES.NURSE_BANK;
default:
return null;
}
}
/** A step the nurse can act on now — not passed, not waiting on an admin (`in_review`). */
function isActionable(step: VerificationStep): boolean {
return step.status !== 'passed' && step.status !== 'in_review' && routeForStep(step.code) !== null;
}
/**
* The route the B3 "continue" CTA targets: the first actionable step's screen (in checklist order); if
* everything left is waiting on admin (`in_review`), the under-review screen; `null` when approved (the
* publish CTA takes over).
*/
export function nextActionRoute(status: VerificationStatus | undefined): string | null {
if (!status || status.status === 'approved') return null;
const next = status.steps.find(isActionable);
if (next) return routeForStep(next.code);
if (status.steps.some((step) => step.status === 'in_review')) return ROUTES.NURSE_VERIFICATION_REVIEW;
return null;
}
/** The 1-based index of the next actionable step (for the "continue to step N" CTA label). */
export function nextActionableIndex(status: VerificationStatus | undefined): number | null {
if (!status) return null;
const steps = displaySteps(status);
const index = steps.findIndex(isActionable);
return index === -1 ? null : index + 1;
}
@@ -0,0 +1,79 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { ThemeProvider } from '../../theme';
// next-intl echoes keys (and ignores interpolation params) so we assert on the state keys.
jest.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
}));
import DocumentUpload from './DocumentUpload';
function renderUpload(props: Partial<React.ComponentProps<typeof DocumentUpload>> = {}) {
const onUpload = props.onUpload ?? jest.fn().mockResolvedValue({ name: 'license.pdf' });
const utils = render(
<ThemeProvider>
<DocumentUpload label="License" onUpload={onUpload} {...props} />
</ThemeProvider>,
);
const input = utils.container.querySelector('input[type="file"]') as HTMLInputElement;
return { ...utils, input, onUpload };
}
function selectFile(input: HTMLInputElement, file: File) {
Object.defineProperty(input, 'files', { value: [file], configurable: true });
fireEvent.change(input);
}
describe('<DocumentUpload/> component', () => {
it('renders the field label and the idle choose zone', () => {
const { container } = renderUpload();
expect(screen.getByText('License')).toBeInTheDocument();
expect(container.querySelector('[data-upload-state="idle"]')).toBeInTheDocument();
});
it('rejects a disallowed file type before uploading', () => {
const { input, onUpload, container } = renderUpload();
selectFile(input, new File(['x'], 'note.txt', { type: 'text/plain' }));
expect(onUpload).not.toHaveBeenCalled();
expect(container.querySelector('[data-upload-state="error"]')).toBeInTheDocument();
expect(screen.getByText('upload_bad_type')).toBeInTheDocument();
});
it('rejects a file over the size cap before uploading', () => {
const { input, onUpload } = renderUpload({ maxSizeBytes: 10 });
const big = new File([new Uint8Array(50)], 'card.png', { type: 'image/png' });
selectFile(input, big);
expect(onUpload).not.toHaveBeenCalled();
expect(screen.getByText('upload_too_large')).toBeInTheDocument();
});
it('uploads a valid file and shows the success state', async () => {
const onUploaded = jest.fn();
const { input, onUpload } = renderUpload({ onUploaded });
selectFile(input, new File(['%PDF'], 'license.pdf', { type: 'application/pdf' }));
await waitFor(() => expect(screen.getByText('upload_success')).toBeInTheDocument());
expect(onUpload).toHaveBeenCalledTimes(1);
expect(onUploaded).toHaveBeenCalledWith({ name: 'license.pdf' });
});
it('shows a retryable error when the upload fails', async () => {
const onUpload = jest.fn().mockRejectedValue(new Error('boom'));
const { input, container } = renderUpload({ onUpload });
selectFile(input, new File(['%PDF'], 'license.pdf', { type: 'application/pdf' }));
await waitFor(() => expect(container.querySelector('[data-upload-state="error"]')).toBeInTheDocument());
expect(screen.getByText('upload_retry')).toBeInTheDocument();
});
it('renders the rejected state with its reason and a re-upload affordance', () => {
const { container } = renderUpload({ rejected: true, rejectionReason: 'Blurry scan' });
expect(container.querySelector('[data-upload-state="rejected"]')).toBeInTheDocument();
expect(screen.getByText('Blurry scan')).toBeInTheDocument();
expect(screen.getByText('upload_reupload')).toBeInTheDocument();
});
it('renders an already-uploaded document from server metadata', () => {
const { container } = renderUpload({ existingDoc: { name: 'prior-license.pdf' } });
expect(container.querySelector('[data-upload-state="success"]')).toBeInTheDocument();
expect(screen.getByText('prior-license.pdf')).toBeInTheDocument();
});
});
@@ -0,0 +1,313 @@
'use client';
import { ChangeEvent, FunctionComponent, useEffect, useRef, useState } from 'react';
import { useTranslations } from 'next-intl';
import Box from '@mui/material/Box';
import LinearProgress from '@mui/material/LinearProgress';
import Paper from '@mui/material/Paper';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import AppButton from '../common/AppButton';
import AppIcon from '../common/AppIcon';
import { ACCEPTED_DOCUMENT_TYPES, MAX_DOCUMENT_SIZE_BYTES } from '@/services/verification/constants';
/** The stored document as the uploader displays it — a name + optional size, never bytes. */
export interface UploadedDocInfo {
name: string;
sizeBytes?: number;
}
type UploadState = 'idle' | 'uploading' | 'success' | 'error';
export interface DocumentUploadProps {
/** Field label (already translated). */
label: string;
/** Optional helper text under the label. */
hint?: string;
/** Accepted MIME types. Defaults to jpg/png/pdf (the b6 object-storage limits). */
accept?: readonly string[];
/** Max file size in bytes. Defaults to 5 MB. */
maxSizeBytes?: number;
/** Mobile camera hint: `environment` (rear — ID card) or `user` (front — selfie). */
capture?: 'user' | 'environment';
disabled?: boolean;
/**
* The async upload action — receives the file + a progress reporter (0100) and resolves with the
* stored doc. For a server step this calls the verification seam; for a local capture (B4 identity) it
* validates + resolves locally without a round-trip.
*/
onUpload: (file: File, onProgress: (percent: number) => void) => Promise<UploadedDocInfo>;
/** Called after a successful upload with the resolved metadata. */
onUploaded?: (doc: UploadedDocInfo) => void;
/** A previously-uploaded document (drives the "already uploaded ✓" state from server metadata). */
existingDoc?: UploadedDocInfo | null;
/** When the step was rejected — renders the reason and a re-upload affordance (never a dead end). */
rejected?: boolean;
rejectionReason?: string;
}
/**
* Reusable document uploader for every verification document step (national-ID card, license, education
* cert, criminal record). Owns the full state machine — idle → validating → uploading (progress %) →
* success (✓ + file name / local image preview) → error (retry) — with client-side type/size validation
* before any upload and a re-upload affordance on reject. Returns the server's stored **metadata** only;
* a local image preview never becomes the source of the "uploaded" truth. Its own chrome strings come
* from the `verification` namespace; the caller passes the field `label`/`hint`.
* @component DocumentUpload
*/
const DocumentUpload: FunctionComponent<DocumentUploadProps> = ({
label,
hint,
accept = ACCEPTED_DOCUMENT_TYPES,
maxSizeBytes = MAX_DOCUMENT_SIZE_BYTES,
capture,
disabled = false,
onUpload,
onUploaded,
existingDoc = null,
rejected = false,
rejectionReason,
}) => {
const t = useTranslations('verification');
const inputRef = useRef<HTMLInputElement>(null);
const [state, setState] = useState<UploadState>('idle');
const [progress, setProgress] = useState(0);
const [errorKey, setErrorKey] = useState<string | null>(null);
const [fileName, setFileName] = useState<string | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
// A local image preview is an object URL — revoke it when it changes or the component unmounts.
useEffect(() => {
return () => {
if (previewUrl) URL.revokeObjectURL(previewUrl);
};
}, [previewUrl]);
const openPicker = () => inputRef.current?.click();
const onFileSelected = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = ''; // allow re-picking the same file after an error
if (!file) return;
if (!accept.includes(file.type)) {
setState('error');
setErrorKey('upload_bad_type');
return;
}
if (file.size > maxSizeBytes) {
setState('error');
setErrorKey('upload_too_large');
return;
}
setErrorKey(null);
setFileName(file.name);
if (file.type.startsWith('image/')) {
setPreviewUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
return URL.createObjectURL(file);
});
}
setProgress(0);
setState('uploading');
try {
const doc = await onUpload(file, setProgress);
setFileName(doc.name);
setState('success');
onUploaded?.(doc);
} catch {
// 401/403/5xx are already toasted by the fetch layer; show a retryable inline error here.
setState('error');
setErrorKey('upload_error');
}
};
const megabytes = Math.round(maxSizeBytes / (1024 * 1024));
const acceptAttr = accept.join(',');
// The "already uploaded" resting state is driven by server metadata (existingDoc) or a just-completed
// upload — never by retained bytes.
const showUploaded = state === 'success' || (state === 'idle' && existingDoc != null);
const uploadedName = fileName ?? existingDoc?.name ?? '';
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{label}
</Typography>
{hint ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{hint}
</Typography>
) : null}
<input
ref={inputRef}
type="file"
accept={acceptAttr}
capture={capture}
hidden
disabled={disabled}
onChange={onFileSelected}
/>
{rejected ? (
<Paper
elevation={0}
data-upload-state="rejected"
sx={{
p: 2,
borderRadius: 2,
border: '1px solid',
borderColor: 'divider',
borderInlineStartWidth: 4,
borderInlineStartColor: 'var(--bal-error)',
display: 'flex',
flexDirection: 'column',
gap: 1,
}}
>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
<AppIcon icon="rejected" size={20} color="var(--bal-error)" />
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{t('upload_rejected')}
</Typography>
</Stack>
{rejectionReason ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{rejectionReason}
</Typography>
) : null}
<AppButton
variant="outlined"
color="primary"
startIcon="upload"
onClick={openPicker}
disabled={disabled}
sx={{ m: 0, alignSelf: 'flex-start' }}
>
{t('upload_reupload')}
</AppButton>
</Paper>
) : state === 'uploading' ? (
<Paper elevation={0} data-upload-state="uploading" sx={uploadedSx}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
<AppIcon icon="upload" size={20} color="var(--bal-primary)" />
<Typography variant="body2" sx={{ fontWeight: 600, flexGrow: 1, wordBreak: 'break-all' }}>
{fileName}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{progress}%
</Typography>
</Stack>
<LinearProgress variant="determinate" value={progress} sx={{ borderRadius: 1, height: 6 }} />
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('upload_uploading')}
</Typography>
</Paper>
) : showUploaded ? (
<Paper elevation={0} data-upload-state="success" sx={uploadedSx}>
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center' }}>
{previewUrl ? (
<Box
component="img"
src={previewUrl}
alt=""
sx={{ width: 48, height: 48, objectFit: 'cover', borderRadius: 1.5, flexShrink: 0 }}
/>
) : (
<AppIcon icon="document" size={28} color="var(--bal-primary)" />
)}
<Stack sx={{ gap: 0.25, flexGrow: 1, minWidth: 0 }}>
<Typography variant="body2" sx={{ fontWeight: 600, wordBreak: 'break-all' }}>
{uploadedName}
</Typography>
<Stack direction="row" sx={{ gap: 0.5, alignItems: 'center' }}>
<AppIcon icon="verified" size={16} color="var(--bal-success)" />
<Typography variant="caption" sx={{ color: 'var(--bal-success)' }}>
{t('upload_success')}
</Typography>
</Stack>
</Stack>
<AppButton variant="text" color="primary" onClick={openPicker} disabled={disabled} sx={{ m: 0 }}>
{t('upload_change')}
</AppButton>
</Stack>
</Paper>
) : state === 'error' ? (
<Paper
elevation={0}
data-upload-state="error"
sx={{ ...uploadedSx, borderInlineStartWidth: 4, borderInlineStartColor: 'var(--bal-error)' }}
>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
<AppIcon icon="error" size={20} color="var(--bal-error)" />
<Typography variant="body2" sx={{ color: 'var(--bal-error)', flexGrow: 1 }}>
{t(errorKey ?? 'upload_error', { size: megabytes })}
</Typography>
</Stack>
<AppButton
variant="outlined"
color="primary"
startIcon="refresh"
onClick={openPicker}
disabled={disabled}
sx={{ m: 0, alignSelf: 'flex-start' }}
>
{t('upload_retry')}
</AppButton>
</Paper>
) : (
<Box
role="button"
tabIndex={disabled ? -1 : 0}
data-upload-state="idle"
onClick={disabled ? undefined : openPicker}
onKeyDown={(event) => {
if (!disabled && (event.key === 'Enter' || event.key === ' ')) {
event.preventDefault();
openPicker();
}
}}
sx={{
p: 3,
borderRadius: 2,
border: '1px dashed',
borderColor: 'divider',
textAlign: 'center',
cursor: disabled ? 'default' : 'pointer',
opacity: disabled ? 0.6 : 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 0.75,
transition: 'border-color 120ms',
'&:hover': disabled ? undefined : { borderColor: 'var(--bal-primary)' },
}}
>
<AppIcon icon={capture ? 'camera' : 'upload'} size={28} color="var(--bal-primary)" />
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{capture ? t('upload_capture') : t('upload_choose')}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('upload_size_hint', { size: megabytes })}
</Typography>
</Box>
)}
</Box>
);
};
// Shared card styling for the uploading / success / error states.
const uploadedSx = {
p: 2,
borderRadius: 2,
border: '1px solid',
borderColor: 'divider',
display: 'flex',
flexDirection: 'column',
gap: 1,
} as const;
export default DocumentUpload;
@@ -0,0 +1,2 @@
export { default } from './DocumentUpload';
export type { DocumentUploadProps, UploadedDocInfo } from './DocumentUpload';
@@ -0,0 +1,38 @@
import { render, screen } from '@testing-library/react';
import { ThemeProvider } from '../../theme';
// next-intl echoes keys so we assert on the label key each state maps to.
jest.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
}));
import TrustBadge from './TrustBadge';
import type { BadgeState } from '@/services/verification/types';
function renderBadge(state: BadgeState) {
return render(
<ThemeProvider>
<TrustBadge state={state} />
</ThemeProvider>,
);
}
describe('<TrustBadge/> component', () => {
it('renders the verified label + a data attribute for the verified state', () => {
const { container } = renderBadge('verified');
expect(screen.getByText('badge_verified')).toBeInTheDocument();
expect(container.querySelector('[data-badge-state="verified"]')).toBeInTheDocument();
});
it('renders the unverified state distinctly (neutral, not alarming)', () => {
const { container } = renderBadge('unverified');
expect(screen.getByText('badge_unverified')).toBeInTheDocument();
expect(container.querySelector('[data-badge-state="unverified"]')).toBeInTheDocument();
});
it('renders expired as its own state, distinct from unverified', () => {
const { container } = renderBadge('expired');
expect(screen.getByText('badge_expired')).toBeInTheDocument();
expect(container.querySelector('[data-badge-state="expired"]')).toBeInTheDocument();
});
});
@@ -0,0 +1,50 @@
import { FunctionComponent } from 'react';
import { useTranslations } from 'next-intl';
import Chip, { ChipProps } from '@mui/material/Chip';
import AppIcon from '../common/AppIcon';
import type { BadgeState } from '@/services/verification/types';
interface BadgeStyle {
bg: string;
fg: string;
icon: string;
labelKey: string;
}
// Colors resolve from the semantic --bal-* tokens so the badge switches with the color scheme.
// verified = green trust mark; unverified = neutral (never alarming); expired = amber "needs renewal"
// (distinct from unverified — a required credential lapsed). Never a hard-coded hex.
const BADGE_STYLE: Record<BadgeState, BadgeStyle> = {
verified: { bg: 'var(--bal-success)', fg: 'var(--bal-success-contrast)', icon: 'verified', labelKey: 'badge_verified' },
unverified: { bg: 'var(--bal-divider)', fg: 'var(--bal-text-secondary)', icon: 'info', labelKey: 'badge_unverified' },
expired: { bg: 'var(--bal-warning)', fg: 'var(--bal-warning-contrast)', icon: 'warning', labelKey: 'badge_expired' },
};
export interface TrustBadgeProps extends Omit<ChipProps, 'color' | 'icon' | 'label'> {
/** The trust state — verified / unverified / expired. */
state: BadgeState;
}
/**
* The public trust signal (the "✓ تاییدشده" mark) rendered on a nurse's profile and — reused unchanged
* in f6 — on search results and the public nurse profile. Fed by `GetVerifiedBadgeQuery`; the state is
* derived by the caller (`ownBadgeState`/`publicBadgeState`). Honest by construction: `verified` only
* renders when the aggregate is approved; `expired` is visually distinct from never-verified.
* @component TrustBadge
*/
const TrustBadge: FunctionComponent<TrustBadgeProps> = ({ state, size = 'small', sx, ...rest }) => {
const t = useTranslations('verification');
const style = BADGE_STYLE[state];
return (
<Chip
data-badge-state={state}
size={size}
label={t(style.labelKey)}
icon={<AppIcon icon={style.icon} size={16} color={style.fg} />}
sx={{ backgroundColor: style.bg, color: style.fg, fontWeight: 700, ...sx }}
{...rest}
/>
);
};
export default TrustBadge;
@@ -0,0 +1,2 @@
export { default } from './TrustBadge';
export type { TrustBadgeProps } from './TrustBadge';
@@ -48,6 +48,13 @@ import PostSurgeryIcon from '@mui/icons-material/HealingOutlined';
import InfantIcon from '@mui/icons-material/ChildCareOutlined';
import ChronicIcon from '@mui/icons-material/MonitorHeartOutlined';
import CompanionshipIcon from '@mui/icons-material/VolunteerActivismOutlined';
// Verification — nurse trust flow (f5/b6): document upload, credential + identity, re-upload
import UploadIcon from '@mui/icons-material/CloudUploadOutlined';
import DocumentIcon from '@mui/icons-material/InsertDriveFileOutlined';
import RefreshIcon from '@mui/icons-material/RefreshOutlined';
import IdentityIcon from '@mui/icons-material/BadgeOutlined';
import LicenseIcon from '@mui/icons-material/WorkspacePremiumOutlined';
import PublishIcon from '@mui/icons-material/RocketLaunchOutlined';
/**
* List of all available Icon names
@@ -110,4 +117,10 @@ export const ICONS /* Note: Setting type disables property autocomplete :( was -
infant: InfantIcon,
chronic: ChronicIcon,
companionship: CompanionshipIcon,
upload: UploadIcon,
document: DocumentIcon,
refresh: RefreshIcon,
identity: IdentityIcon,
license: LicenseIcon,
publish: PublishIcon,
};
+6
View File
@@ -15,6 +15,8 @@ import BankStatusPanel from './BankStatusPanel';
import CategoryTile from './CategoryTile';
import PriceDisplay from './PriceDisplay';
import VariantCard from './VariantCard';
import TrustBadge from './TrustBadge';
import DocumentUpload from './DocumentUpload';
export {
UserInfo,
@@ -32,6 +34,8 @@ export {
CategoryTile,
PriceDisplay,
VariantCard,
TrustBadge,
DocumentUpload,
};
export type { PlaceholderScreenProps } from './PlaceholderScreen';
export type { OtpInputProps } from './OtpInput';
@@ -47,3 +51,5 @@ export type { BankStatusPanelProps } from './BankStatusPanel';
export type { CategoryTileProps } from './CategoryTile';
export type { PriceDisplayProps } from './PriceDisplay';
export type { VariantCardProps } from './VariantCard';
export type { TrustBadgeProps } from './TrustBadge';
export type { DocumentUploadProps, UploadedDocInfo } from './DocumentUpload';
+4
View File
@@ -24,7 +24,11 @@ export const ROUTES = {
// Coverage-area editor — the cities/districts the nurse will travel to (feeds f6 search).
NURSE_COVERAGE: '/nurse/coverage',
NURSE_BANK: '/nurse/bank',
// Verification (trust engine) subtree — B3 hub + the staged submission screens (B4/B5/B6).
NURSE_VERIFICATION: '/nurse/verification',
NURSE_VERIFICATION_IDENTITY: '/nurse/verification/identity',
NURSE_VERIFICATION_CREDENTIALS: '/nurse/verification/credentials',
NURSE_VERIFICATION_REVIEW: '/nurse/verification/review',
NURSE_VISITS: '/nurse/visits',
// Admin / backoffice console
@@ -0,0 +1,123 @@
import { clientFetch } from '@/lib/api/client';
import { ApiError } from '@/lib/api/errors';
import { unwrap, type ApiEnvelope } from '@/lib/api/types';
import type {
CredentialDetailsInput,
DocumentConfirmedResult,
IdentityKycInput,
RunStepResult,
TrustBadge,
UploadUrlResult,
VerificationApi,
VerificationDocument,
VerificationStatus,
} from '../types';
const BASE = '/api/v1/nurse_verification';
const NURSES_BASE = '/api/v1/nurses';
/**
* Computes the browser-side integrity hash the confirm endpoint records against the uploaded bytes
* (SHA-256 hex). Runs in the browser only (Web Crypto); the mock skips it.
*/
async function sha256Hex(file: File): Promise<string> {
const buffer = await file.arrayBuffer();
const digest = await crypto.subtle.digest('SHA-256', buffer);
return Array.from(new Uint8Array(digest))
.map((byte) => byte.toString(16).padStart(2, '0'))
.join('');
}
/**
* PUTs the file bytes to the signed object-storage URL with upload **progress**. This is a direct PUT
* to `IObjectStorage` (not our API), so it uses XHR `fetch` can't report upload progress and the
* signed URL needs no bearer. The bearer-carrying JSON calls still go through `clientFetch`.
*/
function putSignedUrl(uploadUrl: string, file: File, onProgress?: (percent: number) => void): Promise<void> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('PUT', uploadUrl);
xhr.setRequestHeader('Content-Type', file.type);
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) onProgress?.(Math.round((event.loaded / event.total) * 100));
};
xhr.onload = () =>
xhr.status >= 200 && xhr.status < 300
? resolve()
: reject(new ApiError(xhr.status, 'Object storage upload failed', 'upload_failed'));
xhr.onerror = () => reject(new ApiError(0, 'Network error during upload', 'network_error'));
xhr.send(file);
});
}
/**
* Real HTTP implementation of the VerificationApi seam (b6 contract). Routes are action-style +
* snake_case; JSON bodies/fields are camelCase; step ids come from the route. Automated-step failures
* come back as `200` with `stepStatus:"failed"` surfaced, not thrown. Selected once
* USE_VERIFICATION_MOCK is false.
*
* Gap: `submitCredentialDetails` has no nurse-facing b6 endpoint (admin enters the structured fields on
* review) it is filed in `for-backend.md` and no-ops here; the document uploads it accompanies ARE
* contract-backed (upload_url PUT documents). The mock persists the details for the standalone demo.
*/
export const verificationClientApi: VerificationApi = {
getStatus: async () => unwrap(await clientFetch<ApiEnvelope<VerificationStatus>>(BASE)),
start: async () =>
unwrap(await clientFetch<ApiEnvelope<VerificationStatus>>(`${BASE}/submit`, { method: 'POST' })),
runIdentityKyc: async ({ nationalId, livenessCaptured }: IdentityKycInput) =>
unwrap(
await clientFetch<ApiEnvelope<RunStepResult>>(`${BASE}/steps/identity_kyc/run`, {
method: 'POST',
body: JSON.stringify({ nationalId, livenessPayload: livenessCaptured ? 'captured' : null }),
}),
),
runShahkarMatch: async () =>
unwrap(await clientFetch<ApiEnvelope<RunStepResult>>(`${BASE}/steps/shahkar_match/run`, { method: 'POST' })),
runBankVerification: async () =>
unwrap(
await clientFetch<ApiEnvelope<RunStepResult>>(`${BASE}/steps/bank_account_verification/run`, {
method: 'POST',
}),
),
uploadStepDocument: async (stepId, file, onProgress): Promise<VerificationDocument> => {
const { objectStorageKey, uploadUrl } = unwrap(
await clientFetch<ApiEnvelope<UploadUrlResult>>(`${BASE}/steps/${stepId}/upload_url`, {
method: 'POST',
body: JSON.stringify({ contentType: file.type, fileName: file.name }),
}),
);
await putSignedUrl(uploadUrl, file, onProgress);
const integrityHash = await sha256Hex(file);
const confirmed = unwrap(
await clientFetch<ApiEnvelope<DocumentConfirmedResult>>(`${BASE}/steps/${stepId}/documents`, {
method: 'POST',
body: JSON.stringify({
objectStorageKey,
integrityHash,
contentType: file.type,
fileSizeBytes: file.size,
originalFileName: file.name,
}),
}),
);
return {
id: confirmed.documentId,
contentType: file.type,
fileSizeBytes: file.size,
originalFileName: file.name,
url: '',
};
},
// No nurse-facing endpoint yet (see gap note above / for-backend.md); the accompanying document
// uploads carry the real signal. Kept as a seam method so the mock can persist details unchanged.
submitCredentialDetails: async (_input: CredentialDetailsInput) => {},
getTrustBadge: async (nurseId) =>
unwrap(await clientFetch<ApiEnvelope<TrustBadge>>(`${NURSES_BASE}/${nurseId}/trust_badge`)),
};
@@ -0,0 +1,10 @@
import { USE_VERIFICATION_MOCK } from '../constants';
import type { VerificationApi } from '../types';
import { verificationClientApi } from './clientApi';
import { verificationMockApi } from './mockApi';
/**
* The selected VerificationApi implementation the single seam the hooks import. Selection is by
* config (USE_VERIFICATION_MOCK), never by scattered `if (mock)` checks.
*/
export const verificationApi: VerificationApi = USE_VERIFICATION_MOCK ? verificationMockApi : verificationClientApi;
@@ -0,0 +1,189 @@
import { sleep } from '@/utils';
import { ApiError } from '@/lib/api/errors';
import type {
IdentityKycInput,
StepTypeCode,
TrustBadge,
VerificationApi,
VerificationDocument,
VerificationStatus,
VerificationStep,
VerificationStepStatus,
} from '../types';
import { NATIONAL_ID_LENGTH } from '../constants';
const MOCK_LATENCY_MS = 300;
/**
* The seeded step-types, in checklist order (mirrors the b6 required step-type seed). Every step is
* required, so `steps.length` is the "Y" of the "X از Y" meter. `automated` drives the honest copy and
* whether the step runs (`/run`) or waits on a manual document + admin decision.
*/
const SEED: ReadonlyArray<{ code: StepTypeCode; automated: boolean }> = [
{ code: 'identity_kyc', automated: true },
{ code: 'shahkar_match', automated: true },
{ code: 'moh_competency_license', automated: false },
{ code: 'ino_membership', automated: false },
{ code: 'criminal_record', automated: false },
{ code: 'bank_account_verification', automated: true },
];
// Deterministic test triggers matching the backend b6 mock seams (documented in the mock registry).
const KYC_FAIL_NATIONAL_ID = '0000000000'; // MockIdentityKycProvider fail id
const SHAHKAR_SHARED_SIM_NATIONAL_ID = '1111111111'; // stands in for the shared-SIM handled failure
let steps: VerificationStep[] = [];
let nextStepId = 1;
let nextDocId = 1;
let boundNationalId = '';
let approvedAt: string | null = null;
const nationalIdShape = new RegExp(`^\\d{${NATIONAL_ID_LENGTH}}$`);
function findStep(code: string): VerificationStep | undefined {
return steps.find((step) => step.code === code);
}
function setStepStatus(code: string, status: VerificationStepStatus, failureReason: string | null = null): void {
steps = steps.map((step) => (step.code === code ? { ...step, status, failureReason } : step));
}
/** Re-aggregate exactly as the server would: approved only when every step passes; blockers are the rest. */
function aggregate(): VerificationStatus {
if (steps.length === 0) {
return { status: 'not_started', isBookable: false, blockingSteps: [], steps: [] };
}
const blockingSteps = steps.filter((step) => step.status !== 'passed').map((step) => step.code);
const allPassed = blockingSteps.length === 0;
const anyInReview = steps.some((step) => step.status === 'in_review');
const status = allPassed ? 'approved' : anyInReview ? 'in_review' : 'pending';
return { status, isBookable: allPassed, blockingSteps, steps: steps.map((step) => ({ ...step })) };
}
function seedSteps(): void {
if (steps.length > 0) return; // idempotent — never duplicates a step (contract submit semantics)
steps = SEED.map(({ code, automated }) => ({
id: nextStepId++,
code,
displayName: code,
status: 'not_started',
isAutomated: automated,
expiresAt: null,
failureReason: null,
}));
}
/**
* In-memory mock behind the VerificationApi seam. Drives the whole nurse journey end-to-end the
* automated runs (identity/shahkar/bank), the manual document uploads ( in_review), the structured
* credential details, and the admin-approval simulation (`__mockApproveAll`) that flips the aggregate
* to `approved` so a human can watch the trust badge + publish gate unlock. Mirrors the real shapes for
* a one-line swap.
*/
export const verificationMockApi: VerificationApi = {
getStatus: async () => {
await sleep(MOCK_LATENCY_MS);
return aggregate();
},
start: async () => {
await sleep(MOCK_LATENCY_MS);
seedSteps();
return aggregate();
},
runIdentityKyc: async ({ nationalId }: IdentityKycInput) => {
await sleep(MOCK_LATENCY_MS);
if (!nationalIdShape.test(nationalId)) {
throw new ApiError(400, 'Malformed national id', 'invalid_national_id');
}
boundNationalId = nationalId;
const step = findStep('identity_kyc');
if (!step) throw new ApiError(400, 'Verification not started', 'not_started');
if (nationalId === KYC_FAIL_NATIONAL_ID) {
setStepStatus('identity_kyc', 'failed', 'kyc_no_match');
return { stepId: step.id, stepStatus: 'failed', failureReason: 'kyc_no_match' };
}
setStepStatus('identity_kyc', 'passed');
return { stepId: step.id, stepStatus: 'passed', failureReason: null };
},
runShahkarMatch: async () => {
await sleep(MOCK_LATENCY_MS);
const step = findStep('shahkar_match');
if (!step) throw new ApiError(400, 'Verification not started', 'not_started');
if (findStep('identity_kyc')?.status !== 'passed') {
throw new ApiError(400, 'Identity KYC required first', 'kyc_required');
}
if (boundNationalId === SHAHKAR_SHARED_SIM_NATIONAL_ID) {
setStepStatus('shahkar_match', 'failed', 'shared_sim');
return { stepId: step.id, stepStatus: 'failed', failureReason: 'shared_sim' };
}
setStepStatus('shahkar_match', 'passed');
return { stepId: step.id, stepStatus: 'passed', failureReason: null };
},
runBankVerification: async () => {
await sleep(MOCK_LATENCY_MS);
const step = findStep('bank_account_verification');
if (!step) throw new ApiError(400, 'Verification not started', 'not_started');
if (findStep('identity_kyc')?.status !== 'passed') {
throw new ApiError(400, 'Identity KYC required first', 'kyc_required');
}
setStepStatus('bank_account_verification', 'passed');
return { stepId: step.id, stepStatus: 'passed', failureReason: null };
},
uploadStepDocument: async (stepId, file, onProgress) => {
// Simulate the signed-URL PUT progress, then confirm (→ in_review).
for (let percent = 0; percent <= 100; percent += 25) {
onProgress?.(percent);
await sleep(MOCK_LATENCY_MS / 5);
}
const step = steps.find((candidate) => candidate.id === stepId);
if (!step) throw new ApiError(404, 'Step not found', 'not_found');
setStepStatus(step.code, 'in_review');
return {
id: nextDocId++,
contentType: file.type,
fileSizeBytes: file.size,
originalFileName: file.name,
url: '',
} satisfies VerificationDocument;
},
submitCredentialDetails: async (input) => {
await sleep(MOCK_LATENCY_MS);
// The server validates the structured registry fields; the mock enforces the one the UI collects.
if (input.inoNumber.trim().length === 0) {
throw new ApiError(400, 'INO number is required', 'ino_number_required');
}
},
getTrustBadge: async (nurseId) => {
await sleep(MOCK_LATENCY_MS);
const agg = aggregate();
return {
nurseId,
isVerified: agg.status === 'approved' && agg.isBookable,
approvedAt,
credentialTypes: agg.status === 'approved' ? ['moh_competency_license', 'ino_membership'] : [],
} satisfies TrustBadge;
},
};
/**
* Dev-only admin-decision simulation (reachable from B3/B6 while `USE_VERIFICATION_MOCK` is true) the
* b6 admin review queue is deferred to f15, so this stands in to let a human **observe** the state
* change: passes every step and flips the aggregate to `approved`. Never shipped against the real
* backend (the caller gates it on the mock flag).
*/
export function __mockApproveAll(): void {
approvedAt = '2026-07-09T00:00:00.000Z';
steps = steps.map((step) => ({ ...step, status: 'passed', failureReason: null }));
}
/** Dev-only: reject a manual step with a reason, to exercise the rejected-with-reason re-submit path. */
export function __mockRejectStep(code: string, reason: string): void {
setStepStatus(code, 'failed', reason);
}
@@ -0,0 +1,27 @@
/**
* When true, the verification domain is served by the in-memory mock (apis/mockApi.ts) behind the
* VerificationApi seam. The b6 routes exist server-side, but like `catalog` the mock lets the full
* nurse flow (checklist identity run credential upload under-review admin-approval verified
* badge + publish gate) demo standalone before the backend is reachable in this environment. Flip to
* false to hit the live endpoints no hook/component changes (see
* dev/shared-working-context/reports/mocks-registry.md).
*/
export const USE_VERIFICATION_MOCK = true;
/**
* The checklist is **moderately fresh** submitting a step changes it, and every mutation invalidates
* it, so a short staleTime avoids a refetch when B3 and B6 mount from the same cached query.
*/
export const VERIFICATION_STATUS_STALE_TIME = 30_000;
/** The public trust badge changes only on a step decision / suspension / expiry — keep it warm longer. */
export const TRUST_BADGE_STALE_TIME = 5 * 60_000; // 5 min
export const TRUST_BADGE_GC_TIME = 30 * 60_000; // 30 min
/** Client-side document guardrails (mirrors the b6 `IObjectStorage` limits). jpg/png/pdf, 5 MB cap. */
export const ACCEPTED_DOCUMENT_TYPES: readonly string[] = ['image/jpeg', 'image/png', 'application/pdf'] as const;
export const ACCEPTED_IMAGE_TYPES: readonly string[] = ['image/jpeg', 'image/png'] as const;
export const MAX_DOCUMENT_SIZE_BYTES = 5 * 1024 * 1024; // 5 MB
/** The national-ID is a 10-digit code with an official checksum — validated before the KYC run. */
export const NATIONAL_ID_LENGTH = 10;
@@ -0,0 +1,20 @@
import { useQuery } from '@tanstack/react-query';
import { verificationApi } from '../apis';
import { verificationKeys } from '../keys';
import { TRUST_BADGE_GC_TIME, TRUST_BADGE_STALE_TIME } from '../constants';
/**
* The public trust badge for a nurse (verified state + credential **types**, never numbers). Public
* (`[AllowAnonymous]`) and long-lived it changes only on a step decision / suspension / expiry so a
* generous `staleTime` keeps it warm across the nurse's own profile and (in f6) search + public profile,
* which reuse this same query key. Pass `nurseId = undefined` to disable until the id is known.
*/
export function useNurseTrustBadge(nurseId: number | undefined) {
return useQuery({
queryKey: verificationKeys.badge(nurseId ?? -1),
queryFn: () => verificationApi.getTrustBadge(nurseId as number),
enabled: nurseId != null,
staleTime: TRUST_BADGE_STALE_TIME,
gcTime: TRUST_BADGE_GC_TIME,
});
}
@@ -0,0 +1,19 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { verificationApi } from '../apis';
import { verificationKeys } from '../keys';
/**
* Runs the استعلام شبا IBAN-owner national-id match (money-mule guard) for the
* `bank_account_verification` step. Requires a verified identity + a primary bank account (added on the
* f2 bank screen this checklist deep-links to) a `400` otherwise, surfaced inline. Invalidates the
* status so the checklist reflects the step's new state.
*/
export function useRunBankVerification() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => verificationApi.runBankVerification(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: verificationKeys.status() });
},
});
}
@@ -0,0 +1,18 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { verificationApi } from '../apis';
import { verificationKeys } from '../keys';
/**
* Opens (or re-opens) verification and seeds the checklist called from the B3 "start / continue" CTA
* when the aggregate is `not_started`. Idempotent server-side. Writes the fresh status straight into
* the cache so the checklist renders the seeded steps without a second fetch.
*/
export function useStartVerification() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => verificationApi.start(),
onSuccess: (status) => {
queryClient.setQueryData(verificationKeys.status(), status);
},
});
}
@@ -0,0 +1,22 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { verificationApi } from '../apis';
import { verificationKeys } from '../keys';
import type { CredentialDetailsInput } from '../types';
/**
* Persists the structured professional-credential details (INO number, specialties, license fields) the
* registry needs. The credential **documents** themselves move their steps to `in_review` as they
* upload (via `useUploadVerificationDocument`); this finalises B5 by saving the structured metadata,
* then invalidates the status so the checklist / B6 reflect the in-review credential steps. A missing
* INO number surfaces as `400` inline. (No nurse-facing b6 endpoint accepts these yet gap filed;
* mock-persisted meanwhile.)
*/
export function useSubmitCredentials() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: CredentialDetailsInput) => verificationApi.submitCredentialDetails(input),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: verificationKeys.status() });
},
});
}
@@ -0,0 +1,31 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { verificationApi } from '../apis';
import { verificationKeys } from '../keys';
import type { IdentityKycInput, RunStepResult } from '../types';
export interface SubmitIdentityResult {
identity: RunStepResult;
/** Null when identity KYC itself failed — Shahkar requires a verified national id first. */
shahkar: RunStepResult | null;
}
/**
* Runs the automated identity flow: national-ID + liveness KYC, then only if that **passes** the
* phonenational-id Shahkar match the server chains off the bound national id. Both are surfaced so B4
* can show each step's outcome (incl. the handled shared-SIM Shahkar failure). Invalidates the status
* so B3 reflects the new step states from cache. A malformed national id throws `400`; a vendor
* mismatch is a `failed` step in the result (not a throw).
*/
export function useSubmitIdentity() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (input: IdentityKycInput): Promise<SubmitIdentityResult> => {
const identity = await verificationApi.runIdentityKyc(input);
const shahkar = identity.stepStatus === 'passed' ? await verificationApi.runShahkarMatch() : null;
return { identity, shahkar };
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: verificationKeys.status() });
},
});
}
@@ -0,0 +1,28 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { verificationApi } from '../apis';
import { verificationKeys } from '../keys';
import type { VerificationDocument } from '../types';
export interface UploadDocumentVars {
stepId: number;
file: File;
/** Progress callback (0100) the uploader wires to its progress bar. */
onProgress?: (percent: number) => void;
}
/**
* Uploads a manual step's document (url PUT bytes confirm) and moves it to `in_review`. Progress
* is reported through `vars.onProgress` (React Query can't stream it), so the `<DocumentUpload>`
* component owns the bar while the mutation owns the request + cache invalidation the checklist then
* shows the step `in_review` from cache with no manual refetch.
*/
export function useUploadVerificationDocument() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ stepId, file, onProgress }: UploadDocumentVars): Promise<VerificationDocument> =>
verificationApi.uploadStepDocument(stepId, file, onProgress),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: verificationKeys.status() });
},
});
}
@@ -0,0 +1,21 @@
import { useQuery } from '@tanstack/react-query';
import { useIsAuthenticated } from '@/hooks';
import { verificationApi } from '../apis';
import { verificationKeys } from '../keys';
import { VERIFICATION_STATUS_STALE_TIME } from '../constants';
/**
* The nurse's own verification checklist + aggregate status. **The single cached source B3 and B6 both
* read** one query, two views (checklist hub / under-review). Every submit/upload/run mutation
* invalidates `verificationKeys.status()`, so the checklist re-renders from cache with no manual
* refetch. A moderate `staleTime` avoids a refetch when the two screens mount in sequence.
*/
export function useVerificationStatus() {
const isAuthenticated = useIsAuthenticated();
return useQuery({
queryKey: verificationKeys.status(),
queryFn: () => verificationApi.getStatus(),
enabled: isAuthenticated,
staleTime: VERIFICATION_STATUS_STALE_TIME,
});
}
@@ -0,0 +1,7 @@
export { useVerificationStatus } from './hooks/useVerificationStatus';
export { useStartVerification } from './hooks/useStartVerification';
export { useSubmitIdentity } from './hooks/useSubmitIdentity';
export { useRunBankVerification } from './hooks/useRunBankVerification';
export { useUploadVerificationDocument } from './hooks/useUploadVerificationDocument';
export { useSubmitCredentials } from './hooks/useSubmitCredentials';
export { useNurseTrustBadge } from './hooks/useNurseTrustBadge';
+18
View File
@@ -0,0 +1,18 @@
/**
* React Query key factory for the verification domain. The nurse's own `status()` is the **single
* cached source** that both B3 (checklist) and B6 (under-review) read one query, two views. Every
* submit/upload/run mutation invalidates `status()` so the checklist re-renders from cache with no
* manual refetch. The public `badge(nurseId)` is longer-lived and reused by search/f6.
*/
export const verificationKeys = {
all: ['verification'] as const,
// The signed-in nurse's checklist — moderately fresh; every mutation invalidates it.
status: () => [...verificationKeys.all, 'status'] as const,
// A manual step's uploaded documents (metadata only), if a screen ever lists them separately.
documents: (stepCode: string) => [...verificationKeys.all, 'documents', stepCode] as const,
// The public trust badge — keyed per nurse; reused by the own-profile view and f6 search/profile.
badge: (nurseId: number) => [...verificationKeys.all, 'badge', nurseId] as const,
};
+196
View File
@@ -0,0 +1,196 @@
/**
* Verification domain the trust engine's front-end data layer. Shapes mirror the b6 contract
* (`dev/contracts/domains/verification.md`) exactly; 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"):
* - `VerificationStatus.status` is the **single source of verification truth**; `isBookable` is the
* only flag the UI gates on. The client never infers `is_verified`.
* - Steps are **data-driven**: render the ordered `steps[]`, mapping each `code`/`status` to a label
* + chip. A new step type appearing in the response must render without a code change.
* - Automated steps (`isAutomated:true`) run via a `/run` endpoint; manual steps take a document upload
* and wait for an admin decision. **Honest copy** keys off `isAutomated` a manual step is never
* presented as an automated authority check.
* - Credential **numbers never cross the wire** (encrypted, never serialized); the badge exposes
* credential **types** only.
*/
/** The aggregate `nurse_verifications.status` — the single source of verification truth. */
export type VerificationAggregateStatus =
| 'not_started'
| 'pending'
| 'in_review'
| 'approved'
| 'rejected'
| 'suspended';
/** Per-step `verification_steps.status`. `failed` renders as the rejected (red) chip; `expired` re-gates. */
export type VerificationStepStatus =
| 'not_started'
| 'pending'
| 'in_review'
| 'passed'
| 'failed'
| 'expired';
/** The six seeded, **stable** step-type codes. Labels are i18n keys off the code — never derived from it. */
export type StepTypeCode =
| 'identity_kyc'
| 'shahkar_match'
| 'moh_competency_license'
| 'ino_membership'
| 'criminal_record'
| 'bank_account_verification';
/** The three credential-bearing step types recorded on admin approval. */
export type CredentialType = 'moh_competency_license' | 'ino_membership' | 'criminal_record';
/** How a credential was verified. Today every real credential resolves `manual` (admin review). */
export type VerificationMethod = 'manual' | 'portal' | 'api';
/**
* Trust-badge display state derived client-side, not a wire enum. `verified` when the badge/aggregate
* is approved; `expired` when a required credential lapsed (distinct from never-verified); else
* `unverified`. The public badge endpoint only carries `isVerified`, so `expired` is computed by the
* nurse's own-profile view from its `VerificationStatus`; public consumers (search/f6) see verified/unverified.
*/
export type BadgeState = 'verified' | 'unverified' | 'expired';
/** `VerificationStepDto` — one row of the checklist. Every seeded step is required (Y of the "X از Y" meter). */
export interface VerificationStep {
id: number;
code: string;
/** Server-provided fallback label; the UI prefers the i18n label keyed off `code`. */
displayName: string;
status: VerificationStepStatus;
isAutomated: boolean;
expiresAt: string | null;
failureReason: string | null;
}
/** `VerificationStatusDto` — the aggregate + ordered per-step list driving B3/B6. */
export interface VerificationStatus {
status: VerificationAggregateStatus;
isBookable: boolean;
/** Step codes still blocking go-live. */
blockingSteps: string[];
steps: VerificationStep[];
}
/** `UploadUrlResult` — a signed PUT target for a manual step's document. */
export interface UploadUrlResult {
objectStorageKey: string;
uploadUrl: string;
}
/** `DocumentConfirmedResult` — the step's new status after a document is confirmed. */
export interface DocumentConfirmedResult {
documentId: number;
stepStatus: VerificationStepStatus;
}
/** `VerificationDocumentDto` — **metadata only**, never bytes. `url` is a short-lived signed GET URL. */
export interface VerificationDocument {
id: number;
contentType: string;
fileSizeBytes: number;
originalFileName: string | null;
url: string;
}
/** `RunStepResult` — the outcome of an automated step run; a vendor fail is `stepStatus:"failed"` + reason. */
export interface RunStepResult {
stepId: number;
stepStatus: VerificationStepStatus;
failureReason: string | null;
}
/** `NurseCredentialDto` — a recorded credential. `credentialNumber` is **never** present. */
export interface NurseCredential {
id: number;
credentialType: CredentialType;
holderNameSnapshot: string;
issuingAuthority: string;
issuedAt: string | null;
expiresAt: string | null;
verificationMethod: VerificationMethod;
}
/** `TrustBadgeDto` — the public trust signal. Credential **types** only, never numbers. */
export interface TrustBadge {
nurseId: number;
isVerified: boolean;
approvedAt: string | null;
credentialTypes: string[];
}
/** Body for the automated identity-KYC run. `livenessCaptured` stands in for the vendor liveness payload. */
export interface IdentityKycInput {
nationalId: string;
livenessCaptured: boolean;
}
/**
* Structured professional-credential details the registry needs (INO number, specialties, license
* number, issuing authority, holder name, issue/expiry). **No nurse-facing b6 endpoint accepts these
* yet** (admin enters them on review) the mock persists them and the gap is filed for the backend
* (`for-backend.md`). The document uploads themselves are contract-backed (upload_url documents).
*/
export interface CredentialDetailsInput {
inoNumber: string;
specialties: string[];
licenseNumber?: string;
issuingAuthority?: string;
holderName?: string;
issuedAt?: string | null;
expiresAt?: string | null;
}
/**
* The verification domain's API seam the real HTTP client and the in-memory mock both implement
* this interface; selection is by config (`USE_VERIFICATION_MOCK`), never scattered `if (mock)` checks.
*/
export interface VerificationApi {
/** The nurse's own checklist + aggregate + blocking summary (a `not_started` empty list, never a 404). */
getStatus(): Promise<VerificationStatus>;
/** Open (or re-open) verification and seed the checklist. Idempotent. */
start(): Promise<VerificationStatus>;
/** Automated national-ID + liveness check. A vendor fail is a `failed` status, not a thrown error. */
runIdentityKyc(input: IdentityKycInput): Promise<RunStepResult>;
/** Automated phone↔national-id Shahkar match (requires identity KYC passed). Shared-SIM is a handled fail. */
runShahkarMatch(): Promise<RunStepResult>;
/** Automated استعلام شبا IBAN-owner ↔ national-id money-mule guard (requires a primary bank account). */
runBankVerification(): Promise<RunStepResult>;
/**
* Upload a document for a manual step (url PUT bytes confirm), moving it to `in_review`. Reports
* upload progress (0100) via `onProgress`. Returns the server's stored **metadata** (never bytes).
*/
uploadStepDocument(stepId: number, file: File, onProgress?: (percent: number) => void): Promise<VerificationDocument>;
/** Persist the structured professional-credential details (gap-filed; mock-persisted for now). */
submitCredentialDetails(input: CredentialDetailsInput): Promise<void>;
/** The public trust badge for a nurse (types only). */
getTrustBadge(nurseId: number): Promise<TrustBadge>;
}
/** The specialties offered as ready-made chips in B5 (nurse can add their own). Stable codes → i18n labels. */
export const SPECIALTY_PRESETS: readonly string[] = ['elderly', 'icu', 'pediatric', 'post_surgery', 'wound_care'] as const;
/** Aggregate statuses at which the nurse's services may go live and the trust badge shows verified. */
export function isApproved(status: VerificationStatus | undefined): boolean {
return status?.status === 'approved' && status.isBookable;
}
/**
* The trust-badge state for the nurse's **own** profile, computed from the full status: `expired` when a
* required step has lapsed (distinct from never-verified), `verified` when approved, else `unverified`.
*/
export function ownBadgeState(status: VerificationStatus | undefined): BadgeState {
if (!status) return 'unverified';
if (status.steps.some((step) => step.status === 'expired')) return 'expired';
return isApproved(status) ? 'verified' : 'unverified';
}
/** The public-badge state (search/f6 + public profile) — no `expired` signal on the public payload. */
export function publicBadgeState(badge: TrustBadge | undefined): BadgeState {
return badge?.isVerified ? 'verified' : 'unverified';
}
@@ -0,0 +1,17 @@
import { NATIONAL_ID_LENGTH } from './constants';
/**
* Validates an Iranian national id (کد ملی): 10 digits with the official mod-11 checksum. The server
* re-validates (contract `400` on a malformed id), but a client check gives an instant field error and
* spares a round-trip. Rejects the trivial all-same-digit ids the algorithm otherwise accepts.
*/
export function isValidNationalId(value: string): boolean {
if (!new RegExp(`^\\d{${NATIONAL_ID_LENGTH}}$`).test(value)) return false;
if (/^(\d)\1{9}$/.test(value)) return false;
const digits = value.split('').map(Number);
const check = digits[9];
const sum = digits.slice(0, 9).reduce((acc, digit, index) => acc + digit * (NATIONAL_ID_LENGTH - index), 0);
const remainder = sum % 11;
return remainder < 2 ? check === remainder : check === 11 - remainder;
}
+150
View File
@@ -0,0 +1,150 @@
# Contract — BNPL provider-financed installments (backend phase b12)
> One-line: the "pay with installments" checkout alternative. A family checks eligibility, starts a BNPL order
> and is handed off to the provider; the provider callback (or an admin) verifies + settles it — which, in our
> books, is **a card payment that lands net-of-fee** (the provider pays the full booking amount in one lump minus
> its merchant commission and owns 100% of the customer's installments + default risk). Admins can revert. 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/README.md).
**Status:** live as of backend-phase-b12 · **Frontend consumer:** frontend-phase-f11-b12
All money is **IRR Rials, integer, on the wire as a string of digits** (`"10000000"`). We do **not** model the
customer's repayment schedule — `installment_count` is informational (default 4). Timestamps are UTC ISO-8601;
`settled_at` is **nullable** (settlement is contract-defined and not instant); `expected_customer_refund_eta` is a
**date** (`"2026-08-24"`). Internal ledger `account_type`s are never exposed.
## Enums used
- `bnpl_status` (`bnpl_transactions.status`): `eligible` | `token_issued` | `verified` | `settled` | `reverted` |
`cancelled` | `failed`. **Forward-only** (`eligible → token_issued → verified → settled → reverted`); a replayed
callback that would re-drive a completed transition is an idempotent no-op.
- `bnpl_eligibility_status` (`bnpl_transactions.eligibility_status`): `eligible` | `not_eligible` |
`ceiling_exceeded`. On anything but `eligible` the client falls back to card.
- `provider_code`: `snapppay` | `digipay` | `tara` | `torobpay` — selects the provider adapter.
- `refund_channel` (on the revert's refund): always `bnpl_revert` here (see
[`refunds-invoices.md`](refunds-invoices.md)).
## Endpoints
### `POST api/v1/checkout_bnpl/eligibility`
- **Purpose:** check whether the caller can finance an `accepted_awaiting_payment` booking request with a
provider, and record the outcome on a created/updated `bnpl_transactions` row (status `eligible`).
- **Auth:** authenticated (customer, tenancy-scoped) · **Rate-limited:** yes (sensitive) · **Idempotency key:** no
- **Request body:**
```json
{ "bookingRequestId": 42, "providerCode": "snapppay" }
```
- **Success `200` payload (`data`):**
```json
{
"eligibilityStatus": "eligible",
"isEligible": true,
"installmentCount": 4,
"planSummary": "4 interest-free installments, 0% interest, provider-financed.",
"creditCeilingIrr": "2000000000"
}
```
- **Failure cases:** `400` invalid `provider_code` / non-positive id; `401` unauthenticated; `404` request not
found **or not owned by the caller** (tenancy — a cross-customer request is indistinguishable from missing);
`409` already paid / not awaiting payment.
- **Notes:** the order amount is the request's frozen gross (variant price × session count), never client-supplied.
### `POST api/v1/checkout_bnpl/initiate`
- **Purpose:** start the BNPL order — issue the provider payment token + redirect and walk `eligible →
token_issued`.
- **Auth:** authenticated (customer, tenancy-scoped) · **Rate-limited:** yes (sensitive) · **Idempotency key:**
`Idempotency-Key` header (a retried initiate reuses the same token).
- **Request body:**
```json
{ "bookingRequestId": 42, "providerCode": "snapppay" }
```
- **Success `200` payload (`data`):**
```json
{
"bnplTransactionId": 7,
"paymentTransactionId": 15,
"status": "token_issued",
"externalPaymentToken": "mock-bnpl-token-10000000-bnpl-br-42",
"redirectUrl": "https://provider.example/checkout/…"
}
```
- **Failure cases:** `400` invalid input; `401` unauthenticated; `404` request not found / not owned; `409` already
paid / not awaiting payment / payment window lapsed / order no longer startable; `400` provider declined the
order.
- **Notes:** the row is **1:1** with a `payment_transaction` (`UNIQUE(payment_transaction_id)`); a second initiate
reuses the same row. Runs under `lock(booking-request:{id}:payment)`.
### `GET api/v1/checkout_bnpl/{id}`
- **Purpose:** the customer reads **their own** BNPL order.
- **Auth:** authenticated (tenancy-scoped) · **Rate-limited:** yes (sensitive)
- **Success `200`:** the `BnplOrderStatus` shape (below).
- **Failure cases:** `401`; `404` not found **or another customer's** order (clean not-found).
### `POST api/v1/webhooks_bnpl/{provider}`
- **Purpose:** inbound provider callback — verify/settle/revert an order by event type.
- **Auth:** anonymous, **signature-authenticated** · **Rate-limited:** yes (per-IP) · **Idempotency key:**
`(provider_code, external_event_id)` deduped in `payment_webhook_events` before any money moves.
- **Request body:** raw provider payload; the mock verifier reads
`{ "external_event_id": "...", "event_type": "order.settled", "gateway_reference_code": "<token>" }`. Event type
routes: contains `verif` → verify, `settl` → settle, `revert`/`refund` → revert.
- **Success `200` payload (`data`):**
```json
{ "processingStatus": "processed", "duplicate": false }
```
- **Notes:** always `200` (at-least-once tolerant). A bad signature is stored `ignored`; a duplicate is a no-op
(`duplicate: true`); an unknown token is `failed` (retryable). A replayed settle never double-posts the ledger
(webhook dedup + the forward-only state guard).
### `POST api/v1/admin_bnpl/{id}/verify` · `POST api/v1/admin_bnpl/{id}/settle`
- **Purpose:** manually drive verify / settle (also driven by the callback).
- **Auth:** admin (dynamic-permission) · **Rate-limited:** yes (sensitive)
- **Success `200`:** `true`.
- **Failure cases:** `401`/`403`; `404` order not found; `409` wrong state (e.g. settle before verify); `400`
provider declined / settlement does not reconcile.
- **Settle side effects:** records `settled_amount_irr` = `order commission`, `bnpl_commission_irr`, `settled_at`
(nullable — read from the settlement); posts the **net-of-fee ledger group** (card-capture legs **plus** `DEBIT
bnpl_fee_expense / CREDIT escrow_held`, one balanced group, so escrow reflects the **net** cash); confirms the
parent `payment_transaction`**converts the booking**. The nurse's `nurse_payable` accrual equals the
card-path amount (payout **invariant to payment method**). Runs under `lock(bnpl:{id}:settle)`.
### `POST api/v1/admin_bnpl/{id}/revert`
- **Purpose:** reverse a settled BNPL order through the provider.
- **Auth:** admin (dynamic-permission) · **Rate-limited:** yes (sensitive)
- **Request body:** (all optional; omit `refund_percentage` for a full revert)
```json
{ "refundPercentage": 1.0, "ticketId": null, "reasonNotes": "customer cancelled" }
```
- **Success `200` payload (`data`):**
```json
{
"bnplTransactionId": 7,
"refundId": 3,
"status": "reverted",
"revertTransactionId": "…",
"revertedAmountIrr": "8000000",
"expectedCustomerRefundEta": "2026-08-24"
}
```
- **Failure cases:** `401`/`403`; `404` not found; `409` not settled / already reverted; `400` provider refused.
- **Notes:** creates a `refunds` row with `refund_channel='bnpl_revert'` and posts the reversal ledger via the b11
refund path (fee + payout legs; a clawback if the nurse was already paid). Money flows **customer ↔ provider ↔
Balinyaar** only; the customer cash-back is async ~710 business days (`expected_customer_refund_eta`). A
partial (`refund_percentage < 1`) maps to the provider's update-to-strictly-lower verb.
### `GET api/v1/admin_bnpl/{id}`
- **Purpose:** admin reads any BNPL order.
- **Auth:** admin (dynamic-permission) · **Rate-limited:** yes (sensitive)
- **Success `200`:** the `BnplOrderStatus` shape (below).
## Shared shapes
- `BnplOrderStatus``id` (long), `paymentTransactionId` (long), `bookingId` (long?, set at settle),
`providerCode` (string), `status` (`bnpl_status`), `eligibilityStatus` (`bnpl_eligibility_status`?),
`orderAmountIrr` (digit string), `settledAmountIrr` (digit string?), `bnplCommissionIrr` (digit string?),
`currency` (string, `IRR`), `installmentCount` (int, informational), `settledAt` (datetime?, **nullable —
not instant**), `revertTransactionId` (string?), `revertedAmountIrr` (digit string?), `revertedAt` (datetime?),
`providerCommissionReversedAmount` (digit string?), `refundChannel` (string?, `bnpl_revert`),
`expectedCustomerRefundEta` (date?), `createdAt` (datetime).
## Changelog
- b12 — initial contract (eligibility, initiate, customer/admin status, webhook, admin verify/settle/revert).
File diff suppressed because it is too large Load Diff
@@ -12,6 +12,24 @@ One block per completed backend phase. Newest at the top. Backend lane writes he
- **Notes for frontend:** <anything load-bearing>
-->
## backend-phase-12 — BNPL: provider-financed installments (mocked) — 2026-07-09
- **Shipped:** `payments.BnplTransactions` (1:1 with `payment_transaction`, `UNIQUE(payment_transaction_id)`,
settle-split CHECK, forward-only `BnplStatus` machine); `Features/Bnpl/*` (eligibility/initiate/verify/settle/
revert/callback/status); `CheckoutBnplController` (customer) + `WebhooksBnplController` (anon, signed) +
`AdminBnplController` (admin), all rate-limited; `LedgerPosting.BnplSettle` (net-of-fee group w/
`bnpl_fee_expense`); extracted shared `Features/Bookings/BookingConversion` (used by b10 card + b12 settle).
- **Contracts:** dev/contracts/domains/bnpl.md + openapi snapshot refreshed (yes).
- **Mocked:** `IBnplProvider` (per `provider_code` via `IBnplProviderResolver`) + `ICurrencyNormalizer` → 🟡
(configurable mock commission %, non-instant `settled_at`). See reports/mocks-registry.md.
- **Gate:** build clean (0 new code warnings) / tests green (314 pass: 4 identity + 214 foundation + 96 api;
+14 new). Migration `BnplTransactions` created (not applied to a live DB this session — SQLite
`EnsureCreated` builds it for tests).
- **Handoff:** backend/handoff/after-backend-phase-12.md
- **Notes for frontend:** f11-b12 = the "pay with installments" checkout (`checkout_bnpl/eligibility`
`initiate` → provider redirect; declined → fall back to card), the customer order view (`checkout_bnpl/{id}`),
and the admin BNPL revert path with the ~710-day ETA. Money is IRR digit-strings; `settledAt` is nullable
(not instant). A BNPL revert opens a `refund_channel='bnpl_revert'` refund (read via `refunds/{id}/status`).
## backend-phase-11 — Refunds, invoices & nurse clawbacks — 2026-07-09
- **Shipped:** the reversal leg via one migration in the **`payments`** schema — **3 tables** `Refunds`
(fee-leg decomposition + `refund_channel` + `amount = fee_leg + payout_leg` CHECK + **nullable `ticket_id`, no FK**
@@ -0,0 +1,52 @@
# Handoff — after backend-phase-12 (BNPL: provider-financed installments)
**BNPL checkout is live.** A family can now pay for a booking with a provider-financed installment plan
(SnappPay / Digipay / Tara / Torob Pay). The decisive, verified truth: an Iranian provider-financed BNPL order
**settles the full booking amount to Balinyaar in one lump, net of the provider's merchant commission**, and the
provider owns the customer's installments and **100% of default risk**. So in our books a BNPL order is **a card
payment that lands net-of-fee** — one `bnpl_transactions` row (1:1 with its `payment_transaction`), a forward-only
`eligible → token_issued → verified → settled → reverted` state machine, and a settle that posts the card-capture
ledger legs **plus** a `bnpl_fee_expense` leg so escrow reflects the *net* cash. **The nurse's payout is invariant
to payment method.** We do **not** model the customer's repayment schedule.
## What the frontend (f11-b12) can now build
- **"Pay with installments" option at checkout** — `POST checkout_bnpl/eligibility` returns
`eligible`/`not_eligible`/`ceiling_exceeded` + the plan summary ("4 interest-free installments, provider-financed",
`installmentCount`, `creditCeilingIrr`). On anything but `eligible`, **fall back to card**.
- **Start the plan + provider handoff**`POST checkout_bnpl/initiate``token_issued` + `externalPaymentToken`
+ `redirectUrl` (send the customer to the provider). Carries an `Idempotency-Key` header.
- **Order status**`GET checkout_bnpl/{id}` (customer, own order only): status, gross/net/commission, the
**non-instant** `settledAt`, and the revert audit + async customer ETA.
- **Admin BNPL console**`POST admin_bnpl/{id}/verify|settle`, `POST admin_bnpl/{id}/revert` (full or partial),
`GET admin_bnpl/{id}`. The revert surfaces `expectedCustomerRefundEta` (~710 business days) and opens a
`refund_channel='bnpl_revert'` refund (visible via the b11 `GET refunds/{id}/status`).
## Live endpoints / contract
- Contract: **`dev/contracts/domains/bnpl.md`** (enums, DTO shapes, IRR digit-strings, nullable `settled_at`,
failure codes, the net-of-fee settle + customer↔provider↔Balinyaar refund routing). Machine schema:
`dev/contracts/openapi/swagger.v1.json` **refreshed**.
- All money is IRR integer, on the wire as a **digit-string**; `settled_at` is **nullable** (not instant);
`expectedCustomerRefundEta` is a **date**.
- Checkout is customer-scoped + **rate-limited**; the webhook is anonymous (signature-verified) + rate-limited;
admin endpoints are behind the admin policy + rate-limited.
## What is mocked / waiting
- **The provider + currency are mocked** behind **`IBnplProvider`** (per `provider_code` via
**`IBnplProviderResolver`**) and **`ICurrencyNormalizer`** — deterministic, no network; the settle commission is
a configurable mock % (`Seams:Bnpl:CommissionRate`, default 10%). See `reports/mocks-registry.md` for the exact
real-provider steps (SnappPay/Digipay verb sets, encrypted creds).
- **Settlement timing is not modelled as instant**`settled_at` is nullable and read from the settlement
(`Seams:Bnpl:SettlementInstant` toggles the mock). **b13 must not assume BNPL cash funds a payout.**
- **`bnpl_settlement_entries`** (tranched settlement) is **DEFERRED — modeled-but-not-built**; adding it later is a
purely additive migration.
- **Multi-provider routing / failover** is DEFERRED — one active route, config-driven selection.
- The revert reuses the **b11 refund path**; `provider_commission_reversed_amount` is left null there (reconciled
from the provider response later).
## Notes for the next backend phases
- **b13 (payouts):** the `settled_at`-gates-payout coupling lives here — add the
`require_bnpl_settlement_for_payout` config flag and gate a BNPL booking's payout on `bnpl_transactions.settled_at`
actually being set (never pay a nurse before Balinyaar holds the cash). The `nurse_payable` accrual is already
identical to the card path, so payout amounts need no BNPL-specific logic.
- **Shared conversion:** b10's booking-creation was extracted to `Features/Bookings/BookingConversion` — both the
card capture and the BNPL settle use it. Reuse it, don't fork.
@@ -12,6 +12,25 @@ for awareness.
- **Requests filed:** frontend/requests/for-backend.md (yes/no)
-->
## frontend-phase-5-b6 — Nurse verification flow (trust engine) — 2026-07-09
- **Shipped:** `services/verification` domain (types/keys/constants/validation/apis[client+mock(primary)+seam]/
hooks/index) — ONE cached `status()` query drives B3+B6, every mutation invalidates it. The nurse
verification route subtree `nurse/verification/{page(B3),identity(B4),credentials(B5),review(B6)}` +
co-located `VerificationChecklist` / `verificationSteps` (data-driven step rendering, synthetic mobile step).
Two shared components with tests: `<DocumentUpload>` (client type/size validation, progress %, success/
retry, re-upload-on-reject, server-metadata truth + local-capture mode) and `<TrustBadge>` (verified/
unverified/expired off `--bal-*`, reused by f6). Publish gate wired on `nurse/services` (`PublishGate`
disabled until `approved`); trust badge on the nurse profile (own state from `ownBadgeState`). 6 new AppIcons,
4 route constants, `verification` i18n namespace (123 keys) in both locales. Honest copy: manual steps
never claim an automated authority check; a step reads "تاییدشده" only when `passed`.
- **Consumes:** dev/contracts/domains/verification.md + openapi/swagger.v1.json (backend-phase-6). Wire is
**camelCase**; action-style routes under `api/v1/nurse_verification/*` + `api/v1/nurses/{id}/trust_badge`.
- **Mocked client-side:** `services/verification` via `verificationMockApi` behind `USE_VERIFICATION_MOCK`
(**default true** — runs the whole journey standalone incl. a dev-only admin-decision sim, since b6 isn't
reachable here). One-line swap to `verificationClientApi`. See mocks-registry.
- **Gate:** npm run check green · npm run test:ci green (157 tests, +9) · en/fa in sync (123 verification keys).
- **Requests filed:** frontend/requests/for-backend.md — yes (REQ-011 nurse credential-details endpoint).
## frontend-phase-4-b5 — Catalog browse (Home A5) & nurse service builder (B7) — 2026-07-05
- **Shipped:** `services/catalog` domain (types/keys/constants/apis[client+mock+seam]/hooks/index) — the b5
catalog skeleton + nurse pricing layer. Hooks: `useServiceCategories`, `useCategoryOptionGroups` (both
@@ -132,3 +132,21 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
if it binds `page_size`, tell us and we'll switch the client (one line per list call).
- **Proposed shape:** list query = `?page={1-based}&pageSize={≤100}`; response `data` = `{ items, total, page, pageSize }`.
- **Status:** open
## REQ-011 — Nurse-facing endpoint for structured professional-credential details — filed by frontend-phase-5-b6 — 2026-07-09
- **Need:** A nurse-facing command to submit the **structured** credential fields B5 collects alongside the
document uploads: `inoNumber` (شماره نظام پرستاری), `specialties` (string[] — stable codes + free-text),
and optionally `licenseNumber`, `issuingAuthority`, `holderName`, `issuedAt`, `expiresAt`. Proposed:
`POST api/v1/nurse_verification/credential_details` (or fold into the manual-step `documents` confirm body).
- **Why:** The b6 contract records these structured fields **only on the admin `decide`** — there is no
nurse-facing endpoint to capture them. B5 needs them at submission time (the INO number is the nurse's
primary registry key, specialties feed f6 search facets). Today the client collects them and the mock
persists them; the real `verificationClientApi.submitCredentialDetails` **no-ops** (the document uploads
it accompanies ARE contract-backed via `upload_url` → PUT → `documents`). Without this, a swap to the real
backend silently drops the INO number + specialties until an admin re-enters them.
- **Proposed shape:** `POST api/v1/nurse_verification/credential_details` body
`{ inoNumber, specialties: string[], licenseNumber?, issuingAuthority?, holderName?, issuedAt?, expiresAt? }`
`VerificationStatusDto`. Alternatively, extend the manual-step `documents` confirm body with these fields.
- **Also (minor):** the contract's `VerificationStepDto` has no `isRequired` — the client treats **every**
seeded step as required (the "X از Y" meter Y = `steps.length`). Confirm that holds, or add `isRequired`.
- **Status:** open
@@ -0,0 +1,97 @@
# Backend phase 12 — BNPL: provider-financed installments (mocked) — report
**Mission:** let a family pay for a booking with a provider-financed BNPL plan, and record it correctly — a BNPL
order is, in Balinyaar's books, **a card payment that lands net-of-fee**.
## What was built
### Domain (`Baya.Domain/Entities/Bnpl/`)
- **`BnplTransaction`** — one row per order, **1:1 with its `payment_transaction`**. Guarded `Status` mutated only
through cohesive `MarkTokenIssued`/`MarkVerified`/`MarkSettled`/`MarkReverted`/`MarkCancelled`/`MarkFailed`;
`MarkSettled` enforces `settled = order commission` (≥0). Money is IRR `long`; `settled_at`,
`provider_commission_reversed_amount`, and all settle/revert amounts are nullable.
- **`BnplStatus`** + **`BnplTransitions`** — the forward-only state machine (`eligible → token_issued → verified →
settled → reverted/cancelled/failed`), the idempotency spine.
- **`BnplEligibilityStatus`** (`eligible`/`not_eligible`/`ceiling_exceeded`), **`BnplProviderCodes`**
(`snapppay`/`digipay`/`tara`/`torobpay`).
- **`LedgerPosting.BnplSettle`** — the net-of-fee group (card-capture legs **plus** `DEBIT bnpl_fee_expense /
CREDIT escrow_held`), one balanced `transaction_group_id`, `SourceRefType = bnpl_transaction`. Throws if the
capture legs don't reconcile.
### Application (`Baya.Application/Features/Bnpl/`)
- Queries: **`CheckBnplEligibilityQuery`** (records `eligibility_status` on a created/updated row),
**`GetBnplOrderStatusQuery`** (admin/customer, tenancy-scoped).
- Commands: **`InitiateBnplOrderCommand`** (token + `eligible → token_issued`, under
`lock(booking-request:{id}:payment)`, idempotency-keyed), **`VerifyBnplOrderCommand`**, **`SettleBnplOrderCommand`**
(net-of-fee ledger + booking conversion, under `lock(bnpl:{id}:settle)`), **`RevertBnplOrderCommand`** (reuses
b11 `CreateRefundCommand`), **`HandleBnplCallbackCommand`** (webhook dedup + dispatch by event type).
- **`BnplOrderInitializer`** (shared find-or-create of the pending `payment_transaction` + 1:1 BNPL row) and the
extracted **`BookingConversion`** helper (shared by b10 card capture + b12 settle — b10 was refactored to use it).
- New seams in `Contracts/Payments/`: **`IBnplProvider`** (full verb set, supersedes the b11 revert-only stub),
**`IBnplProviderResolver`**, **`ICurrencyNormalizer`**; `IBnplRepository` on `IUnitOfWork`.
### Infrastructure
- **`Persistence`**: `BnplTransactionConfig` (`payments.BnplTransactions`, `UNIQUE(payment_transaction_id)`,
`CK_BnplTransactions_SettleSplit`, filtered token index), `BnplRepository`, `UnitOfWork` wiring, one migration
**`BnplTransactions`**. `IRefundRepository.GetExternalRevertReferenceAsync` added for the revert audit.
- **`CrossCutting/Seams`**: `MockBnplProvider` (deterministic full state machine), `MockBnplProviderResolver`,
`MockCurrencyNormalizer`; `SeamOptions` extended (`BnplOptions` + `CurrencyOptions`); DI registration in
`AddCrossCuttingSeams`.
- **`API`**: `CheckoutBnplController`, `WebhooksBnplController`, `AdminBnplController`.
## What is now testable and exactly how (per phase §7)
Seed a `pending_payment`/accepted booking request with a known three-amount split and a `payment_gateways` row
`type='bnpl', provider_code='snapppay'`; mock commission % via `Seams:Bnpl:CommissionRate` (default 10%).
1. **Eligibility**`POST api/v1/checkout_bnpl/eligibility``eligible` + plan summary; a `bnpl_transactions`
row exists with `eligibility_status` set and `status='eligible'`. *(Covered:
`BnplHandlerTests.Eligibility_creates_row_eligible_and_returns_plan`, `BnplApiTests` eligibility.)*
2. **Initiate**`POST api/v1/checkout_bnpl/initiate``status='token_issued'`, deterministic token + redirect;
1:1 with the `payment_transaction`; a second initiate reuses the same row.
*(`BnplHandlerTests.Initiate_issues_token_and_is_strictly_one_to_one`.)*
3. **Verify → settle (the ledger)** — drive `POST api/v1/webhooks_bnpl/snapppay` (`order.verified` then
`order.settled`) or the admin settle → `verified → settled`; `settled_amount = order commission`, ledger shows
the balanced net-of-fee group and net `escrow_held = settled_amount`.
*(`BnplHandlerTests.Settle_posts_net_of_fee_group_…`, `BnplApiTests.Full_flow_…`, `BnplLedgerAndStateTests`.)*
4. **Payout invariance**`nurse_payable` credited = `gross balinyaar_commission`, **identical to the card path**
and independent of the BNPL commission. *(Asserted in both the handler + api full-flow tests, compared against
`LedgerPosting.CardCapture`.)*
5. **Replayed settle is a no-op** — re-deliver the same settle callback → webhook dedup + state guard reject it;
no second ledger group. *(`BnplApiTests.Replayed_settle_callback_is_idempotent_no_second_ledger`,
`BnplHandlerTests.Replayed_settle_is_a_noop_…`.)*
6. **Revert**`POST api/v1/admin_bnpl/{id}/revert``status='reverted'`, revert audit set; a `refunds` row with
`refund_channel='bnpl_revert'` + `expected_customer_refund_eta`; reversal ledger posts.
*(`BnplApiTests.Admin_revert_reverses_a_settled_order_…`.)*
7. **Status**`GET api/v1/admin_bnpl/{id}` (admin) / `GET api/v1/checkout_bnpl/{id}` (customer, own only).
8. **Guards** — settle-before-verify is a 409; the state machine rejects illegal edges.
*(`BnplHandlerTests.Settle_before_verify_…`, `BnplLedgerAndStateTests.State_machine_…`.)*
Full suite: **314 pass** (4 identity + 214 foundation + 96 api). Build clean, 0 new code warnings.
## What is mocked + how to make it real
- **`IBnplProvider`** (per `provider_code` via **`IBnplProviderResolver`**) — deterministic mock, no network;
settle returns `order round(order × Seams:Bnpl:CommissionRate)` with the commission read from the response.
Real: one adapter per 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`; creds from the encrypted
`payment_gateways.config_json`; per-contract commission read from the settle response. **Do not use the unrelated
Canadian `SnapPayInc/open-api-java-sdk`.**
- **`ICurrencyNormalizer`** — mock ×10 Toman→IRR (`Seams:Currency:TomanToIrrMultiplier`). Real: read the
multiplier/unit from provider config at the boundary.
- **`settled_at` is nullable / non-instant** (`Seams:Bnpl:SettlementInstant`) — the mock models the
daily/T+13/weekly reality. **b13 must not assume BNPL cash funds a payout.**
## Contracts produced / consumed
- **Produced:** `dev/contracts/domains/bnpl.md`; `dev/contracts/openapi/swagger.v1.json` refreshed
(386K → 411K, all BNPL paths present).
- **Consumed:** b10 `payment_transactions`/`ledger_entries`/`payment_webhook_events`/`IWebhookVerifier`/
`IDistributedLock`/`LedgerPosting`; b11 `refunds`/`CreateRefundCommand`/`refund_channel`; b9 booking split +
`BookingFactory`; b1 typed config accessor.
## Follow-ups
- **b13:** the `settled_at`-gates-payout coupling — add `require_bnpl_settlement_for_payout` and gate a BNPL
booking's payout on `bnpl_transactions.settled_at`.
- **DEFERRED:** `bnpl_settlement_entries` (tranched settlement — modeled-but-not-built, additive migration later);
multi-provider routing/failover (one active route today); `provider_commission_reversed_amount` reconciliation
on revert (left null when the b11 refund path drives it).
- The `BnplTransactions` migration was **not applied to a live DB** this session (no reachable SQL Server in the
agent env); apply with `dotnet ef database update` before the next live run, and seed a `type='bnpl'` gateway.
@@ -0,0 +1,111 @@
# Frontend Phase 5 — Nurse verification flow (trust engine) — Report (2026-07-09)
Builds the trust engine's front end: the staged, platform-owned verification a nurse walks before any
service can go live. A nurse lands on a **status checklist (B3)**, submits **identity (B4)**, submits
**professional credentials (B5)**, and waits on **under-review (B6)** until an admin decides — then a
**trust badge** renders and the **publish gate** unlocks. Consumes the b6 `verification` contract. Unlocks
a bookable verified nurse + the `<TrustBadge>` f6 reuses.
## What was built
### `services/verification` domain (`client/src/services/verification/`)
- **`types.ts`** — DTOs mirrored from [`verification.md`](../../contracts/domains/verification.md)
(**camelCase** wire): `VerificationStatus` (aggregate `status` + `isBookable` + `blockingSteps` +
ordered `steps[]`), `VerificationStep`, `RunStepResult`, `UploadUrlResult`, `DocumentConfirmedResult`,
`VerificationDocument` (metadata only), `NurseCredential`, `TrustBadge`, the `VerificationApi` seam, and
the string-literal enums (aggregate/per-step status, the six step codes, credential type, verification
method, `BadgeState`). Helpers: `isApproved`, `ownBadgeState` (own-profile, computes `expired`),
`publicBadgeState` (public/search — verified/unverified only), `SPECIALTY_PRESETS`.
- **`validation.ts`** — `isValidNationalId` (10-digit mod-11 checksum; rejects all-same-digit).
- **`keys.ts`** — `verificationKeys.status()` (the single cached source B3+B6 read), `.documents(code)`,
`.badge(nurseId)`.
- **`constants.ts`** — `USE_VERIFICATION_MOCK` (**default true**), the status `staleTime` (30 s) + badge
`staleTime`/`gcTime`, the document type/size caps (jpg/png/pdf, 5 MB), `NATIONAL_ID_LENGTH`.
- **`apis/`** — `clientApi.ts` (real HTTP, action-style routes, XHR signed-URL PUT for upload progress +
SHA-256 integrity hash), `mockApi.ts` (**primary**, full journey + dev-only admin sim), selecting
`index.ts`. Both implement `VerificationApi`; swap is one line.
- **`hooks/`** — one per file: `useVerificationStatus` (query), `useStartVerification` (setQueryData),
`useSubmitIdentity` (runs KYC → chained Shahkar), `useRunBankVerification`, `useUploadVerificationDocument`
(progress via vars), `useSubmitCredentials`, `useNurseTrustBadge`. **Every mutation invalidates
`status()`** (badge where relevant), so the checklist re-renders from cache with no manual refetch.
### Screens — nurse verification route subtree (`app/[locale]/(private-routes)/nurse/verification/`)
- **B3 `page.tsx`** — the hub: loading skeleton, error+retry, `not_started` start-CTA, the in-progress
**"X از Y" meter + data-driven checklist** (`VerificationChecklist` reusing the shared `StatusChip`), a
**blocking summary**, a single **continue CTA** that routes to the next actionable step (calls
`useStartVerification` first when `not_started`), and the terminal **`approved`** state (publish link).
- **B4 `identity/page.tsx`** — national-ID field (checksum) + national-ID card + liveness selfie **local
captures** (identity stores no server document — they feed the automated KYC), the honest auto-registry
note, submit → `useSubmitIdentity`. Handles national-ID mismatch (`failed`) and the **non-accusatory
shared-SIM** Shahkar failure distinctly.
- **B5 `credentials/page.tsx`** — INO number + specialty chips (presets + add-your-own) + **a
`<DocumentUpload>` per manual credential step (data-driven from `status`)** each moving its step to
`in_review`, + an optional education-cert local upload + optional registry fields; submit →
`useSubmitCredentials`, lands on B6. Copy reflects **manual review**, never an automated authority check.
- **B6 `review/page.tsx`** — the under-review resting screen: "در حال بررسی" + the 2448h note + a
**condensed mini-checklist** (reusing `StatusChip`) — a focused **second view of the same cached
`status()` query**, not a second fetch. CTA back to B3.
- **`verificationSteps.ts`** — the data-driven glue: `displaySteps` (prepends a synthetic passed **mobile**
step), `progressCounts`, `stepLabelKey`/`stepDescriptionKey`, `stepStatusChip` (the green/amber/grey/red
legend), `routeForStep`, `nextActionRoute`/`nextActionableIndex`.
- **Journey stepper:** B4/B5/B6 reuse the shared `StepperHeader` (identity → credentials → review).
### Shared components (with co-located tests)
- **`<DocumentUpload>`** — the reusable uploader: client type/size validation **before** upload, the full
idle → uploading(%) → success(✓ + name / local image preview) → error(retry) machine, re-upload on
reject, server-metadata as the "uploaded" truth, and a **local-capture mode** (B4). Its state chrome uses
the `verification` namespace; the caller passes the field label/hint.
- **`<TrustBadge state=…>`** — verified / unverified / expired off `--bal-*` tokens. Rendered on the nurse's
own profile; **exported so f6 reuses it** in search results + the public profile.
### Wiring
- **Publish gate**`nurse/services/PublishGate.tsx` (in `MyServicesList`): the go-live CTA is **disabled
with a blocked-until-verified explanation** (+ link to B3) until the aggregate is `approved`. Mirrors the
server's guarded `is_verified` flip.
- **Trust badge on the nurse profile** — sourced from the own `VerificationStatus` via `ownBadgeState`; the
unverified banner now shows only until `verified`.
- **6 AppIcons** (`upload/document/refresh/identity/license/publish`), **4 route constants**, the
`verification` **i18n namespace (123 keys)** in both locales in sync.
## What is now testable, and exactly how
Run `npm run dev`, sign in as a **nurse** (mock auth code `123456` if `USE_AUTH_MOCK`), open
**/nurse/verification**:
1. **Checklist:** B3 shows "X از Y" with the mobile step already green, the rest `not_started`. The continue
CTA calls `start` (seeds steps) then routes to B4.
2. **Identity (B4):** a valid کد ملی + card image (watch the uploader progress → ✓) + selfie → submit → the
`identity_kyc` + `shahkar_match` steps update on B3 **without a refresh**. National-ID `0000000000`
failed KYC; national-ID `1111111111` → the non-accusatory **shared-SIM** Shahkar message.
3. **Credentials (B5):** INO number + upload each manual document (→ `in_review`) + specialty chips → submit
→ lands on **B6** ("در حال بررسی", 2448h, mini-checklist).
4. **Approval flips verified:** the **dev-only "Simulate admin review" panel** on B3/B6 (mock-flag-gated —
stands in for the deferred f15 admin queue) → *Approve all* → B3 shows `approved`, the **trust badge**
on the nurse profile shows **verified**, and the **publish CTA on /nurse/services is enabled**.
5. **Rejected step:** *Reject a document* → the step shows its **rejection reason** + a working re-upload
path; the publish CTA stays blocked.
6. React Query Devtools shows **one `verification.status` query** feeding B3 + B6 and invalidating on each
mutation. Toggle `/en``/fa` (RTL) and dark mode — strings + layout flip.
## What is mocked / waiting on a real service
`services/verification` runs behind **`verificationMockApi`** (`USE_VERIFICATION_MOCK = true`) — b6 isn't
reachable in this environment, so the mock drives the full journey (identity/Shahkar/bank runs, document
uploads, admin-decision sim). The **swap is one line** (`USE_VERIFICATION_MOCK = false`): `verificationClientApi`
is wired to every b6 route (`nurse_verification/*`, `nurses/{id}/trust_badge`), same hook signatures + query
keys, no call-site change. See the mocks-registry row for the deterministic test triggers. Vendor calls
(KYC/Shahkar/credential/IBAN/object-storage) are mocked **server-side** by b6 — the front end consumes them
as if real.
## Contracts consumed + gaps filed
- **Consumed:** [`verification.md`](../../contracts/domains/verification.md) + `openapi/swagger.v1.json` (b6).
- **Filed:** **REQ-011** — a nurse-facing endpoint for the structured credential details (INO number,
specialties, license fields) B5 collects; the contract records these only on the admin `decide`, so the
real `submitCredentialDetails` no-ops until it lands (the document uploads are contract-backed). Also
flagged: `VerificationStepDto` has no `isRequired` (the client treats every seeded step as required).
## Follow-ups for later phases
- **f6** reuses `<TrustBadge>` (import from `@/components`) on search results (C2) + the public nurse profile
(C3), sourced from `useNurseTrustBadge(nurseId)` / `publicBadgeState`. The `expired` state is own-profile
only (the public badge payload carries `isVerified` only).
- **f12 payout** depends on the `bank_account_verification` step (this phase deep-links it to the f2 bank screen).
- The **admin verification review queue** (pass/reject, doc viewer, credential entry) is **f15** — the
dev-only mock sim here is a stand-in, gated on `USE_VERIFICATION_MOCK`.
- Deliver **REQ-011** then wire `submitCredentialDetails` + flip `USE_VERIFICATION_MOCK=false`.
@@ -17,8 +17,9 @@ Status legend: 🔴 not built · 🟡 mocked (seam + fake impl in place) · 🟢
| `IPaymentProvider` | backend-phase-10 | Card PSP/IPG — deterministic success | _tbd_ | ZarinPal/Sadad/Vandar/Jibit + Shaparak; merchant/terminal/تسهیم | 🔴 |
| `ISettlementSplitProvider` | backend-phase-10 | تسهیم split — accepts any balanced legs | _tbd_ | Provider split-by-ratio to registered Shebas | 🔴 |
| `IWebhookVerifier` | backend-phase-10 | Callback auth — always valid | _tbd_ | Per-provider HMAC/signature + server-side re-verify | 🔴 |
| `IBnplProvider` | backend-phase-12 | BNPL — drives state machine, fake settle/revert | _tbd_ | SnappPay/Digipay OAuth + verb set; encrypted creds in `payment_gateways.config_json` | 🔴 |
| `ICurrencyNormalizer` | backend-phase-12 | Toman↔IRR — ×10 | _tbd_ | Config-driven per provider boundary | 🔴 |
| `IBnplProvider` | backend-phase-12 | BNPL — `MockBnplProvider` drives the full state machine (eligible→settled→reverted), settle returns `order commission%` | `Seams:Bnpl:{CommissionRate,SettlementInstant,CreditCeilingIrr,NotEligibleMobile,ForceFailure,ReverseProviderCommission}` | SnappPay/Digipay OAuth + verb set; encrypted creds in `payment_gateways.config_json` | 🟡 |
| `IBnplProviderResolver` | backend-phase-12 | Per-`provider_code` selection — maps every known code to the one mock | _none_ | One concrete adapter per code; resolver returns the right one | 🟡 |
| `ICurrencyNormalizer` | backend-phase-12 | Toman↔IRR — ×10 at the boundary | `Seams:Currency:TomanToIrrMultiplier` (default `10`) | Config-driven per provider boundary | 🟡 |
| `IBankTransferProvider` | backend-phase-13 | PAYA/SATNA payout — fake transfer ref | _tbd_ | Jibit/Vandar/Sadad payout; source account; PAYA vs SATNA | 🔴 |
| `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 | 🟡 |
@@ -42,7 +43,8 @@ Status legend: 🔴 not built · 🟡 mocked (seam + fake impl in place) · 🟢
| `ISearchIndexMaintainer` (the "`ISearchIndexWriter`" event shape) | backend-phase-7 | The index-maintenance seam (write side). **The inline SQL path is REAL**`SearchIndexMaintainer` (`Persistence/Services/Search/`) re-derives `nurse_search_index` from source and **stages** it inside the owning source write's unit of work (single `CommitAsync`), invoked from the b3/b4/b5/b6 handlers (`ReindexVariantAsync`/`ReindexNurseAsync`/`FanOutServiceAreaAsync`/`RemoveServiceAreaRowsAsync`/`RebuildAsync`). Only the **outbox/queue routing** for an async Elastic feeder is deferred — the seam is shaped so the same change events can later be emitted to an outbox instead of an inline upsert | _none_ | 1) introduce an `outbox` table + a SaveChanges interceptor that captures each maintainer change as an event row in the same transaction; 2) a background feeder (Hangfire/Quartz or a hosted service) reads the outbox and applies to `ElasticNurseSearch`; 3) keep the inline SQL upsert as the projection/fallback so `RebuildAsync` stays the reconciliation path; 4) test that an outbox replay converges to the same rows as the inline path | 🟡 outbox deferred (inline real) |
| `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` | **owned by backend-phase-12**; thin local stub added in **b11** | BNPL revert/update`MockBnplProvider` (`Baya.Infrastructure.CrossCutting/Seams/`), a **temporary stub so b11's `bnpl_revert` refund path runs before b12 merges**. `RevertAsync`/`UpdateAsync` succeed, echo a deterministic `external_revert_reference`, and report a **nullable** `provider_commission_reversed_amount` (null by default — reconciled from the response, never hardcoded). Registered singleton in `AddCrossCuttingSeams` | `Seams:Bnpl:ForceFailure` (default `false`), `Seams:Bnpl:ReverseProviderCommission` (default `false`) | **b12 owns the real seam definition + adapter** (SnappPay/Tara): settle flow, order lifecycle, real `RevertAsync`(full)/`UpdateAsync`(strictly-lower partial). When b12 lands its real registration supersedes this stub and the refund `bnpl_revert` path calls the real client unchanged | 🟡 (pre-b12 stub) |
| `IBnplProvider` | **backend-phase-12** (superset of the b11 revert-only stub) | BNPL provider`MockBnplProvider` (`Baya.Infrastructure.CrossCutting/Seams/`), **no external call**, drives the full SnappPay-superset verb set `CheckEligibilityAsync`/`CreatePaymentTokenAsync`/`VerifyAsync`/`SettleAsync`/`GetStatusAsync`/`CancelAsync`/`RevertAsync`/`UpdateAsync` and the `eligible → token_issued → verified → settled → reverted/cancelled` machine. Eligibility is `eligible` unless the mobile = `NotEligibleMobile` (→`not_eligible`) or the order exceeds `CreditCeilingIrr` (→`ceiling_exceeded`); token/redirect are deterministic; **settle returns `settledAmountIrr = order round(order × CommissionRate)` + the commission read from the response (never hardcoded) + a nullable `settledAt`** (null when `SettlementInstant=false`, modelling non-instant settlement); revert echoes a deterministic `external_revert_reference` + nullable `provider_commission_reversed_amount`. Selected per `provider_code` by **`IBnplProviderResolver`** (`MockBnplProviderResolver` → the one mock for every known code); the b11 refund `bnpl_revert` path still injects `IBnplProvider` directly. Registered singleton in `AddCrossCuttingSeams` | `Seams:Bnpl:CommissionRate` (default `0.10`), `Seams:Bnpl:SettlementInstant` (default `true`), `Seams:Bnpl:CreditCeilingIrr` (default `2000000000`), `Seams:Bnpl:NotEligibleMobile` (default `09120000099`), `Seams:Bnpl:ForceFailure` (default `false`), `Seams:Bnpl:ReverseProviderCommission` (default `false`) | 1) implement one concrete adapter per `provider_code` (**SnappPay** OAuth `api/online/v1/oauth/token` + `offer/v1/eligible` + `payment/v1/token\|verify\|settle\|revert\|cancel\|update\|status`, or **Digipay** UPG `tickets/business?type=13` + `purchases/verify` + `purchases/deliver?type=13` + `refunds`/`reverse`); 2) read credentials from the **encrypted** `payment_gateways.config_json`; 3) do Toman↔Rial via `ICurrencyNormalizer` at the adapter boundary; 4) read the **per-contract commission from the settle response**, never hardcode; 5) map the provider event shape into the callback so `HandleBnplCallback` dispatch is unchanged; 6) register per-code in `IBnplProviderResolver` (config-selected) — handlers unchanged. **Warn: do NOT use the unrelated Canadian `SnapPayInc/open-api-java-sdk`.** | 🟡 |
| `ICurrencyNormalizer` | backend-phase-12 | Toman↔IRR at the provider boundary — `MockCurrencyNormalizer` (`Baya.Infrastructure.CrossCutting/Seams/`): `ToIrr(amount,"TOMAN")` = `amount × TomanToIrrMultiplier`, IRR passes through; `ToDisplayToman` divides back. **Conversion happens ONLY here, never internally.** Registered singleton in `AddCrossCuttingSeams` | `Seams:Currency:TomanToIrrMultiplier` (default `10`) | Read the multiplier (or a per-provider unit) from provider config; the interface stays — a currency redenomination is a config change | 🟡 |
| `INursePayoutStatus` | backend-phase-11 (interim; **b13** owns the real impl) | "Was the nurse already paid for this booking?" — `NursePayoutStatusService` (`Persistence/Services/Payments/`) derives it from the booking's `dispute_window_ends_at` close (the same gate b13 pays out on), with a `refund_assume_nurse_paid` config override. Not a mock of an external — a **temporary derivation** standing in for the b13 `nurse_payout_booking_links` lookup. Registered scoped in `AddPersistenceServices` | `refund_assume_nurse_paid` (`platform_configs`, default `false`) | In b13: implement `IsNursePaidForBookingAsync` as a real `nurse_payout_booking_links` join (a booking linked to a paid-out `nurse_payouts` batch ⇒ paid), swap the registration — the refund pre-payout/clawback fork is unchanged | 🟡 |
> Exact config keys and file paths get filled in by the phase that builds each seam. Keep the
@@ -64,3 +66,4 @@ the frontend can build before the backend phase merges, and swap to the real HTT
| `ServiceAreasApi` | `client/src/services/serviceAreas/apis/mockApi.ts` | Nurse coverage areas (list whole-city-first / add / remove). Enforces `UNIQUE(cityId, districtId)` exactly as the server — a duplicate (incl. a second whole-city row) throws the same **`409`** (`area_duplicate`) so the coverage editor's inline dup handling is demonstrable | `USE_SERVICE_AREAS_MOCK` (`services/serviceAreas/constants.ts`, default `true`) | b4 `nurse_service_areas/*` are live; set flag `false``serviceAreasClientApi` is wired (maps the server 409 to the same inline message). No hook/component change | 🟡 |
| `AddressMapPicker` (map stand-in) | `client/src/components/geography/AddressMapPicker.tsx` | **Not a real map** — a bounded, tappable/draggable marker canvas (CSS grid, no Neshan/Google tiles, no network) that maps the pointer position to `{ latitude, longitude }` around the chosen city's centroid (`CITY_CENTROIDS`/`IRAN_CENTROID` in `services/geography/constants.ts`). Emits real coordinates for the create/update request | _none (component boundary)_ | Replace the canvas internals with a real map widget (Neshan/Google, inlined per the client CSP) that emits the same `{ latitude, longitude }` via `onChange``AddressForm` and every caller stay unchanged | 🟡 |
| `CatalogApi` | `client/src/services/catalog/apis/mockApi.ts` (+ `apis/seed.ts`) | The catalog skeleton + nurse pricing layer. **Categories mirror the b5 seed exactly** (5 categories, ids 15, `sortOrder` 04). Seeds representative **option groups/values** the fresh backend does **not** (an admin authors them per category) — incl. required + optional groups and one **cross-category** (`serviceCategoryId=null`) group — so the builder's required-option gate + cross-category rendering demo. Enforces the server's create validation in-memory: `400` missing required dimension / bad price, and the `(nurse, category, option-set)` duplicate **`409`** (via `optionSetSignature`). Variant store seeded **empty** so the offerings empty-state demos; the nurse builds variants live (across price units). `create`/`update`/`set_active`/`list`(active-first, paginated)/`get`. Money stays an **IRR digit-string** end-to-end | `USE_CATALOG_MOCK` (`services/catalog/constants.ts`, default `true`) | b5 `catalog/*` + `nurse_variants/*` are live; set flag `false``catalogClientApi` is wired to the action-style routes (camelCase bodies, `pageSize` pagination per REQ-010, `category_id` snake_case filter). **When swapped, categories will have NO option groups until an admin authors them** (the mock's groups were illustrative). No hook/component change | 🟡 |
| `VerificationApi` | `client/src/services/verification/apis/mockApi.ts` | The whole nurse trust journey (b6). Seeds the six required steps on `start` (idempotent); `runIdentityKyc` passes any well-formed 10-digit id **except** `0000000000` (→ `failed`/`kyc_no_match`, matches backend `MockIdentityKycProvider`); `runShahkarMatch` requires identity passed, fails **shared-SIM** when the bound national id is `1111111111` (→ `failed`/`shared_sim`); `runBankVerification` passes (assumes a primary bank account); `uploadStepDocument` simulates signed-URL PUT progress then moves the step to `in_review` (metadata only); `submitCredentialDetails` validates the INO number. Re-aggregates like the server (`approved` only when every step passes). **Dev-only** `__mockApproveAll()`/`__mockRejectStep(code,reason)` stand in for the deferred (f15) admin review queue so a human can watch `is_verified`/the trust badge/the publish gate flip — reachable from B3/B6 only while the flag is true | `USE_VERIFICATION_MOCK` (`services/verification/constants.ts`, default `true`) | b6 `nurse_verification/*` + `nurses/{id}/trust_badge` are live; set flag `false``verificationClientApi` is wired (action-style routes, camelCase, XHR signed-URL PUT for upload progress + SHA-256 integrity hash). **Caveat:** the real `submitCredentialDetails` no-ops pending REQ-011 (no nurse-facing endpoint for the structured INO/specialties fields yet) — the document uploads it accompanies are contract-backed. No hook/component change | 🟡 |
+32 -3
View File
@@ -81,15 +81,15 @@ projects/assemblies, Clean-Architecture layers, and cross-layer dependencies.
```
src/
├── Core/
│ ├── Baya.Domain Entities (User, Role, UserSession, RoleNames…, Identity/ (NurseProfile, CustomerProfile, Patient, NurseBankAccount, CustomerAddress), Geography/ (Province, City, District, NurseServiceArea), Catalog/ (ServiceCategory, ServiceOptionGroup, ServiceOptionValue, NurseServiceVariant, NurseServiceVariantOption, PriceUnits), Verification/ (NurseVerification, VerificationStepType, VerificationStep, VerificationDocument, NurseCredential + VerificationStatus/VerificationStepStatus enums), Search/ (NurseSearchIndex — the denormalized search projection), Booking/ (BookingRequest — the money-free pre-payment intent + BookingRequestStatus/BookingRequestTransitions forward-only status guard + CaregiverGender codes; b9 adds Booking/BookingSession/BookingCareInstruction/VisitVerification/CancellationPolicy + their status/transition tables + BookingAmounts money split), + 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); + Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts areas), Contracts/ (incl. Contracts/Common cross-cutting seams incl. IBankAccountOwnershipVerifier + IGeocoder + IVariantSnapshotSerializer + IShahkarVerifier + IIdentityKycProvider + ICredentialVerifier + the platform-signal facade contracts + Contracts/Search (INurseSearch read seam + ISearchIndexMaintainer write seam) + Contracts/Persistence per-domain repositories on IUnitOfWork incl. IVerificationRepository), Models/, pipeline behaviors (Common/ — validators auto-registered from this assembly; VerificationAggregator + IdentityNameMatch helpers)
│ ├── Baya.Domain Entities (User, Role, UserSession, RoleNames…, Identity/ (NurseProfile, CustomerProfile, Patient, NurseBankAccount, CustomerAddress), Geography/ (Province, City, District, NurseServiceArea), Catalog/ (ServiceCategory, ServiceOptionGroup, ServiceOptionValue, NurseServiceVariant, NurseServiceVariantOption, PriceUnits), Verification/ (NurseVerification, VerificationStepType, VerificationStep, VerificationDocument, NurseCredential + VerificationStatus/VerificationStepStatus enums), Search/ (NurseSearchIndex — the denormalized search projection), Booking/ (BookingRequest — the money-free pre-payment intent + BookingRequestStatus/BookingRequestTransitions forward-only status guard + CaregiverGender codes; b9 adds Booking/BookingSession/BookingCareInstruction/VisitVerification/CancellationPolicy + their status/transition tables + BookingAmounts money split), Payments/ (b10 ledger/txn/webhook/gateway + LedgerPosting; b11 adds Refunds/ + Invoices/), Bnpl/ (b12 BnplTransaction + BnplStatus/BnplTransitions/BnplEligibilityStatus/BnplProviderCodes — the net-of-fee card-payment model), + 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)
├── Infrastructure/
│ ├── Baya.Infrastructure.Persistence ApplicationDbContext (+ encrypted-PII value converters & phone-hash sync), ValueConversion/, Repositories/, Configuration/ (per-area EF config incl. SearchConfig/ + BookingConfig/ — b8 BookingRequest + b9 bookings/sessions/care/EVV/cancellation-policy configs & seed), Repositories/ (incl. b9 BookingRepository + CancellationPolicyRepository), Migrations/, Interceptors/ (AuditFieldInterceptor — audit-fields + audit-log rows), Services/ (DB-backed platform-signal facades + notification-retention hosted service + Search/ = SearchIndexMaintainer + SqlNurseSearch + Booking/ = BookingRequestExpiryHostedService)
│ ├── Baya.Infrastructure.Identity Jwt/, Identity/ (Managers, Stores, PermissionManager, Seed, CurrentUser/)
│ ├── Baya.Infrastructure.CrossCutting Serilog wiring + Seams/ (mock impls of the cross-cutting seams incl. LoggingSmsSender + MockBankAccountOwnershipVerifier + MockShahkarVerifier + MockIdentityKycProvider + MockCredentialVerifier + MockPaymentCaptureSimulator) + AddCrossCuttingSeams
│ └── Baya.Infrastructure.Monitoring HealthChecks, OpenTelemetry, prometheus-net
├── API/
│ ├── Baya.Web.Api Program.cs, Controllers/V1/ (Ping + Auth/Me phone-OTP surface + admin PlatformConfig/Holidays/Audit/SupportAlerts + current-user Notifications + public Geo + admin AdminGeo + nurse NurseServiceAreas + customer CustomerAddresses + public Catalog + admin AdminCatalog + nurse NurseVariants + nurse NurseVerification + admin AdminVerificationStepTypes/AdminVerifications + public Nurses (trust badge) + public Search + admin AdminSearch + customer/nurse BookingRequests + admin AdminBookingRequests + customer/nurse/admin Bookings + nurse/admin BookingSessions + admin AdminEvv + admin AdminCancellationPolicies + customer PaymentsController + public WebhooksController + admin AdminRefunds/AdminClawbacks/AdminInvoices + customer Refunds/Invoices), 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), appsettings*.json
│ ├── Baya.WebFramework BaseController (incl. 401/403 OperationResult mapping), Filters/, Middlewares/, Swagger/, Routing/, ServiceConfiguration/ (rate limiting)
│ └── Plugins/Baya.Web.Plugins.Grpc gRPC services + .proto models (User only)
├── Shared/Baya.SharedKernel Extensions + validation base
@@ -363,6 +363,35 @@ per-domain repos `IRefundRepository` + `IInvoiceRepository` on `IUnitOfWork`; co
introduced here as a **thin local stub** so the `bnpl_revert` path runs before b12 merges — **b12 owns the real
seam definition**.
**BNPL — provider-financed installments (backend-phase-12).** The `payments` schema gains one table —
`BnplTransactions` (entity in `Domain/Entities/Bnpl/`, config in `Persistence/Configuration/BnplConfig/`, one
migration `BnplTransactions`) — **1:1 with its `payment_transaction`** (`UNIQUE(payment_transaction_id)`).
A BNPL order is, in our books, **a card payment that lands net-of-fee**: there is no customer-installment
tracking (the provider owns the schedule + 100% default risk). Features under
`Baya.Application/Features/Bnpl/{Commands|Queries}/` (eligibility/initiate/verify/settle/revert/callback/status);
per-domain repo `IBnplRepository` on `IUnitOfWork`; controllers `CheckoutBnplController` (customer, rate-limited)
/ `WebhooksBnplController` (anonymous, signature-verified, rate-limited) / `AdminBnplController` (admin,
rate-limited). The b10 booking-conversion path was extracted to the shared **`Features/Bookings/BookingConversion`**
helper (used by both the card `ConfirmPaymentAndPostLedger` and the BNPL settle). Load-bearing rules:
- **Forward-only `BnplStatus` state machine** (`eligible → token_issued → verified → settled →
reverted/cancelled/failed`, `BnplTransitions`), mutated only through the entity's mark-* methods — the
idempotency spine. A replayed settle/revert that would re-drive a completed transition is an idempotent no-op.
- **Settle posts the net-of-fee group via `LedgerPosting.BnplSettle`** — the card-capture legs **plus** `DEBIT
bnpl_fee_expense / CREDIT escrow_held` for the provider commission, one balanced `transaction_group_id`, so
escrow reflects the **net** cash (`settled_amount_irr = order commission`). Settle confirms the parent
`payment_transaction` (which triggers the booking conversion) exactly like the card capture.
- **The nurse's payout is invariant to payment method**`nurse_payable` comes from the booking split
(`gross commission`), **never** from `settled_amount_irr`; the BNPL commission is a **platform expense**.
- **`settled_at` is per-transaction and nullable** — never assumed instant; the commission is read from the
actual settlement, never hardcoded. **Currency is normalized to IRR at the provider boundary only**.
- **Revert reuses the b11 refund path** (`CreateRefundCommand` with `refund_channel='bnpl_revert'`) — money
flows customer ↔ provider ↔ Balinyaar only; the async ~710-business-day customer ETA is surfaced.
- **Two new seams** in `Application/Contracts/Payments/`: **`IBnplProvider`** (the full SnappPay-superset verb
set, superseding b11's revert-only stub; the b11 refund path still injects it) selected per `provider_code`
by **`IBnplProviderResolver`**, and **`ICurrencyNormalizer`** (Toman↔IRR at the boundary). Mocks
(`MockBnplProvider`/`MockBnplProviderResolver`/`MockCurrencyNormalizer`) in `CrossCutting/Seams/`, registered by
`AddCrossCuttingSeams`. `bnpl_settlement_entries` (tranched settlement) is **DEFERRED — modeled-but-not-built**.
**Keeping the Project map current.** When a change touches the architecture — adds, removes, or
renames a project/assembly, a Clean-Architecture layer, or a major folder, or changes a cross-layer
dependency — you **must** update this Project map (and the dependency rule above, if affected) in the
@@ -0,0 +1,56 @@
using System.ComponentModel.DataAnnotations;
using Asp.Versioning;
using Baya.Application.Features.Bnpl.Commands.RevertBnplOrder;
using Baya.Application.Features.Bnpl.Commands.SettleBnplOrder;
using Baya.Application.Features.Bnpl.Commands.VerifyBnplOrder;
using Baya.Application.Features.Bnpl.Queries.GetBnplOrderStatus;
using Baya.Application.Models.Bnpl;
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-only BNPL operations console: manually drive verify/settle (also driven by the provider callback) and
/// the payout-/refund-sensitive full revert, plus read an order. The revert reverses through the provider only
/// (customer ↔ provider ↔ Balinyaar) and surfaces the async ~710-business-day customer ETA. Rate-limited as
/// money endpoints.
/// </summary>
[ApiVersion("1")]
[ApiController]
[Route("api/v{version:apiVersion}/admin_bnpl")]
[Authorize(ConstantPolicies.DynamicPermission)]
[EnableRateLimiting(RateLimitingServiceExtension.SensitivePolicy)]
[Display(Description = "Admin BNPL verify/settle/revert + order status")]
public sealed class AdminBnplController(ISender sender) : BaseController
{
[HttpPost("{id}/[action]")]
[ProducesOkApiResponseType<bool>]
public async Task<IActionResult> Verify(long id, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new VerifyBnplOrderCommand(id), cancellationToken));
[HttpPost("{id}/[action]")]
[ProducesOkApiResponseType<bool>]
public async Task<IActionResult> Settle(long id, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new SettleBnplOrderCommand(id), cancellationToken));
[HttpPost("{id}/[action]")]
[ProducesOkApiResponseType<RevertBnplResult>]
public async Task<IActionResult> Revert(long id, RevertBnplBody body, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(
new RevertBnplOrderCommand(id, body.RefundPercentage, body.TicketId, body.ReasonNotes), cancellationToken));
[HttpGet("{id}")]
[ProducesOkApiResponseType<BnplOrderStatusDto>]
public async Task<IActionResult> Get(long id, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new GetBnplOrderStatusQuery(id, AdminView: true), cancellationToken));
/// <summary>The revert body (the order id comes from the route). Omit <c>refund_percentage</c> for a full revert.</summary>
public record RevertBnplBody(decimal? RefundPercentage, long? TicketId, string? ReasonNotes);
}
@@ -0,0 +1,52 @@
using System.ComponentModel.DataAnnotations;
using Asp.Versioning;
using Baya.Application.Features.Bnpl.Commands.InitiateBnplOrder;
using Baya.Application.Features.Bnpl.Queries.CheckBnplEligibility;
using Baya.Application.Features.Bnpl.Queries.GetBnplOrderStatus;
using Baya.Application.Models.Bnpl;
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>
/// The customer-facing BNPL checkout — the "pay with installments" alternative to the card path. Eligibility
/// records the plan on a <c>bnpl_transactions</c> row; initiate issues the provider token + redirect. Both are
/// tenancy-scoped to the caller and rate-limited as money endpoints. The order amount is always the request's
/// frozen gross — never client-supplied.
/// </summary>
[ApiVersion("1")]
[ApiController]
[Route("api/v{version:apiVersion}/checkout_bnpl")]
[Authorize]
[EnableRateLimiting(RateLimitingServiceExtension.SensitivePolicy)]
[Display(Description = "Customer BNPL checkout (eligibility + initiate)")]
public sealed class CheckoutBnplController(ISender sender) : BaseController
{
[HttpPost("[action]")]
[ProducesOkApiResponseType<BnplEligibilityDto>]
public async Task<IActionResult> Eligibility(CheckBnplEligibilityQuery query, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(query, cancellationToken));
[HttpPost("[action]")]
[ProducesOkApiResponseType<InitiateBnplResult>]
public async Task<IActionResult> Initiate(InitiateBnplBody body, CancellationToken cancellationToken)
{
var idempotencyKey = Request.Headers["Idempotency-Key"].FirstOrDefault();
return OperationResult(await sender.Send(
new InitiateBnplOrderCommand(body.BookingRequestId, body.ProviderCode, idempotencyKey), cancellationToken));
}
[HttpGet("{id}")]
[ProducesOkApiResponseType<BnplOrderStatusDto>]
public async Task<IActionResult> Get(long id, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new GetBnplOrderStatusQuery(id, AdminView: false), cancellationToken));
/// <summary>The initiate body (the idempotency key comes from the <c>Idempotency-Key</c> header).</summary>
public record InitiateBnplBody(long BookingRequestId, string ProviderCode);
}
@@ -0,0 +1,43 @@
using System.ComponentModel.DataAnnotations;
using System.IO;
using System.Linq;
using System.Text;
using Asp.Versioning;
using Baya.Application.Features.Bnpl.Commands.HandleBnplCallback;
using Baya.Application.Models.Bnpl;
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>
/// The inbound BNPL provider-callback surface. Authenticated by <b>signature</b>, not a user session, so it is
/// anonymous to the auth pipeline; at-least-once tolerant and idempotency-deduplicated on
/// <c>(provider_code, external_event_id)</c> before any money moves. Rate-limited (per-IP). The raw body is read
/// verbatim and stored in <c>payload_json</c>.
/// </summary>
[ApiVersion("1")]
[ApiController]
[Route("api/v{version:apiVersion}/webhooks_bnpl")]
[AllowAnonymous]
[EnableRateLimiting(RateLimitingServiceExtension.SensitivePolicy)]
[Display(Description = "BNPL provider callbacks (signature-authenticated, idempotent)")]
public sealed class WebhooksBnplController(ISender sender) : BaseController
{
[HttpPost("{provider}")]
[ProducesOkApiResponseType<BnplCallbackResult>]
public async Task<IActionResult> Callback(string provider, CancellationToken cancellationToken)
{
using var reader = new StreamReader(Request.Body, Encoding.UTF8, leaveOpen: true);
var rawBody = await reader.ReadToEndAsync(cancellationToken);
var headers = Request.Headers.ToDictionary(h => h.Key, h => h.Value.ToString(), StringComparer.OrdinalIgnoreCase);
return OperationResult(await sender.Send(new HandleBnplCallbackCommand(provider, headers, rawBody), cancellationToken));
}
}
@@ -2,25 +2,90 @@
namespace Baya.Application.Contracts.Payments;
/// <summary>
/// The Buy-Now-Pay-Later provider seam (SnappPay / Tara / …). <b>b12 owns the real, full definition of this
/// seam</b>; b11 introduces this minimal shape (revert/update) plus a thin local mock so the <c>bnpl_revert</c>
/// refund path is exercised before b12 merges. Money <b>always</b> flows <c>customer ↔ provider ↔ Balinyaar</c>
/// — never nurse→customer or Balinyaar→customer direct. A <b>full</b> reversal is <see cref="RevertAsync"/>; a
/// <b>partial/shortened</b> one is <see cref="UpdateAsync"/> with a strictly-lower amount. Every amount is IRR
/// <c>long</c>; the <paramref name="idempotencyKey"/> makes a retried revert a no-op rather than a double refund.
/// The Buy-Now-Pay-Later provider seam (SnappPay / Digipay / Tara / Torob Pay) — the SnappPay-superset verb set
/// that drives the full <c>eligible → token_issued → verified → settled → reverted/cancelled</c> state machine.
/// <b>One impl per <c>provider_code</c></b>, selected through <see cref="IBnplProviderResolver"/>. A BNPL order
/// is, in our books, a card payment landing net-of-fee: the <b>settle</b> returns the net amount + the provider's
/// merchant commission (read from the actual settlement, never hardcoded) + a <b>per-transaction</b>
/// <c>settled_at</c> that is never assumed instant. Money <b>always</b> flows <c>customer ↔ provider ↔
/// Balinyaar</c>; every amount is IRR <c>long</c> (Toman is converted only in a real adapter's boundary via
/// <see cref="ICurrencyNormalizer"/>); an <c>idempotencyKey</c> makes a retried settle/revert a no-op.
/// </summary>
public interface IBnplProvider
{
/// <summary>Full reversal of a BNPL order back through the provider.</summary>
/// <summary>Checks whether the customer may finance this order — records <c>eligibility_status</c>.</summary>
ValueTask<BnplEligibilityResult> CheckEligibilityAsync(string customerMobile, long orderAmountIrr, CancellationToken cancellationToken = default);
/// <summary>Issues the <c>external_payment_token</c> + the redirect URL that starts the customer's order.</summary>
ValueTask<BnplTokenResult> CreatePaymentTokenAsync(string customerMobile, long orderAmountIrr, string idempotencyKey, CancellationToken cancellationToken = default);
/// <summary>Confirms the order — echoes the amount so the handler can re-check it server-side (never trust the callback alone).</summary>
ValueTask<BnplVerifyResult> VerifyAsync(string externalPaymentToken, long expectedOrderAmountIrr, CancellationToken cancellationToken = default);
/// <summary>Settles the full order to Balinyaar net of the provider commission — returns the net amount, the
/// commission (a platform expense), and the per-transaction <c>settled_at</c>.</summary>
ValueTask<BnplSettleResult> SettleAsync(string externalPaymentToken, long orderAmountIrr, string idempotencyKey, CancellationToken cancellationToken = default);
/// <summary>The provider's current view of the order (reconciliation/support).</summary>
ValueTask<BnplStatusResult> GetStatusAsync(string externalPaymentToken, CancellationToken cancellationToken = default);
/// <summary>Cancels an order before settle.</summary>
ValueTask<BnplRevertResult> CancelAsync(string externalPaymentToken, string idempotencyKey, CancellationToken cancellationToken = default);
/// <summary>Full reversal of a settled BNPL order back through the provider (a <c>bnpl_revert</c> refund).</summary>
ValueTask<BnplRevertResult> RevertAsync(string providerOrderReference, long amountIrr, string idempotencyKey, CancellationToken cancellationToken = default);
/// <summary>Partial revert — reduces the order to a strictly-lower <paramref name="newAmountIrr"/>.</summary>
ValueTask<BnplRevertResult> UpdateAsync(string providerOrderReference, long newAmountIrr, string idempotencyKey, CancellationToken cancellationToken = default);
}
/// <summary>The outcome of a BNPL revert/update.</summary>
/// <summary>The eligibility outcome + plan summary the client shows (or falls back to card on).</summary>
/// <param name="EligibilityStatus">A <c>BnplEligibilityStatus</c> code (<c>eligible</c>/<c>not_eligible</c>/<c>ceiling_exceeded</c>).</param>
/// <param name="InstallmentCount">Informational plan length (default 4 — owned by the provider).</param>
/// <param name="CreditCeilingIrr">The customer's provider credit ceiling (IRR), when known.</param>
/// <param name="PlanSummary">Human copy, e.g. "4 interest-free installments, provider-financed".</param>
public sealed record BnplEligibilityResult(
string EligibilityStatus,
int InstallmentCount,
long? CreditCeilingIrr,
string PlanSummary);
/// <param name="Status">Whether a token was issued.</param>
/// <param name="ExternalPaymentToken">The deterministic token persisted for verify/settle/revert.</param>
/// <param name="RedirectUrl">Where the customer is sent to complete the BNPL order.</param>
/// <param name="ExternalTransactionId">The provider's order/txn id, when issued.</param>
public sealed record BnplTokenResult(
PaymentProviderStatus Status,
string ExternalPaymentToken,
string RedirectUrl,
string? ExternalTransactionId);
/// <param name="Status">The re-verified outcome — only <see cref="PaymentProviderStatus.Succeeded"/> confirms.</param>
/// <param name="OrderAmountIrr">The order amount the provider reports, re-checked against the stored order.</param>
/// <param name="ExternalTransactionId">The provider's order/txn id, when returned.</param>
public sealed record BnplVerifyResult(
PaymentProviderStatus Status,
long OrderAmountIrr,
string? ExternalTransactionId);
/// <param name="Status">Whether the provider settled.</param>
/// <param name="SettledAmountIrr">Net of the provider commission actually received (IRR).</param>
/// <param name="BnplCommissionIrr">The provider's merchant discount (IRR) = a platform expense.</param>
/// <param name="SettledAt">The per-transaction settlement time — <b>nullable</b>, contract-defined, never assumed instant.</param>
/// <param name="ExternalTransactionId">The provider's order/txn id, when returned.</param>
public sealed record BnplSettleResult(
PaymentProviderStatus Status,
long SettledAmountIrr,
long BnplCommissionIrr,
DateTime? SettledAt,
string? ExternalTransactionId);
/// <param name="ProviderStatus">The provider's own status string for the order.</param>
public sealed record BnplStatusResult(string ProviderStatus);
/// <summary>The outcome of a BNPL revert/update/cancel.</summary>
/// <param name="Status">Whether the provider accepted the reversal.</param>
/// <param name="ExternalRevertReference">The provider's revert id, persisted on the refund.</param>
/// <param name="ExternalRevertReference">The provider's revert id, persisted on the refund + the BNPL row.</param>
/// <param name="ProviderCommissionReversedAmount">The provider's own commission it returned — <b>nullable</b>,
/// reconciled from the response, never hardcoded (some providers keep their fee on a refund).</param>
public sealed record BnplRevertResult(
@@ -0,0 +1,14 @@
#nullable enable
namespace Baya.Application.Contracts.Payments;
/// <summary>
/// Resolves the <see cref="IBnplProvider"/> impl for a <c>provider_code</c> (<c>snapppay</c>/<c>digipay</c>/
/// <c>tara</c>/<c>torobpay</c>) — config-driven selection, <b>never an <c>if (mock)</c> branch in a handler</b>.
/// A real system maps each code to its concrete adapter (<c>SnappPayBnplProvider</c>, <c>DigipayBnplProvider</c>,
/// …); this phase ships the mock behind every known code and a single active route. Returns <c>null</c> for an
/// unknown/unconfigured code so the handler can reject it cleanly rather than throw.
/// </summary>
public interface IBnplProviderResolver
{
IBnplProvider? Resolve(string providerCode);
}
@@ -0,0 +1,17 @@
#nullable enable
namespace Baya.Application.Contracts.Payments;
/// <summary>
/// Toman ↔ Rial (IRR) conversion <b>at the provider boundary only</b> — the provider speaks Toman, our books
/// speak IRR. Conversion happens <b>solely</b> here, never internally: everything inside the domain is already
/// IRR <c>long</c>. The mock multiplies Toman ×10 → IRR (and divides back for display); the multiplier is
/// config-driven so a currency redenomination is a config change, not a code change.
/// </summary>
public interface ICurrencyNormalizer
{
/// <summary>Normalizes an amount in <paramref name="currency"/> (<c>IRR</c>/<c>TOMAN</c>) to IRR.</summary>
long ToIrr(long amount, string currency);
/// <summary>Converts an IRR amount back to Toman for display only.</summary>
long ToDisplayToman(long amountIrr);
}
@@ -0,0 +1,43 @@
#nullable enable
using Baya.Application.Models.Bnpl;
using Baya.Domain.Entities.Bnpl;
namespace Baya.Application.Contracts.Persistence;
/// <summary>
/// The BNPL aggregate — one <c>bnpl_transactions</c> row per order (1:1 with its <c>payment_transaction</c>).
/// Writes load tracked rows; reads project to DTOs. The ledger legs themselves are appended through
/// <see cref="IPaymentRepository.AddLedgerEntriesAsync"/> (b10's helper); this repo owns only the BNPL row, the
/// order-context read, and the settle-ledger idempotency probe. Money is IRR <c>long</c>. The
/// <c>UNIQUE(payment_transaction_id)</c> guard is the structural one-BNPL-row-per-order backstop.
/// </summary>
public interface IBnplRepository
{
/// <summary>Everything eligibility/initiate need for a booking request (owner + mobile + status + gross).
/// Null when the request does not exist.</summary>
Task<BnplOrderContext?> GetOrderContextAsync(long bookingRequestId, CancellationToken cancellationToken);
Task AddAsync(BnplTransaction transaction, CancellationToken cancellationToken);
/// <summary>The tracked BNPL row for a payment transaction — the 1:1 lookup eligibility/initiate upsert on.
/// Null when none exists yet.</summary>
Task<BnplTransaction?> GetTrackedByPaymentTransactionIdAsync(long paymentTransactionId, CancellationToken cancellationToken);
Task<BnplTransaction?> GetTrackedByIdAsync(long id, CancellationToken cancellationToken);
/// <summary>The tracked BNPL row for a booking request (via its payment transaction) — the find-or-create
/// anchor eligibility and initiate share so a request has at most one BNPL order. Null when none exists.</summary>
Task<BnplTransaction?> GetTrackedByBookingRequestIdAsync(long bookingRequestId, CancellationToken cancellationToken);
/// <summary>The tracked BNPL row carrying <paramref name="externalPaymentToken"/> — the callback dispatch
/// resolves the order from the token in the payload. Null when absent.</summary>
Task<BnplTransaction?> GetTrackedByTokenAsync(string externalPaymentToken, CancellationToken cancellationToken);
/// <summary>Whether the balanced settle group already exists for this BNPL row — makes the settle post
/// idempotent so a replayed settle never writes a second net-of-fee group.</summary>
Task<bool> SettleLedgerExistsAsync(long bnplTransactionId, CancellationToken cancellationToken);
/// <summary>The BNPL order view + the owning customer's user id for tenancy, and the linked refund's ETA
/// when reverted. Null when absent.</summary>
Task<BnplOrderStatusProjection?> GetStatusAsync(long id, CancellationToken cancellationToken);
}
@@ -34,4 +34,8 @@ public interface IRefundRepository
/// <summary>The customer-facing status of a single refund, with the owning customer's user id for tenancy.
/// Null when absent.</summary>
Task<RefundStatusProjection?> GetStatusAsync(long id, CancellationToken cancellationToken);
/// <summary>The provider revert reference on a <c>bnpl_revert</c> refund — the BNPL revert path records it
/// as <c>revert_transaction_id</c> on the <c>bnpl_transactions</c> row. Null when absent.</summary>
Task<string?> GetExternalRevertReferenceAsync(long refundId, CancellationToken cancellationToken);
}
@@ -21,6 +21,7 @@ public interface IUnitOfWork
public IPaymentRepository PaymentRepository { get; }
public IRefundRepository RefundRepository { get; }
public IInvoiceRepository InvoiceRepository { get; }
public IBnplRepository BnplRepository { get; }
Task CommitAsync();
ValueTask RollBackAsync();
}
@@ -0,0 +1,62 @@
#nullable enable
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Bnpl;
using Baya.Domain.Entities.Bnpl;
using Baya.Domain.Entities.Payments;
namespace Baya.Application.Features.Bnpl;
/// <summary>
/// The single find-or-create for a BNPL order's skeleton — the pending <c>payment_transaction</c> (bnpl gateway)
/// plus the 1:1 <c>bnpl_transactions</c> row — shared by eligibility and initiate so a booking request has at
/// most one BNPL order. The <c>UNIQUE(payment_transaction_id)</c> guard is the structural backstop; this helper
/// is the friendly pre-check. It commits the two rows (the payment transaction id must exist before the BNPL FK)
/// and returns the tracked BNPL row; the caller owns the provider call, the state transition and any lock.
/// </summary>
internal static class BnplOrderInitializer
{
public static async Task<BnplTransaction> EnsureAsync(
IUnitOfWork unitOfWork,
BnplOrderContext context,
long gatewayId,
string providerCode,
string merchantOfRecord,
long orderAmountIrr,
string currency,
string? ipAddress,
CancellationToken cancellationToken)
{
var existing = await unitOfWork.BnplRepository.GetTrackedByBookingRequestIdAsync(context.RequestId, cancellationToken);
if (existing is not null)
return existing;
// A BNPL order is a card payment landing net-of-fee, so it rides the same payment_transactions rail
// (booking_id null until settle binds it, exactly like the card path).
var transaction = new PaymentTransaction
{
BookingRequestId = context.RequestId,
CustomerId = context.CustomerId,
GatewayId = gatewayId,
Amount = orderAmountIrr,
Currency = "IRR",
IsInstallment = true,
IpAddress = ipAddress
};
await unitOfWork.PaymentRepository.AddTransactionAsync(transaction, cancellationToken);
await unitOfWork.CommitAsync();
var bnpl = new BnplTransaction
{
PaymentTransactionId = transaction.Id,
ProviderCode = providerCode,
MerchantOfRecord = merchantOfRecord,
OrderAmountIrr = orderAmountIrr,
Currency = currency,
EligibilityStatus = null
};
await unitOfWork.BnplRepository.AddAsync(bnpl, cancellationToken);
await unitOfWork.CommitAsync();
return bnpl;
}
}
@@ -0,0 +1,140 @@
#nullable enable
using System;
using System.Collections.Generic;
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Payments;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Features.Bnpl.Commands.RevertBnplOrder;
using Baya.Application.Features.Bnpl.Commands.SettleBnplOrder;
using Baya.Application.Features.Bnpl.Commands.VerifyBnplOrder;
using Baya.Application.Models.Bnpl;
using Baya.Application.Models.Common;
using Baya.Domain.Entities.Payments;
using Mediator;
using Microsoft.EntityFrameworkCore;
namespace Baya.Application.Features.Bnpl.Commands.HandleBnplCallback;
internal sealed class HandleBnplCallbackCommandHandler(
ISender sender,
IUnitOfWork unitOfWork,
IWebhookVerifier webhookVerifier,
IDateTimeProvider dateTimeProvider)
: IRequestHandler<HandleBnplCallbackCommand, OperationResult<BnplCallbackResult>>
{
private enum CallbackAction { None, Verify, Settle, Revert }
public async ValueTask<OperationResult<BnplCallbackResult>> Handle(HandleBnplCallbackCommand request, CancellationToken cancellationToken)
{
var headers = request.Headers ?? new Dictionary<string, string>();
var rawBody = request.RawBody ?? string.Empty;
var verification = webhookVerifier.Verify(request.Provider, headers, rawBody);
var now = dateTimeProvider.UtcNow.UtcDateTime;
// Dedup FIRST on the idempotency key: a duplicate replay never mutates money state again.
if (!string.IsNullOrEmpty(verification.ExternalEventId))
{
var duplicate = await unitOfWork.PaymentRepository.GetWebhookEventByKeyAsync(request.Provider, verification.ExternalEventId, cancellationToken);
if (duplicate is not null)
return Success(duplicate.ProcessingStatus, isDuplicate: true);
}
var webhookEvent = new PaymentWebhookEvent
{
ProviderCode = request.Provider,
ExternalEventId = verification.ExternalEventId,
EventType = verification.EventType,
SignatureValid = verification.SignatureValid,
PayloadJson = rawBody,
ReceivedAt = now
};
// An unverified-signature callback mutates nothing — stored ignored and stopped.
if (!verification.SignatureValid)
{
webhookEvent.MarkIgnored(now);
return await PersistNewEventAsync(webhookEvent, cancellationToken);
}
var action = ResolveAction(verification.EventType);
if (action == CallbackAction.None || string.IsNullOrEmpty(verification.GatewayReferenceCode))
{
// Nothing to drive (unknown event / no token) — acknowledged, no money moves.
webhookEvent.MarkProcessed(null, now);
return await PersistNewEventAsync(webhookEvent, cancellationToken);
}
var bnpl = await unitOfWork.BnplRepository.GetTrackedByTokenAsync(verification.GatewayReferenceCode!, cancellationToken);
if (bnpl is null)
{
// No matching order for the token — retryable (failed), never a silent success.
webhookEvent.MarkFailed(now);
return await PersistNewEventAsync(webhookEvent, cancellationToken);
}
// Claim the idempotency key first (inside the same context that mutates state). A racing duplicate insert
// loses on the unique index and is treated as a no-op replay.
await unitOfWork.PaymentRepository.AddWebhookEventAsync(webhookEvent, cancellationToken);
try
{
await unitOfWork.CommitAsync();
}
catch (DbUpdateException)
{
await unitOfWork.RollBackAsync();
return Success(WebhookProcessingStatus.Processed, isDuplicate: true);
}
var dispatched = await DispatchAsync(action, bnpl.Id, verification.ExternalEventId, rawBody, cancellationToken);
if (dispatched)
webhookEvent.MarkProcessed(bnpl.PaymentTransactionId, now);
else
webhookEvent.MarkFailed(now);
await unitOfWork.CommitAsync();
return Success(webhookEvent.ProcessingStatus, isDuplicate: false);
}
private async Task<bool> DispatchAsync(CallbackAction action, long bnplTransactionId, string eventId, string rawBody, CancellationToken cancellationToken)
{
var result = action switch
{
CallbackAction.Verify => (await sender.Send(new VerifyBnplOrderCommand(bnplTransactionId, rawBody), cancellationToken)).IsSuccess,
CallbackAction.Settle => (await sender.Send(new SettleBnplOrderCommand(bnplTransactionId, $"bnpl-settle-{bnplTransactionId}-{eventId}", rawBody), cancellationToken)).IsSuccess,
CallbackAction.Revert => (await sender.Send(new RevertBnplOrderCommand(bnplTransactionId, RefundPercentage: 1m, TicketId: null, ReasonNotes: "provider_callback", rawBody), cancellationToken)).IsSuccess,
_ => false
};
return result;
}
private static CallbackAction ResolveAction(string eventType)
{
if (eventType.Contains("settl", StringComparison.OrdinalIgnoreCase))
return CallbackAction.Settle;
if (eventType.Contains("verif", StringComparison.OrdinalIgnoreCase))
return CallbackAction.Verify;
if (eventType.Contains("revert", StringComparison.OrdinalIgnoreCase) || eventType.Contains("refund", StringComparison.OrdinalIgnoreCase))
return CallbackAction.Revert;
return CallbackAction.None;
}
private async Task<OperationResult<BnplCallbackResult>> PersistNewEventAsync(PaymentWebhookEvent webhookEvent, CancellationToken cancellationToken)
{
await unitOfWork.PaymentRepository.AddWebhookEventAsync(webhookEvent, cancellationToken);
try
{
await unitOfWork.CommitAsync();
}
catch (DbUpdateException)
{
await unitOfWork.RollBackAsync();
return Success(webhookEvent.ProcessingStatus, isDuplicate: true);
}
return Success(webhookEvent.ProcessingStatus, isDuplicate: false);
}
private static OperationResult<BnplCallbackResult> Success(string processingStatus, bool isDuplicate)
=> OperationResult<BnplCallbackResult>.SuccessResult(new BnplCallbackResult(processingStatus, isDuplicate));
}
@@ -0,0 +1,16 @@
#nullable enable
using System.Collections.Generic;
using Baya.Application.Models.Bnpl;
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Bnpl.Commands.HandleBnplCallback;
/// <summary>
/// The inbound BNPL provider-callback entry point. Authenticated by <b>signature</b> (not a session);
/// at-least-once tolerant and deduplicated on <c>(provider_code, external_event_id)</c> in
/// <c>payment_webhook_events</c> before any money moves, then dispatched to verify/settle/revert by event type —
/// all gated by the BNPL status state machine so a re-delivered callback never double-settles or double-posts.
/// </summary>
public record HandleBnplCallbackCommand(string Provider, IReadOnlyDictionary<string, string>? Headers, string? RawBody)
: IRequest<OperationResult<BnplCallbackResult>>;
@@ -0,0 +1,103 @@
#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.Bnpl;
using Baya.Application.Models.Common;
using Baya.Domain.Entities.Bnpl;
using Baya.Domain.Entities.Booking;
using Baya.Domain.Entities.Payments;
using Mediator;
namespace Baya.Application.Features.Bnpl.Commands.InitiateBnplOrder;
internal sealed class InitiateBnplOrderCommandHandler(
ICurrentUser currentUser,
IUnitOfWork unitOfWork,
IBnplProviderResolver providerResolver,
ICurrencyNormalizer currencyNormalizer,
IPlatformConfig platformConfig,
IDistributedLock distributedLock,
IDateTimeProvider dateTimeProvider)
: IRequestHandler<InitiateBnplOrderCommand, OperationResult<InitiateBnplResult>>
{
public async ValueTask<OperationResult<InitiateBnplResult>> Handle(InitiateBnplOrderCommand request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return OperationResult<InitiateBnplResult>.UnauthorizedResult("Not authenticated.");
var customerId = await unitOfWork.CustomerProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
if (customerId is null)
return OperationResult<InitiateBnplResult>.NotFoundResult("Booking request not found.");
var ctx = await unitOfWork.BnplRepository.GetOrderContextAsync(request.BookingRequestId, cancellationToken);
if (ctx is null || ctx.CustomerId != customerId)
return OperationResult<InitiateBnplResult>.NotFoundResult("Booking request not found.");
var provider = providerResolver.Resolve(request.ProviderCode);
if (provider is null)
return OperationResult<InitiateBnplResult>.FailureResult("provider_code", "The BNPL provider is not available.");
var gatewayId = await unitOfWork.PaymentRepository.GetActiveGatewayIdAsync(PaymentGatewayType.Bnpl, cancellationToken);
if (gatewayId is null)
return OperationResult<InitiateBnplResult>.FailureResult("No active BNPL gateway is configured.");
// The whole money mutation runs under the lock; the DB uniques/state machine remain the authoritative
// backstop if the lock is lost. Keyed on the request (a b9 booking exists only after settle).
await using var _ = await distributedLock.AcquireAsync($"booking-request:{request.BookingRequestId}:payment", cancellationToken);
if (await unitOfWork.PaymentRepository.HasSucceededTransactionForRequestAsync(request.BookingRequestId, cancellationToken))
return OperationResult<InitiateBnplResult>.ConflictResult("This booking has already been paid.");
if (ctx.Status != BookingRequestStatus.AcceptedAwaitingPayment)
return OperationResult<InitiateBnplResult>.ConflictResult("This request is not awaiting payment.");
var now = dateTimeProvider.UtcNow.UtcDateTime;
if (ctx.PaymentDeadlineAt is { } deadline && deadline < now)
return OperationResult<InitiateBnplResult>.ConflictResult("The payment window for this request has lapsed.");
// Conversion happens ONLY here, at the provider boundary. The gross is already IRR, so this is a no-op
// today — but a real Toman-quoting provider is normalized through exactly this seam, never internally.
var orderAmountIrr = currencyNormalizer.ToIrr(ctx.GrossIrr, "IRR");
var merchantOfRecord = await platformConfig.GetConfig<string>("bnpl_merchant_of_record", cancellationToken);
var bnpl = await BnplOrderInitializer.EnsureAsync(
unitOfWork, ctx, gatewayId.Value, request.ProviderCode, merchantOfRecord ?? "platform",
orderAmountIrr, "IRR", currentUser.IpAddress, cancellationToken);
// Only an eligible or already-tokenized order can be (re-)initiated; a settled/reverted/cancelled/failed
// one cannot. The provider token call is idempotent, so a replay returns the same token + redirect.
if (bnpl.Status is not (BnplStatus.Eligible or BnplStatus.TokenIssued))
return OperationResult<InitiateBnplResult>.ConflictResult("This BNPL order can no longer be started.");
var idempotencyKey = string.IsNullOrWhiteSpace(request.IdempotencyKey)
? $"bnpl-br-{request.BookingRequestId}"
: request.IdempotencyKey!;
var token = await provider.CreatePaymentTokenAsync(ctx.CustomerMobile, orderAmountIrr, idempotencyKey, cancellationToken);
if (token.Status != PaymentProviderStatus.Succeeded)
{
if (bnpl.Status == BnplStatus.Eligible)
{
bnpl.MarkFailed();
await unitOfWork.CommitAsync();
}
return OperationResult<InitiateBnplResult>.FailureResult("The BNPL provider declined the order.");
}
// On the first initiate, bind the token to the pending payment_transaction so the callback can find it
// (and the filtered UNIQUE(gateway_reference_code) guards it), then walk eligible → token_issued.
if (bnpl.Status == BnplStatus.Eligible)
{
var transaction = await unitOfWork.PaymentRepository.GetTrackedTransactionByIdAsync(bnpl.PaymentTransactionId, cancellationToken);
if (transaction is not null)
transaction.GatewayReferenceCode = token.ExternalPaymentToken;
bnpl.MarkTokenIssued(token.ExternalPaymentToken, token.ExternalTransactionId);
await unitOfWork.CommitAsync();
}
return OperationResult<InitiateBnplResult>.SuccessResult(new InitiateBnplResult(
bnpl.Id, bnpl.PaymentTransactionId, bnpl.Status, token.ExternalPaymentToken, token.RedirectUrl));
}
}
@@ -0,0 +1,16 @@
using Baya.Domain.Entities.Bnpl;
using FluentValidation;
namespace Baya.Application.Features.Bnpl.Commands.InitiateBnplOrder;
public sealed class InitiateBnplOrderCommandValidator : AbstractValidator<InitiateBnplOrderCommand>
{
public InitiateBnplOrderCommandValidator()
{
RuleFor(x => x.BookingRequestId).GreaterThan(0);
RuleFor(x => x.ProviderCode)
.NotEmpty()
.Must(BnplProviderCodes.IsKnown)
.WithMessage("provider_code must be one of snapppay, digipay, tara, torobpay.");
}
}
@@ -0,0 +1,15 @@
using Baya.Application.Models.Bnpl;
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Bnpl.Commands.InitiateBnplOrder;
/// <summary>
/// Starts a BNPL order for an <c>accepted_awaiting_payment</c> booking request owned by the caller: ensures the
/// 1:1 <c>bnpl_transactions</c> row (under the <c>UNIQUE(payment_transaction_id)</c> guard), normalizes the
/// order amount to IRR at the provider boundary, asks the provider for a payment token, transitions
/// <c>eligible → token_issued</c>, and returns the token + redirect. Runs under <c>lock(booking:{id}:payment)</c>
/// and carries an idempotency key so a retried start reuses the same token.
/// </summary>
public record InitiateBnplOrderCommand(long BookingRequestId, string ProviderCode, string? IdempotencyKey)
: IRequest<OperationResult<InitiateBnplResult>>;
@@ -0,0 +1,75 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Features.Refunds.Commands.CreateRefund;
using Baya.Application.Models.Bnpl;
using Baya.Application.Models.Common;
using Baya.Domain.Entities.Bnpl;
using Mediator;
namespace Baya.Application.Features.Bnpl.Commands.RevertBnplOrder;
internal sealed class RevertBnplOrderCommandHandler(
ISender sender,
IUnitOfWork unitOfWork,
IDateTimeProvider dateTimeProvider)
: IRequestHandler<RevertBnplOrderCommand, OperationResult<RevertBnplResult>>
{
public async ValueTask<OperationResult<RevertBnplResult>> Handle(RevertBnplOrderCommand request, CancellationToken cancellationToken)
{
var bnpl = await unitOfWork.BnplRepository.GetTrackedByIdAsync(request.BnplTransactionId, cancellationToken);
if (bnpl is null)
return OperationResult<RevertBnplResult>.NotFoundResult("BNPL order not found.");
// A replayed revert must not double-refund — an already-reverted order is a clean conflict.
if (bnpl.Status == BnplStatus.Reverted)
return OperationResult<RevertBnplResult>.ConflictResult("This BNPL order has already been reverted.");
// Only a settled order has a captured booking + ledger to reverse.
if (bnpl.Status != BnplStatus.Settled)
return OperationResult<RevertBnplResult>.ConflictResult("Only a settled BNPL order can be reverted.");
var transaction = await unitOfWork.PaymentRepository.GetTrackedTransactionByIdAsync(bnpl.PaymentTransactionId, cancellationToken);
if (transaction?.BookingId is not { } bookingId)
return OperationResult<RevertBnplResult>.ConflictResult("The BNPL order has no captured booking to reverse.");
// Reuse the b11 refund path: it creates the refunds row (refund_channel='bnpl_revert'), executes the
// provider revert/update behind the seam, posts the balanced reversal ledger (+ clawback fork), and
// surfaces the async customer ETA. It owns lock(booking:{id}:refund), so we do not lock here.
var refund = await sender.Send(new CreateRefundCommand(
BookingId: bookingId,
TicketId: request.TicketId,
RefundPercentage: request.RefundPercentage ?? 1m,
PlatformFeeRefundedIrr: null,
NursePayoutRefundedIrr: null,
ReasonCategory: "bnpl_revert",
ReasonNotes: request.ReasonNotes,
AdminNotes: null,
ManualBankReference: null), cancellationToken);
if (!refund.IsSuccess)
return new OperationResult<RevertBnplResult>
{
IsSuccess = false,
IsNotFound = refund.IsNotFound,
IsConflict = refund.IsConflict,
IsForbidden = refund.IsForbidden,
IsUnauthorized = refund.IsUnauthorized,
IsException = refund.IsException,
ErrorMessages = refund.ErrorMessages
};
var refundResult = refund.Result;
var revertedAmount = long.TryParse(refundResult.Amount, out var amount) ? amount : 0;
var externalRef = await unitOfWork.RefundRepository.GetExternalRevertReferenceAsync(refundResult.RefundId, cancellationToken);
var now = dateTimeProvider.UtcNow.UtcDateTime;
// provider_commission_reversed_amount is reconciled from the provider response later — nullable here
// (some providers keep their fee on a refund; the b11 refund path does not persist it).
bnpl.MarkReverted(externalRef, revertedAmount, providerCommissionReversedAmount: null, now, request.CallbackPayloadJson);
await unitOfWork.CommitAsync();
return OperationResult<RevertBnplResult>.SuccessResult(new RevertBnplResult(
bnpl.Id, refundResult.RefundId, bnpl.Status, externalRef,
revertedAmount.ToString(), refundResult.ExpectedCustomerRefundEta));
}
}
@@ -0,0 +1,21 @@
using FluentValidation;
namespace Baya.Application.Features.Bnpl.Commands.RevertBnplOrder;
public sealed class RevertBnplOrderCommandValidator : AbstractValidator<RevertBnplOrderCommand>
{
public RevertBnplOrderCommandValidator()
{
RuleFor(x => x.BnplTransactionId).GreaterThan(0);
// A partial revert must be strictly lower than the full order (RefundPercentage < 1); 1 = full revert.
When(x => x.RefundPercentage.HasValue, () =>
{
RuleFor(x => x.RefundPercentage!.Value)
.GreaterThan(0m).LessThanOrEqualTo(1m)
.WithMessage("refund_percentage must be a fraction in (0, 1].");
});
RuleFor(x => x.ReasonNotes).MaximumLength(1000);
}
}
@@ -0,0 +1,24 @@
using Baya.Application.Models.Bnpl;
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Bnpl.Commands.RevertBnplOrder;
/// <summary>
/// Reverses a <c>settled</c> BNPL order back through the provider (full via revert; partial/shortened-visit via
/// update to a strictly-lower amount) and records the reversal audit on the <c>bnpl_transactions</c> row. It
/// <b>reuses the b11 refund path</b> (<c>CreateRefundCommand</c>) — which creates the <c>refunds</c> row with
/// <c>refund_channel='bnpl_revert'</c>, executes the provider revert, posts the balanced reversal ledger (fee +
/// payout legs; a clawback if the nurse was already paid), and surfaces the async ~710-business-day customer
/// ETA. Money <b>always</b> flows customer ↔ provider ↔ Balinyaar. Admin-only.
/// </summary>
/// <param name="BnplTransactionId">The settled BNPL order to reverse.</param>
/// <param name="RefundPercentage">The reversed fraction (01]; 1 = full revert, &lt;1 = a strictly-lower partial.</param>
/// <param name="TicketId">Optional support-ticket link (config-gated "ticket required" rule, b15 FK).</param>
/// <param name="ReasonNotes">Free-text reason recorded on the refund.</param>
public record RevertBnplOrderCommand(
long BnplTransactionId,
decimal? RefundPercentage,
long? TicketId,
string? ReasonNotes,
string? CallbackPayloadJson = null) : IRequest<OperationResult<RevertBnplResult>>;
@@ -0,0 +1,141 @@
#nullable enable
using System.Text.Json;
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Configuration;
using Baya.Application.Contracts.Payments;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Features.Bookings;
using Baya.Application.Models.Common;
using Baya.Domain.Entities.Bnpl;
using Baya.Domain.Entities.Payments;
using Mediator;
using Microsoft.EntityFrameworkCore;
namespace Baya.Application.Features.Bnpl.Commands.SettleBnplOrder;
/// <summary>
/// The BNPL money capture. Mirrors b10's <c>ConfirmPaymentAndPostLedger</c> — same shared booking conversion,
/// same idempotency shape — but posts the <b>net-of-fee</b> group (via <c>LedgerPosting.BnplSettle</c>) so escrow
/// reflects the real cash received, and verifies through the BNPL provider rather than the card PSP. The nurse's
/// <c>nurse_payable</c> accrual is <b>invariant to payment method</b>: it comes from the booking split, never
/// from <c>settled_amount_irr</c>.
/// </summary>
internal sealed class SettleBnplOrderCommandHandler(
IUnitOfWork unitOfWork,
IBnplProviderResolver providerResolver,
IPlatformConfig platformConfig,
IVariantSnapshotSerializer variantSnapshotSerializer,
ISettlementSplitProvider settlementSplitProvider,
IDistributedLock distributedLock,
IDateTimeProvider dateTimeProvider,
INotificationDispatcher notifications)
: IRequestHandler<SettleBnplOrderCommand, OperationResult<bool>>
{
public async ValueTask<OperationResult<bool>> Handle(SettleBnplOrderCommand request, CancellationToken cancellationToken)
{
// Key the lock on the BNPL order (1:1 with the booking) and load state under it, so a racing double
// settle can't both read 'verified'. The webhook dedup + settle-ledger probe are the DB backstops.
await using var _ = await distributedLock.AcquireAsync($"bnpl:{request.BnplTransactionId}:settle", cancellationToken);
var bnpl = await unitOfWork.BnplRepository.GetTrackedByIdAsync(request.BnplTransactionId, cancellationToken);
if (bnpl is null)
return OperationResult<bool>.NotFoundResult("BNPL order not found.");
// Already settled — idempotent no-op (a replayed settle must not re-post the ledger).
if (bnpl.Status == BnplStatus.Settled)
return OperationResult<bool>.SuccessResult(true);
if (bnpl.Status != BnplStatus.Verified || string.IsNullOrEmpty(bnpl.ExternalPaymentToken))
return OperationResult<bool>.ConflictResult("This BNPL order is not ready to settle.");
var transaction = await unitOfWork.PaymentRepository.GetTrackedTransactionByIdAsync(bnpl.PaymentTransactionId, cancellationToken);
if (transaction is null)
return OperationResult<bool>.NotFoundResult("The BNPL payment transaction is missing.");
var provider = providerResolver.Resolve(bnpl.ProviderCode);
if (provider is null)
return OperationResult<bool>.FailureResult("The BNPL provider is not available.");
var idempotencyKey = string.IsNullOrWhiteSpace(request.IdempotencyKey)
? $"bnpl-settle-{bnpl.Id}"
: request.IdempotencyKey!;
var settle = await provider.SettleAsync(bnpl.ExternalPaymentToken!, bnpl.OrderAmountIrr, idempotencyKey, cancellationToken);
if (settle.Status != PaymentProviderStatus.Succeeded)
{
bnpl.MarkFailed();
await unitOfWork.CommitAsync();
return OperationResult<bool>.FailureResult("The BNPL provider declined the settlement.");
}
// Never trust the settlement blindly — the net + commission must reconcile to the stored order amount.
if (settle.SettledAmountIrr < 0 || settle.BnplCommissionIrr < 0
|| settle.SettledAmountIrr + settle.BnplCommissionIrr != bnpl.OrderAmountIrr)
return OperationResult<bool>.FailureResult("The BNPL settlement does not reconcile with the order amount.");
var now = dateTimeProvider.UtcNow.UtcDateTime;
// Create/confirm the booking through the shared conversion (same path as the card capture).
var conversion = await BookingConversion.EnsureBookingAsync(
unitOfWork, platformConfig, variantSnapshotSerializer, transaction.BookingRequestId, now, cancellationToken);
if (conversion is null)
return OperationResult<bool>.ConflictResult("This request can no longer be converted to a booking.");
transaction.MarkSucceeded(conversion.BookingId, BnplStatus.Settled, transaction.GatewayResponseJson);
// Idempotent net-of-fee ledger: the card-capture legs PLUS the bnpl_fee_expense leg, under one balanced
// group, so escrow_held reflects the NET cash. The nurse_payable leg equals the card-path amount.
if (!await unitOfWork.BnplRepository.SettleLedgerExistsAsync(bnpl.Id, cancellationToken))
{
var legs = LedgerPosting.BnplSettle(
conversion.BookingId, conversion.NurseId, conversion.GrossIrr, conversion.CommissionIrr,
conversion.PayoutIrr, settle.BnplCommissionIrr, bnpl.Id, now);
await unitOfWork.PaymentRepository.AddLedgerEntriesAsync(legs, cancellationToken);
}
bnpl.MarkSettled(settle.SettledAmountIrr, settle.BnplCommissionIrr, settle.SettledAt, request.CallbackPayloadJson);
if (settle.ExternalTransactionId is not null)
transaction.GatewayTransactionId ??= settle.ExternalTransactionId;
try
{
await unitOfWork.CommitAsync();
}
catch (DbUpdateException)
{
// A concurrent confirm for the same booking hit the filtered UNIQUE(booking_id) WHERE
// status='succeeded' — the DB backstop. Treat as already-captured.
await unitOfWork.RollBackAsync();
return OperationResult<bool>.SuccessResult(true);
}
// The lawful تسهیم split — the nurse's payout is invariant to payment method (from the booking split,
// never from settled_amount). The provider commission is a platform expense, not the nurse's.
await settlementSplitProvider.RegisterSplitAsync(
conversion.BookingId,
[
new SettlementLeg("nurse-registered-sheba", conversion.PayoutIrr, "nurse"),
new SettlementLeg("platform-registered-sheba", conversion.CommissionIrr, "platform")
],
cancellationToken);
if (conversion.Created && conversion.CustomerUserId is { } customerUserId && conversion.NurseUserId is { } nurseUserId)
await NotifyConfirmedAsync(customerUserId, nurseUserId, conversion.BookingId, cancellationToken);
return OperationResult<bool>.SuccessResult(true);
}
private async Task NotifyConfirmedAsync(int customerUserId, int nurseUserId, long bookingId, CancellationToken cancellationToken)
{
var payload = JsonSerializer.Serialize(new { booking_id = bookingId });
await notifications.DispatchAsync(
new Notification(customerUserId, "booking_confirmed", "Booking confirmed",
"Your installment plan was approved and your booking is confirmed.", payload),
cancellationToken);
await notifications.DispatchAsync(
new Notification(nurseUserId, "booking_confirmed_nurse", "New confirmed booking",
"A booking has been confirmed and paid. The care instructions and schedule are now available.", payload),
cancellationToken);
}
}
@@ -0,0 +1,15 @@
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Bnpl.Commands.SettleBnplOrder;
/// <summary>
/// Settles a <c>verified</c> BNPL order: the provider pays the full order net of its commission, so this records
/// <c>settled_amount_irr</c>/<c>bnpl_commission_irr</c>/<c>settled_at</c> from the <b>actual settlement</b>,
/// posts the <b>net-of-fee</b> ledger group (card-capture legs PLUS the <c>bnpl_fee_expense</c> leg), confirms
/// the parent <c>payment_transaction</c> (which triggers the booking conversion), and transitions
/// <c>verified → settled</c>. Under <c>lock(bnpl:{id}:settle)</c>; carries an idempotency key. A replayed settle
/// is a no-op (state guard + webhook dedup + the settle-ledger idempotency probe).
/// </summary>
public record SettleBnplOrderCommand(long BnplTransactionId, string? IdempotencyKey = null, string? CallbackPayloadJson = null)
: IRequest<OperationResult<bool>>;
@@ -0,0 +1,45 @@
#nullable enable
using Baya.Application.Contracts.Payments;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Domain.Entities.Bnpl;
using Mediator;
namespace Baya.Application.Features.Bnpl.Commands.VerifyBnplOrder;
internal sealed class VerifyBnplOrderCommandHandler(
IUnitOfWork unitOfWork,
IBnplProviderResolver providerResolver)
: IRequestHandler<VerifyBnplOrderCommand, OperationResult<bool>>
{
public async ValueTask<OperationResult<bool>> Handle(VerifyBnplOrderCommand request, CancellationToken cancellationToken)
{
var bnpl = await unitOfWork.BnplRepository.GetTrackedByIdAsync(request.BnplTransactionId, cancellationToken);
if (bnpl is null)
return OperationResult<bool>.NotFoundResult("BNPL order not found.");
// Already verified/settled — idempotent no-op (a replayed callback must not re-drive the transition).
if (bnpl.Status is BnplStatus.Verified or BnplStatus.Settled)
return OperationResult<bool>.SuccessResult(true);
if (bnpl.Status != BnplStatus.TokenIssued || string.IsNullOrEmpty(bnpl.ExternalPaymentToken))
return OperationResult<bool>.ConflictResult("This BNPL order is not awaiting verification.");
var provider = providerResolver.Resolve(bnpl.ProviderCode);
if (provider is null)
return OperationResult<bool>.FailureResult("The BNPL provider is not available.");
var verify = await provider.VerifyAsync(bnpl.ExternalPaymentToken!, bnpl.OrderAmountIrr, cancellationToken);
// Never trust the callback alone — the provider must confirm and the amount must match the stored order.
if (verify.Status != PaymentProviderStatus.Succeeded || verify.OrderAmountIrr != bnpl.OrderAmountIrr)
{
bnpl.MarkFailed();
await unitOfWork.CommitAsync();
return OperationResult<bool>.FailureResult("The BNPL order could not be verified with the provider.");
}
bnpl.MarkVerified(verify.ExternalTransactionId, request.CallbackPayloadJson);
await unitOfWork.CommitAsync();
return OperationResult<bool>.SuccessResult(true);
}
}
@@ -0,0 +1,13 @@
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Bnpl.Commands.VerifyBnplOrder;
/// <summary>
/// Confirms a <c>token_issued</c> BNPL order with the provider and re-checks the amount + reference server-side
/// (<b>never trust the callback alone</b>), then transitions <c>token_issued → verified</c>. Driven by the
/// provider callback and by the admin verify endpoint. Idempotent via the status state guard — an already
/// verified/settled order is a no-op success.
/// </summary>
public record VerifyBnplOrderCommand(long BnplTransactionId, string? CallbackPayloadJson = null)
: IRequest<OperationResult<bool>>;
@@ -0,0 +1,68 @@
#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.Bnpl;
using Baya.Application.Models.Common;
using Baya.Domain.Entities.Bnpl;
using Baya.Domain.Entities.Booking;
using Baya.Domain.Entities.Payments;
using Mediator;
namespace Baya.Application.Features.Bnpl.Queries.CheckBnplEligibility;
internal sealed class CheckBnplEligibilityQueryHandler(
ICurrentUser currentUser,
IUnitOfWork unitOfWork,
IBnplProviderResolver providerResolver,
IPlatformConfig platformConfig)
: IRequestHandler<CheckBnplEligibilityQuery, OperationResult<BnplEligibilityDto>>
{
public async ValueTask<OperationResult<BnplEligibilityDto>> Handle(CheckBnplEligibilityQuery request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return OperationResult<BnplEligibilityDto>.UnauthorizedResult("Not authenticated.");
var customerId = await unitOfWork.CustomerProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
if (customerId is null)
return OperationResult<BnplEligibilityDto>.NotFoundResult("Booking request not found.");
// Tenancy: only the owning customer may finance; any other caller must not learn the request exists.
var ctx = await unitOfWork.BnplRepository.GetOrderContextAsync(request.BookingRequestId, cancellationToken);
if (ctx is null || ctx.CustomerId != customerId)
return OperationResult<BnplEligibilityDto>.NotFoundResult("Booking request not found.");
if (await unitOfWork.PaymentRepository.HasSucceededTransactionForRequestAsync(request.BookingRequestId, cancellationToken))
return OperationResult<BnplEligibilityDto>.ConflictResult("This booking has already been paid.");
if (ctx.Status != BookingRequestStatus.AcceptedAwaitingPayment)
return OperationResult<BnplEligibilityDto>.ConflictResult("This request is not awaiting payment.");
var provider = providerResolver.Resolve(request.ProviderCode);
if (provider is null)
return OperationResult<BnplEligibilityDto>.FailureResult("provider_code", "The BNPL provider is not available.");
var gatewayId = await unitOfWork.PaymentRepository.GetActiveGatewayIdAsync(PaymentGatewayType.Bnpl, cancellationToken);
if (gatewayId is null)
return OperationResult<BnplEligibilityDto>.FailureResult("No active BNPL gateway is configured.");
var eligibility = await provider.CheckEligibilityAsync(ctx.CustomerMobile, ctx.GrossIrr, cancellationToken);
var merchantOfRecord = await platformConfig.GetConfig<string>("bnpl_merchant_of_record", cancellationToken);
var bnpl = await BnplOrderInitializer.EnsureAsync(
unitOfWork, ctx, gatewayId.Value, request.ProviderCode, merchantOfRecord ?? "platform",
ctx.GrossIrr, "IRR", currentUser.IpAddress, cancellationToken);
bnpl.EligibilityStatus = eligibility.EligibilityStatus;
bnpl.InstallmentCount = (byte)eligibility.InstallmentCount;
await unitOfWork.CommitAsync();
var isEligible = eligibility.EligibilityStatus == BnplEligibilityStatus.Eligible;
return OperationResult<BnplEligibilityDto>.SuccessResult(new BnplEligibilityDto(
eligibility.EligibilityStatus,
isEligible,
eligibility.InstallmentCount,
eligibility.PlanSummary,
eligibility.CreditCeilingIrr?.ToString()));
}
}
@@ -0,0 +1,16 @@
using Baya.Domain.Entities.Bnpl;
using FluentValidation;
namespace Baya.Application.Features.Bnpl.Queries.CheckBnplEligibility;
public sealed class CheckBnplEligibilityQueryValidator : AbstractValidator<CheckBnplEligibilityQuery>
{
public CheckBnplEligibilityQueryValidator()
{
RuleFor(x => x.BookingRequestId).GreaterThan(0);
RuleFor(x => x.ProviderCode)
.NotEmpty()
.Must(BnplProviderCodes.IsKnown)
.WithMessage("provider_code must be one of snapppay, digipay, tara, torobpay.");
}
}
@@ -0,0 +1,16 @@
using Baya.Application.Models.Bnpl;
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Bnpl.Queries.CheckBnplEligibility;
/// <summary>
/// Checks whether a family can finance an <c>accepted_awaiting_payment</c> booking request with the chosen BNPL
/// provider, and records the outcome on a created/updated <c>bnpl_transactions</c> row (status <c>eligible</c>).
/// The order amount is the request's frozen gross (variant price × session count) — never client-supplied. The
/// client shows the plan summary on <c>eligible</c> or falls back to card. Owned by the requesting customer.
/// </summary>
/// <param name="BookingRequestId">The accepted request to finance (a b9 <c>bookings</c> row exists only on settle).</param>
/// <param name="ProviderCode">Which BNPL provider to check (<c>snapppay</c>/<c>digipay</c>/<c>tara</c>/<c>torobpay</c>).</param>
public record CheckBnplEligibilityQuery(long BookingRequestId, string ProviderCode)
: IRequest<OperationResult<BnplEligibilityDto>>;
@@ -0,0 +1,27 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Bnpl;
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Bnpl.Queries.GetBnplOrderStatus;
internal sealed class GetBnplOrderStatusQueryHandler(
IUnitOfWork unitOfWork,
ICurrentUser currentUser)
: IRequestHandler<GetBnplOrderStatusQuery, OperationResult<BnplOrderStatusDto>>
{
public async ValueTask<OperationResult<BnplOrderStatusDto>> Handle(GetBnplOrderStatusQuery request, CancellationToken cancellationToken)
{
var projection = await unitOfWork.BnplRepository.GetStatusAsync(request.BnplTransactionId, cancellationToken);
if (projection is null)
return OperationResult<BnplOrderStatusDto>.NotFoundResult("BNPL order not found.");
// Customer view: a cross-customer access is indistinguishable from "not found" — never confirm it exists.
if (!request.AdminView && projection.CustomerUserId != currentUser.UserId)
return OperationResult<BnplOrderStatusDto>.NotFoundResult("BNPL order not found.");
return OperationResult<BnplOrderStatusDto>.SuccessResult(projection.Order);
}
}
@@ -0,0 +1,14 @@
using Baya.Application.Models.Bnpl;
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Bnpl.Queries.GetBnplOrderStatus;
/// <summary>
/// Surfaces a BNPL order: status, the money split (gross/net/commission), the <b>non-instant</b> settlement time,
/// and the revert audit + async customer ETA. <paramref name="AdminView"/> comes from the (policy-gated) admin
/// controller; the customer view is tenancy-scoped to <c>ICurrentUser</c> — a customer can never read another's
/// order (a cross-customer read is a clean not-found).
/// </summary>
public record GetBnplOrderStatusQuery(long BnplTransactionId, bool AdminView)
: IRequest<OperationResult<BnplOrderStatusDto>>;
@@ -0,0 +1,73 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Configuration;
using Baya.Application.Contracts.Persistence;
using Baya.Domain.Entities.Booking;
namespace Baya.Application.Features.Bookings;
/// <summary>
/// The single place a captured payment turns an <c>accepted_awaiting_payment</c> request into a confirmed
/// <c>bookings</c> row (through <see cref="BookingFactory"/>), shared by <b>both</b> capture rails — b10's card
/// <c>ConfirmPaymentAndPostLedger</c> and b12's BNPL settle — so the conversion/idempotency logic lives once.
/// It re-checks the tracked request against a racing cancel/expiry (a clean conflict, never a double
/// conversion), creates the booking, and commits it; the caller owns the ledger posting for its rail (card
/// capture vs the BNPL net-of-fee group) and any notifications. Pure orchestration — no capture, no
/// current-user gate, no card/BNPL specifics.
/// </summary>
internal static class BookingConversion
{
/// <summary>Ensures the booking for <paramref name="bookingRequestId"/> exists (creating + committing it if
/// the request is still convertible) and returns its frozen ledger amounts + participants. Null when the
/// request can no longer be converted (already-terminal, racing cancel/expiry, or missing) — the caller
/// maps that to a conflict. Idempotent: a booking created by a prior confirm is loaded, not re-created.</summary>
public static async Task<BookingConversionResult?> EnsureBookingAsync(
IUnitOfWork unitOfWork,
IPlatformConfig platformConfig,
IVariantSnapshotSerializer variantSnapshotSerializer,
long bookingRequestId,
DateTime now,
CancellationToken cancellationToken)
{
var existingId = await unitOfWork.BookingRepository.GetBookingIdByRequestIdAsync(bookingRequestId, cancellationToken);
if (existingId is { } eb)
{
var existingAmounts = await unitOfWork.BookingRepository.GetLedgerAmountsAsync(eb, cancellationToken);
return existingAmounts is null
? null
: new BookingConversionResult(eb, existingAmounts.NurseId, existingAmounts.GrossIrr, existingAmounts.CommissionIrr, existingAmounts.PayoutIrr, false, null, null);
}
var source = await unitOfWork.BookingRequestRepository.GetConversionSourceAsync(bookingRequestId, cancellationToken);
if (source is null || source.Status != BookingRequestStatus.AcceptedAwaitingPayment)
return null;
var rate = await platformConfig.GetConfig<decimal>("platform_fee_rate", cancellationToken);
var booking = BookingFactory.Create(source, rate, now, pspFeeAmount: null, variantSnapshotSerializer);
// Re-check the tracked request so a racing cancel/expiry is a clean conflict, not a double conversion.
var trackedRequest = await unitOfWork.BookingRequestRepository.GetTrackedByIdAsync(source.RequestId, cancellationToken);
if (trackedRequest is null || !trackedRequest.CanTransitionTo(BookingRequestStatus.Converted))
return null;
trackedRequest.MarkConverted();
await unitOfWork.BookingRepository.AddAsync(booking, cancellationToken);
await unitOfWork.CommitAsync();
return new BookingConversionResult(
booking.Id, booking.NurseId, booking.GrossPriceIrr, booking.BalinyaarCommissionIrr, booking.NursePayoutAmount,
true, source.CustomerUserId, source.NurseUserId);
}
}
/// <summary>The confirmed booking's identity, frozen three-amount split, whether this call created it, and (only
/// on create) the participants to notify. Money is IRR <c>long</c>.</summary>
internal sealed record BookingConversionResult(
long BookingId,
long NurseId,
long GrossIrr,
long CommissionIrr,
long PayoutIrr,
bool Created,
int? CustomerUserId,
int? NurseUserId);
@@ -7,7 +7,6 @@ using Baya.Application.Contracts.Payments;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Features.Bookings;
using Baya.Application.Models.Common;
using Baya.Domain.Entities.Booking;
using Baya.Domain.Entities.Payments;
using Mediator;
using Microsoft.EntityFrameworkCore;
@@ -45,18 +44,20 @@ internal sealed class ConfirmPaymentAndPostLedgerCommandHandler(
return OperationResult<bool>.FailureResult("The payment could not be verified with the gateway.");
}
// Create/confirm the booking through the shared b9 conversion (idempotent on UNIQUE booking_request_id).
var (bookingId, amounts, created, participants) = await EnsureBookingAsync(transaction.BookingRequestId, now, cancellationToken);
if (bookingId is not { } booking)
// Create/confirm the booking through the shared conversion (idempotent on UNIQUE booking_request_id).
var conversion = await BookingConversion.EnsureBookingAsync(
unitOfWork, platformConfig, variantSnapshotSerializer, transaction.BookingRequestId, now, cancellationToken);
if (conversion is null)
return OperationResult<bool>.ConflictResult("This request can no longer be converted to a booking.");
var booking = conversion.BookingId;
transaction.MarkSucceeded(booking, verify.Status.ToString(), transaction.GatewayResponseJson);
// Idempotent ledger: don't post a second capture group if one already exists for this transaction.
if (!await unitOfWork.PaymentRepository.LedgerGroupExistsForTransactionAsync(transaction.Id, cancellationToken))
{
var legs = LedgerPosting.CardCapture(
booking, amounts!.NurseId, amounts.GrossIrr, amounts.CommissionIrr, amounts.PayoutIrr, transaction.Id, now);
booking, conversion.NurseId, conversion.GrossIrr, conversion.CommissionIrr, conversion.PayoutIrr, transaction.Id, now);
await unitOfWork.PaymentRepository.AddLedgerEntriesAsync(legs, cancellationToken);
}
@@ -77,68 +78,29 @@ internal sealed class ConfirmPaymentAndPostLedgerCommandHandler(
await settlementSplitProvider.RegisterSplitAsync(
booking,
[
new SettlementLeg("nurse-registered-sheba", amounts.PayoutIrr, "nurse"),
new SettlementLeg("platform-registered-sheba", amounts.CommissionIrr, "platform")
new SettlementLeg("nurse-registered-sheba", conversion.PayoutIrr, "nurse"),
new SettlementLeg("platform-registered-sheba", conversion.CommissionIrr, "platform")
],
cancellationToken);
if (created && participants is { } p)
await NotifyConfirmedAsync(p, booking, cancellationToken);
if (conversion.Created && conversion.CustomerUserId is { } customerUserId && conversion.NurseUserId is { } nurseUserId)
await NotifyConfirmedAsync(customerUserId, nurseUserId, booking, cancellationToken);
return OperationResult<bool>.SuccessResult(true);
}
private async Task<(long? BookingId, BookingLedgerAmountsLocal? Amounts, bool Created, BookingParticipantsLocal? Participants)>
EnsureBookingAsync(long bookingRequestId, DateTime now, CancellationToken cancellationToken)
{
var existingId = await unitOfWork.BookingRepository.GetBookingIdByRequestIdAsync(bookingRequestId, cancellationToken);
if (existingId is { } eb)
{
var amounts = await unitOfWork.BookingRepository.GetLedgerAmountsAsync(eb, cancellationToken);
return amounts is null
? (null, null, false, null)
: (eb, new BookingLedgerAmountsLocal(amounts.NurseId, amounts.GrossIrr, amounts.CommissionIrr, amounts.PayoutIrr), false, null);
}
var source = await unitOfWork.BookingRequestRepository.GetConversionSourceAsync(bookingRequestId, cancellationToken);
if (source is null || source.Status != BookingRequestStatus.AcceptedAwaitingPayment)
return (null, null, false, null);
var rate = await platformConfig.GetConfig<decimal>("platform_fee_rate", cancellationToken);
var booking = BookingFactory.Create(source, rate, now, pspFeeAmount: null, variantSnapshotSerializer);
// Re-check the tracked request so a racing cancel/expiry is a clean conflict, not a double conversion.
var trackedRequest = await unitOfWork.BookingRequestRepository.GetTrackedByIdAsync(source.RequestId, cancellationToken);
if (trackedRequest is null || !trackedRequest.CanTransitionTo(BookingRequestStatus.Converted))
return (null, null, false, null);
trackedRequest.MarkConverted();
await unitOfWork.BookingRepository.AddAsync(booking, cancellationToken);
await unitOfWork.CommitAsync();
return (
booking.Id,
new BookingLedgerAmountsLocal(booking.NurseId, booking.GrossPriceIrr, booking.BalinyaarCommissionIrr, booking.NursePayoutAmount),
true,
new BookingParticipantsLocal(source.CustomerUserId, source.NurseUserId));
}
private async Task NotifyConfirmedAsync(BookingParticipantsLocal p, long bookingId, CancellationToken cancellationToken)
private async Task NotifyConfirmedAsync(int customerUserId, int nurseUserId, long bookingId, CancellationToken cancellationToken)
{
var payload = JsonSerializer.Serialize(new { booking_id = bookingId });
await notifications.DispatchAsync(
new Notification(p.CustomerUserId, "booking_confirmed", "Booking confirmed",
new Notification(customerUserId, "booking_confirmed", "Booking confirmed",
"Your payment was captured and your booking is confirmed.", payload),
cancellationToken);
await notifications.DispatchAsync(
new Notification(p.NurseUserId, "booking_confirmed_nurse", "New confirmed booking",
new Notification(nurseUserId, "booking_confirmed_nurse", "New confirmed booking",
"A booking has been confirmed and paid. The care instructions and schedule are now available.", payload),
cancellationToken);
}
private sealed record BookingLedgerAmountsLocal(long NurseId, long GrossIrr, long CommissionIrr, long PayoutIrr);
private sealed record BookingParticipantsLocal(int CustomerUserId, int NurseUserId);
}
@@ -0,0 +1,76 @@
#nullable enable
namespace Baya.Application.Models.Bnpl;
/// <summary>
/// The facts eligibility/initiate need for an <c>accepted_awaiting_payment</c> booking request: the owning
/// customer (tenancy) + their user id + mobile (for the provider eligibility/token call), the request status +
/// frozen payment window, and the gross to finance (variant price × session count — the same figure b9 freezes
/// onto the booking on capture). Money is IRR <c>long</c>.
/// </summary>
public record BnplOrderContext(
long RequestId,
long CustomerId,
int CustomerUserId,
string CustomerMobile,
string Status,
System.DateTime? PaymentDeadlineAt,
long GrossIrr);
/// <summary>The eligibility result the client shows (or falls back to card on): the outcome, the plan summary,
/// and whether the client should offer the "pay with installments" option.</summary>
public record BnplEligibilityDto(
string EligibilityStatus,
bool IsEligible,
int InstallmentCount,
string PlanSummary,
string? CreditCeilingIrr);
/// <summary>The result of starting a BNPL order: the token + provider redirect the client hands off to, plus the
/// row + status. No money crosses back here — it is the request's frozen gross.</summary>
public record InitiateBnplResult(
long BnplTransactionId,
long PaymentTransactionId,
string Status,
string ExternalPaymentToken,
string RedirectUrl);
/// <summary>The admin/customer BNPL order view: status, the money split (gross/net/commission), the non-instant
/// settlement time, and the revert audit. Money crosses the wire as digit strings; <c>settled_at</c> is nullable.</summary>
public record BnplOrderStatusDto(
long Id,
long PaymentTransactionId,
long? BookingId,
string ProviderCode,
string Status,
string? EligibilityStatus,
string OrderAmountIrr,
string? SettledAmountIrr,
string? BnplCommissionIrr,
string Currency,
int InstallmentCount,
System.DateTime? SettledAt,
string? RevertTransactionId,
string? RevertedAmountIrr,
System.DateTime? RevertedAt,
string? ProviderCommissionReversedAmount,
string? RefundChannel,
System.DateOnly? ExpectedCustomerRefundEta,
System.DateTimeOffset CreatedAt);
/// <summary>The tenancy envelope for the customer BNPL-order read: the owning customer's user id (compared to
/// <c>ICurrentUser</c>) plus the DTO. A cross-customer access is a clean not-found, never a leak.</summary>
public record BnplOrderStatusProjection(int CustomerUserId, BnplOrderStatusDto Order);
/// <summary>The outcome of ingesting a BNPL provider callback — the terminal processing status + whether it was a
/// deduplicated replay. The endpoint is always success (at-least-once tolerant).</summary>
public record BnplCallbackResult(string ProcessingStatus, bool Duplicate);
/// <summary>What <c>RevertBnplOrderCommand</c> returns — the reverted order, the created refund, the provider
/// revert reference and the async customer cash-back ETA (~710 business days).</summary>
public record RevertBnplResult(
long BnplTransactionId,
long RefundId,
string Status,
string? RevertTransactionId,
string RevertedAmountIrr,
System.DateOnly? ExpectedCustomerRefundEta);
@@ -0,0 +1,18 @@
namespace Baya.Domain.Entities.Bnpl;
/// <summary>
/// The closed <c>bnpl_transactions.eligibility_status</c> code set recorded by the eligibility check — the
/// client shows the installment plan on <see cref="Eligible"/> and falls back to card otherwise. These are the
/// provider-agnostic outcomes; the concrete provider decides the code (credit ceiling, prior history, …).
/// </summary>
public static class BnplEligibilityStatus
{
/// <summary>The customer may use BNPL for this order amount.</summary>
public const string Eligible = "eligible";
/// <summary>The customer is not eligible (no line/blocked) — the client falls back to card.</summary>
public const string NotEligible = "not_eligible";
/// <summary>The order exceeds the customer's provider credit ceiling — the client falls back to card.</summary>
public const string CeilingExceeded = "ceiling_exceeded";
}
@@ -0,0 +1,19 @@
namespace Baya.Domain.Entities.Bnpl;
/// <summary>
/// The closed set of supported <c>bnpl_transactions.provider_code</c> values — each selects one
/// <c>IBnplProvider</c> impl through the resolver. All four Iranian provider-financed BNPLs are uniformly
/// full-upfront / provider-bears-risk / interest-free-to-customer, so they share the mock behaviour; a real
/// system maps each to its concrete adapter. Persisted as these stable snake_case codes.
/// </summary>
public static class BnplProviderCodes
{
public const string SnappPay = "snapppay";
public const string Digipay = "digipay";
public const string Tara = "tara";
public const string TorobPay = "torobpay";
public static readonly IReadOnlyCollection<string> All = [SnappPay, Digipay, Tara, TorobPay];
public static bool IsKnown(string providerCode) => All.Contains(providerCode);
}
@@ -0,0 +1,31 @@
namespace Baya.Domain.Entities.Bnpl;
/// <summary>
/// The closed <c>bnpl_transactions.status</c> code set — the <b>forward-only</b> state machine that is the
/// idempotency spine of the BNPL money path. A replayed <c>settle</c>/<c>revert</c> that would re-drive a
/// completed transition is an idempotent no-op (the handler treats an already-in-target state as done); the
/// allowed edges live in <see cref="BnplTransitions"/>. Persisted as these stable snake_case codes.
/// </summary>
public static class BnplStatus
{
/// <summary>Eligibility checked and approved — the row exists, no token yet.</summary>
public const string Eligible = "eligible";
/// <summary>A provider payment token was issued; the customer is redirected to complete the order.</summary>
public const string TokenIssued = "token_issued";
/// <summary>The provider confirmed the order (amount + reference re-checked server-side).</summary>
public const string Verified = "verified";
/// <summary>The provider settled the full order net-of-commission; the net-of-fee ledger group is posted.</summary>
public const string Settled = "settled";
/// <summary>The order was reversed back through the provider (a <c>bnpl_revert</c> refund).</summary>
public const string Reverted = "reverted";
/// <summary>The order was cancelled before settle. Terminal.</summary>
public const string Cancelled = "cancelled";
/// <summary>The provider refused (eligibility/token/verify/settle). Terminal.</summary>
public const string Failed = "failed";
}
@@ -0,0 +1,138 @@
#nullable enable
using Baya.Domain.Common;
namespace Baya.Domain.Entities.Bnpl;
/// <summary>
/// One BNPL order, <b>1:1 with its <c>payment_transaction</c></b> (the <c>UNIQUE(payment_transaction_id)</c>
/// guard) — the single inbound settlement to reconcile plus the revert path. In Balinyaar's books a BNPL order
/// is a <b>card payment that lands net-of-fee</b>: there is nothing to amortize on our side (the provider owns
/// the customer's installments and 100% of default risk), so this is one row, not a plan+entries tree.
/// <para>
/// <see cref="Status"/> is a <b>forward-only</b> state machine (<see cref="BnplTransitions"/>) mutated only
/// through the cohesive mark-* methods; a replayed <c>settle</c>/<c>revert</c> that would re-drive a completed
/// transition throws (the handler treats an already-in-target state as an idempotent no-op), so the ledger is
/// never double-posted. Every amount is IRR <c>BIGINT</c> — currency is normalized to IRR at the provider
/// boundary, never here.
/// </para>
/// </summary>
public class BnplTransaction : BaseEntity<long>
{
/// <summary>The 1:1 parent payment attempt — <c>UNIQUE</c> so exactly one BNPL row exists per order.</summary>
public long PaymentTransactionId { get; set; }
/// <summary>Selects the provider impl — <c>snapppay</c> / <c>digipay</c> / <c>tara</c> / <c>torobpay</c>.</summary>
public string ProviderCode { get; set; } = null!;
/// <summary>Balinyaar entity or partner center that is merchant-of-record for the order.</summary>
public string MerchantOfRecord { get; set; } = null!;
/// <summary>The provider payment token, issued at initiate and used for verify/settle/revert.</summary>
public string? ExternalPaymentToken { get; private set; }
/// <summary>The provider's own order/txn id, when returned.</summary>
public string? ExternalTransactionId { get; private set; }
/// <summary>The recorded eligibility outcome — a <see cref="BnplEligibilityStatus"/> code.</summary>
public string? EligibilityStatus { get; set; }
/// <summary>Gross order (IRR) = the booking's <c>gross_price_irr</c>. No floats, ever.</summary>
public long OrderAmountIrr { get; set; }
/// <summary>Net of provider commission actually received (IRR) — set at settle from the real settlement.</summary>
public long? SettledAmountIrr { get; private set; }
/// <summary>The provider's merchant discount (IRR) = a <b>platform expense</b>, never the nurse's — set at settle.</summary>
public long? BnplCommissionIrr { get; private set; }
/// <summary><c>IRR</c>/<c>TOMAN</c> at the boundary; normalized to IRR on the way in.</summary>
public string Currency { get; set; } = "IRR";
/// <summary>Informational only (default 4) — the installment schedule is owned by the provider.</summary>
public byte InstallmentCount { get; set; } = 4;
/// <summary>Guarded — a <see cref="BnplStatus"/> code, mutated only through the mark-* methods.</summary>
public string Status { get; private set; } = BnplStatus.Eligible;
/// <summary><b>Per-transaction</b>, contract-defined (daily/T+13/weekly) — nullable, never assumed instant.</summary>
public DateTime? SettledAt { get; private set; }
// ---- reversal path ----
public string? RevertTransactionId { get; private set; }
public long? RevertedAmountIrr { get; private set; }
public DateTime? RevertedAt { get; private set; }
/// <summary>The provider's own commission reversal — <b>nullable</b>, reconciled from the provider response,
/// never hardcoded (some providers keep their fee on a refund).</summary>
public long? ProviderCommissionReversedAmount { get; private set; }
/// <summary><c>bnpl_revert</c> on a reversal.</summary>
public string? RefundChannel { get; private set; }
/// <summary>Raw verify/settle/revert payload for reconciliation and audit.</summary>
public string? CallbackPayloadJson { get; set; }
public DateTimeOffset? DeletedAt { get; set; }
public bool CanTransitionTo(string target) => BnplTransitions.CanTransition(Status, target);
private void Transition(string target)
{
if (!BnplTransitions.CanTransition(Status, target))
throw new InvalidOperationException($"Illegal BNPL transition {Status} → {target}.");
Status = target;
}
/// <summary>Records the provider token and transitions <c>eligible → token_issued</c>.</summary>
public void MarkTokenIssued(string externalPaymentToken, string? externalTransactionId)
{
Transition(BnplStatus.TokenIssued);
ExternalPaymentToken = externalPaymentToken;
ExternalTransactionId ??= externalTransactionId;
}
/// <summary>Server-side amount + reference re-checked by the handler; transitions <c>token_issued → verified</c>.</summary>
public void MarkVerified(string? externalTransactionId, string? callbackPayloadJson)
{
Transition(BnplStatus.Verified);
ExternalTransactionId ??= externalTransactionId;
CallbackPayloadJson = callbackPayloadJson ?? CallbackPayloadJson;
}
/// <summary>Records the actual settlement (net + commission + per-transaction <paramref name="settledAt"/>)
/// and transitions <c>verified → settled</c>. Enforces <c>settled = order commission</c> and non-negativity
/// so a settled row can never carry an unbalanced split.</summary>
public void MarkSettled(long settledAmountIrr, long commissionIrr, DateTime? settledAt, string? callbackPayloadJson)
{
if (commissionIrr < 0 || settledAmountIrr < 0)
throw new InvalidOperationException("BNPL settlement amounts must be non-negative.");
if (settledAmountIrr != OrderAmountIrr - commissionIrr)
throw new InvalidOperationException(
$"BNPL settlement would not reconcile: settled {settledAmountIrr} != order {OrderAmountIrr} commission {commissionIrr}.");
Transition(BnplStatus.Settled);
SettledAmountIrr = settledAmountIrr;
BnplCommissionIrr = commissionIrr;
SettledAt = settledAt;
CallbackPayloadJson = callbackPayloadJson ?? CallbackPayloadJson;
}
/// <summary>Records the provider-mediated reversal audit and transitions to <c>reverted</c>. The customer
/// cash-back is async and owned by the provider — money flows customer ↔ provider ↔ Balinyaar only.</summary>
public void MarkReverted(
string? revertTransactionId, long revertedAmountIrr, long? providerCommissionReversedAmount,
DateTime revertedAt, string? callbackPayloadJson)
{
Transition(BnplStatus.Reverted);
RevertTransactionId = revertTransactionId;
RevertedAmountIrr = revertedAmountIrr;
ProviderCommissionReversedAmount = providerCommissionReversedAmount;
RevertedAt = revertedAt;
RefundChannel = Refunds.RefundChannel.BnplRevert;
CallbackPayloadJson = callbackPayloadJson ?? CallbackPayloadJson;
}
public void MarkCancelled() => Transition(BnplStatus.Cancelled);
public void MarkFailed() => Transition(BnplStatus.Failed);
}
@@ -0,0 +1,27 @@
namespace Baya.Domain.Entities.Bnpl;
/// <summary>
/// The forward-only allowed-edge table for the <see cref="BnplStatus"/> machine (mirrors
/// <c>BookingRequestTransitions</c>/<c>RefundTransitions</c>). Every write goes through
/// <see cref="BnplTransaction"/>'s cohesive mark-* methods, which assert the edge here — an illegal transition
/// throws, and a replayed callback that would re-drive a completed transition is rejected before it can
/// re-post the ledger. Terminal states have no outgoing edge.
/// </summary>
public static class BnplTransitions
{
private static readonly IReadOnlyDictionary<string, IReadOnlyCollection<string>> Allowed =
new Dictionary<string, IReadOnlyCollection<string>>
{
[BnplStatus.Eligible] = [BnplStatus.TokenIssued, BnplStatus.Failed, BnplStatus.Cancelled],
[BnplStatus.TokenIssued] = [BnplStatus.Verified, BnplStatus.Failed, BnplStatus.Cancelled],
[BnplStatus.Verified] = [BnplStatus.Settled, BnplStatus.Failed, BnplStatus.Reverted, BnplStatus.Cancelled],
[BnplStatus.Settled] = [BnplStatus.Reverted],
// Terminal states — no outgoing edges.
[BnplStatus.Reverted] = [],
[BnplStatus.Cancelled] = [],
[BnplStatus.Failed] = []
};
public static bool CanTransition(string from, string to)
=> Allowed.TryGetValue(from, out var targets) && targets.Contains(to);
}
@@ -36,6 +36,56 @@ public static class LedgerPosting
];
}
/// <summary>
/// The <b>BNPL settle group</b>: the card-capture legs <b>plus</b> the provider-fee leg, all under one fresh
/// <see cref="LedgerEntry.TransactionGroupId"/>, so <c>escrow_held</c> reflects the <b>net</b> cash actually
/// received (<c>order commission</c>), not the gross:
/// <code>
/// DEBIT escrow_held order (= gross)
/// CREDIT platform_revenue commission
/// CREDIT nurse_payable payout
/// DEBIT bnpl_fee_expense bnpl_commission
/// CREDIT escrow_held bnpl_commission
/// </code>
/// The three booking amounts come <b>frozen from the booking</b> (never recomputed) and must reconcile
/// (<c>gross = commission + payout</c>); the <b>nurse's payout is invariant to payment method</b> — the BNPL
/// commission is a platform expense and never touches it. Throws if the group would not balance, so an
/// unbalanced settle can never be persisted. Posted once (the state guard + webhook dedup make a replay a no-op).
/// </summary>
public static IReadOnlyList<LedgerEntry> BnplSettle(
long bookingId,
long nurseId,
long grossIrr,
long commissionIrr,
long payoutIrr,
long bnplCommissionIrr,
long bnplTransactionId,
DateTime createdAt)
{
if (grossIrr != commissionIrr + payoutIrr)
throw new InvalidOperationException(
$"BNPL settle group would not balance: gross {grossIrr} != commission {commissionIrr} + payout {payoutIrr}.");
if (bnplCommissionIrr < 0)
throw new InvalidOperationException("BNPL provider commission must be non-negative.");
var group = Guid.NewGuid();
var legs = new List<LedgerEntry>
{
Leg(group, LedgerAccountType.EscrowHeld, LedgerDirection.Debit, grossIrr, null, bookingId, LedgerSourceRefType.BnplTransaction, bnplTransactionId, createdAt),
Leg(group, LedgerAccountType.PlatformRevenue, LedgerDirection.Credit, commissionIrr, null, bookingId, LedgerSourceRefType.BnplTransaction, bnplTransactionId, createdAt),
Leg(group, LedgerAccountType.NursePayable, LedgerDirection.Credit, payoutIrr, nurseId, bookingId, LedgerSourceRefType.BnplTransaction, bnplTransactionId, createdAt)
};
// The provider-fee leg: the merchant discount is a platform expense, so escrow reflects only the net cash.
if (bnplCommissionIrr > 0)
{
legs.Add(Leg(group, LedgerAccountType.BnplFeeExpense, LedgerDirection.Debit, bnplCommissionIrr, null, bookingId, LedgerSourceRefType.BnplTransaction, bnplTransactionId, createdAt));
legs.Add(Leg(group, LedgerAccountType.EscrowHeld, LedgerDirection.Credit, bnplCommissionIrr, null, bookingId, LedgerSourceRefType.BnplTransaction, bnplTransactionId, createdAt));
}
return legs;
}
/// <summary>
/// The <b>pre-payout refund reversal</b> (the nurse has not been paid, the common case): <c>DEBIT
/// platform_revenue fee + DEBIT nurse_payable payout / CREDIT refund_payable (sum)</c> under one group.
@@ -1,27 +1,78 @@
#nullable enable
using Baya.Application.Contracts.Payments;
using Baya.Domain.Entities.Bnpl;
using Microsoft.Extensions.Options;
namespace Baya.Infrastructure.CrossCutting.Seams;
/// <summary>
/// A thin, deterministic mock <see cref="IBnplProvider"/> so b11's <c>bnpl_revert</c> refund path is exercised
/// before b12 merges — <b>b12 owns the real seam definition and its full adapter</b> (SnappPay/Tara). Revert
/// and update both succeed, echo a deterministic <c>external_revert_reference</c> derived from the order +
/// idempotency key, and report a nullable provider commission reversal (null by default — some providers keep
/// their fee on a refund; the amount is reconciled from the response, never hardcoded).
/// A deterministic, network-free mock <see cref="IBnplProvider"/> that drives the full SnappPay-superset verb
/// set — <c>eligible → token_issued → verified → settled → reverted/cancelled</c> — with no external call. One
/// instance stands in for every <c>provider_code</c> (all Iranian provider-financed BNPLs behave the same);
/// a real system maps each code to its concrete adapter (SnappPay OAuth/eligible/token/verify/settle/revert,
/// Digipay UPG ticket/verify/deliver/refund). The mock commission % is <see cref="BnplOptions.CommissionRate"/>,
/// so the settle returns <c>order commission</c> and the handler reads the commission from the response, never
/// hardcoded. A real adapter reads the actual deducted amount from each settlement.
/// </summary>
public sealed class MockBnplProvider(IOptions<SeamOptions> options) : IBnplProvider
{
private readonly BnplOptions _options = options.Value.Bnpl;
public ValueTask<BnplEligibilityResult> CheckEligibilityAsync(string customerMobile, long orderAmountIrr, CancellationToken cancellationToken = default)
{
var status = string.Equals(customerMobile, _options.NotEligibleMobile, StringComparison.Ordinal)
? BnplEligibilityStatus.NotEligible
: orderAmountIrr > _options.CreditCeilingIrr
? BnplEligibilityStatus.CeilingExceeded
: BnplEligibilityStatus.Eligible;
return ValueTask.FromResult(new BnplEligibilityResult(
status, InstallmentCount: 4, CreditCeilingIrr: _options.CreditCeilingIrr,
PlanSummary: "4 interest-free installments, 0% interest, provider-financed."));
}
public ValueTask<BnplTokenResult> CreatePaymentTokenAsync(string customerMobile, long orderAmountIrr, string idempotencyKey, CancellationToken cancellationToken = default)
{
if (_options.ForceFailure)
return ValueTask.FromResult(new BnplTokenResult(PaymentProviderStatus.Failed, string.Empty, string.Empty, null));
var token = $"mock-bnpl-token-{orderAmountIrr}-{idempotencyKey}";
return ValueTask.FromResult(new BnplTokenResult(
PaymentProviderStatus.Succeeded, token,
RedirectUrl: $"https://mock-bnpl.local/checkout/{token}",
ExternalTransactionId: $"mock-bnpl-order-{idempotencyKey}"));
}
public ValueTask<BnplVerifyResult> VerifyAsync(string externalPaymentToken, long expectedOrderAmountIrr, CancellationToken cancellationToken = default)
=> ValueTask.FromResult(new BnplVerifyResult(
PaymentProviderStatus.Succeeded, expectedOrderAmountIrr, $"mock-bnpl-verify-{externalPaymentToken}"));
public ValueTask<BnplSettleResult> SettleAsync(string externalPaymentToken, long orderAmountIrr, string idempotencyKey, CancellationToken cancellationToken = default)
{
// The merchant discount is read from the (mock) settlement — commission %, never hardcoded in a handler.
var commission = (long)Math.Round(orderAmountIrr * _options.CommissionRate, MidpointRounding.AwayFromZero);
var settled = orderAmountIrr - commission;
// Settlement timing is contract-defined and not instant — the mock can model it null (deferred) or now.
DateTime? settledAt = _options.SettlementInstant ? DateTime.UtcNow : null;
return ValueTask.FromResult(new BnplSettleResult(
PaymentProviderStatus.Succeeded, settled, commission, settledAt, $"mock-bnpl-settle-{idempotencyKey}"));
}
public ValueTask<BnplStatusResult> GetStatusAsync(string externalPaymentToken, CancellationToken cancellationToken = default)
=> ValueTask.FromResult(new BnplStatusResult("settled"));
public ValueTask<BnplRevertResult> CancelAsync(string externalPaymentToken, string idempotencyKey, CancellationToken cancellationToken = default)
=> Reversal(externalPaymentToken, idempotencyKey);
public ValueTask<BnplRevertResult> RevertAsync(string providerOrderReference, long amountIrr, string idempotencyKey, CancellationToken cancellationToken = default)
=> Result(providerOrderReference, idempotencyKey);
=> Reversal(providerOrderReference, idempotencyKey);
public ValueTask<BnplRevertResult> UpdateAsync(string providerOrderReference, long newAmountIrr, string idempotencyKey, CancellationToken cancellationToken = default)
=> Result(providerOrderReference, idempotencyKey);
=> Reversal(providerOrderReference, idempotencyKey);
private ValueTask<BnplRevertResult> Result(string providerOrderReference, string idempotencyKey)
private ValueTask<BnplRevertResult> Reversal(string providerOrderReference, string idempotencyKey)
{
if (_options.ForceFailure)
return ValueTask.FromResult(new BnplRevertResult(PaymentProviderStatus.Failed, null, null));
@@ -0,0 +1,18 @@
#nullable enable
using Baya.Application.Contracts.Payments;
using Baya.Domain.Entities.Bnpl;
namespace Baya.Infrastructure.CrossCutting.Seams;
/// <summary>
/// Config-driven <see cref="IBnplProviderResolver"/> — maps every known <c>provider_code</c> to the deterministic
/// <see cref="MockBnplProvider"/> (all four Iranian provider-financed BNPLs share the mock behaviour). A real
/// system registers one concrete adapter per code (<c>SnappPayBnplProvider</c>, <c>DigipayBnplProvider</c>, …)
/// and this resolver returns the right one; swapping is a registration change, <b>never</b> an <c>if (mock)</c>
/// branch in a handler. An unknown code resolves to <c>null</c> so the handler rejects it cleanly.
/// </summary>
public sealed class MockBnplProviderResolver(MockBnplProvider provider) : IBnplProviderResolver
{
public IBnplProvider? Resolve(string providerCode)
=> BnplProviderCodes.IsKnown(providerCode) ? provider : null;
}
@@ -0,0 +1,24 @@
#nullable enable
using Baya.Application.Contracts.Payments;
using Microsoft.Extensions.Options;
namespace Baya.Infrastructure.CrossCutting.Seams;
/// <summary>
/// Deterministic mock <see cref="ICurrencyNormalizer"/> — Toman ↔ IRR at the provider boundary only. Toman is
/// multiplied by <see cref="CurrencyOptions.TomanToIrrMultiplier"/> (10) to get IRR (and divided back for
/// display); IRR passes through unchanged. A real adapter reads the multiplier from provider config; the
/// conversion never happens internally, only here.
/// </summary>
public sealed class MockCurrencyNormalizer(IOptions<SeamOptions> options) : ICurrencyNormalizer
{
private readonly CurrencyOptions _options = options.Value.Currency;
public long ToIrr(long amount, string currency)
=> string.Equals(currency, "TOMAN", StringComparison.OrdinalIgnoreCase)
? amount * _options.TomanToIrrMultiplier
: amount;
public long ToDisplayToman(long amountIrr)
=> _options.TomanToIrrMultiplier == 0 ? amountIrr : amountIrr / _options.TomanToIrrMultiplier;
}
@@ -18,6 +18,18 @@ public sealed class SeamOptions
public PaymentsOptions Payments { get; set; } = new();
public MoadianOptions Moadian { get; set; } = new();
public BnplOptions Bnpl { get; set; } = new();
public CurrencyOptions Currency { get; set; } = new();
}
/// <summary>
/// Tunes the mock <c>ICurrencyNormalizer</c> (b12). Toman → IRR multiplies by <see cref="TomanToIrrMultiplier"/>
/// (10); IRR passes through. The real adapter reads the multiplier from provider config. Conversion happens only
/// at the provider boundary, never internally.
/// </summary>
public sealed class CurrencyOptions
{
/// <summary>How many Rial one Toman is (10). A currency redenomination is a config change, not a code change.</summary>
public long TomanToIrrMultiplier { get; set; } = 10;
}
/// <summary>
@@ -32,18 +44,32 @@ public sealed class MoadianOptions
}
/// <summary>
/// Tunes the thin local mock <c>IBnplProvider</c> b11 registers until b12 ships the real seam. By default a
/// revert/update succeeds and the provider keeps its commission (null reversal). The real b12 adapter ignores
/// these.
/// Tunes the deterministic mock <c>IBnplProvider</c> (b12, superset of the b11 revert-only stub). The mock
/// commission % drives the settle (net = order commission); the real adapter reads the actual deducted amount
/// from each settlement and ignores these knobs.
/// </summary>
public sealed class BnplOptions
{
/// <summary>When true, every revert/update fails so the refund-channel-refused path is testable.</summary>
/// <summary>When true, token/revert/update/cancel all fail so the provider-declined paths are testable.</summary>
public bool ForceFailure { get; set; }
/// <summary>When true, the mock reports the provider returned its commission (a non-null, zero reversal
/// placeholder) so the <c>provider_commission_reversed_amount</c> reconciliation is exercised.</summary>
public bool ReverseProviderCommission { get; set; }
/// <summary>The mock provider merchant commission rate (fraction) the settle deducts. Read from the response
/// by the handler, never hardcoded there; the real adapter reads the true per-contract deducted amount.</summary>
public decimal CommissionRate { get; set; } = 0.10m;
/// <summary>When true the mock settle stamps <c>settled_at = now</c>; false models the non-instant
/// (deferred/T+13/weekly) settlement with a null <c>settled_at</c>.</summary>
public bool SettlementInstant { get; set; } = true;
/// <summary>The provider credit ceiling (IRR); an order above it returns <c>ceiling_exceeded</c>.</summary>
public long CreditCeilingIrr { get; set; } = 2_000_000_000;
/// <summary>A designated test mobile that returns <c>not_eligible</c> so the fall-back-to-card path is testable.</summary>
public string NotEligibleMobile { get; set; } = "09120000099";
}
/// <summary>
@@ -60,10 +60,18 @@ public static class ServiceCollectionExtension
services.AddSingleton<IDistributedLock, InProcessDistributedLock>();
// Refunds/invoices seams (backend-phase-11). سامانه مودیان e-invoicing is mocked (pending/no-ref by
// default; config can force registered). IBnplProvider is a thin local stub so the bnpl_revert refund
// path runs before b12 merges — b12 owns the real seam. Both swap in by a registration change only.
// default; config can force registered).
services.AddSingleton<IMoadianClient, MockMoadianClient>();
services.AddSingleton<IBnplProvider, MockBnplProvider>();
// BNPL seams (backend-phase-12). The deterministic MockBnplProvider drives the full eligible → settled →
// reverted state machine with no network; the resolver selects one impl per provider_code (config-driven,
// never an if(mock) in a handler); ICurrencyNormalizer does Toman↔IRR at the boundary only. A real
// SnappPay/Digipay adapter + real Redis normalizer swap in by a registration change only. IBnplProvider
// is still registered directly for the b11 refund path's bnpl_revert channel.
services.AddSingleton<MockBnplProvider>();
services.AddSingleton<IBnplProvider>(sp => sp.GetRequiredService<MockBnplProvider>());
services.AddSingleton<IBnplProviderResolver, MockBnplProviderResolver>();
services.AddSingleton<ICurrencyNormalizer, MockCurrencyNormalizer>();
return services;
}
@@ -0,0 +1,45 @@
using Baya.Domain.Entities.Bnpl;
using Baya.Domain.Entities.Payments;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Baya.Infrastructure.Persistence.Configuration.BnplConfig;
/// <summary>
/// <c>bnpl_transactions</c> — one row per BNPL order, <b>1:1 with its <c>payment_transaction</c></b>. The
/// <c>UNIQUE(payment_transaction_id)</c> is the structural one-BNPL-row-per-order guard; the settle invariant
/// <c>settled = order commission</c> (with both net + commission set together) is a DB CHECK mirroring b9/b11.
/// The state-machine guard on <c>status</c> lives in the entity; there is no <c>bnpl_settlement_entries</c> child
/// table (tranched settlement is DEFERRED — adding it later is a purely additive migration).
/// </summary>
internal sealed class BnplTransactionConfig : IEntityTypeConfiguration<BnplTransaction>
{
public void Configure(EntityTypeBuilder<BnplTransaction> builder)
{
builder.ToTable("BnplTransactions", "payments", t => t.HasCheckConstraint(
"CK_BnplTransactions_SettleSplit",
"([SettledAmountIrr] IS NULL AND [BnplCommissionIrr] IS NULL) " +
"OR ([SettledAmountIrr] = [OrderAmountIrr] - [BnplCommissionIrr] " +
"AND [SettledAmountIrr] >= 0 AND [BnplCommissionIrr] >= 0)"));
builder.Property(t => t.ProviderCode).HasMaxLength(50).IsRequired();
builder.Property(t => t.MerchantOfRecord).HasMaxLength(40).IsRequired();
builder.Property(t => t.ExternalPaymentToken).HasMaxLength(200);
builder.Property(t => t.ExternalTransactionId).HasMaxLength(200);
builder.Property(t => t.EligibilityStatus).HasMaxLength(30);
builder.Property(t => t.Currency).HasMaxLength(5).IsRequired();
builder.Property(t => t.Status).HasMaxLength(30).IsRequired();
builder.Property(t => t.RevertTransactionId).HasMaxLength(200);
builder.Property(t => t.RefundChannel).HasMaxLength(20);
// Strict 1:1 — exactly one BNPL row per order. The structural guard, not just a handler pre-check.
builder.HasIndex(t => t.PaymentTransactionId).IsUnique();
// The callback dispatch resolves the order from the provider token in the payload.
builder.HasIndex(t => t.ExternalPaymentToken).HasFilter("[ExternalPaymentToken] IS NOT NULL");
builder.HasIndex(t => t.Status);
builder.HasOne<PaymentTransaction>().WithMany().HasForeignKey(t => t.PaymentTransactionId).IsRequired();
builder.HasQueryFilter(t => t.DeletedAt == null);
}
}
@@ -0,0 +1,88 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Baya.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class BnplTransactions : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "BnplTransactions",
schema: "payments",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
PaymentTransactionId = table.Column<long>(type: "bigint", nullable: false),
ProviderCode = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
MerchantOfRecord = table.Column<string>(type: "nvarchar(40)", maxLength: 40, nullable: false),
ExternalPaymentToken = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
ExternalTransactionId = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
EligibilityStatus = table.Column<string>(type: "nvarchar(30)", maxLength: 30, nullable: true),
OrderAmountIrr = table.Column<long>(type: "bigint", nullable: false),
SettledAmountIrr = table.Column<long>(type: "bigint", nullable: true),
BnplCommissionIrr = table.Column<long>(type: "bigint", nullable: true),
Currency = table.Column<string>(type: "nvarchar(5)", maxLength: 5, nullable: false),
InstallmentCount = table.Column<byte>(type: "tinyint", nullable: false),
Status = table.Column<string>(type: "nvarchar(30)", maxLength: 30, nullable: false),
SettledAt = table.Column<DateTime>(type: "datetime2", nullable: true),
RevertTransactionId = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
RevertedAmountIrr = table.Column<long>(type: "bigint", nullable: true),
RevertedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
ProviderCommissionReversedAmount = table.Column<long>(type: "bigint", nullable: true),
RefundChannel = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: true),
CallbackPayloadJson = table.Column<string>(type: "nvarchar(max)", 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_BnplTransactions", x => x.Id);
table.CheckConstraint("CK_BnplTransactions_SettleSplit", "([SettledAmountIrr] IS NULL AND [BnplCommissionIrr] IS NULL) OR ([SettledAmountIrr] = [OrderAmountIrr] - [BnplCommissionIrr] AND [SettledAmountIrr] >= 0 AND [BnplCommissionIrr] >= 0)");
table.ForeignKey(
name: "FK_BnplTransactions_PaymentTransactions_PaymentTransactionId",
column: x => x.PaymentTransactionId,
principalSchema: "payments",
principalTable: "PaymentTransactions",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_BnplTransactions_ExternalPaymentToken",
schema: "payments",
table: "BnplTransactions",
column: "ExternalPaymentToken",
filter: "[ExternalPaymentToken] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_BnplTransactions_PaymentTransactionId",
schema: "payments",
table: "BnplTransactions",
column: "PaymentTransactionId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_BnplTransactions_Status",
schema: "payments",
table: "BnplTransactions",
column: "Status");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "BnplTransactions",
schema: "payments");
}
}
}
@@ -98,6 +98,115 @@ namespace Baya.Infrastructure.Persistence.Migrations
b.ToTable("AuditLogs", "ops");
});
modelBuilder.Entity("Baya.Domain.Entities.Bnpl.BnplTransaction", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<long?>("BnplCommissionIrr")
.HasColumnType("bigint");
b.Property<string>("CallbackPayloadJson")
.HasColumnType("nvarchar(max)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("CreatedById")
.HasColumnType("int");
b.Property<string>("Currency")
.IsRequired()
.HasMaxLength(5)
.HasColumnType("nvarchar(5)");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("datetimeoffset");
b.Property<string>("EligibilityStatus")
.HasMaxLength(30)
.HasColumnType("nvarchar(30)");
b.Property<string>("ExternalPaymentToken")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("ExternalTransactionId")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<byte>("InstallmentCount")
.HasColumnType("tinyint");
b.Property<string>("MerchantOfRecord")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("nvarchar(40)");
b.Property<DateTimeOffset?>("ModifiedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ModifiedById")
.HasColumnType("int");
b.Property<long>("OrderAmountIrr")
.HasColumnType("bigint");
b.Property<long>("PaymentTransactionId")
.HasColumnType("bigint");
b.Property<string>("ProviderCode")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<long?>("ProviderCommissionReversedAmount")
.HasColumnType("bigint");
b.Property<string>("RefundChannel")
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<string>("RevertTransactionId")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<long?>("RevertedAmountIrr")
.HasColumnType("bigint");
b.Property<DateTime?>("RevertedAt")
.HasColumnType("datetime2");
b.Property<long?>("SettledAmountIrr")
.HasColumnType("bigint");
b.Property<DateTime?>("SettledAt")
.HasColumnType("datetime2");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("nvarchar(30)");
b.HasKey("Id");
b.HasIndex("ExternalPaymentToken")
.HasFilter("[ExternalPaymentToken] IS NOT NULL");
b.HasIndex("PaymentTransactionId")
.IsUnique();
b.HasIndex("Status");
b.ToTable("BnplTransactions", "payments", t =>
{
t.HasCheckConstraint("CK_BnplTransactions_SettleSplit", "([SettledAmountIrr] IS NULL AND [BnplCommissionIrr] IS NULL) OR ([SettledAmountIrr] = [OrderAmountIrr] - [BnplCommissionIrr] AND [SettledAmountIrr] >= 0 AND [BnplCommissionIrr] >= 0)");
});
});
modelBuilder.Entity("Baya.Domain.Entities.Booking.Booking", b =>
{
b.Property<long>("Id")
@@ -4203,6 +4312,15 @@ namespace Baya.Infrastructure.Persistence.Migrations
.HasForeignKey("ActorUserId");
});
modelBuilder.Entity("Baya.Domain.Entities.Bnpl.BnplTransaction", b =>
{
b.HasOne("Baya.Domain.Entities.Payments.PaymentTransaction", null)
.WithMany()
.HasForeignKey("PaymentTransactionId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("Baya.Domain.Entities.Booking.Booking", b =>
{
b.HasOne("Baya.Domain.Entities.Booking.BookingRequest", null)
@@ -0,0 +1,111 @@
#nullable enable
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Bnpl;
using Baya.Domain.Entities.Booking;
using Baya.Domain.Entities.Bnpl;
using Baya.Domain.Entities.Identity;
using Baya.Domain.Entities.Payments;
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 BnplRepository : BaseAsyncRepository<BnplTransaction>, IBnplRepository
{
public BnplRepository(ApplicationDbContext dbContext) : base(dbContext)
{
}
public Task<BnplOrderContext?> GetOrderContextAsync(long bookingRequestId, CancellationToken cancellationToken)
=> (from r in DbContext.Set<BookingRequest>().AsNoTracking()
where r.Id == bookingRequestId
join c in DbContext.Set<CustomerProfile>() on r.CustomerId equals c.Id
join u in DbContext.Set<User>() on c.UserId equals u.Id
select new BnplOrderContext(
r.Id,
r.CustomerId,
u.Id,
u.PhoneNumber,
r.Status,
r.PaymentDeadlineAt,
r.Variant.Price * (r.Variant.SessionCount != null ? r.Variant.SessionCount.Value : 1)))
.FirstOrDefaultAsync(cancellationToken);
public Task AddAsync(BnplTransaction transaction, CancellationToken cancellationToken)
=> base.AddAsync(transaction);
public Task<BnplTransaction?> GetTrackedByPaymentTransactionIdAsync(long paymentTransactionId, CancellationToken cancellationToken)
=> Table.FirstOrDefaultAsync(t => t.PaymentTransactionId == paymentTransactionId, cancellationToken);
public Task<BnplTransaction?> GetTrackedByIdAsync(long id, CancellationToken cancellationToken)
=> Table.FirstOrDefaultAsync(t => t.Id == id, cancellationToken);
public Task<BnplTransaction?> GetTrackedByBookingRequestIdAsync(long bookingRequestId, CancellationToken cancellationToken)
// A correlated subquery keeps the root Table query tracking (a join to an AsNoTracking set would detach
// the returned entity, so a later mutation would never persist).
=> Table.FirstOrDefaultAsync(
t => DbContext.Set<PaymentTransaction>().Any(pt => pt.Id == t.PaymentTransactionId && pt.BookingRequestId == bookingRequestId),
cancellationToken);
public Task<BnplTransaction?> GetTrackedByTokenAsync(string externalPaymentToken, CancellationToken cancellationToken)
=> Table.FirstOrDefaultAsync(t => t.ExternalPaymentToken == externalPaymentToken, cancellationToken);
public Task<bool> SettleLedgerExistsAsync(long bnplTransactionId, CancellationToken cancellationToken)
=> DbContext.Set<LedgerEntry>().AsNoTracking().AnyAsync(
e => e.SourceRefType == LedgerSourceRefType.BnplTransaction && e.SourceRefId == bnplTransactionId,
cancellationToken);
public async Task<BnplOrderStatusProjection?> GetStatusAsync(long id, CancellationToken cancellationToken)
{
var row = await (from t in TableNoTracking
where t.Id == id
join pt in DbContext.Set<PaymentTransaction>() on t.PaymentTransactionId equals pt.Id
join c in DbContext.Set<CustomerProfile>() on pt.CustomerId equals c.Id
select new
{
c.UserId,
t.Id,
t.PaymentTransactionId,
pt.BookingId,
t.ProviderCode,
t.Status,
t.EligibilityStatus,
t.OrderAmountIrr,
t.SettledAmountIrr,
t.BnplCommissionIrr,
t.Currency,
t.InstallmentCount,
t.SettledAt,
t.RevertTransactionId,
t.RevertedAmountIrr,
t.RevertedAt,
t.ProviderCommissionReversedAmount,
t.RefundChannel,
t.CreatedAt
})
.FirstOrDefaultAsync(cancellationToken);
if (row is null)
return null;
// The async customer cash-back ETA lives on the linked bnpl_revert refund (b11), surfaced for the UI.
DateOnly? eta = null;
if (row.BookingId is { } bookingId && row.Status == BnplStatus.Reverted)
eta = await DbContext.Set<Refund>().AsNoTracking()
.Where(refund => refund.BookingId == bookingId && refund.RefundChannel == RefundChannel.BnplRevert)
.OrderByDescending(refund => refund.Id)
.Select(refund => refund.ExpectedCustomerRefundEta)
.FirstOrDefaultAsync(cancellationToken);
var dto = new BnplOrderStatusDto(
row.Id, row.PaymentTransactionId, row.BookingId, row.ProviderCode, row.Status, row.EligibilityStatus,
row.OrderAmountIrr.ToString(), row.SettledAmountIrr?.ToString(), row.BnplCommissionIrr?.ToString(),
row.Currency, row.InstallmentCount, row.SettledAt,
row.RevertTransactionId, row.RevertedAmountIrr?.ToString(), row.RevertedAt,
row.ProviderCommissionReversedAmount?.ToString(), row.RefundChannel, eta, row.CreatedAt);
return new BnplOrderStatusProjection(row.UserId, dto);
}
}
@@ -25,6 +25,7 @@ public class UnitOfWork : IUnitOfWork
public IPaymentRepository PaymentRepository { get; }
public IRefundRepository RefundRepository { get; }
public IInvoiceRepository InvoiceRepository { get; }
public IBnplRepository BnplRepository { get; }
public UnitOfWork(ApplicationDbContext db)
{
@@ -48,6 +49,7 @@ public class UnitOfWork : IUnitOfWork
PaymentRepository = new PaymentRepository(_db);
RefundRepository = new RefundRepository(_db);
InvoiceRepository = new InvoiceRepository(_db);
BnplRepository = new BnplRepository(_db);
}
public Task CommitAsync()
@@ -132,6 +132,12 @@ internal sealed class RefundRepository : BaseAsyncRepository<Refund>, IRefundRep
return new RefundStatusProjection(row.UserId, dto);
}
public Task<string?> GetExternalRevertReferenceAsync(long refundId, CancellationToken cancellationToken)
=> TableNoTracking
.Where(r => r.Id == refundId)
.Select(r => r.ExternalRevertReference)
.FirstOrDefaultAsync(cancellationToken);
// Show only the last 4 characters of an external reference to the customer — never the full PSP/BNPL id.
private static string? Mask(string? reference)
{
@@ -0,0 +1,256 @@
using System.Net;
using System.Net.Http.Json;
using System.Text;
using Baya.Application.Contracts.Identity;
using Baya.Domain.Entities.Booking;
using Baya.Domain.Entities.Bnpl;
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 BnplApiTests(BayaApiFactory factory) : IClassFixture<BayaApiFactory>
{
[Fact]
public async Task Eligibility_Unauthenticated_Returns401()
{
var client = factory.CreateClient();
var response = await client.PostAsJsonAsync("/api/v1/checkout_bnpl/eligibility", new { bookingRequestId = 1L, providerCode = "snapppay" });
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task Eligibility_InvalidProvider_Returns400()
{
var client = factory.CreateClient();
await ProfileTestClient.AuthenticateAsync(factory, client, "09131902101", "customer");
var response = await client.PostAsJsonAsync("/api/v1/checkout_bnpl/eligibility", new { bookingRequestId = 1L, providerCode = "not_a_provider" });
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task Full_flow_eligibility_initiate_settle_posts_net_of_fee_ledger_and_invariant_payout()
{
var client = factory.CreateClient();
const string phone = "09131902102";
await ProfileTestClient.AuthenticateAsync(factory, client, phone, "customer");
var (requestId, nurseId) = await SeedAcceptedBnplRequestAsync(phone, price: 10_000_000);
// 1. Eligibility → eligible + a bnpl_transactions row in status 'eligible'.
var eligibility = await client.PostAsJsonAsync("/api/v1/checkout_bnpl/eligibility",
new { bookingRequestId = requestId, providerCode = BnplProviderCodes.SnappPay });
Assert.Equal(HttpStatusCode.OK, eligibility.StatusCode);
Assert.True((await AuthTestClient.ReadDataAsync(eligibility)).GetProperty("isEligible").GetBoolean());
// 2. Initiate → token_issued + a token/redirect.
var initiate = await client.PostAsJsonAsync("/api/v1/checkout_bnpl/initiate",
new { bookingRequestId = requestId, providerCode = BnplProviderCodes.SnappPay });
Assert.Equal(HttpStatusCode.OK, initiate.StatusCode);
var initData = await AuthTestClient.ReadDataAsync(initiate);
var token = initData.GetProperty("externalPaymentToken").GetString()!;
Assert.Equal("token_issued", initData.GetProperty("status").GetString());
// 3. Provider callbacks verify then settle (anonymous, signature-authenticated).
Assert.Equal(HttpStatusCode.OK, (await PostCallbackAsync(client, CallbackBody("evt-verify-1", "order.verified", token))).StatusCode);
var settle = await PostCallbackAsync(client, CallbackBody("evt-settle-1", "order.settled", token));
Assert.Equal(HttpStatusCode.OK, settle.StatusCode);
Assert.Equal(WebhookProcessingStatus.Processed, (await AuthTestClient.ReadDataAsync(settle)).GetProperty("processingStatus").GetString());
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var booking = db.Set<BookingEntity>().AsNoTracking().Single(b => b.BookingRequestId == requestId);
Assert.Equal(BookingStatus.Confirmed, booking.Status);
var legs = db.Set<LedgerEntry>().AsNoTracking().Where(l => l.BookingId == booking.Id).ToList();
Assert.Equal(5, legs.Count); // capture (3) + provider-fee (2)
Assert.Equal(
legs.Where(l => l.Direction == LedgerDirection.Debit).Sum(l => l.AmountIrr),
legs.Where(l => l.Direction == LedgerDirection.Credit).Sum(l => l.AmountIrr));
// Net escrow = settled amount (gross provider commission), and the bnpl_fee_expense leg exists.
Assert.Equal(1_000_000, legs.Where(l => l.AccountType == LedgerAccountType.BnplFeeExpense).Sum(l => l.AmountIrr)); // 10%
var escrowNet = legs.Where(l => l.AccountType == LedgerAccountType.EscrowHeld && l.Direction == LedgerDirection.Debit).Sum(l => l.AmountIrr)
- legs.Where(l => l.AccountType == LedgerAccountType.EscrowHeld && l.Direction == LedgerDirection.Credit).Sum(l => l.AmountIrr);
Assert.Equal(9_000_000, escrowNet);
// Payout invariant to method — identical to the card path (gross 15% commission).
var payable = legs.Single(l => l.AccountType == LedgerAccountType.NursePayable);
Assert.Equal(nurseId, payable.NurseId);
Assert.Equal(8_500_000, payable.AmountIrr);
var settledTxn = db.Set<PaymentTransaction>().AsNoTracking().Single(t => t.BookingRequestId == requestId && t.Status == PaymentTransactionStatus.Succeeded);
var bnpl = db.Set<BnplTransaction>().AsNoTracking().Single(b => b.PaymentTransactionId == settledTxn.Id);
Assert.Equal(BnplStatus.Settled, bnpl.Status);
Assert.Equal(9_000_000, bnpl.SettledAmountIrr);
Assert.Equal(1_000_000, bnpl.BnplCommissionIrr);
}
[Fact]
public async Task Replayed_settle_callback_is_idempotent_no_second_ledger()
{
var client = factory.CreateClient();
const string phone = "09131902103";
await ProfileTestClient.AuthenticateAsync(factory, client, phone, "customer");
var (requestId, _) = await SeedAcceptedBnplRequestAsync(phone, price: 5_000_000);
await client.PostAsJsonAsync("/api/v1/checkout_bnpl/eligibility", new { bookingRequestId = requestId, providerCode = BnplProviderCodes.SnappPay });
var initiate = await client.PostAsJsonAsync("/api/v1/checkout_bnpl/initiate", new { bookingRequestId = requestId, providerCode = BnplProviderCodes.SnappPay });
var token = (await AuthTestClient.ReadDataAsync(initiate)).GetProperty("externalPaymentToken").GetString()!;
await PostCallbackAsync(client, CallbackBody("evt-verify-2", "order.verified", token));
var body = CallbackBody("evt-settle-2", "order.settled", token);
var first = await PostCallbackAsync(client, body);
var replay = await PostCallbackAsync(client, body);
Assert.Equal(HttpStatusCode.OK, first.StatusCode);
Assert.Equal(HttpStatusCode.OK, replay.StatusCode);
Assert.True((await AuthTestClient.ReadDataAsync(replay)).GetProperty("duplicate").GetBoolean());
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var booking = db.Set<BookingEntity>().AsNoTracking().Single(b => b.BookingRequestId == requestId);
Assert.Equal(5, db.Set<LedgerEntry>().AsNoTracking().Count(l => l.BookingId == booking.Id));
Assert.Equal(1, db.Set<PaymentWebhookEvent>().AsNoTracking().Count(e => e.ExternalEventId == "evt-settle-2"));
}
[Fact]
public async Task Admin_revert_reverses_a_settled_order_and_opens_a_bnpl_revert_refund()
{
var client = factory.CreateClient();
const string phone = "09131902104";
await ProfileTestClient.AuthenticateAsync(factory, client, phone, "customer");
var (requestId, _) = await SeedAcceptedBnplRequestAsync(phone, price: 8_000_000);
await client.PostAsJsonAsync("/api/v1/checkout_bnpl/eligibility", new { bookingRequestId = requestId, providerCode = BnplProviderCodes.SnappPay });
var initiate = await client.PostAsJsonAsync("/api/v1/checkout_bnpl/initiate", new { bookingRequestId = requestId, providerCode = BnplProviderCodes.SnappPay });
var token = (await AuthTestClient.ReadDataAsync(initiate)).GetProperty("externalPaymentToken").GetString()!;
await PostCallbackAsync(client, CallbackBody("evt-verify-3", "order.verified", token));
await PostCallbackAsync(client, CallbackBody("evt-settle-3", "order.settled", token));
long bnplId;
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var pt = db.Set<PaymentTransaction>().AsNoTracking().Single(t => t.GatewayReferenceCode == token);
bnplId = db.Set<BnplTransaction>().AsNoTracking().Single(b => b.PaymentTransactionId == pt.Id).Id;
}
// A customer may not revert (admin-only) — the dynamic-permission policy forbids it.
var forbidden = await client.PostAsJsonAsync($"/api/v1/admin_bnpl/{bnplId}/revert", new { });
Assert.Equal(HttpStatusCode.Forbidden, forbidden.StatusCode);
var admin = factory.CreateClient();
await AdminTestClient.AuthenticateAsync(factory, admin, "09131902199");
var revert = await admin.PostAsJsonAsync($"/api/v1/admin_bnpl/{bnplId}/revert", new { });
Assert.Equal(HttpStatusCode.OK, revert.StatusCode);
var revertData = await AuthTestClient.ReadDataAsync(revert);
Assert.Equal(BnplStatus.Reverted, revertData.GetProperty("status").GetString());
Assert.Equal("8000000", revertData.GetProperty("revertedAmountIrr").GetString());
var status = await admin.GetAsync($"/api/v1/admin_bnpl/{bnplId}");
Assert.Equal(HttpStatusCode.OK, status.StatusCode);
var statusData = await AuthTestClient.ReadDataAsync(status);
Assert.Equal(BnplStatus.Reverted, statusData.GetProperty("status").GetString());
Assert.False(statusData.GetProperty("refundChannel").GetString() is null);
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var pt = db.Set<PaymentTransaction>().AsNoTracking().Single(t => t.GatewayReferenceCode == token);
var refund = db.Set<Baya.Domain.Entities.Refunds.Refund>().AsNoTracking().Single(r => r.PaymentTransactionId == pt.Id);
Assert.Equal(Baya.Domain.Entities.Refunds.RefundChannel.BnplRevert, refund.RefundChannel);
Assert.NotNull(refund.ExpectedCustomerRefundEta);
}
}
private static string CallbackBody(string eventId, string eventType, string token)
=> $"{{\"external_event_id\":\"{eventId}\",\"event_type\":\"{eventType}\",\"gateway_reference_code\":\"{token}\"}}";
private static Task<HttpResponseMessage> PostCallbackAsync(HttpClient client, string body)
=> client.PostAsync("/api/v1/webhooks_bnpl/snapppay", new StringContent(body, Encoding.UTF8, "application/json"));
/// <summary>Seeds reference data + a bookable nurse + an accepted request owned by the authenticated customer,
/// plus an active BNPL gateway. Returns the request id and nurse profile id.</summary>
private async Task<(long RequestId, long NurseId)> SeedAcceptedBnplRequestAsync(string customerPhone, long price)
{
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var userManager = scope.ServiceProvider.GetRequiredService<IAppUserManager>();
var customerUser = await userManager.GetUserByPhoneNumber(customerPhone);
var customer = db.Set<CustomerProfile>().FirstOrDefault(c => c.UserId == customerUser!.Id);
if (customer is null)
{
customer = new CustomerProfile { UserId = customerUser!.Id };
db.Set<CustomerProfile>().Add(customer);
db.SaveChanges();
}
if (!db.Set<PaymentGateway>().Any(g => g.Type == PaymentGatewayType.Bnpl && g.IsActive))
{
db.Set<PaymentGateway>().Add(new PaymentGateway
{
ProviderCode = BnplProviderCodes.SnappPay, Type = PaymentGatewayType.Bnpl, DisplayName = "SnappPay",
ConfigJson = "{\"merchantId\":\"test\",\"sandbox\":true}", IsActive = true, Priority = 0
});
db.SaveChanges();
}
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();
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 = customerPhone,
Latitude = 35.6892m, Longitude = 51.3890m, IsPrimary = true
};
db.Set<CustomerAddress>().Add(address);
var nurseUser = new User { UserName = $"nurse_{Guid.NewGuid():N}", PhoneNumber = $"0912{Random.Shared.Next(1000000, 9999999)}", 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();
var variant = new NurseServiceVariant
{
NurseId = nurse.Id, ServiceCategoryId = category.Id, Price = price, 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(2026, 8, 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();
return (request.Id, nurse.Id);
}
}
@@ -0,0 +1,136 @@
using Baya.Application.Features.Bnpl.Commands.InitiateBnplOrder;
using Baya.Application.Features.Bnpl.Commands.SettleBnplOrder;
using Baya.Application.Features.Bnpl.Queries.CheckBnplEligibility;
using Baya.Domain.Entities.Bnpl;
using Baya.Domain.Entities.Payments;
using Microsoft.EntityFrameworkCore;
using BookingEntity = Baya.Domain.Entities.Booking.Booking;
namespace Baya.Test.Foundation.Bnpl;
public class BnplHandlerTests
{
private static readonly DateTimeOffset Now = new(2026, 8, 10, 10, 0, 0, TimeSpan.Zero);
[Fact]
public async Task Eligibility_creates_row_eligible_and_returns_plan()
{
using var host = new BnplTestHost();
var requestId = host.AddAcceptedRequest(10_000_000);
var handler = new CheckBnplEligibilityQueryHandler(host.AsCustomer(), host.UnitOfWork, host.Resolver(), host.Config());
var result = await handler.Handle(new CheckBnplEligibilityQuery(requestId, BnplProviderCodes.SnappPay), CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.True(result.Result.IsEligible);
Assert.Equal(BnplEligibilityStatus.Eligible, result.Result.EligibilityStatus);
Assert.Equal(4, result.Result.InstallmentCount);
var bnpl = host.Db.Set<BnplTransaction>().AsNoTracking().Single();
Assert.Equal(BnplStatus.Eligible, bnpl.Status);
Assert.Equal(BnplEligibilityStatus.Eligible, bnpl.EligibilityStatus);
Assert.Equal(10_000_000, bnpl.OrderAmountIrr);
}
[Fact]
public async Task Initiate_issues_token_and_is_strictly_one_to_one()
{
using var host = new BnplTestHost();
var requestId = host.AddAcceptedRequest(10_000_000);
var handler = new InitiateBnplOrderCommandHandler(
host.AsCustomer(), host.UnitOfWork, host.Resolver(), host.Currency, host.Config(), host.Lock, host.Clock(Now));
var first = await handler.Handle(new InitiateBnplOrderCommand(requestId, BnplProviderCodes.SnappPay, "idem-1"), CancellationToken.None);
Assert.True(first.IsSuccess);
Assert.Equal(BnplStatus.TokenIssued, first.Result.Status);
Assert.False(string.IsNullOrEmpty(first.Result.ExternalPaymentToken));
// A second initiate reuses the same 1:1 row (the UNIQUE(payment_transaction_id) guard) — no second order.
var second = await handler.Handle(new InitiateBnplOrderCommand(requestId, BnplProviderCodes.SnappPay, "idem-1"), CancellationToken.None);
Assert.True(second.IsSuccess);
Assert.Equal(first.Result.BnplTransactionId, second.Result.BnplTransactionId);
Assert.Equal(1, host.Db.Set<BnplTransaction>().AsNoTracking().Count());
}
[Fact]
public async Task Settle_posts_net_of_fee_group_and_nurse_payout_is_invariant_to_method()
{
using var host = new BnplTestHost();
var (bnplId, requestId, _) = host.SeedVerifiedOrder(10_000_000);
var handler = SettleHandler(host, commissionRate: 0.10m);
var result = await handler.Handle(new SettleBnplOrderCommand(bnplId), CancellationToken.None);
Assert.True(result.IsSuccess);
var bnpl = host.ReloadBnpl(bnplId);
Assert.Equal(BnplStatus.Settled, bnpl.Status);
Assert.Equal(1_000_000, bnpl.BnplCommissionIrr); // 10% of 10M
Assert.Equal(9_000_000, bnpl.SettledAmountIrr); // net = order commission
var bookingId = host.Db.Set<BookingEntity>().AsNoTracking().Single(b => b.BookingRequestId == requestId).Id;
var legs = host.LedgerFor(bookingId);
Assert.Equal(5, legs.Count); // capture (3) + provider-fee (2)
Assert.Equal(
legs.Where(l => l.Direction == LedgerDirection.Debit).Sum(l => l.AmountIrr),
legs.Where(l => l.Direction == LedgerDirection.Credit).Sum(l => l.AmountIrr));
// The bnpl_fee_expense leg, and escrow reflecting NET cash (10M debit 1M credit = 9M = settled).
Assert.Equal(1_000_000, Leg(legs, LedgerAccountType.BnplFeeExpense, LedgerDirection.Debit));
Assert.Equal(10_000_000, Leg(legs, LedgerAccountType.EscrowHeld, LedgerDirection.Debit));
Assert.Equal(1_000_000, Leg(legs, LedgerAccountType.EscrowHeld, LedgerDirection.Credit));
// Payout invariant to method: nurse_payable = gross commission(0.15) = 8.5M, independent of the BNPL fee.
Assert.Equal(8_500_000, Leg(legs, LedgerAccountType.NursePayable, LedgerDirection.Credit));
Assert.Equal(1_500_000, Leg(legs, LedgerAccountType.PlatformRevenue, LedgerDirection.Credit));
// Identical to the card-capture path (the DoD payout-invariance assertion).
var card = LedgerPosting.CardCapture(bookingId, host.NurseId, 10_000_000, 1_500_000, 8_500_000, 1, Now.UtcDateTime);
Assert.Equal(
card.Single(l => l.AccountType == LedgerAccountType.NursePayable).AmountIrr,
Leg(legs, LedgerAccountType.NursePayable, LedgerDirection.Credit));
}
[Fact]
public async Task Replayed_settle_is_a_noop_no_second_ledger_group()
{
using var host = new BnplTestHost();
var (bnplId, requestId, _) = host.SeedVerifiedOrder(10_000_000);
var handler = SettleHandler(host, commissionRate: 0.10m);
await handler.Handle(new SettleBnplOrderCommand(bnplId), CancellationToken.None);
var bookingId = host.Db.Set<BookingEntity>().AsNoTracking().Single(b => b.BookingRequestId == requestId).Id;
var before = host.LedgerFor(bookingId).Count;
var replay = await handler.Handle(new SettleBnplOrderCommand(bnplId), CancellationToken.None);
Assert.True(replay.IsSuccess); // state guard → idempotent no-op
Assert.Equal(before, host.LedgerFor(bookingId).Count);
}
[Fact]
public async Task Settle_before_verify_is_rejected_by_the_state_guard()
{
using var host = new BnplTestHost();
var requestId = host.AddAcceptedRequest(10_000_000);
// Drive only to token_issued (skip verify) via the real initiate handler.
var initiate = new InitiateBnplOrderCommandHandler(
host.AsCustomer(), host.UnitOfWork, host.Resolver(), host.Currency, host.Config(), host.Lock, host.Clock(Now));
var order = await initiate.Handle(new InitiateBnplOrderCommand(requestId, BnplProviderCodes.SnappPay, "idem-1"), CancellationToken.None);
var settle = SettleHandler(host, commissionRate: 0.10m);
var result = await settle.Handle(new SettleBnplOrderCommand(order.Result.BnplTransactionId), CancellationToken.None);
Assert.False(result.IsSuccess);
Assert.True(result.IsConflict);
Assert.Empty(host.Db.Set<LedgerEntry>().AsNoTracking().ToList());
}
private static SettleBnplOrderCommandHandler SettleHandler(BnplTestHost host, decimal commissionRate)
=> new(host.UnitOfWork, host.Resolver(commissionRate), host.Config(), host.Serializer,
host.Settlement, host.Lock, host.Clock(Now), host.Notifications());
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,56 @@
using Baya.Domain.Entities.Bnpl;
using Baya.Domain.Entities.Payments;
namespace Baya.Test.Foundation.Bnpl;
public class BnplLedgerAndStateTests
{
[Fact]
public void BnplSettle_group_is_balanced_and_reflects_net_escrow()
{
var legs = LedgerPosting.BnplSettle(
bookingId: 1, nurseId: 2, grossIrr: 10_000_000, commissionIrr: 1_500_000,
payoutIrr: 8_500_000, bnplCommissionIrr: 1_000_000, bnplTransactionId: 3, createdAt: DateTime.UtcNow);
Assert.Equal(5, legs.Count);
Assert.Equal(
legs.Where(l => l.Direction == LedgerDirection.Debit).Sum(l => l.AmountIrr),
legs.Where(l => l.Direction == LedgerDirection.Credit).Sum(l => l.AmountIrr));
// Net escrow = 10M debit 1M credit = 9M (the settled amount), not the gross.
var escrowNet = legs.Where(l => l.AccountType == LedgerAccountType.EscrowHeld && l.Direction == LedgerDirection.Debit).Sum(l => l.AmountIrr)
- legs.Where(l => l.AccountType == LedgerAccountType.EscrowHeld && l.Direction == LedgerDirection.Credit).Sum(l => l.AmountIrr);
Assert.Equal(9_000_000, escrowNet);
Assert.All(legs, l => Assert.Equal(LedgerSourceRefType.BnplTransaction, l.SourceRefType));
}
[Fact]
public void BnplSettle_throws_when_the_capture_legs_do_not_reconcile()
=> Assert.Throws<InvalidOperationException>(() => LedgerPosting.BnplSettle(1, 2, 10_000_000, 1_500_000, 8_000_000, 1_000_000, 3, DateTime.UtcNow));
[Fact]
public void State_machine_allows_forward_edges_and_rejects_illegal_ones()
{
Assert.True(BnplTransitions.CanTransition(BnplStatus.Eligible, BnplStatus.TokenIssued));
Assert.True(BnplTransitions.CanTransition(BnplStatus.TokenIssued, BnplStatus.Verified));
Assert.True(BnplTransitions.CanTransition(BnplStatus.Verified, BnplStatus.Settled));
Assert.True(BnplTransitions.CanTransition(BnplStatus.Settled, BnplStatus.Reverted));
Assert.False(BnplTransitions.CanTransition(BnplStatus.TokenIssued, BnplStatus.Settled)); // must verify first
Assert.False(BnplTransitions.CanTransition(BnplStatus.Settled, BnplStatus.Settled)); // no self-loop
Assert.False(BnplTransitions.CanTransition(BnplStatus.Reverted, BnplStatus.Settled)); // terminal
}
[Fact]
public void MarkSettled_enforces_the_net_equals_order_minus_commission_invariant()
{
var bnpl = new BnplTransaction { OrderAmountIrr = 10_000_000 };
bnpl.MarkTokenIssued("t", null);
bnpl.MarkVerified(null, null);
Assert.Throws<InvalidOperationException>(() => bnpl.MarkSettled(settledAmountIrr: 9_500_000, commissionIrr: 1_000_000, settledAt: null, null));
bnpl.MarkSettled(settledAmountIrr: 9_000_000, commissionIrr: 1_000_000, settledAt: null, null);
Assert.Equal(BnplStatus.Settled, bnpl.Status);
}
}
@@ -0,0 +1,208 @@
using Baya.Application.Common;
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Configuration;
using Baya.Application.Contracts.Payments;
using Baya.Domain.Entities.Booking;
using Baya.Domain.Entities.Bnpl;
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.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;
namespace Baya.Test.Foundation.Bnpl;
/// <summary>
/// A self-contained SQLite host exercising the real EF model (the strict 1:1 <c>UNIQUE(payment_transaction_id)</c>,
/// the settle-split CHECK, the append-only ledger) for the b12 BNPL engine. Seeds one bookable nurse + one
/// customer + an active BNPL gateway, and can seed a verified BNPL order so a test can drive the real settle
/// handler against the real <see cref="UnitOfWork"/> with a deterministic provider mock.
/// </summary>
public sealed class BnplTestHost : IDisposable
{
private readonly SqliteConnection _connection;
public ApplicationDbContext Db { get; }
public UnitOfWork UnitOfWork { get; }
public long CustomerId { get; }
public int CustomerUserId { get; }
public long NurseId { get; }
public int NurseUserId { get; }
public long PatientId { get; }
public long AddressId { get; }
public long CategoryId { get; }
public long GatewayId { get; }
public BnplTestHost()
{
_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();
CategoryId = category.Id;
var customerUser = new User { UserName = "cust1", PhoneNumber = "09120000001", Gender = "male", Name = "علی", FamilyName = "رضایی", IsActive = true };
Db.Users.Add(customerUser);
Db.SaveChanges();
CustomerUserId = customerUser.Id;
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;
var nurseUser = new User { UserName = "nurse1", PhoneNumber = "09120000002", Gender = "female", Name = "زهرا", FamilyName = "احمدی", IsActive = true };
Db.Users.Add(nurseUser);
Db.SaveChanges();
NurseUserId = nurseUser.Id;
var nurse = new NurseProfile { UserId = nurseUser.Id };
nurse.MarkVerified();
nurse.SetAcceptingBookings(true);
Db.Set<NurseProfile>().Add(nurse);
Db.SaveChanges();
NurseId = nurse.Id;
var gateway = new PaymentGateway
{
ProviderCode = BnplProviderCodes.SnappPay, Type = PaymentGatewayType.Bnpl, DisplayName = "SnappPay",
ConfigJson = "{\"merchantId\":\"test\",\"sandbox\":true}", IsActive = true, Priority = 0
};
Db.Set<PaymentGateway>().Add(gateway);
Db.SaveChanges();
GatewayId = gateway.Id;
}
public long AddAcceptedRequest(long price)
{
var variant = new NurseServiceVariant
{
NurseId = NurseId, ServiceCategoryId = CategoryId, Price = price, 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, 8, 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();
return request.Id;
}
/// <summary>Seeds a verified BNPL order (its pending payment_transaction + the 1:1 bnpl_transactions row walked
/// eligible → token_issued → verified) against a fresh accepted request. Returns (bnplId, requestId, token).</summary>
public (long BnplId, long RequestId, string Token) SeedVerifiedOrder(long gross)
{
var requestId = AddAcceptedRequest(gross);
var token = $"tok-{Guid.NewGuid():N}";
var transaction = new PaymentTransaction
{
BookingRequestId = requestId, CustomerId = CustomerId, GatewayId = GatewayId,
Amount = gross, Currency = "IRR", IsInstallment = true, GatewayReferenceCode = token
};
Db.Set<PaymentTransaction>().Add(transaction);
Db.SaveChanges();
var bnpl = new BnplTransaction
{
PaymentTransactionId = transaction.Id, ProviderCode = BnplProviderCodes.SnappPay,
MerchantOfRecord = "platform", OrderAmountIrr = gross, Currency = "IRR"
};
bnpl.EligibilityStatus = BnplEligibilityStatus.Eligible;
bnpl.MarkTokenIssued(token, "order-1");
bnpl.MarkVerified("verify-1", null);
Db.Set<BnplTransaction>().Add(bnpl);
Db.SaveChanges();
return (bnpl.Id, requestId, token);
}
// ---- seams ----
public IVariantSnapshotSerializer Serializer { get; } = new VariantSnapshotSerializer();
public ISettlementSplitProvider Settlement { get; } = new MockSettlementSplitProvider();
public IDistributedLock Lock { get; } = new InProcessDistributedLock();
public ICurrencyNormalizer Currency { get; } = new MockCurrencyNormalizer(Options.Create(new SeamOptions()));
public IBnplProviderResolver Resolver(decimal commissionRate = 0.10m, bool forceFailure = false)
{
var opts = Options.Create(new SeamOptions { Bnpl = new BnplOptions { CommissionRate = commissionRate, ForceFailure = forceFailure } });
return new MockBnplProviderResolver(new MockBnplProvider(opts));
}
public ICurrentUser AsCustomer()
{
var u = Substitute.For<ICurrentUser>();
u.UserId.Returns(CustomerUserId);
u.Roles.Returns(new[] { RoleNames.Customer });
return u;
}
public IDateTimeProvider Clock(DateTimeOffset now)
{
var c = Substitute.For<IDateTimeProvider>();
c.UtcNow.Returns(now);
return c;
}
public IPlatformConfig Config(decimal feeRate = 0.15m)
{
var cfg = Substitute.For<IPlatformConfig>();
cfg.GetConfig<decimal>("platform_fee_rate", Arg.Any<CancellationToken>()).Returns(feeRate);
cfg.GetConfig<string>("bnpl_merchant_of_record", Arg.Any<CancellationToken>()).Returns("platform");
return cfg;
}
public INotificationDispatcher Notifications() => Substitute.For<INotificationDispatcher>();
public IReadOnlyList<LedgerEntry> LedgerFor(long bookingId)
=> Db.Set<LedgerEntry>().AsNoTracking().Where(l => l.BookingId == bookingId).OrderBy(l => l.Id).ToList();
public BnplTransaction ReloadBnpl(long id)
=> Db.Set<BnplTransaction>().AsNoTracking().Single(b => b.Id == id);
public void Dispose()
{
Db.Dispose();
_connection.Dispose();
}
}