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