feat: update payment gateway selection to use active gateways from backend config
This commit is contained in:
@@ -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 آن را نشان نمیدهد).
|
||||||
@@ -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} ریال`}
|
||||||
|
|||||||
Reference in New Issue
Block a user