feat: update payment gateway selection to use active gateways from backend config

This commit is contained in:
hamed
2026-07-02 12:19:44 +03:30
parent e5c9e2a312
commit 9e67ce4872
2 changed files with 185 additions and 14 deletions
+161
View File
@@ -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
<Select
labelId="bank-select-label"
value={selectedBank}
label="انتخاب درگاه پرداخت"
onChange={(e) => setSelectedBank(e.target.value)}
>
{gateways.map((g) => (
<MenuItem key={g.name} value={g.name}>{g.label}</MenuItem>
))}
</Select>
```
اگر `!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 آن را نشان نمی‌دهد).
+24 -14
View File
@@ -7,13 +7,9 @@ import { request } from "@/services/response";
const PAYMENT_TTL = 900; const PAYMENT_TTL = 900;
const banks = [
{ id: "mellat", title: "بانک ملت" },
{ id: "sep", title: "سامان (سپ)" },
];
function Paying({ setStep, appointmentId, expiresAt, doctor, data, isForAnother, selectedSlot }) { 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 [loading, setLoading] = useState(false);
const [testMode, setTestMode] = useState(null); const [testMode, setTestMode] = useState(null);
const [feeRials, setFeeRials] = useState(null); const [feeRials, setFeeRials] = useState(null);
@@ -33,10 +29,13 @@ function Paying({ setStep, appointmentId, expiresAt, doctor, data, isForAnother,
request request
.getPaymentConfig() .getPaymentConfig()
.then((res) => { .then((res) => {
const gws = Array.isArray(res?.data?.gateways) ? res.data.gateways : [];
setTestMode(Boolean(res?.data?.test_mode)); setTestMode(Boolean(res?.data?.test_mode));
setFeeRials(Number(res?.data?.appointment_fee_rials) || 0); 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) => { const formatTime = (seconds) => {
@@ -54,6 +53,7 @@ function Paying({ setStep, appointmentId, expiresAt, doctor, data, isForAnother,
const handlePayment = async () => { const handlePayment = async () => {
if (!appointmentId) return; if (!appointmentId) return;
if (!testMode && !selectedBank) return;
setLoading(true); setLoading(true);
try { try {
const res = await request.postAppointmentPayment({ const res = await request.postAppointmentPayment({
@@ -61,9 +61,10 @@ function Paying({ setStep, appointmentId, expiresAt, doctor, data, isForAnother,
gateway: testMode ? "mellat" : selectedBank, gateway: testMode ? "mellat" : selectedBank,
frontend_address: `${window.location.origin}/payment/result`, frontend_address: `${window.location.origin}/payment/result`,
}); });
const redirectUrl = res?.data?.redirect_url; // به بک‌اند می‌رویم؛ آنجا صلاحیت تأیید و به درگاه بانک منتقل می‌شود.
if (redirectUrl) { const payUrl = res?.data?.pay_url || res?.data?.redirect_url;
window.location.href = redirectUrl; if (payUrl) {
window.location.href = payUrl;
} else { } else {
setStep((prev) => prev + 1); setStep((prev) => prev + 1);
} }
@@ -155,6 +156,12 @@ function Paying({ setStep, appointmentId, expiresAt, doctor, data, isForAnother,
</p> </p>
</div> </div>
</div> </div>
) : gateways.length === 0 ? (
<div className="rounded-[10px] border border-[#F3C0C1] bg-[#FDEEEE] p-[14px]">
<p className="text-[#E0383B] text-[14px] font-medium">
درگاه پرداخت فعالی موجود نیست. لطفاً بعداً تلاش کنید.
</p>
</div>
) : ( ) : (
<FormControl fullWidth> <FormControl fullWidth>
<InputLabel id="bank-select-label">انتخاب درگاه پرداخت</InputLabel> <InputLabel id="bank-select-label">انتخاب درگاه پرداخت</InputLabel>
@@ -164,9 +171,9 @@ function Paying({ setStep, appointmentId, expiresAt, doctor, data, isForAnother,
label="انتخاب درگاه پرداخت" label="انتخاب درگاه پرداخت"
onChange={(e) => setSelectedBank(e.target.value)} onChange={(e) => setSelectedBank(e.target.value)}
> >
{banks.map((bank) => ( {gateways.map((g) => (
<MenuItem key={bank.id} value={bank.id}> <MenuItem key={g.name} value={g.name}>
{bank.title} {g.label}
</MenuItem> </MenuItem>
))} ))}
</Select> </Select>
@@ -198,7 +205,10 @@ function Paying({ setStep, appointmentId, expiresAt, doctor, data, isForAnother,
className="!flex !w-full md:!w-fit !mr-auto !gap-[6px] !px-[16px] !py-[10px] !min-w-[180px]" className="!flex !w-full md:!w-fit !mr-auto !gap-[6px] !px-[16px] !py-[10px] !min-w-[180px]"
onClick={handlePayment} onClick={handlePayment}
variant="contained" variant="contained"
disabled={loading || testMode === null || feeRials === null} disabled={
loading || testMode === null || feeRials === null ||
(!testMode && !selectedBank)
}
> >
<span className={`text-[#FFF] text-[16px] font-medium ${loading ? "opacity-0" : ""}`}> <span className={`text-[#FFF] text-[16px] font-medium ${loading ? "opacity-0" : ""}`}>
{testMode ? "پرداخت آزمایشی" : `پرداخت ${rials} ریال`} {testMode ? "پرداخت آزمایشی" : `پرداخت ${rials} ریال`}