diff --git a/client/CLAUDE.md b/client/CLAUDE.md index edcd2d9..2e6fc66 100644 --- a/client/CLAUDE.md +++ b/client/CLAUDE.md @@ -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 diff --git a/client/messages/en.json b/client/messages/en.json index 67005eb..6095f24 100644 --- a/client/messages/en.json +++ b/client/messages/en.json @@ -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 24–48 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" } } diff --git a/client/messages/fa.json b/client/messages/fa.json index 2edd221..954dba2 100644 --- a/client/messages/fa.json +++ b/client/messages/fa.json @@ -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": "بارگذاری دوباره" } } diff --git a/client/src/app/[locale]/(private-routes)/nurse/profile/page.tsx b/client/src/app/[locale]/(private-routes)/nurse/profile/page.tsx index f104bb2..8b84c19 100644 --- a/client/src/app/[locale]/(private-routes)/nurse/profile/page.tsx +++ b/client/src/app/[locale]/(private-routes)/nurse/profile/page.tsx @@ -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(null); const [avatarUrl, setAvatarUrl] = useState(initial?.avatarUrl ?? null); @@ -63,41 +67,47 @@ const NurseProfileForm: FunctionComponent<{ initial: NurseProfile | null }> = ({ return ( - - {t('title')} - + + + {t('title')} + + {/* The public trust signal on the nurse's own profile — the same badge f6 reuses in search. */} + + {t('subtitle')} - {/* Not bookable until verification (f5) — a neutral placeholder, not the real banner. */} - - - - - - - {t('unverified_title')} - - - {t('unverified_body')} - - - - {t('unverified_cta')} - + {/* Blocked-until-verified banner — shown until the aggregate is approved (incl. the expired state). */} + {badgeState !== 'verified' ? ( + + + + + + + {t('unverified_title')} + + + {t('unverified_body')} + + + + {t('unverified_cta')} + + - - + + ) : null} diff --git a/client/src/app/[locale]/(private-routes)/nurse/services/MyServicesList.tsx b/client/src/app/[locale]/(private-routes)/nurse/services/MyServicesList.tsx index bdcbbab..53759df 100644 --- a/client/src/app/[locale]/(private-routes)/nurse/services/MyServicesList.tsx +++ b/client/src/app/[locale]/(private-routes)/nurse/services/MyServicesList.tsx @@ -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 = ({ onAdd, onEdit ) : null} + + {isLoading ? ( {[0, 1].map((key) => ( diff --git a/client/src/app/[locale]/(private-routes)/nurse/services/PublishGate.tsx b/client/src/app/[locale]/(private-routes)/nurse/services/PublishGate.tsx new file mode 100644 index 0000000..d9fe89d --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/nurse/services/PublishGate.tsx @@ -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 ( + + + + + + {approved ? t('publish_ready_title') : t('publish_blocked_title')} + + + {approved ? t('publish_ready_body') : t('publish_blocked_body')} + + + + + + enqueueSnackbar(t('publish_done'), { variant: 'success' })} + sx={{ m: 0 }} + > + {t('publish_cta')} + + {!approved ? ( + + {t('publish_complete_verification')} + + ) : null} + + + ); +}; + +export default PublishGate; diff --git a/client/src/app/[locale]/(private-routes)/nurse/verification/VerificationChecklist.tsx b/client/src/app/[locale]/(private-routes)/nurse/verification/VerificationChecklist.tsx new file mode 100644 index 0000000..a31239d --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/nurse/verification/VerificationChecklist.tsx @@ -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 = ({ 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 ( + + + + {steps.map((step) => ( + + ))} + + + ); +}; + +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 ( + + + + {t('progress_title')} + + + {t('progress_count', { passed: format(passed), total: format(total) })} + + + + + ); +}; + +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 ( + + + + + + {label} + + {description ? ( + + {description} + + ) : null} + + + + + {showReason && reason ? ( + + {reason} + + ) : null} + + {showAutoNote ? ( + + {t('auto_query_note')} + + ) : null} + + {route && (highlighted || step.status === 'failed' || step.status === 'expired') ? ( + + {step.status === 'failed' || step.status === 'expired' ? t('row_fix') : t('row_go')} + + ) : null} + + ); +}; + +export default VerificationChecklist; diff --git a/client/src/app/[locale]/(private-routes)/nurse/verification/credentials/page.tsx b/client/src/app/[locale]/(private-routes)/nurse/verification/credentials/page.tsx new file mode 100644 index 0000000..23957af --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/nurse/verification/credentials/page.tsx @@ -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([]); + const [customSpecialty, setCustomSpecialty] = useState(''); + const [issuingAuthority, setIssuingAuthority] = useState(''); + const [issuedAt, setIssuedAt] = useState(''); + const [expiresAt, setExpiresAt] = useState(''); + const [uploadedSteps, setUploadedSteps] = useState>({}); + + 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 ; + + if (!status || manualSteps.length === 0) { + return ( + + + {t('credentials_title')} + + + {t('credentials_needs_start')} + + + {t('back_to_checklist')} + + + ); + } + + const anyUploaded = Object.values(uploadedSteps).some(Boolean); + + return ( + + + + {t('credentials_title')} + + + {t('credentials_subtitle')} + + + + + + + + { + 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. */} + + {manualSteps.map((step) => ( + setUploadedSteps((prev) => ({ ...prev, [step.id]: true }))} + rejected={step.status === 'failed'} + rejectionReason={step.failureReason ?? undefined} + existingDoc={step.status === 'in_review' ? { name: t('doc_uploaded') } : null} + /> + ))} + + + {/* Supplementary education certificate — a local attachment (no dedicated step). */} + ({ name: file.name })} /> + + + + {t('specialties_label')} + + + {t('specialties_hint')} + + + {SPECIALTY_PRESETS.map((preset) => { + const selected = specialties.includes(preset); + return ( + 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) => ( + toggleSpecialty(value)} + sx={{ fontWeight: 600, backgroundColor: 'var(--bal-primary)', color: 'var(--bal-primary-contrast)' }} + /> + ))} + + + setCustomSpecialty(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + addCustomSpecialty(); + } + }} + /> + + {t('specialty_add')} + + + + + {/* Optional registry details the admin cross-checks — issue/expiry are stored UTC (Shamsi shown elsewhere). */} + + + {t('registry_details_label')} + + setIssuingAuthority(event.target.value)} + fullWidth + /> + + setIssuedAt(event.target.value)} + slotProps={{ inputLabel: { shrink: true } }} + sx={{ flex: 1, minWidth: 160 }} + /> + setExpiresAt(event.target.value)} + slotProps={{ inputLabel: { shrink: true } }} + sx={{ flex: 1, minWidth: 160 }} + /> + + + + + + + {t('manual_review_note')} + + + + + + {submitCredentials.isPending ? t('credentials_submitting') : t('credentials_submit')} + + + {t('back_to_checklist')} + + + + ); +} diff --git a/client/src/app/[locale]/(private-routes)/nurse/verification/identity/page.tsx b/client/src/app/[locale]/(private-routes)/nurse/verification/identity/page.tsx new file mode 100644 index 0000000..f0116f9 --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/nurse/verification/identity/page.tsx @@ -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(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 ( + + + + {t('identity_title')} + + + {t('identity_subtitle')} + + + + + + + + { + 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 + /> + + setCardCaptured(true)} + /> + + setSelfieCaptured(true)} + /> + + + + + {t('auto_registry_note')} + + + + {submitError ? ( + + {t(`error_${submitError.key}`)} + + ) : null} + + {!cardCaptured ? ( + + {t('card_recommended')} + + ) : null} + + + + {submitIdentity.isPending ? t('identity_submitting') : t('identity_submit')} + + + {t('back_to_checklist')} + + + + ); +} diff --git a/client/src/app/[locale]/(private-routes)/nurse/verification/page.tsx b/client/src/app/[locale]/(private-routes)/nurse/verification/page.tsx index e614552..15291e4 100644 --- a/client/src/app/[locale]/(private-routes)/nurse/verification/page.tsx +++ b/client/src/app/[locale]/(private-routes)/nurse/verification/page.tsx @@ -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 ; +/** + * 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 ( + + + + {t('title')} + + + {t('subtitle')} + + + + {isLoading ? ( + + + {[0, 1, 2, 3].map((key) => ( + + ))} + + ) : isError ? ( + refetch()} sx={{ m: 0 }}> + {t('retry')} + + } + > + {t('load_error')} + + ) : !status || status.status === 'not_started' ? ( + + ) : status.status === 'approved' ? ( + go(ROUTES.NURSE_SERVICES)} /> + ) : ( + <> + + 0} /> + + + )} + + {/* 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' ? ( + { + __mockApproveAll(); + refreshStatus(); + }} + onReject={() => { + __mockRejectStep('moh_competency_license', 'blurry_scan'); + refreshStatus(); + }} + /> + ) : null} + + ); +} + +function NotStarted({ onStart, pending }: { onStart: () => void; pending: boolean }) { + const t = useTranslations('verification'); + return ( + + + + {t('start_title')} + + + {t('start_body')} + + + {pending ? t('starting') : t('start_cta')} + + + ); +} + +function Approved({ onPublish }: { onPublish: () => void }) { + const t = useTranslations('verification'); + return ( + + + + + {t('approved_title')} + + + + {t('approved_body')} + + + {t('approved_cta')} + + + ); +} + +function BlockingSummary({ hasBlocking }: { hasBlocking: boolean }) { + const t = useTranslations('verification'); + if (!hasBlocking) return null; + return ( + + {t('blocking_summary')} + + ); +} + +function ContinueCta({ + status, + onContinue, +}: { + status: Parameters[0]; + onContinue: () => void; +}) { + const t = useTranslations('verification'); + const index = nextActionableIndex(status); + const route = nextActionRoute(status); + if (route == null) return null; + return ( + + {index != null ? t('continue_step', { n: index }) : t('continue')} + + ); +} + +function MockAdminControls({ onApprove, onReject }: { onApprove: () => void; onReject: () => void }) { + const t = useTranslations('verification'); + return ( + + + {t('mock_admin_title')} + + + + {t('mock_admin_approve')} + + + {t('mock_admin_reject')} + + + + ); } diff --git a/client/src/app/[locale]/(private-routes)/nurse/verification/review/page.tsx b/client/src/app/[locale]/(private-routes)/nurse/verification/review/page.tsx new file mode 100644 index 0000000..0d32eca --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/nurse/verification/review/page.tsx @@ -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 24–48h 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 ; + + const steps = displaySteps(status); + const isApproved = status?.status === 'approved'; + + return ( + + + + + + + + + + {isApproved ? t('review_approved_title') : t('review_title')} + + + + {isApproved ? t('review_approved_body') : t('review_body')} + + {!isApproved ? ( + + {t('review_eta')} + + ) : null} + + + + + {t('review_summary_title')} + + {steps.map((step) => { + const chip = stepStatusChip(step.status); + const label = t.has(stepLabelKey(step.code)) ? t(stepLabelKey(step.code)) : step.displayName; + return ( + + {label} + + + ); + })} + + + + {t('review_view_status')} + + + ); +} diff --git a/client/src/app/[locale]/(private-routes)/nurse/verification/verificationSteps.ts b/client/src/app/[locale]/(private-routes)/nurse/verification/verificationSteps.ts new file mode 100644 index 0000000..5622bbf --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/nurse/verification/verificationSteps.ts @@ -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; +} diff --git a/client/src/components/DocumentUpload/DocumentUpload.test.tsx b/client/src/components/DocumentUpload/DocumentUpload.test.tsx new file mode 100644 index 0000000..70cbda9 --- /dev/null +++ b/client/src/components/DocumentUpload/DocumentUpload.test.tsx @@ -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> = {}) { + const onUpload = props.onUpload ?? jest.fn().mockResolvedValue({ name: 'license.pdf' }); + const utils = render( + + + , + ); + 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(' 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(); + }); +}); diff --git a/client/src/components/DocumentUpload/DocumentUpload.tsx b/client/src/components/DocumentUpload/DocumentUpload.tsx new file mode 100644 index 0000000..71fb6dd --- /dev/null +++ b/client/src/components/DocumentUpload/DocumentUpload.tsx @@ -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 (0–100) 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; + /** 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 = ({ + 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(null); + + const [state, setState] = useState('idle'); + const [progress, setProgress] = useState(0); + const [errorKey, setErrorKey] = useState(null); + const [fileName, setFileName] = useState(null); + const [previewUrl, setPreviewUrl] = useState(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) => { + 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 ( + + + {label} + + {hint ? ( + + {hint} + + ) : null} + + + + {rejected ? ( + + + + + {t('upload_rejected')} + + + {rejectionReason ? ( + + {rejectionReason} + + ) : null} + + {t('upload_reupload')} + + + ) : state === 'uploading' ? ( + + + + + {fileName} + + + {progress}% + + + + + {t('upload_uploading')} + + + ) : showUploaded ? ( + + + {previewUrl ? ( + + ) : ( + + )} + + + {uploadedName} + + + + + {t('upload_success')} + + + + + {t('upload_change')} + + + + ) : state === 'error' ? ( + + + + + {t(errorKey ?? 'upload_error', { size: megabytes })} + + + + {t('upload_retry')} + + + ) : ( + { + 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)' }, + }} + > + + + {capture ? t('upload_capture') : t('upload_choose')} + + + {t('upload_size_hint', { size: megabytes })} + + + )} + + ); +}; + +// 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; diff --git a/client/src/components/DocumentUpload/index.tsx b/client/src/components/DocumentUpload/index.tsx new file mode 100644 index 0000000..5787e7a --- /dev/null +++ b/client/src/components/DocumentUpload/index.tsx @@ -0,0 +1,2 @@ +export { default } from './DocumentUpload'; +export type { DocumentUploadProps, UploadedDocInfo } from './DocumentUpload'; diff --git a/client/src/components/TrustBadge/TrustBadge.test.tsx b/client/src/components/TrustBadge/TrustBadge.test.tsx new file mode 100644 index 0000000..2135975 --- /dev/null +++ b/client/src/components/TrustBadge/TrustBadge.test.tsx @@ -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( + + + , + ); +} + +describe(' 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(); + }); +}); diff --git a/client/src/components/TrustBadge/TrustBadge.tsx b/client/src/components/TrustBadge/TrustBadge.tsx new file mode 100644 index 0000000..20cdd99 --- /dev/null +++ b/client/src/components/TrustBadge/TrustBadge.tsx @@ -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 = { + 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 { + /** 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 = ({ state, size = 'small', sx, ...rest }) => { + const t = useTranslations('verification'); + const style = BADGE_STYLE[state]; + return ( + } + sx={{ backgroundColor: style.bg, color: style.fg, fontWeight: 700, ...sx }} + {...rest} + /> + ); +}; + +export default TrustBadge; diff --git a/client/src/components/TrustBadge/index.tsx b/client/src/components/TrustBadge/index.tsx new file mode 100644 index 0000000..116b978 --- /dev/null +++ b/client/src/components/TrustBadge/index.tsx @@ -0,0 +1,2 @@ +export { default } from './TrustBadge'; +export type { TrustBadgeProps } from './TrustBadge'; diff --git a/client/src/components/common/AppIcon/config.ts b/client/src/components/common/AppIcon/config.ts index 80fe620..d412280 100644 --- a/client/src/components/common/AppIcon/config.ts +++ b/client/src/components/common/AppIcon/config.ts @@ -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, }; diff --git a/client/src/components/index.tsx b/client/src/components/index.tsx index 3ed1cd7..3dd6ca3 100644 --- a/client/src/components/index.tsx +++ b/client/src/components/index.tsx @@ -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'; diff --git a/client/src/constants/routes.ts b/client/src/constants/routes.ts index 82b9845..9bf523a 100644 --- a/client/src/constants/routes.ts +++ b/client/src/constants/routes.ts @@ -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 diff --git a/client/src/services/verification/apis/clientApi.ts b/client/src/services/verification/apis/clientApi.ts new file mode 100644 index 0000000..9fefd4e --- /dev/null +++ b/client/src/services/verification/apis/clientApi.ts @@ -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 { + 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 { + 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>(BASE)), + + start: async () => + unwrap(await clientFetch>(`${BASE}/submit`, { method: 'POST' })), + + runIdentityKyc: async ({ nationalId, livenessCaptured }: IdentityKycInput) => + unwrap( + await clientFetch>(`${BASE}/steps/identity_kyc/run`, { + method: 'POST', + body: JSON.stringify({ nationalId, livenessPayload: livenessCaptured ? 'captured' : null }), + }), + ), + + runShahkarMatch: async () => + unwrap(await clientFetch>(`${BASE}/steps/shahkar_match/run`, { method: 'POST' })), + + runBankVerification: async () => + unwrap( + await clientFetch>(`${BASE}/steps/bank_account_verification/run`, { + method: 'POST', + }), + ), + + uploadStepDocument: async (stepId, file, onProgress): Promise => { + const { objectStorageKey, uploadUrl } = unwrap( + await clientFetch>(`${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>(`${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>(`${NURSES_BASE}/${nurseId}/trust_badge`)), +}; diff --git a/client/src/services/verification/apis/index.ts b/client/src/services/verification/apis/index.ts new file mode 100644 index 0000000..8af8120 --- /dev/null +++ b/client/src/services/verification/apis/index.ts @@ -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; diff --git a/client/src/services/verification/apis/mockApi.ts b/client/src/services/verification/apis/mockApi.ts new file mode 100644 index 0000000..493a441 --- /dev/null +++ b/client/src/services/verification/apis/mockApi.ts @@ -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); +} diff --git a/client/src/services/verification/constants.ts b/client/src/services/verification/constants.ts new file mode 100644 index 0000000..20c3bee --- /dev/null +++ b/client/src/services/verification/constants.ts @@ -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; diff --git a/client/src/services/verification/hooks/useNurseTrustBadge.ts b/client/src/services/verification/hooks/useNurseTrustBadge.ts new file mode 100644 index 0000000..7e8daab --- /dev/null +++ b/client/src/services/verification/hooks/useNurseTrustBadge.ts @@ -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, + }); +} diff --git a/client/src/services/verification/hooks/useRunBankVerification.ts b/client/src/services/verification/hooks/useRunBankVerification.ts new file mode 100644 index 0000000..988c29c --- /dev/null +++ b/client/src/services/verification/hooks/useRunBankVerification.ts @@ -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() }); + }, + }); +} diff --git a/client/src/services/verification/hooks/useStartVerification.ts b/client/src/services/verification/hooks/useStartVerification.ts new file mode 100644 index 0000000..7c3cb82 --- /dev/null +++ b/client/src/services/verification/hooks/useStartVerification.ts @@ -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); + }, + }); +} diff --git a/client/src/services/verification/hooks/useSubmitCredentials.ts b/client/src/services/verification/hooks/useSubmitCredentials.ts new file mode 100644 index 0000000..ae7facf --- /dev/null +++ b/client/src/services/verification/hooks/useSubmitCredentials.ts @@ -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() }); + }, + }); +} diff --git a/client/src/services/verification/hooks/useSubmitIdentity.ts b/client/src/services/verification/hooks/useSubmitIdentity.ts new file mode 100644 index 0000000..ea0a6b2 --- /dev/null +++ b/client/src/services/verification/hooks/useSubmitIdentity.ts @@ -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 + * phone↔national-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 => { + const identity = await verificationApi.runIdentityKyc(input); + const shahkar = identity.stepStatus === 'passed' ? await verificationApi.runShahkarMatch() : null; + return { identity, shahkar }; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: verificationKeys.status() }); + }, + }); +} diff --git a/client/src/services/verification/hooks/useUploadVerificationDocument.ts b/client/src/services/verification/hooks/useUploadVerificationDocument.ts new file mode 100644 index 0000000..1e082ff --- /dev/null +++ b/client/src/services/verification/hooks/useUploadVerificationDocument.ts @@ -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 (0–100) 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 `` + * 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 => + verificationApi.uploadStepDocument(stepId, file, onProgress), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: verificationKeys.status() }); + }, + }); +} diff --git a/client/src/services/verification/hooks/useVerificationStatus.ts b/client/src/services/verification/hooks/useVerificationStatus.ts new file mode 100644 index 0000000..74153d6 --- /dev/null +++ b/client/src/services/verification/hooks/useVerificationStatus.ts @@ -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, + }); +} diff --git a/client/src/services/verification/index.ts b/client/src/services/verification/index.ts new file mode 100644 index 0000000..7ca29e3 --- /dev/null +++ b/client/src/services/verification/index.ts @@ -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'; diff --git a/client/src/services/verification/keys.ts b/client/src/services/verification/keys.ts new file mode 100644 index 0000000..b491539 --- /dev/null +++ b/client/src/services/verification/keys.ts @@ -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, +}; diff --git a/client/src/services/verification/types.ts b/client/src/services/verification/types.ts new file mode 100644 index 0000000..2370084 --- /dev/null +++ b/client/src/services/verification/types.ts @@ -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` 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; + /** Open (or re-open) verification and seed the checklist. Idempotent. */ + start(): Promise; + /** Automated national-ID + liveness check. A vendor fail is a `failed` status, not a thrown error. */ + runIdentityKyc(input: IdentityKycInput): Promise; + /** Automated phone↔national-id Shahkar match (requires identity KYC passed). Shared-SIM is a handled fail. */ + runShahkarMatch(): Promise; + /** Automated استعلام شبا IBAN-owner ↔ national-id money-mule guard (requires a primary bank account). */ + runBankVerification(): Promise; + /** + * Upload a document for a manual step (url → PUT bytes → confirm), moving it to `in_review`. Reports + * upload progress (0–100) via `onProgress`. Returns the server's stored **metadata** (never bytes). + */ + uploadStepDocument(stepId: number, file: File, onProgress?: (percent: number) => void): Promise; + /** Persist the structured professional-credential details (gap-filed; mock-persisted for now). */ + submitCredentialDetails(input: CredentialDetailsInput): Promise; + /** The public trust badge for a nurse (types only). */ + getTrustBadge(nurseId: number): Promise; +} + +/** 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'; +} diff --git a/client/src/services/verification/validation.ts b/client/src/services/verification/validation.ts new file mode 100644 index 0000000..13346fa --- /dev/null +++ b/client/src/services/verification/validation.ts @@ -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; +} diff --git a/dev/contracts/domains/bnpl.md b/dev/contracts/domains/bnpl.md new file mode 100644 index 0000000..e17b2c2 --- /dev/null +++ b/dev/contracts/domains/bnpl.md @@ -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": "" }`. 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 ~7–10 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). diff --git a/dev/contracts/openapi/swagger.v1.json b/dev/contracts/openapi/swagger.v1.json index c8e78d8..bd66aa1 100644 --- a/dev/contracts/openapi/swagger.v1.json +++ b/dev/contracts/openapi/swagger.v1.json @@ -11,6 +11,328 @@ } ], "paths": { + "/api/v1/admin_bnpl/{id}/verify": { + "post": { + "tags": [ + "AdminBnpl" + ], + "operationId": "AdminBnpl_Verify", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + }, + "x-position": 1 + } + ], + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfBoolean" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/admin_bnpl/{id}/settle": { + "post": { + "tags": [ + "AdminBnpl" + ], + "operationId": "AdminBnpl_Settle", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + }, + "x-position": 1 + } + ], + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfBoolean" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/admin_bnpl/{id}/revert": { + "post": { + "tags": [ + "AdminBnpl" + ], + "operationId": "AdminBnpl_Revert", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + }, + "x-position": 1 + } + ], + "requestBody": { + "x-name": "body", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RevertBnplBody" + } + } + }, + "required": true, + "x-position": 2 + }, + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfRevertBnplResult" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/admin_bnpl/{id}": { + "get": { + "tags": [ + "AdminBnpl" + ], + "summary": "Retrieves a AdminBnpl by unique id", + "operationId": "AdminBnpl_Get", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "A unique id for the AdminBnpl", + "schema": { + "type": "integer", + "format": "int64" + }, + "x-position": 1 + } + ], + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfBnplOrderStatusDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, "/api/v1/admin_booking_requests/expire": { "post": { "tags": [ @@ -4895,6 +5217,239 @@ } } }, + "/api/v1/checkout_bnpl/eligibility": { + "post": { + "tags": [ + "CheckoutBnpl" + ], + "operationId": "CheckoutBnpl_Eligibility", + "requestBody": { + "x-name": "query", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CheckBnplEligibilityQuery" + } + } + }, + "required": true, + "x-position": 1 + }, + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfBnplEligibilityDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/checkout_bnpl/initiate": { + "post": { + "tags": [ + "CheckoutBnpl" + ], + "operationId": "CheckoutBnpl_Initiate", + "requestBody": { + "x-name": "body", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InitiateBnplBody" + } + } + }, + "required": true, + "x-position": 1 + }, + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfInitiateBnplResult" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/checkout_bnpl/{id}": { + "get": { + "tags": [ + "CheckoutBnpl" + ], + "summary": "Retrieves a CheckoutBnpl by unique id", + "operationId": "CheckoutBnpl_Get", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "A unique id for the CheckoutBnpl", + "schema": { + "type": "integer", + "format": "int64" + }, + "x-position": 1 + } + ], + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfBnplOrderStatusDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, "/api/v1/customer_addresses/create": { "post": { "tags": [ @@ -9695,6 +10250,77 @@ ] } }, + "/api/v1/webhooks_bnpl/{provider}": { + "post": { + "tags": [ + "WebhooksBnpl" + ], + "operationId": "WebhooksBnpl_Callback", + "parameters": [ + { + "name": "provider", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "x-position": 1 + } + ], + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfBnplCallbackResult" + } + } + } + } + } + } + }, "/api/v1/webhooks/payments/{provider}": { "post": { "tags": [ @@ -9840,6 +10466,196 @@ 500 ] }, + "ApiResultOfBoolean": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "type": "boolean" + } + } + } + ] + }, + "ApiResultOfRevertBnplResult": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/RevertBnplResult" + } + ] + } + } + } + ] + }, + "RevertBnplResult": { + "type": "object", + "additionalProperties": false, + "properties": { + "bnplTransactionId": { + "type": "integer", + "format": "int64" + }, + "refundId": { + "type": "integer", + "format": "int64" + }, + "status": { + "type": "string" + }, + "revertTransactionId": { + "type": "string", + "nullable": true + }, + "revertedAmountIrr": { + "type": "string" + }, + "expectedCustomerRefundEta": { + "type": "string", + "format": "date", + "nullable": true + } + } + }, + "RevertBnplBody": { + "type": "object", + "description": "The revert body (the order id comes from the route). Omit refund_percentage for a full revert.", + "additionalProperties": false, + "properties": { + "refundPercentage": { + "type": "number", + "format": "decimal", + "nullable": true + }, + "ticketId": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "reasonNotes": { + "type": "string", + "nullable": true + } + } + }, + "ApiResultOfBnplOrderStatusDto": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/BnplOrderStatusDto" + } + ] + } + } + } + ] + }, + "BnplOrderStatusDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "paymentTransactionId": { + "type": "integer", + "format": "int64" + }, + "bookingId": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "providerCode": { + "type": "string" + }, + "status": { + "type": "string" + }, + "eligibilityStatus": { + "type": "string", + "nullable": true + }, + "orderAmountIrr": { + "type": "string" + }, + "settledAmountIrr": { + "type": "string", + "nullable": true + }, + "bnplCommissionIrr": { + "type": "string", + "nullable": true + }, + "currency": { + "type": "string" + }, + "installmentCount": { + "type": "integer", + "format": "int32" + }, + "settledAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "revertTransactionId": { + "type": "string", + "nullable": true + }, + "revertedAmountIrr": { + "type": "string", + "nullable": true + }, + "revertedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "providerCommissionReversedAmount": { + "type": "string", + "nullable": true + }, + "refundChannel": { + "type": "string", + "nullable": true + }, + "expectedCustomerRefundEta": { + "type": "string", + "format": "date", + "nullable": true + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + } + }, "ApiResultOfExpireBookingRequestsResult": { "allOf": [ { @@ -10327,22 +11143,6 @@ } } }, - "ApiResultOfBoolean": { - "allOf": [ - { - "$ref": "#/components/schemas/ApiResult" - }, - { - "type": "object", - "additionalProperties": false, - "properties": { - "data": { - "type": "boolean" - } - } - } - ] - }, "WriteOffClawbackBody": { "type": "object", "description": "The write-off body (the id comes from the route).", @@ -12874,6 +13674,123 @@ } ] }, + "ApiResultOfBnplEligibilityDto": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/BnplEligibilityDto" + } + ] + } + } + } + ] + }, + "BnplEligibilityDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "eligibilityStatus": { + "type": "string" + }, + "isEligible": { + "type": "boolean" + }, + "installmentCount": { + "type": "integer", + "format": "int32" + }, + "planSummary": { + "type": "string" + }, + "creditCeilingIrr": { + "type": "string", + "nullable": true + } + } + }, + "CheckBnplEligibilityQuery": { + "type": "object", + "additionalProperties": false, + "properties": { + "bookingRequestId": { + "type": "integer", + "format": "int64" + }, + "providerCode": { + "type": "string", + "nullable": true + } + } + }, + "ApiResultOfInitiateBnplResult": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/InitiateBnplResult" + } + ] + } + } + } + ] + }, + "InitiateBnplResult": { + "type": "object", + "additionalProperties": false, + "properties": { + "bnplTransactionId": { + "type": "integer", + "format": "int64" + }, + "paymentTransactionId": { + "type": "integer", + "format": "int64" + }, + "status": { + "type": "string" + }, + "externalPaymentToken": { + "type": "string" + }, + "redirectUrl": { + "type": "string" + } + } + }, + "InitiateBnplBody": { + "type": "object", + "description": "The initiate body (the idempotency key comes from the Idempotency-Key header).", + "additionalProperties": false, + "properties": { + "bookingRequestId": { + "type": "integer", + "format": "int64" + }, + "providerCode": { + "type": "string", + "nullable": true + } + } + }, "ApiResultOfCustomerAddressDto": { "allOf": [ { @@ -15118,6 +16035,39 @@ } } }, + "ApiResultOfBnplCallbackResult": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/BnplCallbackResult" + } + ] + } + } + } + ] + }, + "BnplCallbackResult": { + "type": "object", + "additionalProperties": false, + "properties": { + "processingStatus": { + "type": "string" + }, + "duplicate": { + "type": "boolean" + } + } + }, "ApiResultOfWebhookIngestResult": { "allOf": [ { diff --git a/dev/shared-working-context/backend/STATUS.md b/dev/shared-working-context/backend/STATUS.md index 9a85406..7d8aad4 100644 --- a/dev/shared-working-context/backend/STATUS.md +++ b/dev/shared-working-context/backend/STATUS.md @@ -12,6 +12,24 @@ One block per completed backend phase. Newest at the top. Backend lane writes he - **Notes for frontend:** --> +## 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 ~7–10-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** diff --git a/dev/shared-working-context/backend/handoff/after-backend-phase-12.md b/dev/shared-working-context/backend/handoff/after-backend-phase-12.md new file mode 100644 index 0000000..5a17dfc --- /dev/null +++ b/dev/shared-working-context/backend/handoff/after-backend-phase-12.md @@ -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` (~7–10 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. diff --git a/dev/shared-working-context/frontend/STATUS.md b/dev/shared-working-context/frontend/STATUS.md index 52c0af9..8519b2c 100644 --- a/dev/shared-working-context/frontend/STATUS.md +++ b/dev/shared-working-context/frontend/STATUS.md @@ -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: `` (client type/size validation, progress %, success/ + retry, re-upload-on-reject, server-metadata truth + local-capture mode) and `` (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 diff --git a/dev/shared-working-context/frontend/requests/for-backend.md b/dev/shared-working-context/frontend/requests/for-backend.md index cbff73f..d2f70c4 100644 --- a/dev/shared-working-context/frontend/requests/for-backend.md +++ b/dev/shared-working-context/frontend/requests/for-backend.md @@ -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 diff --git a/dev/shared-working-context/reports/backend-phase-12-report.md b/dev/shared-working-context/reports/backend-phase-12-report.md new file mode 100644 index 0000000..a78b976 --- /dev/null +++ b/dev/shared-working-context/reports/backend-phase-12-report.md @@ -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+1–3/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. diff --git a/dev/shared-working-context/reports/frontend-phase-5-report.md b/dev/shared-working-context/reports/frontend-phase-5-report.md new file mode 100644 index 0000000..c774675 --- /dev/null +++ b/dev/shared-working-context/reports/frontend-phase-5-report.md @@ -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 `` 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 + `` 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 24–48h 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) +- **``** — 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. +- **``** — 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** ("در حال بررسی", 24–48h, 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 `` (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`. diff --git a/dev/shared-working-context/reports/mocks-registry.md b/dev/shared-working-context/reports/mocks-registry.md index 30f2633..75c71e9 100644 --- a/dev/shared-working-context/reports/mocks-registry.md +++ b/dev/shared-working-context/reports/mocks-registry.md @@ -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 1–5, `sortOrder` 0–4). 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 | 🟡 | diff --git a/server/CLAUDE.md b/server/CLAUDE.md index 4058814..19acad1 100644 --- a/server/CLAUDE.md +++ b/server/CLAUDE.md @@ -81,15 +81,15 @@ projects/assemblies, Clean-Architecture layers, and cross-layer dependencies. ``` src/ ├── Core/ -│ ├── Baya.Domain Entities (User, Role, UserSession, RoleNames…, Identity/ (NurseProfile, CustomerProfile, Patient, NurseBankAccount, CustomerAddress), Geography/ (Province, City, District, NurseServiceArea), Catalog/ (ServiceCategory, ServiceOptionGroup, ServiceOptionValue, NurseServiceVariant, NurseServiceVariantOption, PriceUnits), Verification/ (NurseVerification, VerificationStepType, VerificationStep, VerificationDocument, NurseCredential + VerificationStatus/VerificationStepStatus enums), Search/ (NurseSearchIndex — the denormalized search projection), Booking/ (BookingRequest — the money-free pre-payment intent + BookingRequestStatus/BookingRequestTransitions forward-only status guard + CaregiverGender codes; b9 adds Booking/BookingSession/BookingCareInstruction/VisitVerification/CancellationPolicy + their status/transition tables + BookingAmounts money split), + 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 ~7–10-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 diff --git a/server/src/API/Baya.Web.Api/Controllers/V1/AdminBnplController.cs b/server/src/API/Baya.Web.Api/Controllers/V1/AdminBnplController.cs new file mode 100644 index 0000000..f9f3085 --- /dev/null +++ b/server/src/API/Baya.Web.Api/Controllers/V1/AdminBnplController.cs @@ -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; + +/// +/// 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 ~7–10-business-day customer ETA. Rate-limited as +/// money endpoints. +/// +[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] + public async Task Verify(long id, CancellationToken cancellationToken) + => OperationResult(await sender.Send(new VerifyBnplOrderCommand(id), cancellationToken)); + + [HttpPost("{id}/[action]")] + [ProducesOkApiResponseType] + public async Task Settle(long id, CancellationToken cancellationToken) + => OperationResult(await sender.Send(new SettleBnplOrderCommand(id), cancellationToken)); + + [HttpPost("{id}/[action]")] + [ProducesOkApiResponseType] + public async Task Revert(long id, RevertBnplBody body, CancellationToken cancellationToken) + => OperationResult(await sender.Send( + new RevertBnplOrderCommand(id, body.RefundPercentage, body.TicketId, body.ReasonNotes), cancellationToken)); + + [HttpGet("{id}")] + [ProducesOkApiResponseType] + public async Task Get(long id, CancellationToken cancellationToken) + => OperationResult(await sender.Send(new GetBnplOrderStatusQuery(id, AdminView: true), cancellationToken)); + + /// The revert body (the order id comes from the route). Omit refund_percentage for a full revert. + public record RevertBnplBody(decimal? RefundPercentage, long? TicketId, string? ReasonNotes); +} diff --git a/server/src/API/Baya.Web.Api/Controllers/V1/CheckoutBnplController.cs b/server/src/API/Baya.Web.Api/Controllers/V1/CheckoutBnplController.cs new file mode 100644 index 0000000..8c2be1d --- /dev/null +++ b/server/src/API/Baya.Web.Api/Controllers/V1/CheckoutBnplController.cs @@ -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; + +/// +/// The customer-facing BNPL checkout — the "pay with installments" alternative to the card path. Eligibility +/// records the plan on a bnpl_transactions 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. +/// +[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] + public async Task Eligibility(CheckBnplEligibilityQuery query, CancellationToken cancellationToken) + => OperationResult(await sender.Send(query, cancellationToken)); + + [HttpPost("[action]")] + [ProducesOkApiResponseType] + public async Task 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] + public async Task Get(long id, CancellationToken cancellationToken) + => OperationResult(await sender.Send(new GetBnplOrderStatusQuery(id, AdminView: false), cancellationToken)); + + /// The initiate body (the idempotency key comes from the Idempotency-Key header). + public record InitiateBnplBody(long BookingRequestId, string ProviderCode); +} diff --git a/server/src/API/Baya.Web.Api/Controllers/V1/WebhooksBnplController.cs b/server/src/API/Baya.Web.Api/Controllers/V1/WebhooksBnplController.cs new file mode 100644 index 0000000..d4ac0bc --- /dev/null +++ b/server/src/API/Baya.Web.Api/Controllers/V1/WebhooksBnplController.cs @@ -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; + +/// +/// The inbound BNPL provider-callback surface. Authenticated by signature, not a user session, so it is +/// anonymous to the auth pipeline; at-least-once tolerant and idempotency-deduplicated on +/// (provider_code, external_event_id) before any money moves. Rate-limited (per-IP). The raw body is read +/// verbatim and stored in payload_json. +/// +[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] + public async Task 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)); + } +} diff --git a/server/src/Core/Baya.Application/Contracts/Payments/IBnplProvider.cs b/server/src/Core/Baya.Application/Contracts/Payments/IBnplProvider.cs index de903a5..2a3e19d 100644 --- a/server/src/Core/Baya.Application/Contracts/Payments/IBnplProvider.cs +++ b/server/src/Core/Baya.Application/Contracts/Payments/IBnplProvider.cs @@ -2,25 +2,90 @@ namespace Baya.Application.Contracts.Payments; /// -/// The Buy-Now-Pay-Later provider seam (SnappPay / Tara / …). b12 owns the real, full definition of this -/// seam; b11 introduces this minimal shape (revert/update) plus a thin local mock so the bnpl_revert -/// refund path is exercised before b12 merges. Money always flows customer ↔ provider ↔ Balinyaar -/// — never nurse→customer or Balinyaar→customer direct. A full reversal is ; a -/// partial/shortened one is with a strictly-lower amount. Every amount is IRR -/// long; the 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 eligible → token_issued → verified → settled → reverted/cancelled state machine. +/// One impl per provider_code, selected through . A BNPL order +/// is, in our books, a card payment landing net-of-fee: the settle returns the net amount + the provider's +/// merchant commission (read from the actual settlement, never hardcoded) + a per-transaction +/// settled_at that is never assumed instant. Money always flows customer ↔ provider ↔ +/// Balinyaar; every amount is IRR long (Toman is converted only in a real adapter's boundary via +/// ); an idempotencyKey makes a retried settle/revert a no-op. /// public interface IBnplProvider { - /// Full reversal of a BNPL order back through the provider. + /// Checks whether the customer may finance this order — records eligibility_status. + ValueTask CheckEligibilityAsync(string customerMobile, long orderAmountIrr, CancellationToken cancellationToken = default); + + /// Issues the external_payment_token + the redirect URL that starts the customer's order. + ValueTask CreatePaymentTokenAsync(string customerMobile, long orderAmountIrr, string idempotencyKey, CancellationToken cancellationToken = default); + + /// Confirms the order — echoes the amount so the handler can re-check it server-side (never trust the callback alone). + ValueTask VerifyAsync(string externalPaymentToken, long expectedOrderAmountIrr, CancellationToken cancellationToken = default); + + /// Settles the full order to Balinyaar net of the provider commission — returns the net amount, the + /// commission (a platform expense), and the per-transaction settled_at. + ValueTask SettleAsync(string externalPaymentToken, long orderAmountIrr, string idempotencyKey, CancellationToken cancellationToken = default); + + /// The provider's current view of the order (reconciliation/support). + ValueTask GetStatusAsync(string externalPaymentToken, CancellationToken cancellationToken = default); + + /// Cancels an order before settle. + ValueTask CancelAsync(string externalPaymentToken, string idempotencyKey, CancellationToken cancellationToken = default); + + /// Full reversal of a settled BNPL order back through the provider (a bnpl_revert refund). ValueTask RevertAsync(string providerOrderReference, long amountIrr, string idempotencyKey, CancellationToken cancellationToken = default); /// Partial revert — reduces the order to a strictly-lower . ValueTask UpdateAsync(string providerOrderReference, long newAmountIrr, string idempotencyKey, CancellationToken cancellationToken = default); } -/// The outcome of a BNPL revert/update. +/// The eligibility outcome + plan summary the client shows (or falls back to card on). +/// A BnplEligibilityStatus code (eligible/not_eligible/ceiling_exceeded). +/// Informational plan length (default 4 — owned by the provider). +/// The customer's provider credit ceiling (IRR), when known. +/// Human copy, e.g. "4 interest-free installments, provider-financed". +public sealed record BnplEligibilityResult( + string EligibilityStatus, + int InstallmentCount, + long? CreditCeilingIrr, + string PlanSummary); + +/// Whether a token was issued. +/// The deterministic token persisted for verify/settle/revert. +/// Where the customer is sent to complete the BNPL order. +/// The provider's order/txn id, when issued. +public sealed record BnplTokenResult( + PaymentProviderStatus Status, + string ExternalPaymentToken, + string RedirectUrl, + string? ExternalTransactionId); + +/// The re-verified outcome — only confirms. +/// The order amount the provider reports, re-checked against the stored order. +/// The provider's order/txn id, when returned. +public sealed record BnplVerifyResult( + PaymentProviderStatus Status, + long OrderAmountIrr, + string? ExternalTransactionId); + +/// Whether the provider settled. +/// Net of the provider commission actually received (IRR). +/// The provider's merchant discount (IRR) = a platform expense. +/// The per-transaction settlement time — nullable, contract-defined, never assumed instant. +/// The provider's order/txn id, when returned. +public sealed record BnplSettleResult( + PaymentProviderStatus Status, + long SettledAmountIrr, + long BnplCommissionIrr, + DateTime? SettledAt, + string? ExternalTransactionId); + +/// The provider's own status string for the order. +public sealed record BnplStatusResult(string ProviderStatus); + +/// The outcome of a BNPL revert/update/cancel. /// Whether the provider accepted the reversal. -/// The provider's revert id, persisted on the refund. +/// The provider's revert id, persisted on the refund + the BNPL row. /// The provider's own commission it returned — nullable, /// reconciled from the response, never hardcoded (some providers keep their fee on a refund). public sealed record BnplRevertResult( diff --git a/server/src/Core/Baya.Application/Contracts/Payments/IBnplProviderResolver.cs b/server/src/Core/Baya.Application/Contracts/Payments/IBnplProviderResolver.cs new file mode 100644 index 0000000..aa14c52 --- /dev/null +++ b/server/src/Core/Baya.Application/Contracts/Payments/IBnplProviderResolver.cs @@ -0,0 +1,14 @@ +#nullable enable +namespace Baya.Application.Contracts.Payments; + +/// +/// Resolves the impl for a provider_code (snapppay/digipay/ +/// tara/torobpay) — config-driven selection, never an if (mock) branch in a handler. +/// A real system maps each code to its concrete adapter (SnappPayBnplProvider, DigipayBnplProvider, +/// …); this phase ships the mock behind every known code and a single active route. Returns null for an +/// unknown/unconfigured code so the handler can reject it cleanly rather than throw. +/// +public interface IBnplProviderResolver +{ + IBnplProvider? Resolve(string providerCode); +} diff --git a/server/src/Core/Baya.Application/Contracts/Payments/ICurrencyNormalizer.cs b/server/src/Core/Baya.Application/Contracts/Payments/ICurrencyNormalizer.cs new file mode 100644 index 0000000..1a32f0d --- /dev/null +++ b/server/src/Core/Baya.Application/Contracts/Payments/ICurrencyNormalizer.cs @@ -0,0 +1,17 @@ +#nullable enable +namespace Baya.Application.Contracts.Payments; + +/// +/// Toman ↔ Rial (IRR) conversion at the provider boundary only — the provider speaks Toman, our books +/// speak IRR. Conversion happens solely here, never internally: everything inside the domain is already +/// IRR long. 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. +/// +public interface ICurrencyNormalizer +{ + /// Normalizes an amount in (IRR/TOMAN) to IRR. + long ToIrr(long amount, string currency); + + /// Converts an IRR amount back to Toman for display only. + long ToDisplayToman(long amountIrr); +} diff --git a/server/src/Core/Baya.Application/Contracts/Persistence/IBnplRepository.cs b/server/src/Core/Baya.Application/Contracts/Persistence/IBnplRepository.cs new file mode 100644 index 0000000..7dcb341 --- /dev/null +++ b/server/src/Core/Baya.Application/Contracts/Persistence/IBnplRepository.cs @@ -0,0 +1,43 @@ +#nullable enable +using Baya.Application.Models.Bnpl; +using Baya.Domain.Entities.Bnpl; + +namespace Baya.Application.Contracts.Persistence; + +/// +/// The BNPL aggregate — one bnpl_transactions row per order (1:1 with its payment_transaction). +/// Writes load tracked rows; reads project to DTOs. The ledger legs themselves are appended through +/// (b10's helper); this repo owns only the BNPL row, the +/// order-context read, and the settle-ledger idempotency probe. Money is IRR long. The +/// UNIQUE(payment_transaction_id) guard is the structural one-BNPL-row-per-order backstop. +/// +public interface IBnplRepository +{ + /// Everything eligibility/initiate need for a booking request (owner + mobile + status + gross). + /// Null when the request does not exist. + Task GetOrderContextAsync(long bookingRequestId, CancellationToken cancellationToken); + + Task AddAsync(BnplTransaction transaction, CancellationToken cancellationToken); + + /// The tracked BNPL row for a payment transaction — the 1:1 lookup eligibility/initiate upsert on. + /// Null when none exists yet. + Task GetTrackedByPaymentTransactionIdAsync(long paymentTransactionId, CancellationToken cancellationToken); + + Task GetTrackedByIdAsync(long id, CancellationToken cancellationToken); + + /// 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. + Task GetTrackedByBookingRequestIdAsync(long bookingRequestId, CancellationToken cancellationToken); + + /// The tracked BNPL row carrying — the callback dispatch + /// resolves the order from the token in the payload. Null when absent. + Task GetTrackedByTokenAsync(string externalPaymentToken, CancellationToken cancellationToken); + + /// 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. + Task SettleLedgerExistsAsync(long bnplTransactionId, CancellationToken cancellationToken); + + /// The BNPL order view + the owning customer's user id for tenancy, and the linked refund's ETA + /// when reverted. Null when absent. + Task GetStatusAsync(long id, CancellationToken cancellationToken); +} diff --git a/server/src/Core/Baya.Application/Contracts/Persistence/IRefundRepository.cs b/server/src/Core/Baya.Application/Contracts/Persistence/IRefundRepository.cs index 1f3c2dc..87e0334 100644 --- a/server/src/Core/Baya.Application/Contracts/Persistence/IRefundRepository.cs +++ b/server/src/Core/Baya.Application/Contracts/Persistence/IRefundRepository.cs @@ -34,4 +34,8 @@ public interface IRefundRepository /// The customer-facing status of a single refund, with the owning customer's user id for tenancy. /// Null when absent. Task GetStatusAsync(long id, CancellationToken cancellationToken); + + /// The provider revert reference on a bnpl_revert refund — the BNPL revert path records it + /// as revert_transaction_id on the bnpl_transactions row. Null when absent. + Task GetExternalRevertReferenceAsync(long refundId, CancellationToken cancellationToken); } diff --git a/server/src/Core/Baya.Application/Contracts/Persistence/IUnitOfWork.cs b/server/src/Core/Baya.Application/Contracts/Persistence/IUnitOfWork.cs index a965dff..ef12ba3 100644 --- a/server/src/Core/Baya.Application/Contracts/Persistence/IUnitOfWork.cs +++ b/server/src/Core/Baya.Application/Contracts/Persistence/IUnitOfWork.cs @@ -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(); } diff --git a/server/src/Core/Baya.Application/Features/Bnpl/BnplOrderInitializer.cs b/server/src/Core/Baya.Application/Features/Bnpl/BnplOrderInitializer.cs new file mode 100644 index 0000000..6773ced --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Bnpl/BnplOrderInitializer.cs @@ -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; + +/// +/// The single find-or-create for a BNPL order's skeleton — the pending payment_transaction (bnpl gateway) +/// plus the 1:1 bnpl_transactions row — shared by eligibility and initiate so a booking request has at +/// most one BNPL order. The UNIQUE(payment_transaction_id) 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. +/// +internal static class BnplOrderInitializer +{ + public static async Task 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; + } +} diff --git a/server/src/Core/Baya.Application/Features/Bnpl/Commands/HandleBnplCallback/HandleBnplCallbackCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Bnpl/Commands/HandleBnplCallback/HandleBnplCallbackCommand.Handler.cs new file mode 100644 index 0000000..588f129 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Bnpl/Commands/HandleBnplCallback/HandleBnplCallbackCommand.Handler.cs @@ -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> +{ + private enum CallbackAction { None, Verify, Settle, Revert } + + public async ValueTask> Handle(HandleBnplCallbackCommand request, CancellationToken cancellationToken) + { + var headers = request.Headers ?? new Dictionary(); + 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 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> 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 Success(string processingStatus, bool isDuplicate) + => OperationResult.SuccessResult(new BnplCallbackResult(processingStatus, isDuplicate)); +} diff --git a/server/src/Core/Baya.Application/Features/Bnpl/Commands/HandleBnplCallback/HandleBnplCallbackCommand.cs b/server/src/Core/Baya.Application/Features/Bnpl/Commands/HandleBnplCallback/HandleBnplCallbackCommand.cs new file mode 100644 index 0000000..4ad33aa --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Bnpl/Commands/HandleBnplCallback/HandleBnplCallbackCommand.cs @@ -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; + +/// +/// The inbound BNPL provider-callback entry point. Authenticated by signature (not a session); +/// at-least-once tolerant and deduplicated on (provider_code, external_event_id) in +/// payment_webhook_events 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. +/// +public record HandleBnplCallbackCommand(string Provider, IReadOnlyDictionary? Headers, string? RawBody) + : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Bnpl/Commands/InitiateBnplOrder/InitiateBnplOrderCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Bnpl/Commands/InitiateBnplOrder/InitiateBnplOrderCommand.Handler.cs new file mode 100644 index 0000000..8f0e970 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Bnpl/Commands/InitiateBnplOrder/InitiateBnplOrderCommand.Handler.cs @@ -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> +{ + public async ValueTask> Handle(InitiateBnplOrderCommand request, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return OperationResult.UnauthorizedResult("Not authenticated."); + + var customerId = await unitOfWork.CustomerProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken); + if (customerId is null) + return OperationResult.NotFoundResult("Booking request not found."); + + var ctx = await unitOfWork.BnplRepository.GetOrderContextAsync(request.BookingRequestId, cancellationToken); + if (ctx is null || ctx.CustomerId != customerId) + return OperationResult.NotFoundResult("Booking request not found."); + + var provider = providerResolver.Resolve(request.ProviderCode); + if (provider is null) + return OperationResult.FailureResult("provider_code", "The BNPL provider is not available."); + + var gatewayId = await unitOfWork.PaymentRepository.GetActiveGatewayIdAsync(PaymentGatewayType.Bnpl, cancellationToken); + if (gatewayId is null) + return OperationResult.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.ConflictResult("This booking has already been paid."); + if (ctx.Status != BookingRequestStatus.AcceptedAwaitingPayment) + return OperationResult.ConflictResult("This request is not awaiting payment."); + + var now = dateTimeProvider.UtcNow.UtcDateTime; + if (ctx.PaymentDeadlineAt is { } deadline && deadline < now) + return OperationResult.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("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.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.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.SuccessResult(new InitiateBnplResult( + bnpl.Id, bnpl.PaymentTransactionId, bnpl.Status, token.ExternalPaymentToken, token.RedirectUrl)); + } +} diff --git a/server/src/Core/Baya.Application/Features/Bnpl/Commands/InitiateBnplOrder/InitiateBnplOrderCommand.Validator.cs b/server/src/Core/Baya.Application/Features/Bnpl/Commands/InitiateBnplOrder/InitiateBnplOrderCommand.Validator.cs new file mode 100644 index 0000000..fec5005 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Bnpl/Commands/InitiateBnplOrder/InitiateBnplOrderCommand.Validator.cs @@ -0,0 +1,16 @@ +using Baya.Domain.Entities.Bnpl; +using FluentValidation; + +namespace Baya.Application.Features.Bnpl.Commands.InitiateBnplOrder; + +public sealed class InitiateBnplOrderCommandValidator : AbstractValidator +{ + 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."); + } +} diff --git a/server/src/Core/Baya.Application/Features/Bnpl/Commands/InitiateBnplOrder/InitiateBnplOrderCommand.cs b/server/src/Core/Baya.Application/Features/Bnpl/Commands/InitiateBnplOrder/InitiateBnplOrderCommand.cs new file mode 100644 index 0000000..41ac9c1 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Bnpl/Commands/InitiateBnplOrder/InitiateBnplOrderCommand.cs @@ -0,0 +1,15 @@ +using Baya.Application.Models.Bnpl; +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Bnpl.Commands.InitiateBnplOrder; + +/// +/// Starts a BNPL order for an accepted_awaiting_payment booking request owned by the caller: ensures the +/// 1:1 bnpl_transactions row (under the UNIQUE(payment_transaction_id) guard), normalizes the +/// order amount to IRR at the provider boundary, asks the provider for a payment token, transitions +/// eligible → token_issued, and returns the token + redirect. Runs under lock(booking:{id}:payment) +/// and carries an idempotency key so a retried start reuses the same token. +/// +public record InitiateBnplOrderCommand(long BookingRequestId, string ProviderCode, string? IdempotencyKey) + : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Bnpl/Commands/RevertBnplOrder/RevertBnplOrderCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Bnpl/Commands/RevertBnplOrder/RevertBnplOrderCommand.Handler.cs new file mode 100644 index 0000000..220673b --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Bnpl/Commands/RevertBnplOrder/RevertBnplOrderCommand.Handler.cs @@ -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> +{ + public async ValueTask> Handle(RevertBnplOrderCommand request, CancellationToken cancellationToken) + { + var bnpl = await unitOfWork.BnplRepository.GetTrackedByIdAsync(request.BnplTransactionId, cancellationToken); + if (bnpl is null) + return OperationResult.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.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.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.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 + { + 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.SuccessResult(new RevertBnplResult( + bnpl.Id, refundResult.RefundId, bnpl.Status, externalRef, + revertedAmount.ToString(), refundResult.ExpectedCustomerRefundEta)); + } +} diff --git a/server/src/Core/Baya.Application/Features/Bnpl/Commands/RevertBnplOrder/RevertBnplOrderCommand.Validator.cs b/server/src/Core/Baya.Application/Features/Bnpl/Commands/RevertBnplOrder/RevertBnplOrderCommand.Validator.cs new file mode 100644 index 0000000..3e6fb5f --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Bnpl/Commands/RevertBnplOrder/RevertBnplOrderCommand.Validator.cs @@ -0,0 +1,21 @@ +using FluentValidation; + +namespace Baya.Application.Features.Bnpl.Commands.RevertBnplOrder; + +public sealed class RevertBnplOrderCommandValidator : AbstractValidator +{ + 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); + } +} diff --git a/server/src/Core/Baya.Application/Features/Bnpl/Commands/RevertBnplOrder/RevertBnplOrderCommand.cs b/server/src/Core/Baya.Application/Features/Bnpl/Commands/RevertBnplOrder/RevertBnplOrderCommand.cs new file mode 100644 index 0000000..b31bae1 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Bnpl/Commands/RevertBnplOrder/RevertBnplOrderCommand.cs @@ -0,0 +1,24 @@ +using Baya.Application.Models.Bnpl; +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Bnpl.Commands.RevertBnplOrder; + +/// +/// Reverses a settled 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 bnpl_transactions row. It +/// reuses the b11 refund path (CreateRefundCommand) — which creates the refunds row with +/// refund_channel='bnpl_revert', executes the provider revert, posts the balanced reversal ledger (fee + +/// payout legs; a clawback if the nurse was already paid), and surfaces the async ~7–10-business-day customer +/// ETA. Money always flows customer ↔ provider ↔ Balinyaar. Admin-only. +/// +/// The settled BNPL order to reverse. +/// The reversed fraction (0–1]; 1 = full revert, <1 = a strictly-lower partial. +/// Optional support-ticket link (config-gated "ticket required" rule, b15 FK). +/// Free-text reason recorded on the refund. +public record RevertBnplOrderCommand( + long BnplTransactionId, + decimal? RefundPercentage, + long? TicketId, + string? ReasonNotes, + string? CallbackPayloadJson = null) : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Bnpl/Commands/SettleBnplOrder/SettleBnplOrderCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Bnpl/Commands/SettleBnplOrder/SettleBnplOrderCommand.Handler.cs new file mode 100644 index 0000000..9614524 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Bnpl/Commands/SettleBnplOrder/SettleBnplOrderCommand.Handler.cs @@ -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; + +/// +/// The BNPL money capture. Mirrors b10's ConfirmPaymentAndPostLedger — same shared booking conversion, +/// same idempotency shape — but posts the net-of-fee group (via LedgerPosting.BnplSettle) so escrow +/// reflects the real cash received, and verifies through the BNPL provider rather than the card PSP. The nurse's +/// nurse_payable accrual is invariant to payment method: it comes from the booking split, never +/// from settled_amount_irr. +/// +internal sealed class SettleBnplOrderCommandHandler( + IUnitOfWork unitOfWork, + IBnplProviderResolver providerResolver, + IPlatformConfig platformConfig, + IVariantSnapshotSerializer variantSnapshotSerializer, + ISettlementSplitProvider settlementSplitProvider, + IDistributedLock distributedLock, + IDateTimeProvider dateTimeProvider, + INotificationDispatcher notifications) + : IRequestHandler> +{ + public async ValueTask> 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.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.SuccessResult(true); + if (bnpl.Status != BnplStatus.Verified || string.IsNullOrEmpty(bnpl.ExternalPaymentToken)) + return OperationResult.ConflictResult("This BNPL order is not ready to settle."); + + var transaction = await unitOfWork.PaymentRepository.GetTrackedTransactionByIdAsync(bnpl.PaymentTransactionId, cancellationToken); + if (transaction is null) + return OperationResult.NotFoundResult("The BNPL payment transaction is missing."); + + var provider = providerResolver.Resolve(bnpl.ProviderCode); + if (provider is null) + return OperationResult.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.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.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.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.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.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); + } +} diff --git a/server/src/Core/Baya.Application/Features/Bnpl/Commands/SettleBnplOrder/SettleBnplOrderCommand.cs b/server/src/Core/Baya.Application/Features/Bnpl/Commands/SettleBnplOrder/SettleBnplOrderCommand.cs new file mode 100644 index 0000000..832bad0 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Bnpl/Commands/SettleBnplOrder/SettleBnplOrderCommand.cs @@ -0,0 +1,15 @@ +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Bnpl.Commands.SettleBnplOrder; + +/// +/// Settles a verified BNPL order: the provider pays the full order net of its commission, so this records +/// settled_amount_irr/bnpl_commission_irr/settled_at from the actual settlement, +/// posts the net-of-fee ledger group (card-capture legs PLUS the bnpl_fee_expense leg), confirms +/// the parent payment_transaction (which triggers the booking conversion), and transitions +/// verified → settled. Under lock(bnpl:{id}:settle); carries an idempotency key. A replayed settle +/// is a no-op (state guard + webhook dedup + the settle-ledger idempotency probe). +/// +public record SettleBnplOrderCommand(long BnplTransactionId, string? IdempotencyKey = null, string? CallbackPayloadJson = null) + : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Bnpl/Commands/VerifyBnplOrder/VerifyBnplOrderCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Bnpl/Commands/VerifyBnplOrder/VerifyBnplOrderCommand.Handler.cs new file mode 100644 index 0000000..49a8453 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Bnpl/Commands/VerifyBnplOrder/VerifyBnplOrderCommand.Handler.cs @@ -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> +{ + public async ValueTask> Handle(VerifyBnplOrderCommand request, CancellationToken cancellationToken) + { + var bnpl = await unitOfWork.BnplRepository.GetTrackedByIdAsync(request.BnplTransactionId, cancellationToken); + if (bnpl is null) + return OperationResult.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.SuccessResult(true); + if (bnpl.Status != BnplStatus.TokenIssued || string.IsNullOrEmpty(bnpl.ExternalPaymentToken)) + return OperationResult.ConflictResult("This BNPL order is not awaiting verification."); + + var provider = providerResolver.Resolve(bnpl.ProviderCode); + if (provider is null) + return OperationResult.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.FailureResult("The BNPL order could not be verified with the provider."); + } + + bnpl.MarkVerified(verify.ExternalTransactionId, request.CallbackPayloadJson); + await unitOfWork.CommitAsync(); + return OperationResult.SuccessResult(true); + } +} diff --git a/server/src/Core/Baya.Application/Features/Bnpl/Commands/VerifyBnplOrder/VerifyBnplOrderCommand.cs b/server/src/Core/Baya.Application/Features/Bnpl/Commands/VerifyBnplOrder/VerifyBnplOrderCommand.cs new file mode 100644 index 0000000..4027e81 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Bnpl/Commands/VerifyBnplOrder/VerifyBnplOrderCommand.cs @@ -0,0 +1,13 @@ +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Bnpl.Commands.VerifyBnplOrder; + +/// +/// Confirms a token_issued BNPL order with the provider and re-checks the amount + reference server-side +/// (never trust the callback alone), then transitions token_issued → verified. 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. +/// +public record VerifyBnplOrderCommand(long BnplTransactionId, string? CallbackPayloadJson = null) + : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Bnpl/Queries/CheckBnplEligibility/CheckBnplEligibilityQuery.Handler.cs b/server/src/Core/Baya.Application/Features/Bnpl/Queries/CheckBnplEligibility/CheckBnplEligibilityQuery.Handler.cs new file mode 100644 index 0000000..34c88bc --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Bnpl/Queries/CheckBnplEligibility/CheckBnplEligibilityQuery.Handler.cs @@ -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> +{ + public async ValueTask> Handle(CheckBnplEligibilityQuery request, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return OperationResult.UnauthorizedResult("Not authenticated."); + + var customerId = await unitOfWork.CustomerProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken); + if (customerId is null) + return OperationResult.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.NotFoundResult("Booking request not found."); + + if (await unitOfWork.PaymentRepository.HasSucceededTransactionForRequestAsync(request.BookingRequestId, cancellationToken)) + return OperationResult.ConflictResult("This booking has already been paid."); + if (ctx.Status != BookingRequestStatus.AcceptedAwaitingPayment) + return OperationResult.ConflictResult("This request is not awaiting payment."); + + var provider = providerResolver.Resolve(request.ProviderCode); + if (provider is null) + return OperationResult.FailureResult("provider_code", "The BNPL provider is not available."); + + var gatewayId = await unitOfWork.PaymentRepository.GetActiveGatewayIdAsync(PaymentGatewayType.Bnpl, cancellationToken); + if (gatewayId is null) + return OperationResult.FailureResult("No active BNPL gateway is configured."); + + var eligibility = await provider.CheckEligibilityAsync(ctx.CustomerMobile, ctx.GrossIrr, cancellationToken); + + var merchantOfRecord = await platformConfig.GetConfig("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.SuccessResult(new BnplEligibilityDto( + eligibility.EligibilityStatus, + isEligible, + eligibility.InstallmentCount, + eligibility.PlanSummary, + eligibility.CreditCeilingIrr?.ToString())); + } +} diff --git a/server/src/Core/Baya.Application/Features/Bnpl/Queries/CheckBnplEligibility/CheckBnplEligibilityQuery.Validator.cs b/server/src/Core/Baya.Application/Features/Bnpl/Queries/CheckBnplEligibility/CheckBnplEligibilityQuery.Validator.cs new file mode 100644 index 0000000..e3e6b21 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Bnpl/Queries/CheckBnplEligibility/CheckBnplEligibilityQuery.Validator.cs @@ -0,0 +1,16 @@ +using Baya.Domain.Entities.Bnpl; +using FluentValidation; + +namespace Baya.Application.Features.Bnpl.Queries.CheckBnplEligibility; + +public sealed class CheckBnplEligibilityQueryValidator : AbstractValidator +{ + 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."); + } +} diff --git a/server/src/Core/Baya.Application/Features/Bnpl/Queries/CheckBnplEligibility/CheckBnplEligibilityQuery.cs b/server/src/Core/Baya.Application/Features/Bnpl/Queries/CheckBnplEligibility/CheckBnplEligibilityQuery.cs new file mode 100644 index 0000000..686e0d6 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Bnpl/Queries/CheckBnplEligibility/CheckBnplEligibilityQuery.cs @@ -0,0 +1,16 @@ +using Baya.Application.Models.Bnpl; +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Bnpl.Queries.CheckBnplEligibility; + +/// +/// Checks whether a family can finance an accepted_awaiting_payment booking request with the chosen BNPL +/// provider, and records the outcome on a created/updated bnpl_transactions row (status eligible). +/// The order amount is the request's frozen gross (variant price × session count) — never client-supplied. The +/// client shows the plan summary on eligible or falls back to card. Owned by the requesting customer. +/// +/// The accepted request to finance (a b9 bookings row exists only on settle). +/// Which BNPL provider to check (snapppay/digipay/tara/torobpay). +public record CheckBnplEligibilityQuery(long BookingRequestId, string ProviderCode) + : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Bnpl/Queries/GetBnplOrderStatus/GetBnplOrderStatusQuery.Handler.cs b/server/src/Core/Baya.Application/Features/Bnpl/Queries/GetBnplOrderStatus/GetBnplOrderStatusQuery.Handler.cs new file mode 100644 index 0000000..9d81a40 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Bnpl/Queries/GetBnplOrderStatus/GetBnplOrderStatusQuery.Handler.cs @@ -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> +{ + public async ValueTask> Handle(GetBnplOrderStatusQuery request, CancellationToken cancellationToken) + { + var projection = await unitOfWork.BnplRepository.GetStatusAsync(request.BnplTransactionId, cancellationToken); + if (projection is null) + return OperationResult.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.NotFoundResult("BNPL order not found."); + + return OperationResult.SuccessResult(projection.Order); + } +} diff --git a/server/src/Core/Baya.Application/Features/Bnpl/Queries/GetBnplOrderStatus/GetBnplOrderStatusQuery.cs b/server/src/Core/Baya.Application/Features/Bnpl/Queries/GetBnplOrderStatus/GetBnplOrderStatusQuery.cs new file mode 100644 index 0000000..bd82b74 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Bnpl/Queries/GetBnplOrderStatus/GetBnplOrderStatusQuery.cs @@ -0,0 +1,14 @@ +using Baya.Application.Models.Bnpl; +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Bnpl.Queries.GetBnplOrderStatus; + +/// +/// Surfaces a BNPL order: status, the money split (gross/net/commission), the non-instant settlement time, +/// and the revert audit + async customer ETA. comes from the (policy-gated) admin +/// controller; the customer view is tenancy-scoped to ICurrentUser — a customer can never read another's +/// order (a cross-customer read is a clean not-found). +/// +public record GetBnplOrderStatusQuery(long BnplTransactionId, bool AdminView) + : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Bookings/BookingConversion.cs b/server/src/Core/Baya.Application/Features/Bookings/BookingConversion.cs new file mode 100644 index 0000000..e91b5c5 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Bookings/BookingConversion.cs @@ -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; + +/// +/// The single place a captured payment turns an accepted_awaiting_payment request into a confirmed +/// bookings row (through ), shared by both capture rails — b10's card +/// ConfirmPaymentAndPostLedger 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. +/// +internal static class BookingConversion +{ + /// Ensures the booking for 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. + public static async Task 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("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); + } +} + +/// 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 long. +internal sealed record BookingConversionResult( + long BookingId, + long NurseId, + long GrossIrr, + long CommissionIrr, + long PayoutIrr, + bool Created, + int? CustomerUserId, + int? NurseUserId); diff --git a/server/src/Core/Baya.Application/Features/Payments/Commands/ConfirmPaymentAndPostLedger/ConfirmPaymentAndPostLedgerCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Payments/Commands/ConfirmPaymentAndPostLedger/ConfirmPaymentAndPostLedgerCommand.Handler.cs index 320a6d0..2210a34 100644 --- a/server/src/Core/Baya.Application/Features/Payments/Commands/ConfirmPaymentAndPostLedger/ConfirmPaymentAndPostLedgerCommand.Handler.cs +++ b/server/src/Core/Baya.Application/Features/Payments/Commands/ConfirmPaymentAndPostLedger/ConfirmPaymentAndPostLedgerCommand.Handler.cs @@ -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.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.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.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("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); } diff --git a/server/src/Core/Baya.Application/Models/Bnpl/BnplProjections.cs b/server/src/Core/Baya.Application/Models/Bnpl/BnplProjections.cs new file mode 100644 index 0000000..ddab042 --- /dev/null +++ b/server/src/Core/Baya.Application/Models/Bnpl/BnplProjections.cs @@ -0,0 +1,76 @@ +#nullable enable +namespace Baya.Application.Models.Bnpl; + +/// +/// The facts eligibility/initiate need for an accepted_awaiting_payment 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 long. +/// +public record BnplOrderContext( + long RequestId, + long CustomerId, + int CustomerUserId, + string CustomerMobile, + string Status, + System.DateTime? PaymentDeadlineAt, + long GrossIrr); + +/// 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. +public record BnplEligibilityDto( + string EligibilityStatus, + bool IsEligible, + int InstallmentCount, + string PlanSummary, + string? CreditCeilingIrr); + +/// 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. +public record InitiateBnplResult( + long BnplTransactionId, + long PaymentTransactionId, + string Status, + string ExternalPaymentToken, + string RedirectUrl); + +/// 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; settled_at is nullable. +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); + +/// The tenancy envelope for the customer BNPL-order read: the owning customer's user id (compared to +/// ICurrentUser) plus the DTO. A cross-customer access is a clean not-found, never a leak. +public record BnplOrderStatusProjection(int CustomerUserId, BnplOrderStatusDto Order); + +/// 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). +public record BnplCallbackResult(string ProcessingStatus, bool Duplicate); + +/// What RevertBnplOrderCommand returns — the reverted order, the created refund, the provider +/// revert reference and the async customer cash-back ETA (~7–10 business days). +public record RevertBnplResult( + long BnplTransactionId, + long RefundId, + string Status, + string? RevertTransactionId, + string RevertedAmountIrr, + System.DateOnly? ExpectedCustomerRefundEta); diff --git a/server/src/Core/Baya.Domain/Entities/Bnpl/BnplEligibilityStatus.cs b/server/src/Core/Baya.Domain/Entities/Bnpl/BnplEligibilityStatus.cs new file mode 100644 index 0000000..74e2e6d --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/Bnpl/BnplEligibilityStatus.cs @@ -0,0 +1,18 @@ +namespace Baya.Domain.Entities.Bnpl; + +/// +/// The closed bnpl_transactions.eligibility_status code set recorded by the eligibility check — the +/// client shows the installment plan on and falls back to card otherwise. These are the +/// provider-agnostic outcomes; the concrete provider decides the code (credit ceiling, prior history, …). +/// +public static class BnplEligibilityStatus +{ + /// The customer may use BNPL for this order amount. + public const string Eligible = "eligible"; + + /// The customer is not eligible (no line/blocked) — the client falls back to card. + public const string NotEligible = "not_eligible"; + + /// The order exceeds the customer's provider credit ceiling — the client falls back to card. + public const string CeilingExceeded = "ceiling_exceeded"; +} diff --git a/server/src/Core/Baya.Domain/Entities/Bnpl/BnplProviderCodes.cs b/server/src/Core/Baya.Domain/Entities/Bnpl/BnplProviderCodes.cs new file mode 100644 index 0000000..8fbd6bb --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/Bnpl/BnplProviderCodes.cs @@ -0,0 +1,19 @@ +namespace Baya.Domain.Entities.Bnpl; + +/// +/// The closed set of supported bnpl_transactions.provider_code values — each selects one +/// IBnplProvider 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. +/// +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 All = [SnappPay, Digipay, Tara, TorobPay]; + + public static bool IsKnown(string providerCode) => All.Contains(providerCode); +} diff --git a/server/src/Core/Baya.Domain/Entities/Bnpl/BnplStatus.cs b/server/src/Core/Baya.Domain/Entities/Bnpl/BnplStatus.cs new file mode 100644 index 0000000..384ed86 --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/Bnpl/BnplStatus.cs @@ -0,0 +1,31 @@ +namespace Baya.Domain.Entities.Bnpl; + +/// +/// The closed bnpl_transactions.status code set — the forward-only state machine that is the +/// idempotency spine of the BNPL money path. A replayed settle/revert 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 . Persisted as these stable snake_case codes. +/// +public static class BnplStatus +{ + /// Eligibility checked and approved — the row exists, no token yet. + public const string Eligible = "eligible"; + + /// A provider payment token was issued; the customer is redirected to complete the order. + public const string TokenIssued = "token_issued"; + + /// The provider confirmed the order (amount + reference re-checked server-side). + public const string Verified = "verified"; + + /// The provider settled the full order net-of-commission; the net-of-fee ledger group is posted. + public const string Settled = "settled"; + + /// The order was reversed back through the provider (a bnpl_revert refund). + public const string Reverted = "reverted"; + + /// The order was cancelled before settle. Terminal. + public const string Cancelled = "cancelled"; + + /// The provider refused (eligibility/token/verify/settle). Terminal. + public const string Failed = "failed"; +} diff --git a/server/src/Core/Baya.Domain/Entities/Bnpl/BnplTransaction.cs b/server/src/Core/Baya.Domain/Entities/Bnpl/BnplTransaction.cs new file mode 100644 index 0000000..076357a --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/Bnpl/BnplTransaction.cs @@ -0,0 +1,138 @@ +#nullable enable +using Baya.Domain.Common; + +namespace Baya.Domain.Entities.Bnpl; + +/// +/// One BNPL order, 1:1 with its payment_transaction (the UNIQUE(payment_transaction_id) +/// guard) — the single inbound settlement to reconcile plus the revert path. In Balinyaar's books a BNPL order +/// is a card payment that lands net-of-fee: 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. +/// +/// is a forward-only state machine () mutated only +/// through the cohesive mark-* methods; a replayed settle/revert 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 BIGINT — currency is normalized to IRR at the provider +/// boundary, never here. +/// +/// +public class BnplTransaction : BaseEntity +{ + /// The 1:1 parent payment attempt — UNIQUE so exactly one BNPL row exists per order. + public long PaymentTransactionId { get; set; } + + /// Selects the provider impl — snapppay / digipay / tara / torobpay. + public string ProviderCode { get; set; } = null!; + + /// Balinyaar entity or partner center that is merchant-of-record for the order. + public string MerchantOfRecord { get; set; } = null!; + + /// The provider payment token, issued at initiate and used for verify/settle/revert. + public string? ExternalPaymentToken { get; private set; } + + /// The provider's own order/txn id, when returned. + public string? ExternalTransactionId { get; private set; } + + /// The recorded eligibility outcome — a code. + public string? EligibilityStatus { get; set; } + + /// Gross order (IRR) = the booking's gross_price_irr. No floats, ever. + public long OrderAmountIrr { get; set; } + + /// Net of provider commission actually received (IRR) — set at settle from the real settlement. + public long? SettledAmountIrr { get; private set; } + + /// The provider's merchant discount (IRR) = a platform expense, never the nurse's — set at settle. + public long? BnplCommissionIrr { get; private set; } + + /// IRR/TOMAN at the boundary; normalized to IRR on the way in. + public string Currency { get; set; } = "IRR"; + + /// Informational only (default 4) — the installment schedule is owned by the provider. + public byte InstallmentCount { get; set; } = 4; + + /// Guarded — a code, mutated only through the mark-* methods. + public string Status { get; private set; } = BnplStatus.Eligible; + + /// Per-transaction, contract-defined (daily/T+1–3/weekly) — nullable, never assumed instant. + 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; } + + /// The provider's own commission reversal — nullable, reconciled from the provider response, + /// never hardcoded (some providers keep their fee on a refund). + public long? ProviderCommissionReversedAmount { get; private set; } + + /// bnpl_revert on a reversal. + public string? RefundChannel { get; private set; } + + /// Raw verify/settle/revert payload for reconciliation and audit. + 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; + } + + /// Records the provider token and transitions eligible → token_issued. + public void MarkTokenIssued(string externalPaymentToken, string? externalTransactionId) + { + Transition(BnplStatus.TokenIssued); + ExternalPaymentToken = externalPaymentToken; + ExternalTransactionId ??= externalTransactionId; + } + + /// Server-side amount + reference re-checked by the handler; transitions token_issued → verified. + public void MarkVerified(string? externalTransactionId, string? callbackPayloadJson) + { + Transition(BnplStatus.Verified); + ExternalTransactionId ??= externalTransactionId; + CallbackPayloadJson = callbackPayloadJson ?? CallbackPayloadJson; + } + + /// Records the actual settlement (net + commission + per-transaction ) + /// and transitions verified → settled. Enforces settled = order − commission and non-negativity + /// so a settled row can never carry an unbalanced split. + 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; + } + + /// Records the provider-mediated reversal audit and transitions to reverted. The customer + /// cash-back is async and owned by the provider — money flows customer ↔ provider ↔ Balinyaar only. + 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); +} diff --git a/server/src/Core/Baya.Domain/Entities/Bnpl/BnplTransitions.cs b/server/src/Core/Baya.Domain/Entities/Bnpl/BnplTransitions.cs new file mode 100644 index 0000000..47f599f --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/Bnpl/BnplTransitions.cs @@ -0,0 +1,27 @@ +namespace Baya.Domain.Entities.Bnpl; + +/// +/// The forward-only allowed-edge table for the machine (mirrors +/// BookingRequestTransitions/RefundTransitions). Every write goes through +/// '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. +/// +public static class BnplTransitions +{ + private static readonly IReadOnlyDictionary> Allowed = + new Dictionary> + { + [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); +} diff --git a/server/src/Core/Baya.Domain/Entities/Payments/LedgerPosting.cs b/server/src/Core/Baya.Domain/Entities/Payments/LedgerPosting.cs index d62dfe8..4a0ea21 100644 --- a/server/src/Core/Baya.Domain/Entities/Payments/LedgerPosting.cs +++ b/server/src/Core/Baya.Domain/Entities/Payments/LedgerPosting.cs @@ -36,6 +36,56 @@ public static class LedgerPosting ]; } + /// + /// The BNPL settle group: the card-capture legs plus the provider-fee leg, all under one fresh + /// , so escrow_held reflects the net cash actually + /// received (order − commission), not the gross: + /// + /// DEBIT escrow_held order (= gross) + /// CREDIT platform_revenue commission + /// CREDIT nurse_payable payout + /// DEBIT bnpl_fee_expense bnpl_commission + /// CREDIT escrow_held bnpl_commission + /// + /// The three booking amounts come frozen from the booking (never recomputed) and must reconcile + /// (gross = commission + payout); the nurse's payout is invariant to payment method — 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). + /// + public static IReadOnlyList 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 + { + 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; + } + /// /// The pre-payout refund reversal (the nurse has not been paid, the common case): DEBIT /// platform_revenue fee + DEBIT nurse_payable payout / CREDIT refund_payable (sum) under one group. diff --git a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/MockBnplProvider.cs b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/MockBnplProvider.cs index f86300f..ddd5e5d 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/MockBnplProvider.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/MockBnplProvider.cs @@ -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; /// -/// A thin, deterministic mock so b11's bnpl_revert refund path is exercised -/// before b12 merges — b12 owns the real seam definition and its full adapter (SnappPay/Tara). Revert -/// and update both succeed, echo a deterministic external_revert_reference 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 that drives the full SnappPay-superset verb +/// set — eligible → token_issued → verified → settled → reverted/cancelled — with no external call. One +/// instance stands in for every provider_code (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 , +/// so the settle returns order − commission and the handler reads the commission from the response, never +/// hardcoded. A real adapter reads the actual deducted amount from each settlement. /// public sealed class MockBnplProvider(IOptions options) : IBnplProvider { private readonly BnplOptions _options = options.Value.Bnpl; + public ValueTask 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 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 VerifyAsync(string externalPaymentToken, long expectedOrderAmountIrr, CancellationToken cancellationToken = default) + => ValueTask.FromResult(new BnplVerifyResult( + PaymentProviderStatus.Succeeded, expectedOrderAmountIrr, $"mock-bnpl-verify-{externalPaymentToken}")); + + public ValueTask 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 GetStatusAsync(string externalPaymentToken, CancellationToken cancellationToken = default) + => ValueTask.FromResult(new BnplStatusResult("settled")); + + public ValueTask CancelAsync(string externalPaymentToken, string idempotencyKey, CancellationToken cancellationToken = default) + => Reversal(externalPaymentToken, idempotencyKey); + public ValueTask RevertAsync(string providerOrderReference, long amountIrr, string idempotencyKey, CancellationToken cancellationToken = default) - => Result(providerOrderReference, idempotencyKey); + => Reversal(providerOrderReference, idempotencyKey); public ValueTask UpdateAsync(string providerOrderReference, long newAmountIrr, string idempotencyKey, CancellationToken cancellationToken = default) - => Result(providerOrderReference, idempotencyKey); + => Reversal(providerOrderReference, idempotencyKey); - private ValueTask Result(string providerOrderReference, string idempotencyKey) + private ValueTask Reversal(string providerOrderReference, string idempotencyKey) { if (_options.ForceFailure) return ValueTask.FromResult(new BnplRevertResult(PaymentProviderStatus.Failed, null, null)); diff --git a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/MockBnplProviderResolver.cs b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/MockBnplProviderResolver.cs new file mode 100644 index 0000000..447a441 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/MockBnplProviderResolver.cs @@ -0,0 +1,18 @@ +#nullable enable +using Baya.Application.Contracts.Payments; +using Baya.Domain.Entities.Bnpl; + +namespace Baya.Infrastructure.CrossCutting.Seams; + +/// +/// Config-driven — maps every known provider_code to the deterministic +/// (all four Iranian provider-financed BNPLs share the mock behaviour). A real +/// system registers one concrete adapter per code (SnappPayBnplProvider, DigipayBnplProvider, …) +/// and this resolver returns the right one; swapping is a registration change, never an if (mock) +/// branch in a handler. An unknown code resolves to null so the handler rejects it cleanly. +/// +public sealed class MockBnplProviderResolver(MockBnplProvider provider) : IBnplProviderResolver +{ + public IBnplProvider? Resolve(string providerCode) + => BnplProviderCodes.IsKnown(providerCode) ? provider : null; +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/MockCurrencyNormalizer.cs b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/MockCurrencyNormalizer.cs new file mode 100644 index 0000000..f84f033 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/MockCurrencyNormalizer.cs @@ -0,0 +1,24 @@ +#nullable enable +using Baya.Application.Contracts.Payments; +using Microsoft.Extensions.Options; + +namespace Baya.Infrastructure.CrossCutting.Seams; + +/// +/// Deterministic mock — Toman ↔ IRR at the provider boundary only. Toman is +/// multiplied by (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. +/// +public sealed class MockCurrencyNormalizer(IOptions 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; +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/SeamOptions.cs b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/SeamOptions.cs index f9677a3..d512478 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/SeamOptions.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/SeamOptions.cs @@ -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(); +} + +/// +/// Tunes the mock ICurrencyNormalizer (b12). Toman → IRR multiplies by +/// (10); IRR passes through. The real adapter reads the multiplier from provider config. Conversion happens only +/// at the provider boundary, never internally. +/// +public sealed class CurrencyOptions +{ + /// How many Rial one Toman is (10). A currency redenomination is a config change, not a code change. + public long TomanToIrrMultiplier { get; set; } = 10; } /// @@ -32,18 +44,32 @@ public sealed class MoadianOptions } /// -/// Tunes the thin local mock IBnplProvider 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 IBnplProvider (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. /// public sealed class BnplOptions { - /// When true, every revert/update fails so the refund-channel-refused path is testable. + /// When true, token/revert/update/cancel all fail so the provider-declined paths are testable. public bool ForceFailure { get; set; } /// When true, the mock reports the provider returned its commission (a non-null, zero reversal /// placeholder) so the provider_commission_reversed_amount reconciliation is exercised. public bool ReverseProviderCommission { get; set; } + + /// 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. + public decimal CommissionRate { get; set; } = 0.10m; + + /// When true the mock settle stamps settled_at = now; false models the non-instant + /// (deferred/T+1–3/weekly) settlement with a null settled_at. + public bool SettlementInstant { get; set; } = true; + + /// The provider credit ceiling (IRR); an order above it returns ceiling_exceeded. + public long CreditCeilingIrr { get; set; } = 2_000_000_000; + + /// A designated test mobile that returns not_eligible so the fall-back-to-card path is testable. + public string NotEligibleMobile { get; set; } = "09120000099"; } /// diff --git a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/ServiceConfiguration/ServiceCollectionExtension.cs b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/ServiceConfiguration/ServiceCollectionExtension.cs index 4af9f25..4522bc3 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/ServiceConfiguration/ServiceCollectionExtension.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/ServiceConfiguration/ServiceCollectionExtension.cs @@ -60,10 +60,18 @@ public static class ServiceCollectionExtension services.AddSingleton(); // 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(); - services.AddSingleton(); + + // 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(); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(); + services.AddSingleton(); return services; } diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/BnplConfig/BnplTransactionConfig.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/BnplConfig/BnplTransactionConfig.cs new file mode 100644 index 0000000..ac3dec1 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/BnplConfig/BnplTransactionConfig.cs @@ -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; + +/// +/// bnpl_transactions — one row per BNPL order, 1:1 with its payment_transaction. The +/// UNIQUE(payment_transaction_id) is the structural one-BNPL-row-per-order guard; the settle invariant +/// settled = order − commission (with both net + commission set together) is a DB CHECK mirroring b9/b11. +/// The state-machine guard on status lives in the entity; there is no bnpl_settlement_entries child +/// table (tranched settlement is DEFERRED — adding it later is a purely additive migration). +/// +internal sealed class BnplTransactionConfig : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder 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().WithMany().HasForeignKey(t => t.PaymentTransactionId).IsRequired(); + + builder.HasQueryFilter(t => t.DeletedAt == null); + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260708231405_BnplTransactions.Designer.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260708231405_BnplTransactions.Designer.cs new file mode 100644 index 0000000..e03e62b --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260708231405_BnplTransactions.Designer.cs @@ -0,0 +1,4987 @@ +// +using System; +using Baya.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Baya.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260708231405_BnplTransactions")] + partial class BnplTransactions + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Baya.Domain.Entities.Analytics.SystemEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("OccurredAt") + .HasColumnType("datetimeoffset"); + + b.Property("PropsJson") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.HasIndex("OccurredAt"); + + b.HasIndex("UserId"); + + b.ToTable("SystemEvents", "ops"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Audit.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("ActorUserId") + .HasColumnType("int"); + + b.Property("ChangedFieldsJson") + .HasColumnType("nvarchar(max)"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("OccurredAt") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("ActorUserId"); + + b.HasIndex("OccurredAt"); + + b.HasIndex("EntityType", "EntityId"); + + b.ToTable("AuditLogs", "ops"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Bnpl.BnplTransaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BnplCommissionIrr") + .HasColumnType("bigint"); + + b.Property("CallbackPayloadJson") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("Currency") + .IsRequired() + .HasMaxLength(5) + .HasColumnType("nvarchar(5)"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("EligibilityStatus") + .HasMaxLength(30) + .HasColumnType("nvarchar(30)"); + + b.Property("ExternalPaymentToken") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ExternalTransactionId") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("InstallmentCount") + .HasColumnType("tinyint"); + + b.Property("MerchantOfRecord") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("OrderAmountIrr") + .HasColumnType("bigint"); + + b.Property("PaymentTransactionId") + .HasColumnType("bigint"); + + b.Property("ProviderCode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ProviderCommissionReversedAmount") + .HasColumnType("bigint"); + + b.Property("RefundChannel") + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("RevertTransactionId") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RevertedAmountIrr") + .HasColumnType("bigint"); + + b.Property("RevertedAt") + .HasColumnType("datetime2"); + + b.Property("SettledAmountIrr") + .HasColumnType("bigint"); + + b.Property("SettledAt") + .HasColumnType("datetime2"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("nvarchar(30)"); + + b.HasKey("Id"); + + b.HasIndex("ExternalPaymentToken") + .HasFilter("[ExternalPaymentToken] IS NOT NULL"); + + b.HasIndex("PaymentTransactionId") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("BnplTransactions", "payments", t => + { + t.HasCheckConstraint("CK_BnplTransactions_SettleSplit", "([SettledAmountIrr] IS NULL AND [BnplCommissionIrr] IS NULL) OR ([SettledAmountIrr] = [OrderAmountIrr] - [BnplCommissionIrr] AND [SettledAmountIrr] >= 0 AND [BnplCommissionIrr] >= 0)"); + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Booking.Booking", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AddressSnapshotJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("BalinyaarCommissionIrr") + .HasColumnType("bigint"); + + b.Property("BookingRequestId") + .HasColumnType("bigint"); + + b.Property("CancellationPolicyCode") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("CancellationReason") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("CancellationRefundPercentage") + .HasPrecision(5, 2) + .HasColumnType("decimal(5,2)"); + + b.Property("CancelledAt") + .HasColumnType("datetime2"); + + b.Property("CancelledBy") + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("CompletedAt") + .HasColumnType("datetime2"); + + b.Property("ConfirmedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("CustomerAddressId") + .HasColumnType("bigint"); + + b.Property("CustomerId") + .HasColumnType("bigint"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DisputeWindowEndsAt") + .HasColumnType("datetime2"); + + b.Property("GrossPriceIrr") + .HasColumnType("bigint"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseId") + .HasColumnType("bigint"); + + b.Property("NursePayoutAmount") + .HasColumnType("bigint"); + + b.Property("PartnerCenterId") + .HasColumnType("bigint"); + + b.Property("PatientId") + .HasColumnType("bigint"); + + b.Property("PlatformFeeRate") + .HasPrecision(5, 4) + .HasColumnType("decimal(5,4)"); + + b.Property("PspFeeAmount") + .HasColumnType("bigint"); + + b.Property("RefundableAmountIrr") + .HasColumnType("bigint"); + + b.Property("ScheduledDate") + .HasColumnType("date"); + + b.Property("ScheduledTimeEnd") + .HasColumnType("time"); + + b.Property("ScheduledTimeStart") + .HasColumnType("time"); + + b.Property("SessionCount") + .HasColumnType("smallint"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("nvarchar(30)"); + + b.Property("VariantId") + .HasColumnType("bigint"); + + b.Property("VariantSnapshotJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("BookingRequestId") + .IsUnique(); + + b.HasIndex("CustomerAddressId"); + + b.HasIndex("DisputeWindowEndsAt"); + + b.HasIndex("PatientId"); + + b.HasIndex("VariantId"); + + b.HasIndex("CustomerId", "Status"); + + b.HasIndex("NurseId", "Status"); + + b.ToTable("Bookings", "booking", t => + { + t.HasCheckConstraint("CK_Bookings_AmountSplit", "[GrossPriceIrr] = [BalinyaarCommissionIrr] + [NursePayoutAmount] AND [GrossPriceIrr] >= 0 AND [BalinyaarCommissionIrr] >= 0 AND [NursePayoutAmount] >= 0"); + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Booking.BookingCareInstruction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Allergies") + .HasColumnType("nvarchar(max)"); + + b.Property("BookingId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("CurrentConditions") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("EmergencyContactName") + .HasColumnType("nvarchar(max)"); + + b.Property("EmergencyContactPhone") + .HasColumnType("nvarchar(max)"); + + b.Property("Medications") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("SpecialInstructions") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("BookingId") + .IsUnique(); + + b.ToTable("BookingCareInstructions", "booking"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Booking.BookingRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("CustomerAddressId") + .HasColumnType("bigint"); + + b.Property("CustomerId") + .HasColumnType("bigint"); + + b.Property("CustomerNotes") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseId") + .HasColumnType("bigint"); + + b.Property("NurseRejectionReason") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("NurseResponseDeadlineAt") + .HasColumnType("datetime2"); + + b.Property("PatientId") + .HasColumnType("bigint"); + + b.Property("PaymentDeadlineAt") + .HasColumnType("datetime2"); + + b.Property("RequestedDate") + .HasColumnType("date"); + + b.Property("RequestedTimeEnd") + .HasColumnType("time"); + + b.Property("RequestedTimeStart") + .HasColumnType("time"); + + b.Property("RequiredCaregiverGender") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("VariantId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("CustomerAddressId"); + + b.HasIndex("PatientId"); + + b.HasIndex("VariantId"); + + b.HasIndex("CustomerId", "Status"); + + b.HasIndex("NurseId", "Status"); + + b.HasIndex("Status", "NurseResponseDeadlineAt"); + + b.HasIndex("Status", "PaymentDeadlineAt"); + + b.ToTable("BookingRequests", "booking"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Booking.BookingSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BookingId") + .HasColumnType("bigint"); + + b.Property("CancellationEventId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("PayoutEligibleAt") + .HasColumnType("datetime2"); + + b.Property("ScheduledDate") + .HasColumnType("date"); + + b.Property("ScheduledTimeEnd") + .HasColumnType("time"); + + b.Property("ScheduledTimeStart") + .HasColumnType("time"); + + b.Property("SessionIndex") + .HasColumnType("int"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("VisitPayoutAmount") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("BookingId", "SessionIndex"); + + b.HasIndex("Status", "ScheduledDate"); + + b.ToTable("BookingSessions", "booking"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Booking.CancellationPolicy", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AppliesTo") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("FeeAmountIrr") + .HasColumnType("bigint"); + + b.Property("FeeRate") + .HasPrecision(5, 4) + .HasColumnType("decimal(5,4)"); + + b.Property("HoursBeforeStartMax") + .HasColumnType("int"); + + b.Property("HoursBeforeStartMin") + .HasColumnType("int"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("RefundPercentage") + .HasPrecision(5, 2) + .HasColumnType("decimal(5,2)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("AppliesTo", "IsActive"); + + b.ToTable("CancellationPolicies", "booking"); + + b.HasData( + new + { + Id = 1L, + AppliesTo = "customer", + Code = "standard_24h", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + FeeAmountIrr = 0L, + HoursBeforeStartMin = 24, + IsActive = true, + RefundPercentage = 100m + }, + new + { + Id = 2L, + AppliesTo = "customer", + Code = "standard_inside_24h", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + FeeAmountIrr = 0L, + HoursBeforeStartMax = 24, + IsActive = true, + RefundPercentage = 50m + }, + new + { + Id = 3L, + AppliesTo = "nurse", + Code = "nurse_no_show", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + FeeAmountIrr = 0L, + FeeRate = 0m, + IsActive = true, + RefundPercentage = 100m + }, + new + { + Id = 4L, + AppliesTo = "admin", + Code = "admin_cancellation", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + FeeAmountIrr = 0L, + IsActive = true, + RefundPercentage = 100m + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Booking.VisitVerification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BookingSessionId") + .HasColumnType("bigint"); + + b.Property("CheckInAddressMatch") + .HasColumnType("bit"); + + b.Property("CheckInAt") + .HasColumnType("datetime2"); + + b.Property("CheckInDistanceMeters") + .HasPrecision(10, 2) + .HasColumnType("decimal(10,2)"); + + b.Property("CheckInLat") + .HasPrecision(9, 6) + .HasColumnType("decimal(9,6)"); + + b.Property("CheckInLng") + .HasPrecision(9, 6) + .HasColumnType("decimal(9,6)"); + + b.Property("CheckOutAt") + .HasColumnType("datetime2"); + + b.Property("CheckOutLat") + .HasPrecision(9, 6) + .HasColumnType("decimal(9,6)"); + + b.Property("CheckOutLng") + .HasPrecision(9, 6) + .HasColumnType("decimal(9,6)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("BookingSessionId") + .IsUnique(); + + b.HasIndex("CheckInAddressMatch"); + + b.ToTable("VisitVerifications", "booking"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.NurseServiceVariant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseId") + .HasColumnType("bigint"); + + b.Property("OptionSetHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Price") + .HasColumnType("bigint"); + + b.Property("PriceUnit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("ServiceCategoryId") + .HasColumnType("bigint"); + + b.Property("SessionCount") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ServiceCategoryId"); + + b.HasIndex("NurseId", "IsActive"); + + b.HasIndex("NurseId", "ServiceCategoryId", "OptionSetHash") + .IsUnique() + .HasDatabaseName("UX_NurseServiceVariants_Nurse_Category_OptionSet") + .HasFilter("[DeletedAt] IS NULL"); + + b.ToTable("NurseServiceVariants", "catalog"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.NurseServiceVariantOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("OptionGroupId") + .HasColumnType("bigint"); + + b.Property("OptionValueId") + .HasColumnType("bigint"); + + b.Property("VariantId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("OptionGroupId"); + + b.HasIndex("OptionValueId"); + + b.HasIndex("VariantId", "OptionGroupId") + .IsUnique() + .HasDatabaseName("UX_NurseServiceVariantOptions_Variant_Group"); + + b.ToTable("NurseServiceVariantOptions", "catalog"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DescriptionEn") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("DescriptionFa") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("IconKey") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("NameFa") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("IsActive", "SortOrder"); + + b.ToTable("ServiceCategories", "catalog"); + + b.HasData( + new + { + Id = 1L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Elderly Care", + NameFa = "مراقبت از سالمند", + SortOrder = 1 + }, + new + { + Id = 2L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Post-Surgery Recovery", + NameFa = "مراقبت پس از جراحی", + SortOrder = 2 + }, + new + { + Id = 3L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Infant Care", + NameFa = "مراقبت از نوزاد", + SortOrder = 3 + }, + new + { + Id = 4L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Chronic Illness Management", + NameFa = "مدیریت بیماری مزمن", + SortOrder = 4 + }, + new + { + Id = 5L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Companionship", + NameFa = "همراهی و مراقبت روزمره", + SortOrder = 5 + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceOptionGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("IsRequired") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("NameFa") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("ServiceCategoryId") + .HasColumnType("bigint"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("ServiceCategoryId", "SortOrder"); + + b.ToTable("ServiceOptionGroups", "catalog"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceOptionValue", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("NameFa") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("OptionGroupId") + .HasColumnType("bigint"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("OptionGroupId", "SortOrder"); + + b.ToTable("ServiceOptionValues", "catalog"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Configuration.PlatformConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DataType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("Value") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("PlatformConfigs", "ops"); + + b.HasData( + new + { + Id = 1L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "decimal", + Description = "Balinyaar commission rate on the booking gross (fraction).", + Key = "platform_fee_rate", + Value = "0.15" + }, + new + { + Id = 2L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "decimal", + Description = "VAT rate applied to the commission line only (fraction).", + Key = "vat_rate", + Value = "0.10" + }, + new + { + Id = 3L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Hours after check-out a booking can be disputed.", + Key = "dispute_window_hours", + Value = "72" + }, + new + { + Id = 4L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Minutes a family has to pay before a pending booking expires.", + Key = "booking_payment_deadline_minutes", + Value = "30" + }, + new + { + Id = 5L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Hours a nurse has to accept/decline a booking request.", + Key = "nurse_response_deadline_hours", + Value = "24" + }, + new + { + Id = 6L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Weekly payout cadence in days.", + Key = "nurse_payout_interval_days", + Value = "7" + }, + new + { + Id = 7L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Allowed EVV check-in distance from the care address.", + Key = "evv_location_tolerance_meters", + Value = "200" + }, + new + { + Id = 8L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "decimal", + Description = "A review at or below this rating raises a support alert.", + Key = "min_rating_for_support_alert", + Value = "2" + }, + new + { + Id = 9L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "string", + Description = "Who is merchant of record for BNPL orders (platform|nurse).", + Key = "bnpl_merchant_of_record", + Value = "platform" + }, + new + { + Id = 10L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "decimal", + Description = "BNPL provider commission rate (fraction).", + Key = "bnpl_provider_commission_rate", + Value = "0.07" + }, + new + { + Id = 11L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "string", + Description = "When BNPL settles funds to the platform (immediate|deferred).", + Key = "bnpl_settlement_timing", + Value = "immediate" + }, + new + { + Id = 12L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "json", + Description = "Tiered cancellation refund policy: refund_percent by hours before the visit.", + Key = "cancellation_tiers", + Value = "[{\"min_hours_before\":48,\"refund_percent\":100},{\"min_hours_before\":24,\"refund_percent\":50},{\"min_hours_before\":0,\"refund_percent\":0}]" + }, + new + { + Id = 13L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Seconds a phone must wait before another OTP can be requested.", + Key = "auth_otp_resend_seconds", + Value = "120" + }, + new + { + Id = 14L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Wrong-code attempts allowed before OTP verification is refused until a fresh code.", + Key = "auth_otp_max_attempts", + Value = "5" + }, + new + { + Id = 15L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Refresh-token session lifetime in days.", + Key = "auth_session_ttl_days", + Value = "30" + }, + new + { + Id = 16L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Hours between credential-expiry scans (the scheduled cron is deferred; the scan is admin-triggered today).", + Key = "verification_expiry_scan_cadence_hours", + Value = "24" + }, + new + { + Id = 17L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Minutes after a session's scheduled start with no EVV check-in before it is flagged a no-show.", + Key = "no_show_threshold_minutes", + Value = "60" + }, + new + { + Id = 18L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Hours between no-show sweeps (the scheduled cron is deferred; the sweep is admin-triggered today).", + Key = "no_show_scan_cadence_hours", + Value = "1" + }, + new + { + Id = 19L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "bool", + Description = "Whether a refund must link a support ticket (b11). Off until b15 ships the tickets table.", + Key = "refund_ticket_required", + Value = "false" + }, + new + { + Id = 20L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Business days shown as the customer BNPL refund ETA (b11).", + Key = "bnpl_refund_eta_business_days", + Value = "10" + }, + new + { + Id = 21L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "bool", + Description = "Ops/testing override that forces the post-payout clawback path for refunds (b11); b13 replaces the derivation.", + Key = "refund_assume_nurse_paid", + Value = "false" + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.City", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("NameFa") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("ProvinceId") + .HasColumnType("bigint"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("ProvinceId", "SortOrder"); + + b.ToTable("Cities", "geo"); + + b.HasData( + new + { + Id = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Tehran", + NameFa = "تهران", + ProvinceId = 1L, + SortOrder = 1 + }, + new + { + Id = 102L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Karaj", + NameFa = "کرج", + ProvinceId = 2L, + SortOrder = 1 + }, + new + { + Id = 103L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Isfahan", + NameFa = "اصفهان", + ProvinceId = 3L, + SortOrder = 1 + }, + new + { + Id = 104L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Shiraz", + NameFa = "شیراز", + ProvinceId = 4L, + SortOrder = 1 + }, + new + { + Id = 105L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Mashhad", + NameFa = "مشهد", + ProvinceId = 5L, + SortOrder = 1 + }, + new + { + Id = 106L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Tabriz", + NameFa = "تبریز", + ProvinceId = 6L, + SortOrder = 1 + }, + new + { + Id = 107L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Urmia", + NameFa = "ارومیه", + ProvinceId = 7L, + SortOrder = 1 + }, + new + { + Id = 108L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Ahvaz", + NameFa = "اهواز", + ProvinceId = 8L, + SortOrder = 1 + }, + new + { + Id = 109L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Qom", + NameFa = "قم", + ProvinceId = 9L, + SortOrder = 1 + }, + new + { + Id = 110L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Kerman", + NameFa = "کرمان", + ProvinceId = 10L, + SortOrder = 1 + }, + new + { + Id = 111L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Rasht", + NameFa = "رشت", + ProvinceId = 11L, + SortOrder = 1 + }, + new + { + Id = 112L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Sari", + NameFa = "ساری", + ProvinceId = 12L, + SortOrder = 1 + }, + new + { + Id = 113L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Arak", + NameFa = "اراک", + ProvinceId = 13L, + SortOrder = 1 + }, + new + { + Id = 114L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Ardabil", + NameFa = "اردبیل", + ProvinceId = 14L, + SortOrder = 1 + }, + new + { + Id = 115L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Qazvin", + NameFa = "قزوین", + ProvinceId = 15L, + SortOrder = 1 + }, + new + { + Id = 116L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Kermanshah", + NameFa = "کرمانشاه", + ProvinceId = 16L, + SortOrder = 1 + }, + new + { + Id = 117L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Bojnord", + NameFa = "بجنورد", + ProvinceId = 17L, + SortOrder = 1 + }, + new + { + Id = 118L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Birjand", + NameFa = "بیرجند", + ProvinceId = 18L, + SortOrder = 1 + }, + new + { + Id = 119L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Hamadan", + NameFa = "همدان", + ProvinceId = 19L, + SortOrder = 1 + }, + new + { + Id = 120L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Sanandaj", + NameFa = "سنندج", + ProvinceId = 20L, + SortOrder = 1 + }, + new + { + Id = 121L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Khorramabad", + NameFa = "خرم‌آباد", + ProvinceId = 21L, + SortOrder = 1 + }, + new + { + Id = 122L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Gorgan", + NameFa = "گرگان", + ProvinceId = 22L, + SortOrder = 1 + }, + new + { + Id = 123L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Bandar Abbas", + NameFa = "بندرعباس", + ProvinceId = 23L, + SortOrder = 1 + }, + new + { + Id = 124L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Bushehr", + NameFa = "بوشهر", + ProvinceId = 24L, + SortOrder = 1 + }, + new + { + Id = 125L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Zanjan", + NameFa = "زنجان", + ProvinceId = 25L, + SortOrder = 1 + }, + new + { + Id = 126L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Semnan", + NameFa = "سمنان", + ProvinceId = 26L, + SortOrder = 1 + }, + new + { + Id = 127L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Yazd", + NameFa = "یزد", + ProvinceId = 27L, + SortOrder = 1 + }, + new + { + Id = 128L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Zahedan", + NameFa = "زاهدان", + ProvinceId = 28L, + SortOrder = 1 + }, + new + { + Id = 129L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Shahrekord", + NameFa = "شهرکرد", + ProvinceId = 29L, + SortOrder = 1 + }, + new + { + Id = 130L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Yasuj", + NameFa = "یاسوج", + ProvinceId = 30L, + SortOrder = 1 + }, + new + { + Id = 131L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Ilam", + NameFa = "ایلام", + ProvinceId = 31L, + SortOrder = 1 + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.District", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CityId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("NameFa") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("CityId", "SortOrder"); + + b.ToTable("Districts", "geo"); + + b.HasData( + new + { + Id = 1001L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 1", + NameFa = "منطقه ۱", + SortOrder = 1 + }, + new + { + Id = 1002L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 2", + NameFa = "منطقه ۲", + SortOrder = 2 + }, + new + { + Id = 1003L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 3", + NameFa = "منطقه ۳", + SortOrder = 3 + }, + new + { + Id = 1004L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 4", + NameFa = "منطقه ۴", + SortOrder = 4 + }, + new + { + Id = 1005L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 5", + NameFa = "منطقه ۵", + SortOrder = 5 + }, + new + { + Id = 1006L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 6", + NameFa = "منطقه ۶", + SortOrder = 6 + }, + new + { + Id = 1007L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 7", + NameFa = "منطقه ۷", + SortOrder = 7 + }, + new + { + Id = 1008L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 8", + NameFa = "منطقه ۸", + SortOrder = 8 + }, + new + { + Id = 1009L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 9", + NameFa = "منطقه ۹", + SortOrder = 9 + }, + new + { + Id = 1010L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 10", + NameFa = "منطقه ۱۰", + SortOrder = 10 + }, + new + { + Id = 1011L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 11", + NameFa = "منطقه ۱۱", + SortOrder = 11 + }, + new + { + Id = 1012L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 12", + NameFa = "منطقه ۱۲", + SortOrder = 12 + }, + new + { + Id = 1013L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 13", + NameFa = "منطقه ۱۳", + SortOrder = 13 + }, + new + { + Id = 1014L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 14", + NameFa = "منطقه ۱۴", + SortOrder = 14 + }, + new + { + Id = 1015L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 15", + NameFa = "منطقه ۱۵", + SortOrder = 15 + }, + new + { + Id = 1016L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 16", + NameFa = "منطقه ۱۶", + SortOrder = 16 + }, + new + { + Id = 1017L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 17", + NameFa = "منطقه ۱۷", + SortOrder = 17 + }, + new + { + Id = 1018L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 18", + NameFa = "منطقه ۱۸", + SortOrder = 18 + }, + new + { + Id = 1019L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 19", + NameFa = "منطقه ۱۹", + SortOrder = 19 + }, + new + { + Id = 1020L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 20", + NameFa = "منطقه ۲۰", + SortOrder = 20 + }, + new + { + Id = 1021L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 21", + NameFa = "منطقه ۲۱", + SortOrder = 21 + }, + new + { + Id = 1022L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 22", + NameFa = "منطقه ۲۲", + SortOrder = 22 + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.NurseServiceArea", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CityId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DistrictId") + .HasColumnType("bigint"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("CityId"); + + b.HasIndex("DistrictId"); + + b.HasIndex("NurseId", "CityId") + .IsUnique() + .HasDatabaseName("UX_NurseServiceAreas_Nurse_City_WholeCity") + .HasFilter("[DistrictId] IS NULL AND [DeletedAt] IS NULL"); + + b.HasIndex("NurseId", "CityId", "DistrictId") + .IsUnique() + .HasDatabaseName("UX_NurseServiceAreas_Nurse_City_District") + .HasFilter("[DistrictId] IS NOT NULL AND [DeletedAt] IS NULL"); + + b.ToTable("NurseServiceAreas", "geo"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.Province", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("NameFa") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("SortOrder"); + + b.ToTable("Provinces", "geo"); + + b.HasData( + new + { + Id = 1L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Tehran", + NameFa = "تهران", + SortOrder = 1 + }, + new + { + Id = 2L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Alborz", + NameFa = "البرز", + SortOrder = 2 + }, + new + { + Id = 3L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Isfahan", + NameFa = "اصفهان", + SortOrder = 3 + }, + new + { + Id = 4L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Fars", + NameFa = "فارس", + SortOrder = 4 + }, + new + { + Id = 5L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Razavi Khorasan", + NameFa = "خراسان رضوی", + SortOrder = 5 + }, + new + { + Id = 6L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "East Azerbaijan", + NameFa = "آذربایجان شرقی", + SortOrder = 6 + }, + new + { + Id = 7L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "West Azerbaijan", + NameFa = "آذربایجان غربی", + SortOrder = 7 + }, + new + { + Id = 8L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Khuzestan", + NameFa = "خوزستان", + SortOrder = 8 + }, + new + { + Id = 9L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Qom", + NameFa = "قم", + SortOrder = 9 + }, + new + { + Id = 10L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Kerman", + NameFa = "کرمان", + SortOrder = 10 + }, + new + { + Id = 11L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Gilan", + NameFa = "گیلان", + SortOrder = 11 + }, + new + { + Id = 12L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Mazandaran", + NameFa = "مازندران", + SortOrder = 12 + }, + new + { + Id = 13L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Markazi", + NameFa = "مرکزی", + SortOrder = 13 + }, + new + { + Id = 14L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Ardabil", + NameFa = "اردبیل", + SortOrder = 14 + }, + new + { + Id = 15L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Qazvin", + NameFa = "قزوین", + SortOrder = 15 + }, + new + { + Id = 16L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Kermanshah", + NameFa = "کرمانشاه", + SortOrder = 16 + }, + new + { + Id = 17L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "North Khorasan", + NameFa = "خراسان شمالی", + SortOrder = 17 + }, + new + { + Id = 18L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "South Khorasan", + NameFa = "خراسان جنوبی", + SortOrder = 18 + }, + new + { + Id = 19L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Hamadan", + NameFa = "همدان", + SortOrder = 19 + }, + new + { + Id = 20L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Kurdistan", + NameFa = "کردستان", + SortOrder = 20 + }, + new + { + Id = 21L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Lorestan", + NameFa = "لرستان", + SortOrder = 21 + }, + new + { + Id = 22L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Golestan", + NameFa = "گلستان", + SortOrder = 22 + }, + new + { + Id = 23L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Hormozgan", + NameFa = "هرمزگان", + SortOrder = 23 + }, + new + { + Id = 24L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Bushehr", + NameFa = "بوشهر", + SortOrder = 24 + }, + new + { + Id = 25L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Zanjan", + NameFa = "زنجان", + SortOrder = 25 + }, + new + { + Id = 26L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Semnan", + NameFa = "سمنان", + SortOrder = 26 + }, + new + { + Id = 27L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Yazd", + NameFa = "یزد", + SortOrder = 27 + }, + new + { + Id = 28L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Sistan and Baluchestan", + NameFa = "سیستان و بلوچستان", + SortOrder = 28 + }, + new + { + Id = 29L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Chaharmahal and Bakhtiari", + NameFa = "چهارمحال و بختیاری", + SortOrder = 29 + }, + new + { + Id = 30L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Kohgiluyeh and Boyer-Ahmad", + NameFa = "کهگیلویه و بویراحمد", + SortOrder = 30 + }, + new + { + Id = 31L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Ilam", + NameFa = "ایلام", + SortOrder = 31 + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Holidays.IranianHoliday", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("HolidayDate") + .HasColumnType("date"); + + b.Property("IsBankClosed") + .HasColumnType("bit"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NameFa") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("HolidayDate") + .IsUnique(); + + b.ToTable("IranianHolidays", "ops"); + + b.HasData( + new + { + Id = 1L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HolidayDate = new DateOnly(2026, 2, 11), + IsBankClosed = true, + NameFa = "پیروزی انقلاب اسلامی", + Type = "national" + }, + new + { + Id = 2L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HolidayDate = new DateOnly(2026, 3, 21), + IsBankClosed = true, + NameFa = "نوروز", + Type = "national" + }, + new + { + Id = 3L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HolidayDate = new DateOnly(2026, 3, 22), + IsBankClosed = true, + NameFa = "نوروز", + Type = "national" + }, + new + { + Id = 4L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HolidayDate = new DateOnly(2026, 3, 23), + IsBankClosed = true, + NameFa = "نوروز", + Type = "national" + }, + new + { + Id = 5L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HolidayDate = new DateOnly(2026, 3, 24), + IsBankClosed = true, + NameFa = "نوروز", + Type = "national" + }, + new + { + Id = 6L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HolidayDate = new DateOnly(2026, 4, 1), + IsBankClosed = true, + NameFa = "روز طبیعت (سیزده‌به‌در)", + Type = "official" + }, + new + { + Id = 7L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HolidayDate = new DateOnly(2026, 6, 26), + IsBankClosed = true, + NameFa = "عید سعید قربان", + Type = "religious" + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.CustomerAddress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AddressLine") + .HasColumnType("nvarchar(max)"); + + b.Property("CityId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("CustomerId") + .HasColumnType("bigint"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DistrictId") + .HasColumnType("bigint"); + + b.Property("IsPrimary") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("Latitude") + .HasPrecision(9, 6) + .HasColumnType("decimal(9,6)"); + + b.Property("Longitude") + .HasPrecision(9, 6) + .HasColumnType("decimal(9,6)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("PostalCode") + .HasColumnType("nvarchar(max)"); + + b.Property("RecipientName") + .HasColumnType("nvarchar(max)"); + + b.Property("RecipientPhone") + .HasColumnType("nvarchar(max)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("CityId"); + + b.HasIndex("CustomerId") + .IsUnique() + .HasDatabaseName("UX_CustomerAddresses_Customer_Primary") + .HasFilter("[IsPrimary] = 1 AND [DeletedAt] IS NULL"); + + b.HasIndex("DistrictId"); + + b.ToTable("CustomerAddresses", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.CustomerProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DefaultEmergencyContactName") + .HasColumnType("nvarchar(max)"); + + b.Property("DefaultEmergencyContactPhone") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("CustomerProfiles", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.NurseBankAccount", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountHolderFromBank") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("AccountHolderName") + .HasColumnType("nvarchar(max)"); + + b.Property("BankName") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("Iban") + .HasColumnType("nvarchar(max)"); + + b.Property("IbanHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("IsPrimary") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("IsVerified") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("MatchedNationalId") + .HasColumnType("bit"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseId") + .HasColumnType("bigint"); + + b.Property("OwnershipVendorRef") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("VerifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("VerifiedByAdminId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("IbanHash") + .IsUnique(); + + b.HasIndex("NurseId") + .IsUnique() + .HasDatabaseName("UX_NurseBankAccounts_NurseId_Primary") + .HasFilter("[IsPrimary] = 1"); + + b.HasIndex("VerifiedByAdminId"); + + b.ToTable("NurseBankAccounts", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.NurseProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AverageRating") + .ValueGeneratedOnAdd() + .HasPrecision(3, 2) + .HasColumnType("decimal(3,2)") + .HasDefaultValue(0m); + + b.Property("Bio") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("EducationField") + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("EducationLevel") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("IsAcceptingBookings") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("IsVerified") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("PartnerCenterId") + .HasColumnType("bigint"); + + b.Property("SpecializationsJson") + .HasColumnType("nvarchar(max)"); + + b.Property("TotalCompletedBookings") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.Property("TotalReviews") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.Property("UserId") + .HasColumnType("int"); + + b.Property("YearsOfExperience") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("NurseProfiles", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.Patient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BirthDate") + .HasColumnType("date"); + + b.Property("BloodType") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("CustomerId") + .HasColumnType("bigint"); + + b.Property("DisplayName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("FirstName") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Gender") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("InitialMedicalNotes") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("LastName") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId"); + + b.ToTable("Patients", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Invoices.Invoice", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BnplCommissionIrr") + .HasColumnType("bigint"); + + b.Property("BookingId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("GrossIrr") + .HasColumnType("bigint"); + + b.Property("InvoiceNumber") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("IssuedAt") + .HasColumnType("datetime2"); + + b.Property("IssuingEntityType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("MoadianReferenceNumber") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("MoadianStatus") + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("PartnerCenterId") + .HasColumnType("bigint"); + + b.Property("PdfStorageKey") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("PlatformCommissionIrr") + .HasColumnType("bigint"); + + b.Property("VatIrr") + .HasColumnType("bigint"); + + b.Property("VatRate") + .HasPrecision(5, 4) + .HasColumnType("decimal(5,4)"); + + b.HasKey("Id"); + + b.HasIndex("BookingId") + .IsUnique(); + + b.HasIndex("InvoiceNumber") + .IsUnique(); + + b.ToTable("Invoices", "payments"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Invoices.InvoiceNumberSequence", b => + { + b.Property("Id") + .HasColumnType("int"); + + b.Property("NextValue") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.ToTable("InvoiceNumberSequences", "payments"); + + b.HasData( + new + { + Id = 1, + NextValue = 1L + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Notifications.Notification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Body") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DataJson") + .HasColumnType("nvarchar(max)"); + + b.Property("IsRead") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("ReadAt") + .HasColumnType("datetimeoffset"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "IsRead", "CreatedAt"); + + b.ToTable("Notifications", "ops"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Payments.LedgerEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountType") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("AmountIrr") + .HasColumnType("bigint"); + + b.Property("BookingId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Direction") + .IsRequired() + .HasMaxLength(6) + .HasColumnType("nvarchar(6)"); + + b.Property("Memo") + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("NurseId") + .HasColumnType("bigint"); + + b.Property("SourceRefId") + .HasColumnType("bigint"); + + b.Property("SourceRefType") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("TransactionGroupId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("BookingId"); + + b.HasIndex("NurseId"); + + b.HasIndex("TransactionGroupId"); + + b.HasIndex("AccountType", "NurseId"); + + b.HasIndex("SourceRefType", "SourceRefId"); + + b.ToTable("LedgerEntries", "payments"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Payments.PaymentGateway", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConfigJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("Priority") + .HasColumnType("int"); + + b.Property("ProviderCode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("Type", "IsActive", "Priority"); + + b.ToTable("PaymentGateways", "payments"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Payments.PaymentTransaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Amount") + .HasColumnType("bigint"); + + b.Property("BookingId") + .HasColumnType("bigint"); + + b.Property("BookingRequestId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("Currency") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("nvarchar(3)"); + + b.Property("CustomerId") + .HasColumnType("bigint"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("GatewayId") + .HasColumnType("bigint"); + + b.Property("GatewayReferenceCode") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("GatewayResponseCode") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("GatewayResponseJson") + .HasColumnType("nvarchar(max)"); + + b.Property("GatewayTransactionId") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("IpAddress") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("IsInstallment") + .HasColumnType("bit"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("UserAgent") + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.HasKey("Id"); + + b.HasIndex("BookingId") + .IsUnique() + .HasFilter("[Status] = 'succeeded' AND [BookingId] IS NOT NULL"); + + b.HasIndex("CustomerId"); + + b.HasIndex("GatewayId"); + + b.HasIndex("GatewayReferenceCode") + .IsUnique() + .HasFilter("[GatewayReferenceCode] IS NOT NULL"); + + b.HasIndex("BookingId", "Status"); + + b.HasIndex("BookingRequestId", "Status"); + + b.ToTable("PaymentTransactions", "payments"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Payments.PaymentWebhookEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("ExternalEventId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("PayloadJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ProcessedAt") + .HasColumnType("datetime2"); + + b.Property("ProcessingStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("ProviderCode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ReceivedAt") + .HasColumnType("datetime2"); + + b.Property("RelatedPaymentTransactionId") + .HasColumnType("bigint"); + + b.Property("SignatureValid") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("ProviderCode", "ExternalEventId") + .IsUnique(); + + b.ToTable("PaymentWebhookEvents", "payments"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Refunds.NurseClawback", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AmountIrr") + .HasColumnType("bigint"); + + b.Property("BookingId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseId") + .HasColumnType("bigint"); + + b.Property("OriginalPayoutId") + .HasColumnType("bigint"); + + b.Property("RecoveredInPayoutId") + .HasColumnType("bigint"); + + b.Property("RefundId") + .HasColumnType("bigint"); + + b.Property("ResolutionNotes") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("ResolvedAt") + .HasColumnType("datetime2"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("nvarchar(30)"); + + b.HasKey("Id"); + + b.HasIndex("BookingId"); + + b.HasIndex("NurseId"); + + b.HasIndex("OriginalPayoutId"); + + b.HasIndex("RecoveredInPayoutId"); + + b.HasIndex("RefundId") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("NurseClawbacks", "payments"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Refunds.Refund", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AdminNotes") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("Amount") + .HasColumnType("bigint"); + + b.Property("ApprovedByAdminId") + .HasColumnType("int"); + + b.Property("BookingId") + .HasColumnType("bigint"); + + b.Property("CancellationPolicyCode") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ExpectedCustomerRefundEta") + .HasColumnType("date"); + + b.Property("ExternalRevertReference") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("GatewayRefundReference") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NursePayoutRefundedIrr") + .HasColumnType("bigint"); + + b.Property("PaymentTransactionId") + .HasColumnType("bigint"); + + b.Property("PlatformFeeRefundedIrr") + .HasColumnType("bigint"); + + b.Property("ProcessedAt") + .HasColumnType("datetime2"); + + b.Property("ReasonCategory") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ReasonNotes") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("RefundChannel") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("RefundPercentage") + .HasPrecision(6, 4) + .HasColumnType("decimal(6,4)"); + + b.Property("RefundPercentageApplied") + .HasPrecision(5, 2) + .HasColumnType("decimal(5,2)"); + + b.Property("RejectedReason") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("RequestedByCustomerId") + .HasColumnType("bigint"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("TicketId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("BookingId"); + + b.HasIndex("PaymentTransactionId"); + + b.HasIndex("RequestedByCustomerId"); + + b.HasIndex("Status"); + + b.HasIndex("TicketId"); + + b.ToTable("Refunds", "payments", t => + { + t.HasCheckConstraint("CK_Refunds_LegSplit", "[Amount] = [PlatformFeeRefundedIrr] + [NursePayoutRefundedIrr] AND [Amount] >= 0 AND [PlatformFeeRefundedIrr] >= 0 AND [NursePayoutRefundedIrr] >= 0"); + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Search.NurseSearchIndex", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AverageRating") + .HasPrecision(3, 2) + .HasColumnType("decimal(3,2)"); + + b.Property("CityId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DistrictId") + .HasColumnType("bigint"); + + b.Property("IsSearchable") + .HasColumnType("bit"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseGender") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("NurseId") + .HasColumnType("bigint"); + + b.Property("Price") + .HasColumnType("bigint"); + + b.Property("PriceUnit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("ServiceCategoryId") + .HasColumnType("bigint"); + + b.Property("TotalCompletedBookings") + .HasColumnType("int"); + + b.Property("TotalReviews") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("VariantId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("NurseId"); + + b.HasIndex("VariantId", "CityId") + .IsUnique() + .HasDatabaseName("UX_NurseSearchIndex_Variant_City_WholeCity") + .HasFilter("[DistrictId] IS NULL AND [DeletedAt] IS NULL"); + + b.HasIndex("VariantId", "CityId", "DistrictId") + .IsUnique() + .HasDatabaseName("UX_NurseSearchIndex_Variant_City_District") + .HasFilter("[DistrictId] IS NOT NULL AND [DeletedAt] IS NULL"); + + b.HasIndex("IsSearchable", "ServiceCategoryId", "CityId", "DistrictId") + .HasDatabaseName("IX_NurseSearchIndex_Search"); + + SqlServerIndexBuilderExtensions.IncludeProperties(b.HasIndex("IsSearchable", "ServiceCategoryId", "CityId", "DistrictId"), new[] { "Price", "NurseGender", "AverageRating", "TotalReviews", "NurseId", "VariantId" }); + + b.ToTable("NurseSearchIndices", "search"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.SupportAlerts.SupportAlert", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BookingId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("OwnerUserId") + .HasColumnType("int"); + + b.Property("ResolutionNote") + .HasColumnType("nvarchar(max)"); + + b.Property("ResolvedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ReviewId") + .HasColumnType("bigint"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.HasIndex("Status"); + + b.HasIndex("Type"); + + b.ToTable("SupportAlerts", "ops"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("Roles", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.RoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedClaim") + .HasColumnType("datetime2"); + + b.Property("RoleId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("RoleClaims", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("UserId"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("FamilyName") + .HasColumnType("nvarchar(max)"); + + b.Property("Gender") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("GeneratedCode") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("NationalId") + .HasColumnType("nvarchar(max)"); + + b.Property("NationalIdVerifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneHash") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("PhoneVerifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("ShahkarVerifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.HasIndex("PhoneHash") + .IsUnique() + .HasFilter("[PhoneHash] IS NOT NULL"); + + b.ToTable("Users", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserClaims", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("LoggedOn") + .HasColumnType("datetime2"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("UserLogins", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserRefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("IsValid") + .HasColumnType("bit"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserRefreshTokens", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserRole", b => + { + b.Property("UserId") + .HasColumnType("int"); + + b.Property("RoleId") + .HasColumnType("int"); + + b.Property("CreatedUserRoleDate") + .HasColumnType("datetime2"); + + b.Property("GrantedAt") + .HasColumnType("datetimeoffset"); + + b.Property("GrantedById") + .HasColumnType("int"); + + b.Property("RevokedAt") + .HasColumnType("datetimeoffset"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("GrantedById"); + + b.HasIndex("RoleId"); + + b.ToTable("UserRoles", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeviceInfo") + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("ExpiresAt") + .HasColumnType("datetimeoffset"); + + b.Property("IpAddress") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("IsRevoked") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("RefreshTokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("RevokedAt") + .HasColumnType("datetimeoffset"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("RefreshTokenHash") + .IsUnique(); + + b.HasIndex("UserId", "IsRevoked"); + + b.ToTable("UserSessions", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserToken", b => + { + b.Property("UserId") + .HasColumnType("int"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("GeneratedTime") + .HasColumnType("datetime2"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("UserTokens", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.NurseCredential", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("CredentialNumber") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("CredentialType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ExpiresAt") + .HasColumnType("date"); + + b.Property("HolderNameSnapshot") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("IssuedAt") + .HasColumnType("date"); + + b.Property("IssuingAuthority") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseId") + .HasColumnType("bigint"); + + b.Property("VerificationMethod") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("VerificationSource") + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("VerifiedByAdminId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("VerifiedByAdminId"); + + b.HasIndex("NurseId", "CredentialType"); + + b.ToTable("NurseCredentials", "verif"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.NurseVerification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ApprovedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("InternalNotes") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseId") + .HasColumnType("bigint"); + + b.Property("RejectedAt") + .HasColumnType("datetimeoffset"); + + b.Property("RejectionReason") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("ReviewedByAdminId") + .HasColumnType("int"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("SubmittedAt") + .HasColumnType("datetimeoffset"); + + b.Property("SuspendedAt") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("NurseId") + .IsUnique(); + + b.HasIndex("ReviewedByAdminId"); + + b.ToTable("NurseVerifications", "verif"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.VerificationDocument", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("FileSizeBytes") + .HasColumnType("bigint"); + + b.Property("IntegrityHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("ObjectStorageKey") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("OriginalFileName") + .HasMaxLength(260) + .HasColumnType("nvarchar(260)"); + + b.Property("StepId") + .HasColumnType("bigint"); + + b.Property("UploadedByUserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("StepId"); + + b.HasIndex("UploadedByUserId"); + + b.ToTable("VerificationDocuments", "verif"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.VerificationStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("ExpiresAt") + .HasColumnType("datetimeoffset"); + + b.Property("ExternalResponseJson") + .HasColumnType("nvarchar(max)"); + + b.Property("FailureReason") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("IsAutomated") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseVerificationId") + .HasColumnType("bigint"); + + b.Property("StartedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("StepTypeId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("StepTypeId"); + + b.HasIndex("NurseVerificationId", "StepTypeId") + .IsUnique() + .HasDatabaseName("UX_VerificationSteps_Verification_StepType"); + + b.ToTable("VerificationSteps", "verif"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.VerificationStepType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AutomationProvider") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("IsAutomated") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("IsRequired") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsActive", "SortOrder"); + + b.ToTable("VerificationStepTypes", "verif"); + + b.HasData( + new + { + Id = 1L, + AutomationProvider = "identity_kyc_vendor", + Code = "identity_kyc", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + Description = "National-ID validity, name match and photo/video liveness via an Iranian e-KYC vendor.", + DisplayName = "Identity Verification (KYC)", + IsActive = true, + IsAutomated = true, + IsRequired = true, + SortOrder = 1 + }, + new + { + Id = 2L, + AutomationProvider = "shahkar", + Code = "shahkar_match", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + Description = "Confirms the login SIM is registered to the nurse's own national ID (شاهکار).", + DisplayName = "Shahkar Phone Binding", + IsActive = true, + IsAutomated = true, + IsRequired = true, + SortOrder = 2 + }, + new + { + Id = 3L, + Code = "moh_competency_license", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + Description = "پروانه صلاحیت حرفه‌ای — the MoH-mandated in-home nursing licence (bundles the criminal-record screen). Manual today.", + DisplayName = "MoH Professional Competency License", + IsActive = true, + IsAutomated = false, + IsRequired = true, + SortOrder = 3 + }, + new + { + Id = 4L, + Code = "ino_membership", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + Description = "نظام پرستاری membership cross-check (ino.ir). Manual today.", + DisplayName = "Nursing Organization (INO) Membership", + IsActive = true, + IsAutomated = false, + IsRequired = true, + SortOrder = 4 + }, + new + { + Id = 5L, + Code = "criminal_record", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + Description = "عدم سوء پیشینه — consent-gated, nurse-uploaded, time-limited (reverts on expiry).", + DisplayName = "Criminal Record Certificate", + IsActive = true, + IsAutomated = false, + IsRequired = true, + SortOrder = 5 + }, + new + { + Id = 6L, + AutomationProvider = "sheba", + Code = "bank_account_verification", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + Description = "استعلام شبا — the payout IBAN owner's national ID must equal the verified nurse national ID.", + DisplayName = "Bank Account (IBAN) Ownership", + IsActive = true, + IsAutomated = true, + IsRequired = true, + SortOrder = 6 + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Analytics.SystemEvent", b => + { + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("UserId"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Audit.AuditLog", b => + { + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("ActorUserId"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Bnpl.BnplTransaction", b => + { + b.HasOne("Baya.Domain.Entities.Payments.PaymentTransaction", null) + .WithMany() + .HasForeignKey("PaymentTransactionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Booking.Booking", b => + { + b.HasOne("Baya.Domain.Entities.Booking.BookingRequest", null) + .WithOne() + .HasForeignKey("Baya.Domain.Entities.Booking.Booking", "BookingRequestId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Identity.CustomerAddress", null) + .WithMany() + .HasForeignKey("CustomerAddressId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Identity.CustomerProfile", null) + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", null) + .WithMany() + .HasForeignKey("NurseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Identity.Patient", null) + .WithMany() + .HasForeignKey("PatientId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Catalog.NurseServiceVariant", null) + .WithMany() + .HasForeignKey("VariantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Booking.BookingCareInstruction", b => + { + b.HasOne("Baya.Domain.Entities.Booking.Booking", "Booking") + .WithOne("CareInstructions") + .HasForeignKey("Baya.Domain.Entities.Booking.BookingCareInstruction", "BookingId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Booking"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Booking.BookingRequest", b => + { + b.HasOne("Baya.Domain.Entities.Identity.CustomerAddress", "CustomerAddress") + .WithMany() + .HasForeignKey("CustomerAddressId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Identity.CustomerProfile", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", "Nurse") + .WithMany() + .HasForeignKey("NurseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Identity.Patient", "Patient") + .WithMany() + .HasForeignKey("PatientId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Catalog.NurseServiceVariant", "Variant") + .WithMany() + .HasForeignKey("VariantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Customer"); + + b.Navigation("CustomerAddress"); + + b.Navigation("Nurse"); + + b.Navigation("Patient"); + + b.Navigation("Variant"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Booking.BookingSession", b => + { + b.HasOne("Baya.Domain.Entities.Booking.Booking", "Booking") + .WithMany("Sessions") + .HasForeignKey("BookingId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Booking"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Booking.VisitVerification", b => + { + b.HasOne("Baya.Domain.Entities.Booking.BookingSession", "Session") + .WithOne("Verification") + .HasForeignKey("Baya.Domain.Entities.Booking.VisitVerification", "BookingSessionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Session"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.NurseServiceVariant", b => + { + b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", "Nurse") + .WithMany() + .HasForeignKey("NurseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Catalog.ServiceCategory", "ServiceCategory") + .WithMany("Variants") + .HasForeignKey("ServiceCategoryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Nurse"); + + b.Navigation("ServiceCategory"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.NurseServiceVariantOption", b => + { + b.HasOne("Baya.Domain.Entities.Catalog.ServiceOptionGroup", "OptionGroup") + .WithMany() + .HasForeignKey("OptionGroupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Catalog.ServiceOptionValue", "OptionValue") + .WithMany() + .HasForeignKey("OptionValueId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Catalog.NurseServiceVariant", "Variant") + .WithMany("Options") + .HasForeignKey("VariantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("OptionGroup"); + + b.Navigation("OptionValue"); + + b.Navigation("Variant"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceOptionGroup", b => + { + b.HasOne("Baya.Domain.Entities.Catalog.ServiceCategory", "ServiceCategory") + .WithMany("OptionGroups") + .HasForeignKey("ServiceCategoryId"); + + b.Navigation("ServiceCategory"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceOptionValue", b => + { + b.HasOne("Baya.Domain.Entities.Catalog.ServiceOptionGroup", "OptionGroup") + .WithMany("Values") + .HasForeignKey("OptionGroupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("OptionGroup"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.City", b => + { + b.HasOne("Baya.Domain.Entities.Geography.Province", "Province") + .WithMany("Cities") + .HasForeignKey("ProvinceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Province"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.District", b => + { + b.HasOne("Baya.Domain.Entities.Geography.City", "City") + .WithMany("Districts") + .HasForeignKey("CityId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("City"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.NurseServiceArea", b => + { + b.HasOne("Baya.Domain.Entities.Geography.City", "City") + .WithMany() + .HasForeignKey("CityId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Geography.District", "District") + .WithMany() + .HasForeignKey("DistrictId"); + + b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", null) + .WithMany() + .HasForeignKey("NurseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("City"); + + b.Navigation("District"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.CustomerAddress", b => + { + b.HasOne("Baya.Domain.Entities.Geography.City", "City") + .WithMany() + .HasForeignKey("CityId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Identity.CustomerProfile", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Geography.District", "District") + .WithMany() + .HasForeignKey("DistrictId"); + + b.Navigation("City"); + + b.Navigation("Customer"); + + b.Navigation("District"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.CustomerProfile", b => + { + b.HasOne("Baya.Domain.Entities.User.User", "User") + .WithOne() + .HasForeignKey("Baya.Domain.Entities.Identity.CustomerProfile", "UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.NurseBankAccount", b => + { + b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", "Nurse") + .WithMany("BankAccounts") + .HasForeignKey("NurseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("VerifiedByAdminId"); + + b.Navigation("Nurse"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.NurseProfile", b => + { + b.HasOne("Baya.Domain.Entities.User.User", "User") + .WithOne() + .HasForeignKey("Baya.Domain.Entities.Identity.NurseProfile", "UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.Patient", b => + { + b.HasOne("Baya.Domain.Entities.Identity.CustomerProfile", "Customer") + .WithMany("Patients") + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Customer"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Invoices.Invoice", b => + { + b.HasOne("Baya.Domain.Entities.Booking.Booking", null) + .WithMany() + .HasForeignKey("BookingId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Notifications.Notification", b => + { + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Payments.LedgerEntry", b => + { + b.HasOne("Baya.Domain.Entities.Booking.Booking", null) + .WithMany() + .HasForeignKey("BookingId"); + + b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", null) + .WithMany() + .HasForeignKey("NurseId"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Payments.PaymentTransaction", b => + { + b.HasOne("Baya.Domain.Entities.Booking.Booking", null) + .WithMany() + .HasForeignKey("BookingId"); + + b.HasOne("Baya.Domain.Entities.Booking.BookingRequest", null) + .WithMany() + .HasForeignKey("BookingRequestId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Identity.CustomerProfile", null) + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Payments.PaymentGateway", null) + .WithMany() + .HasForeignKey("GatewayId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Refunds.NurseClawback", b => + { + b.HasOne("Baya.Domain.Entities.Booking.Booking", null) + .WithMany() + .HasForeignKey("BookingId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", null) + .WithMany() + .HasForeignKey("NurseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Refunds.Refund", null) + .WithMany() + .HasForeignKey("RefundId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Refunds.Refund", b => + { + b.HasOne("Baya.Domain.Entities.Booking.Booking", null) + .WithMany() + .HasForeignKey("BookingId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Payments.PaymentTransaction", null) + .WithMany() + .HasForeignKey("PaymentTransactionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Identity.CustomerProfile", null) + .WithMany() + .HasForeignKey("RequestedByCustomerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Search.NurseSearchIndex", b => + { + b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", null) + .WithMany() + .HasForeignKey("NurseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Catalog.NurseServiceVariant", "Variant") + .WithMany() + .HasForeignKey("VariantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Variant"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.SupportAlerts.SupportAlert", b => + { + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("OwnerUserId"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.RoleClaim", b => + { + b.HasOne("Baya.Domain.Entities.User.Role", "Role") + .WithMany("Claims") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserClaim", b => + { + b.HasOne("Baya.Domain.Entities.User.User", "User") + .WithMany("Claims") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserLogin", b => + { + b.HasOne("Baya.Domain.Entities.User.User", "User") + .WithMany("Logins") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserRefreshToken", b => + { + b.HasOne("Baya.Domain.Entities.User.User", "User") + .WithMany("UserRefreshTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserRole", b => + { + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("GrantedById"); + + b.HasOne("Baya.Domain.Entities.User.Role", "Role") + .WithMany("Users") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.User.User", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserSession", b => + { + b.HasOne("Baya.Domain.Entities.User.User", "User") + .WithMany("Sessions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserToken", b => + { + b.HasOne("Baya.Domain.Entities.User.User", "User") + .WithMany("Tokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.NurseCredential", b => + { + b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", null) + .WithMany() + .HasForeignKey("NurseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("VerifiedByAdminId"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.NurseVerification", b => + { + b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", "Nurse") + .WithMany() + .HasForeignKey("NurseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("ReviewedByAdminId"); + + b.Navigation("Nurse"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.VerificationDocument", b => + { + b.HasOne("Baya.Domain.Entities.Verification.VerificationStep", "Step") + .WithMany("Documents") + .HasForeignKey("StepId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("UploadedByUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Step"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.VerificationStep", b => + { + b.HasOne("Baya.Domain.Entities.Verification.NurseVerification", "NurseVerification") + .WithMany("Steps") + .HasForeignKey("NurseVerificationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Verification.VerificationStepType", "StepType") + .WithMany("Steps") + .HasForeignKey("StepTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("NurseVerification"); + + b.Navigation("StepType"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Booking.Booking", b => + { + b.Navigation("CareInstructions"); + + b.Navigation("Sessions"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Booking.BookingSession", b => + { + b.Navigation("Verification"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.NurseServiceVariant", b => + { + b.Navigation("Options"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceCategory", b => + { + b.Navigation("OptionGroups"); + + b.Navigation("Variants"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceOptionGroup", b => + { + b.Navigation("Values"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.City", b => + { + b.Navigation("Districts"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.Province", b => + { + b.Navigation("Cities"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.CustomerProfile", b => + { + b.Navigation("Patients"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.NurseProfile", b => + { + b.Navigation("BankAccounts"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.Role", b => + { + b.Navigation("Claims"); + + b.Navigation("Users"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.User", b => + { + b.Navigation("Claims"); + + b.Navigation("Logins"); + + b.Navigation("Sessions"); + + b.Navigation("Tokens"); + + b.Navigation("UserRefreshTokens"); + + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.NurseVerification", b => + { + b.Navigation("Steps"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.VerificationStep", b => + { + b.Navigation("Documents"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.VerificationStepType", b => + { + b.Navigation("Steps"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260708231405_BnplTransactions.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260708231405_BnplTransactions.cs new file mode 100644 index 0000000..a06b62e --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260708231405_BnplTransactions.cs @@ -0,0 +1,88 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Baya.Infrastructure.Persistence.Migrations +{ + /// + public partial class BnplTransactions : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "BnplTransactions", + schema: "payments", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + PaymentTransactionId = table.Column(type: "bigint", nullable: false), + ProviderCode = table.Column(type: "nvarchar(50)", maxLength: 50, nullable: false), + MerchantOfRecord = table.Column(type: "nvarchar(40)", maxLength: 40, nullable: false), + ExternalPaymentToken = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: true), + ExternalTransactionId = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: true), + EligibilityStatus = table.Column(type: "nvarchar(30)", maxLength: 30, nullable: true), + OrderAmountIrr = table.Column(type: "bigint", nullable: false), + SettledAmountIrr = table.Column(type: "bigint", nullable: true), + BnplCommissionIrr = table.Column(type: "bigint", nullable: true), + Currency = table.Column(type: "nvarchar(5)", maxLength: 5, nullable: false), + InstallmentCount = table.Column(type: "tinyint", nullable: false), + Status = table.Column(type: "nvarchar(30)", maxLength: 30, nullable: false), + SettledAt = table.Column(type: "datetime2", nullable: true), + RevertTransactionId = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: true), + RevertedAmountIrr = table.Column(type: "bigint", nullable: true), + RevertedAt = table.Column(type: "datetime2", nullable: true), + ProviderCommissionReversedAmount = table.Column(type: "bigint", nullable: true), + RefundChannel = table.Column(type: "nvarchar(20)", maxLength: 20, nullable: true), + CallbackPayloadJson = table.Column(type: "nvarchar(max)", nullable: true), + DeletedAt = table.Column(type: "datetimeoffset", nullable: true), + CreatedAt = table.Column(type: "datetimeoffset", nullable: false), + ModifiedAt = table.Column(type: "datetimeoffset", nullable: true), + CreatedById = table.Column(type: "int", nullable: true), + ModifiedById = table.Column(type: "int", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_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"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "BnplTransactions", + schema: "payments"); + } + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/ApplicationDbContextModelSnapshot.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/ApplicationDbContextModelSnapshot.cs index eab920c..440c4ff 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/ApplicationDbContextModelSnapshot.cs @@ -98,6 +98,115 @@ namespace Baya.Infrastructure.Persistence.Migrations b.ToTable("AuditLogs", "ops"); }); + modelBuilder.Entity("Baya.Domain.Entities.Bnpl.BnplTransaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BnplCommissionIrr") + .HasColumnType("bigint"); + + b.Property("CallbackPayloadJson") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("Currency") + .IsRequired() + .HasMaxLength(5) + .HasColumnType("nvarchar(5)"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("EligibilityStatus") + .HasMaxLength(30) + .HasColumnType("nvarchar(30)"); + + b.Property("ExternalPaymentToken") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ExternalTransactionId") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("InstallmentCount") + .HasColumnType("tinyint"); + + b.Property("MerchantOfRecord") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("OrderAmountIrr") + .HasColumnType("bigint"); + + b.Property("PaymentTransactionId") + .HasColumnType("bigint"); + + b.Property("ProviderCode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ProviderCommissionReversedAmount") + .HasColumnType("bigint"); + + b.Property("RefundChannel") + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("RevertTransactionId") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RevertedAmountIrr") + .HasColumnType("bigint"); + + b.Property("RevertedAt") + .HasColumnType("datetime2"); + + b.Property("SettledAmountIrr") + .HasColumnType("bigint"); + + b.Property("SettledAt") + .HasColumnType("datetime2"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("nvarchar(30)"); + + b.HasKey("Id"); + + b.HasIndex("ExternalPaymentToken") + .HasFilter("[ExternalPaymentToken] IS NOT NULL"); + + b.HasIndex("PaymentTransactionId") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("BnplTransactions", "payments", t => + { + t.HasCheckConstraint("CK_BnplTransactions_SettleSplit", "([SettledAmountIrr] IS NULL AND [BnplCommissionIrr] IS NULL) OR ([SettledAmountIrr] = [OrderAmountIrr] - [BnplCommissionIrr] AND [SettledAmountIrr] >= 0 AND [BnplCommissionIrr] >= 0)"); + }); + }); + modelBuilder.Entity("Baya.Domain.Entities.Booking.Booking", b => { b.Property("Id") @@ -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) diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/BnplRepository.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/BnplRepository.cs new file mode 100644 index 0000000..60f6ecf --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/BnplRepository.cs @@ -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, IBnplRepository +{ + public BnplRepository(ApplicationDbContext dbContext) : base(dbContext) + { + } + + public Task GetOrderContextAsync(long bookingRequestId, CancellationToken cancellationToken) + => (from r in DbContext.Set().AsNoTracking() + where r.Id == bookingRequestId + join c in DbContext.Set() on r.CustomerId equals c.Id + join u in DbContext.Set() 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 GetTrackedByPaymentTransactionIdAsync(long paymentTransactionId, CancellationToken cancellationToken) + => Table.FirstOrDefaultAsync(t => t.PaymentTransactionId == paymentTransactionId, cancellationToken); + + public Task GetTrackedByIdAsync(long id, CancellationToken cancellationToken) + => Table.FirstOrDefaultAsync(t => t.Id == id, cancellationToken); + + public Task 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().Any(pt => pt.Id == t.PaymentTransactionId && pt.BookingRequestId == bookingRequestId), + cancellationToken); + + public Task GetTrackedByTokenAsync(string externalPaymentToken, CancellationToken cancellationToken) + => Table.FirstOrDefaultAsync(t => t.ExternalPaymentToken == externalPaymentToken, cancellationToken); + + public Task SettleLedgerExistsAsync(long bnplTransactionId, CancellationToken cancellationToken) + => DbContext.Set().AsNoTracking().AnyAsync( + e => e.SourceRefType == LedgerSourceRefType.BnplTransaction && e.SourceRefId == bnplTransactionId, + cancellationToken); + + public async Task GetStatusAsync(long id, CancellationToken cancellationToken) + { + var row = await (from t in TableNoTracking + where t.Id == id + join pt in DbContext.Set() on t.PaymentTransactionId equals pt.Id + join c in DbContext.Set() 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().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); + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/Common/UnitOfWork.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/Common/UnitOfWork.cs index 047fda9..b27a0b7 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/Common/UnitOfWork.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/Common/UnitOfWork.cs @@ -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() diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/RefundRepository.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/RefundRepository.cs index 07d431e..425c490 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/RefundRepository.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/RefundRepository.cs @@ -132,6 +132,12 @@ internal sealed class RefundRepository : BaseAsyncRepository, IRefundRep return new RefundStatusProjection(row.UserId, dto); } + public Task 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) { diff --git a/server/src/Tests/Baya.Test.Api/BnplApiTests.cs b/server/src/Tests/Baya.Test.Api/BnplApiTests.cs new file mode 100644 index 0000000..143dd06 --- /dev/null +++ b/server/src/Tests/Baya.Test.Api/BnplApiTests.cs @@ -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 +{ + [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(); + var booking = db.Set().AsNoTracking().Single(b => b.BookingRequestId == requestId); + Assert.Equal(BookingStatus.Confirmed, booking.Status); + + var legs = db.Set().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().AsNoTracking().Single(t => t.BookingRequestId == requestId && t.Status == PaymentTransactionStatus.Succeeded); + var bnpl = db.Set().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(); + var booking = db.Set().AsNoTracking().Single(b => b.BookingRequestId == requestId); + Assert.Equal(5, db.Set().AsNoTracking().Count(l => l.BookingId == booking.Id)); + Assert.Equal(1, db.Set().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(); + var pt = db.Set().AsNoTracking().Single(t => t.GatewayReferenceCode == token); + bnplId = db.Set().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(); + var pt = db.Set().AsNoTracking().Single(t => t.GatewayReferenceCode == token); + var refund = db.Set().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 PostCallbackAsync(HttpClient client, string body) + => client.PostAsync("/api/v1/webhooks_bnpl/snapppay", new StringContent(body, Encoding.UTF8, "application/json")); + + /// 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. + private async Task<(long RequestId, long NurseId)> SeedAcceptedBnplRequestAsync(string customerPhone, long price) + { + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var userManager = scope.ServiceProvider.GetRequiredService(); + + var customerUser = await userManager.GetUserByPhoneNumber(customerPhone); + var customer = db.Set().FirstOrDefault(c => c.UserId == customerUser!.Id); + if (customer is null) + { + customer = new CustomerProfile { UserId = customerUser!.Id }; + db.Set().Add(customer); + db.SaveChanges(); + } + + if (!db.Set().Any(g => g.Type == PaymentGatewayType.Bnpl && g.IsActive)) + { + db.Set().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().Add(province); + db.SaveChanges(); + var city = new City { ProvinceId = province.Id, NameFa = "تهران", NameEn = "Tehran", SortOrder = 1, IsActive = true }; + db.Set().Add(city); + var category = new ServiceCategory { NameFa = "سالمند", NameEn = "Elderly", SortOrder = 1, IsActive = true }; + db.Set().Add(category); + db.SaveChanges(); + + var patient = new Patient { CustomerId = customer.Id, DisplayName = "پدر", FirstName = "حسن", LastName = "رضایی", Gender = "male", IsActive = true }; + db.Set().Add(patient); + var address = new CustomerAddress + { + CustomerId = customer.Id, CityId = city.Id, Title = "خانه", AddressLine = "خیابان اول", + PostalCode = "1111111111", RecipientName = "علی", RecipientPhone = customerPhone, + Latitude = 35.6892m, Longitude = 51.3890m, IsPrimary = true + }; + db.Set().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().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().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().Add(request); + db.SaveChanges(); + request.Accept(new DateTime(2030, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + db.SaveChanges(); + + return (request.Id, nurse.Id); + } +} diff --git a/server/src/Tests/Baya.Test.Foundation/Bnpl/BnplHandlerTests.cs b/server/src/Tests/Baya.Test.Foundation/Bnpl/BnplHandlerTests.cs new file mode 100644 index 0000000..a6044cc --- /dev/null +++ b/server/src/Tests/Baya.Test.Foundation/Bnpl/BnplHandlerTests.cs @@ -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().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().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().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().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().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 legs, string account, string direction) + => legs.Where(l => l.AccountType == account && l.Direction == direction).Sum(l => l.AmountIrr); +} diff --git a/server/src/Tests/Baya.Test.Foundation/Bnpl/BnplLedgerAndStateTests.cs b/server/src/Tests/Baya.Test.Foundation/Bnpl/BnplLedgerAndStateTests.cs new file mode 100644 index 0000000..f075d83 --- /dev/null +++ b/server/src/Tests/Baya.Test.Foundation/Bnpl/BnplLedgerAndStateTests.cs @@ -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(() => 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(() => 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); + } +} diff --git a/server/src/Tests/Baya.Test.Foundation/Bnpl/BnplTestHost.cs b/server/src/Tests/Baya.Test.Foundation/Bnpl/BnplTestHost.cs new file mode 100644 index 0000000..5352605 --- /dev/null +++ b/server/src/Tests/Baya.Test.Foundation/Bnpl/BnplTestHost.cs @@ -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; + +/// +/// A self-contained SQLite host exercising the real EF model (the strict 1:1 UNIQUE(payment_transaction_id), +/// 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 with a deterministic provider mock. +/// +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().UseSqlite(_connection).Options; + Db = new ApplicationDbContext(options, TestFieldEncryptor.Instance); + Db.Database.EnsureCreated(); + UnitOfWork = new UnitOfWork(Db); + + var province = new Province { NameFa = "تهران", NameEn = "Tehran", SortOrder = 1, IsActive = true }; + Db.Set().Add(province); + Db.SaveChanges(); + var city = new City { ProvinceId = province.Id, NameFa = "تهران", NameEn = "Tehran", SortOrder = 1, IsActive = true }; + Db.Set().Add(city); + var category = new ServiceCategory { NameFa = "سالمند", NameEn = "Elderly", SortOrder = 1, IsActive = true }; + Db.Set().Add(category); + Db.SaveChanges(); + 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().Add(customer); + Db.SaveChanges(); + CustomerId = customer.Id; + + var patient = new Patient { CustomerId = customer.Id, DisplayName = "پدر", FirstName = "حسن", LastName = "رضایی", Gender = "male", IsActive = true }; + Db.Set().Add(patient); + var address = new CustomerAddress + { + CustomerId = customer.Id, CityId = city.Id, Title = "خانه", AddressLine = "خیابان اول", + PostalCode = "1111111111", RecipientName = "علی", RecipientPhone = "09120000001", + Latitude = 35.6892m, Longitude = 51.3890m, IsPrimary = true + }; + Db.Set().Add(address); + Db.SaveChanges(); + PatientId = patient.Id; + AddressId = address.Id; + + 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().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().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().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().Add(request); + Db.SaveChanges(); + request.Accept(new DateTime(2030, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + Db.SaveChanges(); + return request.Id; + } + + /// 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). + 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().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().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(); + u.UserId.Returns(CustomerUserId); + u.Roles.Returns(new[] { RoleNames.Customer }); + return u; + } + + public IDateTimeProvider Clock(DateTimeOffset now) + { + var c = Substitute.For(); + c.UtcNow.Returns(now); + return c; + } + + public IPlatformConfig Config(decimal feeRate = 0.15m) + { + var cfg = Substitute.For(); + cfg.GetConfig("platform_fee_rate", Arg.Any()).Returns(feeRate); + cfg.GetConfig("bnpl_merchant_of_record", Arg.Any()).Returns("platform"); + return cfg; + } + + public INotificationDispatcher Notifications() => Substitute.For(); + + public IReadOnlyList LedgerFor(long bookingId) + => Db.Set().AsNoTracking().Where(l => l.BookingId == bookingId).OrderBy(l => l.Id).ToList(); + + public BnplTransaction ReloadBnpl(long id) + => Db.Set().AsNoTracking().Single(b => b.Id == id); + + public void Dispose() + { + Db.Dispose(); + _connection.Dispose(); + } +}