diff --git a/.claude/prompt/fix-payment-gateway-flow.md b/.claude/prompt/fix-payment-gateway-flow.md new file mode 100644 index 0000000..424a642 --- /dev/null +++ b/.claude/prompt/fix-payment-gateway-flow.md @@ -0,0 +1,161 @@ +# اصلاح جریان پرداخت: انتخاب فقط درگاههای فعالِ بکاند + بازگشت به دامنه مبدأ + +## پروژه + +`nobat724_front` (frontend) + `clinicpro` (backend) — **cross-repo**. + +> **بهروزرسانی (جریان انتقال به بانک از طریق بکاند):** بهجای اینکه frontend مستقیم `redirect_url` بانک را باز کند، مرورگر باید به یک endpoint بکاند برود و بکاند انتقال به درگاه را انجام دهد. این در بکاند با `GET /api/v1/payment/pay/{orderId}` پیاده شد (302 برای درگاه GET مثل سپ، فرم auto-submit با POST برای ملت — چون startpay ملت با POST باز میشود). `POST /api/v1/payment/appointment` حالا علاوه بر `redirect_url` یک `pay_url` هم برمیگرداند؛ frontend باید `pay_url` را باز کند. جریان کامل: کلیک → XHR authenticated (تأیید صلاحیت اوردر + init درگاه) → مرورگر به `pay_url` (بکاند) → بکاند به بانک → پرداخت → callback بکاند (verify) → 302 به `frontend_address` (دامنه مبدأ). + +## زمینه + +جریان پرداخت نوبت بهصورت زیر است و **سمت بکاند کامل پیادهسازی شده**: + +1. کاربر روی «پرداخت» میزند → frontend با `POST /api/v1/payment/appointment` (همراه JWT) و بدنهٔ `{ appointment_uuid, gateway, frontend_address }` درخواست میدهد. +2. بکاند **صلاحیت اوردر** را چک میکند (مالکیت کاربر، وضعیت نوبت `pending|confirmed`)، `frontend_address` را در برابر هاستهای مجاز اعتبارسنجی میکند، **درگاهِ فعال** را resolve میکند (`resolveGateway` → `isGatewayEnabled`)، به درگاه request میزند و `redirect_url` را برمیگرداند. +3. frontend کاربر را با `window.location.href = redirect_url` به درگاه میفرستد. +4. بعد از پرداخت، درگاه به `GET/POST /api/v1/payment/callback/{gateway}` (عمومی، محدود به IP شاپرک) برمیگردد؛ بکاند **صلاحیت پرداخت** را verify میکند (مبلغ، replay، مرجع)، وضعیت را ست میکند، نوبت را confirm میکند و در پایان با `redirectToFrontend` یک **302 به `frontend_address`** میزند: `frontend_address?payment_uuid=...&status=...`. +5. چون `frontend_address` همان دامنهٔ مبدأ کاربر است، بازگشت به **همان دامنهای که از اول آمده بود** انجام میشود. + +پس منطق «ریدایرکت به بکاند → تأیید اوردر → درگاه → بازگشت به بکاند → تأیید پرداخت → بازگشت به دامنه مبدأ» از قبل کار میکند. مشکل فعلی سمت frontend است. + +## مشکل / هدف + +دو ایراد در `components/appointment/paying/index.js`: + +1. **لیست درگاهها هاردکد شده** و به «درگاههای فعالِ بکاند» توجه نمیکند. آرایهٔ ثابت `banks` (mellat, sep) در Select نمایش داده میشود، در حالیکه `GET /api/v1/payment/config` فیلد `gateways` را برمیگرداند که فقط شامل درگاههای **پیکربندیشده و فعال** است (`activeGateways` در بکاند). اگر ادمین یک درگاه را در بکاند غیرفعال کند، همچنان در frontend نمایش داده میشود و انتخاب آن باعث خطای `422 درگاه پرداخت نامعتبر یا غیرفعال است` میشود. +2. **`gateways` از config نادیده گرفته میشود**؛ `getPaymentConfig` فقط `test_mode` و `appointment_fee_rials` را میخواند. + +هدف: Select درگاه فقط از `config.gateways` پر شود، درگاه پیشفرض اولین درگاهِ فعال باشد، و اگر هیچ درگاهی فعال نبود دکمهٔ پرداخت غیرفعال شود. + +## فایلهای مرتبط + +| فایل | نقش | +|------|-----| +| `nobat724_front/components/appointment/paying/index.js` | کامپوننت پرداخت — دکمه، Select درگاه، فراخوانی API | +| `nobat724_front/services/response.js` | `getPaymentConfig`، `postAppointmentPayment` (بدون تغییر) | +| `nobat724_front/app/payment/result/page.js` | صفحهٔ بازگشت از بکاند؛ `payment_uuid` را میخواند و به `/payment/{uuid}` میرود (بدون تغییر) | + +قرارداد `GET /api/v1/payment/config` (از بکاند، بدون تغییر): + +```json +{ + "success": true, + "data": { + "test_mode": false, + "appointment_fee_rials": 250000, + "gateways": [ { "name": "mellat", "label": "بانک ملت" }, { "name": "sep", "label": "سپ (سامان کیش)" } ] + } +} +``` + +در حالت `test_mode: true` بکاند فقط `[{ "name": "mellat", "label": "بانک ملت (آزمایشی)" }]` برمیگرداند و درگاه واقعی resolve نمیشود (`MockGateway`). + +## وضعیت فعلی + +```js +// components/appointment/paying/index.js +const banks = [ + { id: "mellat", title: "بانک ملت" }, + { id: "sep", title: "سامان (سپ)" }, +]; + +function Paying({ ... }) { + const [selectedBank, setSelectedBank] = useState("mellat"); + const [testMode, setTestMode] = useState(null); + const [feeRials, setFeeRials] = useState(null); + // ... + useEffect(() => { + request.getPaymentConfig() + .then((res) => { + setTestMode(Boolean(res?.data?.test_mode)); + setFeeRials(Number(res?.data?.appointment_fee_rials) || 0); + // ❌ res.data.gateways نادیده گرفته میشود + }) + .catch(() => { setTestMode(false); setFeeRials(0); }); + }, []); + + const handlePayment = async () => { + // ... + const res = await request.postAppointmentPayment({ + appointment_uuid: appointmentId, + gateway: testMode ? "mellat" : selectedBank, // ❌ selectedBank از لیست هاردکد + frontend_address: `${window.location.origin}/payment/result`, + }); + const redirectUrl = res?.data?.redirect_url; + if (redirectUrl) window.location.href = redirectUrl; + else setStep((prev) => prev + 1); + }; + // ... + {/* Select درگاه از آرایهٔ ثابت banks پر میشود */} +} +``` + +## وظایف + +### ۱. خواندن `gateways` از config و نگهداری در state + +آرایهٔ ثابت `banks` را حذف کن و بهجای آن از `config.gateways` استفاده کن: + +```js +const [gateways, setGateways] = useState([]); // [{ name, label }] +const [selectedBank, setSelectedBank] = useState(""); // خالی تا config برسد + +useEffect(() => { + request.getPaymentConfig() + .then((res) => { + const gws = Array.isArray(res?.data?.gateways) ? res.data.gateways : []; + setTestMode(Boolean(res?.data?.test_mode)); + setFeeRials(Number(res?.data?.appointment_fee_rials) || 0); + setGateways(gws); + setSelectedBank(gws[0]?.name ?? ""); // پیشفرض = اولین درگاه فعال + }) + .catch(() => { setTestMode(false); setFeeRials(0); setGateways([]); }); +}, []); +``` + +### ۲. پر کردن Select از `gateways` فعال + +در JSX، `banks.map` را با `gateways.map` جایگزین کن (کلید/مقدار = `name`، متن = `label`): + +```jsx + +``` + +اگر `!testMode && gateways.length === 0` بود، بهجای Select یک پیام «درگاه پرداخت فعالی موجود نیست» نشان بده و دکمهٔ پرداخت را غیرفعال کن. + +### ۳. ارسال درگاه انتخابشده و غیرفعالسازی دکمه در نبود درگاه + +در `handlePayment`، gateway را از `selectedBank` بفرست (در test_mode بکاند بههرحال Mock را resolve میکند، ولی مقدار `mellat` سازگار است): + +```js +gateway: testMode ? "mellat" : selectedBank, +``` + +قبل از ارسال، اگر `!testMode && !selectedBank` بود return کن. شرط `disabled` دکمهٔ پرداخت را گسترش بده: + +```jsx +disabled={ + loading || testMode === null || feeRials === null || + (!testMode && !selectedBank) +} +``` + +`frontend_address: `${window.location.origin}/payment/result`` را **بدون تغییر** نگهدار — همین تضمین میکند بازگشت به همان دامنهٔ مبدأ (multi-domain) انجام شود. + +## نکات مهم + +- **بازگشت به دامنه مبدأ از قبل درست است:** چون `frontend_address` از `window.location.origin` ساخته میشود، بکاند در callback به همان دامنهای که کاربر از آن آمده 302 میزند. این را تغییر نده. +- **بررسی تنظیمات بکاند (بدون تغییر کد):** بکاند در `initiateAppointment` مقدار `frontend_address` را با `isAllowedFrontend` در برابر `payment_allowed_frontend_hosts` (SiteConfig، fallback به env) چک میکند؛ اگر لیست خالی باشد یا هاست دامنهٔ شهر در آن نباشد، پاسخ `422 آدرس بازگشت مجاز نیست` است و پرداخت شروع نمیشود. برای پشتیبانی از همهٔ دامنههای چند-شهری، مطمئن شو **هاست همهٔ دامنهها** (بدون `https://`، فقط host مثل `yazd-nobat.ir`) در این تنظیم موجود است. این تغییر داده/کانفیگ است، نه کد. +- درگاهها را در frontend هاردکد نکن؛ منبع واحدِ حقیقت `GET /api/v1/payment/config` → `gateways` است. +- الگوی فراخوانی API همان `services/response.js` → `request.*` با `{ requireAuth: true }` است؛ متد جدیدی لازم نیست. +- بعد از تغییر: `npm run build` در `nobat724_front` تا خطای صفحه/کامپوننت گرفته شود. تست دستی: انتخاب درگاه فعال، رفتن به درگاه، بازگشت به `/payment/result` روی همان دامنه، و نمایش نتیجه در `/payment/{uuid}`. +- edge case: اگر `test_mode` روشن است، Select نمایش داده نمیشود (کارت «درگاه آزمایشی») و باید مثل الان کار کند؛ فقط مطمئن شو منطق جدیدِ `gateways` جریان test_mode را نمیشکند (در test_mode هم `gateways` یک آیتم دارد ولی UI آن را نشان نمیدهد). diff --git a/components/appointment/paying/index.js b/components/appointment/paying/index.js index 8bfafca..089eb53 100644 --- a/components/appointment/paying/index.js +++ b/components/appointment/paying/index.js @@ -7,13 +7,9 @@ import { request } from "@/services/response"; const PAYMENT_TTL = 900; -const banks = [ - { id: "mellat", title: "بانک ملت" }, - { id: "sep", title: "سامان (سپ)" }, -]; - function Paying({ setStep, appointmentId, expiresAt, doctor, data, isForAnother, selectedSlot }) { - const [selectedBank, setSelectedBank] = useState("mellat"); + const [selectedBank, setSelectedBank] = useState(""); + const [gateways, setGateways] = useState([]); const [loading, setLoading] = useState(false); const [testMode, setTestMode] = useState(null); const [feeRials, setFeeRials] = useState(null); @@ -33,10 +29,13 @@ function Paying({ setStep, appointmentId, expiresAt, doctor, data, isForAnother, request .getPaymentConfig() .then((res) => { + const gws = Array.isArray(res?.data?.gateways) ? res.data.gateways : []; setTestMode(Boolean(res?.data?.test_mode)); setFeeRials(Number(res?.data?.appointment_fee_rials) || 0); + setGateways(gws); + setSelectedBank(gws[0]?.name ?? ""); }) - .catch(() => { setTestMode(false); setFeeRials(0); }); + .catch(() => { setTestMode(false); setFeeRials(0); setGateways([]); }); }, []); const formatTime = (seconds) => { @@ -54,6 +53,7 @@ function Paying({ setStep, appointmentId, expiresAt, doctor, data, isForAnother, const handlePayment = async () => { if (!appointmentId) return; + if (!testMode && !selectedBank) return; setLoading(true); try { const res = await request.postAppointmentPayment({ @@ -61,9 +61,10 @@ function Paying({ setStep, appointmentId, expiresAt, doctor, data, isForAnother, gateway: testMode ? "mellat" : selectedBank, frontend_address: `${window.location.origin}/payment/result`, }); - const redirectUrl = res?.data?.redirect_url; - if (redirectUrl) { - window.location.href = redirectUrl; + // به بکاند میرویم؛ آنجا صلاحیت تأیید و به درگاه بانک منتقل میشود. + const payUrl = res?.data?.pay_url || res?.data?.redirect_url; + if (payUrl) { + window.location.href = payUrl; } else { setStep((prev) => prev + 1); } @@ -155,6 +156,12 @@ function Paying({ setStep, appointmentId, expiresAt, doctor, data, isForAnother,
+ ) : gateways.length === 0 ? ( ++ درگاه پرداخت فعالی موجود نیست. لطفاً بعداً تلاش کنید. +
+