feat(payment): add endpoint for payment configuration and update UI for test mode

This commit is contained in:
hamed
2026-06-15 11:35:23 +03:30
parent 5f5b0fa548
commit 8c0b3d8282
5 changed files with 322 additions and 61 deletions
+177
View File
@@ -0,0 +1,177 @@
# نمایش درگاه تست در رابط کاربری هنگام فعال بودن حالت آزمایشی
## زمینه
سیستم پرداخت یک حالت آزمایشی (`payment_test_mode`) دارد که در `SiteConfig` ذخیره می‌شود. وقتی این حالت فعال است، backend در `resolveGateway()` (هر دو `PaymentController` و `SmsWalletController`) بجای درگاه واقعی، درگاه `MockGateway` را به‌کار می‌برد و پول واقعی کسر نمی‌شود.
**مشکل:** Frontend هیچ اطلاعی از حالت تست ندارد. کاربر در `SmsWalletPage` و `SubscriptionPage` انتخاب درگاه می‌بیند (ملت / سپ)، حتی وقتی backend همه را به Mock هدایت می‌کند. این گیج‌کننده است.
**هدف:** وقتی `payment_test_mode = '1'` است:
- انتخاب‌کننده درگاه واقعی پنهان شود
- بجای آن یک نوار زرد/نارنجی نمایش داده شود: «درگاه آزمایشی فعال است — پول واقعی کسر نخواهد شد»
---
## مشکل / هدف
دو صفحه frontend نیاز به تغییر دارند:
1. `SmsWalletPage.tsx` — modal شارژ کیف پول پیامک
2. `SubscriptionPage.tsx` — modal خرید اشتراک
همچنین یک endpoint عمومی (برای همه کاربران احراز هویت شده) باید ساخته شود چون `GET /api/v1/admin/settings` فقط برای `ROLE_ADMIN` است.
---
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `src/Config/Controller/SiteConfigController.php` | endpoint تنظیمات — فعلاً فقط ROLE_ADMIN |
| `src/Config/Repository/SiteConfigRepository.php` | خواندن `payment_test_mode` |
| `src/Payment/Controller/PaymentController.php` | `resolveGateway()` — backend قبلاً درست است |
| `assets/admin/pages/SmsWalletPage.tsx` | modal شارژ — نمایش‌دهنده انتخاب‌کننده درگاه |
| `assets/admin/pages/SubscriptionPage.tsx` | modal اشتراک — نمایش‌دهنده انتخاب‌کننده درگاه |
---
## وضعیت فعلی
### Backend — endpoint تنظیمات فقط ادمین است
```php
// src/Config/Controller/SiteConfigController.php
#[IsGranted('ROLE_ADMIN')]
class SiteConfigController extends BaseController
{
#[Route('/api/v1/admin/settings', methods: ['GET'])]
public function get(): JsonResponse
{
return $this->success($this->configRepo->getAll());
}
}
```
### Frontend — SmsWalletPage modal (خط ۳۴۸–۳۷۳)
```tsx
// همیشه دو گزینه mellat و sep نمایش می‌دهد
{(['mellat', 'sep'] as const).map((gw) => (
<div key={gw} onClick={() => setGateway(gw)} ...>
<div>{gw === 'mellat' ? 'ملت' : 'سپ'}</div>
...
</div>
))}
```
### Frontend — SubscriptionPage modal (خط ۳۰۷–۳۲۷)
```tsx
// همیشه دو گزینه mellat و sep نمایش می‌دهد
{(['mellat', 'sep'] as const).map((gw) => (
<button key={gw} onClick={() => setSelectedGateway(gw)} ...>
<CreditCardIcon ... />
{GATEWAY_LABELS[gw]}
</button>
))}
```
---
## وظایف
### ۱. Backend — endpoint عمومی `GET /api/v1/payment/config`
در `src/Payment/Controller/PaymentController.php` یک متد جدید اضافه کن:
```php
#[Route('/api/v1/payment/config', methods: ['GET'])]
public function config(): JsonResponse
{
return $this->success([
'test_mode' => $this->configRepo->get('payment_test_mode') === '1',
]);
}
```
- **Permission:** `IS_AUTHENTICATED_FULLY` (کلاس `PaymentController` قبلاً این را دارد)
- **مهم:** هرگز credential های درگاه (terminal_id، password، ...) را expose نکن
- پاسخ فقط یک boolean کافی است
- مستندات را در `docs/api/payment.md` اضافه کن
### ۲. Frontend — hook برای خواندن حالت تست
در `assets/admin/pages/SmsWalletPage.tsx` و `assets/admin/pages/SubscriptionPage.tsx`، یک `useQuery` برای خواندن config اضافه کن:
```tsx
const { data: paymentConfigData } = useQuery<ApiResponse<{ test_mode: boolean }>>({
queryKey: ['payment-config'],
queryFn: () => api.get('/api/v1/payment/config'),
staleTime: 5 * 60 * 1000, // 5 دقیقه cache
});
const isTestMode = paymentConfigData?.data?.test_mode ?? false;
```
### ۳. Frontend — SmsWalletPage: پنهان کردن انتخاب‌کننده درگاه در حالت تست
در modal شارژ (`SmsWalletPage.tsx`)، قسمت «انتخاب درگاه پرداخت» را با شرط جایگزین کن:
```tsx
{isTestMode ? (
<div style={{
background: '#fef9c3',
border: '1px solid #fbbf24',
borderRadius: 10,
padding: '12px 16px',
display: 'flex', alignItems: 'center', gap: 10,
}}>
<span style={{ fontSize: 18 }}></span>
<div>
<div style={{ fontWeight: 700, fontSize: 13.5, color: '#92400e' }}>
درگاه آزمایشی فعال است
</div>
<div style={{ fontSize: 12, color: '#b45309', marginTop: 2 }}>
پول واقعی کسر نخواهد شد این تراکنش آزمایشی است
</div>
</div>
</div>
) : (
// کد فعلی انتخاب درگاه (mellat / sep)
<div>...</div>
)}
```
همچنین در mutation، وقتی `isTestMode` است، مقدار `gateway` را به `'mock'` تنظیم کن (یا هر رشته‌ای — backend به هر حال Mock را استفاده می‌کند، ولی باید یک مقدار ارسال کنیم).
### ۴. Frontend — SubscriptionPage: پنهان کردن انتخاب‌کننده درگاه در حالت تست
همان pattern را در `SubscriptionPage.tsx` در قسمت «انتخاب درگاه پرداخت» داخل modal اعمال کن.
همچنین در دکمه پرداخت زیر modal، متن را در حالت تست تغییر بده:
```tsx
{isTestMode
? `پرداخت آزمایشی ${formatRial(purchaseTarget.period.price_rials)}`
: `پرداخت ${formatRial(purchaseTarget.period.price_rials)}`
}
```
---
## نکات مهم
- **Backend قبلاً درست است:** `resolveGateway()` در هر دو `PaymentController` و `SmsWalletController` حالت تست را بررسی می‌کند. فقط UI نیاز به تغییر دارد.
- **Security:** endpoint جدید فقط یک boolean برمی‌گرداند — هیچ اطلاعات حساسی expose نمی‌شود.
- **قرارداد API:** پاسخ باید `{ success: true, data: { test_mode: boolean } }` باشد. در frontend: `data?.data?.test_mode`.
- **در حالت تست:** state انتخاب درگاه (`gateway` / `selectedGateway`) بی‌تأثیر است چون backend آن را نادیده می‌گیرد — ولی باید یک مقدار ارسال شود (از مقدار default `'mellat'` استفاده کن).
- **Stale time:** این تنظیم خیلی نادر تغییر می‌کند، 5 دقیقه staleTime کافی است.
- **Routing ترتیب:** مطمئن شو route `/api/v1/payment/config` قبل از `/api/v1/payment/{uuid}` تعریف شده تا با UUID conflict نکند. در Symfony اگر method‌ها مختلف است مشکلی نیست ولی اگر هر دو GET هستند، route خاص‌تر باید اول باشد.
---
## ترتیب اجرا
1. Backend: اضافه کردن `config()` به `PaymentController`
2. تست route: `ddev exec php bin/console debug:router | grep payment`
3. Cache: `ddev exec php bin/console cache:clear`
4. Frontend: `SmsWalletPage.tsx` — اضافه کردن query و شرط حالت تست
5. Frontend: `SubscriptionPage.tsx` — همان تغییر
6. Build: `ddev exec yarn dev`
7. TypeScript check: `ddev exec npx tsc --noEmit --project tsconfig.json 2>&1 | head -20`
8. مستندات: `docs/api/payment.md`
+59 -33
View File
@@ -43,10 +43,17 @@ export default function SmsWalletPage() {
queryFn: () => api.get('/api/v1/sms/settings'), queryFn: () => api.get('/api/v1/sms/settings'),
}); });
const balance = balanceData?.data; const { data: paymentConfigData } = useQuery<ApiResponse<{ test_mode: boolean }>>({
const logs = logsData?.data ?? EMPTY_LOGS; queryKey: ['payment-config'],
const total = logsData?.meta?.totalRecords ?? 0; queryFn: () => api.get('/api/v1/payment/config'),
const settings = settingsData?.data; staleTime: 5 * 60 * 1000,
});
const balance = balanceData?.data;
const logs = logsData?.data ?? EMPTY_LOGS;
const total = logsData?.meta?.totalRecords ?? 0;
const settings = settingsData?.data;
const isTestMode = paymentConfigData?.data?.test_mode ?? false;
const chargeForm = useForm<ChargeForm>({ resolver: zodResolver(chargeSchema) }); const chargeForm = useForm<ChargeForm>({ resolver: zodResolver(chargeSchema) });
const watchAmount = chargeForm.watch('amount_rials'); const watchAmount = chargeForm.watch('amount_rials');
@@ -345,32 +352,48 @@ export default function SmsWalletPage() {
<Modal open={chargeOpen} onClose={() => setChargeOpen(false)} title="شارژ کیف پول پیامک"> <Modal open={chargeOpen} onClose={() => setChargeOpen(false)} title="شارژ کیف پول پیامک">
<form onSubmit={chargeForm.handleSubmit((d) => chargeMutation.mutate(d))}> <form onSubmit={chargeForm.handleSubmit((d) => chargeMutation.mutate(d))}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{/* انتخاب درگاه — radio cards */} {/* انتخاب درگاه */}
<div> {isTestMode ? (
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 8, color: 'var(--text-2)' }}>انتخاب درگاه پرداخت</div> <div style={{
<div style={{ display: 'flex', gap: 10 }}> background: '#fef9c3',
{(['mellat', 'sep'] as const).map((gw) => ( border: '1px solid #fbbf24',
<div borderRadius: 10,
key={gw} padding: '12px 16px',
onClick={() => setGateway(gw)} display: 'flex', alignItems: 'center', gap: 10,
style={{ }}>
flex: 1, textAlign: 'center', padding: '12px', <span style={{ fontSize: 20 }}></span>
border: `2px solid ${gateway === gw ? 'var(--primary)' : 'var(--border)'}`, <div>
borderRadius: 10, cursor: 'pointer', <div style={{ fontWeight: 700, fontSize: 13.5, color: '#92400e' }}>درگاه آزمایشی فعال است</div>
background: gateway === gw ? 'var(--primary-subtle)' : 'transparent', <div style={{ fontSize: 12, color: '#b45309', marginTop: 2 }}>پول واقعی کسر نخواهد شد این تراکنش آزمایشی است</div>
transition: 'all 0.15s', </div>
}}
>
<div style={{ fontWeight: 700, fontSize: 15, color: gateway === gw ? 'var(--primary)' : 'var(--text-1)' }}>
{gw === 'mellat' ? 'ملت' : 'سپ'}
</div>
<div style={{ fontSize: 12, color: 'var(--text-3)', marginTop: 3 }}>
{gw === 'mellat' ? 'بانک ملت' : 'بانک صادرات'}
</div>
</div>
))}
</div> </div>
</div> ) : (
<div>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 8, color: 'var(--text-2)' }}>انتخاب درگاه پرداخت</div>
<div style={{ display: 'flex', gap: 10 }}>
{(['mellat', 'sep'] as const).map((gw) => (
<div
key={gw}
onClick={() => setGateway(gw)}
style={{
flex: 1, textAlign: 'center', padding: '12px',
border: `2px solid ${gateway === gw ? 'var(--primary)' : 'var(--border)'}`,
borderRadius: 10, cursor: 'pointer',
background: gateway === gw ? 'var(--primary-subtle)' : 'transparent',
transition: 'all 0.15s',
}}
>
<div style={{ fontWeight: 700, fontSize: 15, color: gateway === gw ? 'var(--primary)' : 'var(--text-1)' }}>
{gw === 'mellat' ? 'ملت' : 'سپ'}
</div>
<div style={{ fontSize: 12, color: 'var(--text-3)', marginTop: 3 }}>
{gw === 'mellat' ? 'بانک ملت' : 'بانک صادرات'}
</div>
</div>
))}
</div>
</div>
)}
<div className="field"> <div className="field">
<label>مبلغ (ریال)</label> <label>مبلغ (ریال)</label>
@@ -386,12 +409,15 @@ export default function SmsWalletPage() {
{watchAmount && Number(watchAmount) >= 10000 && ( {watchAmount && Number(watchAmount) >= 10000 && (
<div style={{ <div style={{
background: 'var(--primary-subtle)', background: isTestMode ? '#fef9c3' : 'var(--primary-subtle)',
border: '1px solid oklch(0.85 0.06 256)', border: `1px solid ${isTestMode ? '#fbbf24' : 'oklch(0.85 0.06 256)'}`,
borderRadius: 8, padding: '10px 14px', borderRadius: 8, padding: '10px 14px',
fontSize: 13, color: 'var(--primary)', fontWeight: 500, fontSize: 13, color: isTestMode ? '#92400e' : 'var(--primary)', fontWeight: 500,
}}> }}>
پرداخت {formatRial(Number(watchAmount))} از طریق {gateway === 'mellat' ? 'بانک ملت' : 'سپ'} {isTestMode
? `پرداخت آزمایشی ${formatRial(Number(watchAmount))}`
: `پرداخت ${formatRial(Number(watchAmount))} از طریق ${gateway === 'mellat' ? 'بانک ملت' : 'سپ'}`
}
</div> </div>
)} )}
+53 -28
View File
@@ -60,10 +60,17 @@ export default function SubscriptionPage() {
queryFn: () => api.get('/api/v1/subscription/my'), queryFn: () => api.get('/api/v1/subscription/my'),
}); });
const plans = plansData?.data ?? []; const { data: paymentConfigData } = useQuery<ApiResponse<{ test_mode: boolean }>>({
const myRaw = myData?.data; queryKey: ['payment-config'],
const my = myRaw?.subscription ?? null; queryFn: () => api.get('/api/v1/payment/config'),
const usedTrial = myRaw?.used_trial ?? false; staleTime: 5 * 60 * 1000,
});
const plans = plansData?.data ?? [];
const myRaw = myData?.data;
const my = myRaw?.subscription ?? null;
const usedTrial = myRaw?.used_trial ?? false;
const isTestMode = paymentConfigData?.data?.test_mode ?? false;
const trialMutation = useMutation({ const trialMutation = useMutation({
mutationFn: () => api.post('/api/v1/subscription/trial', {}), mutationFn: () => api.post('/api/v1/subscription/trial', {}),
@@ -300,31 +307,47 @@ export default function SubscriptionPage() {
</div> </div>
{/* انتخاب درگاه */} {/* انتخاب درگاه */}
<div> {isTestMode ? (
<div style={{ fontSize: 12.5, fontWeight: 600, color: 'var(--text-2)', marginBottom: 10 }}> <div style={{
انتخاب درگاه پرداخت background: '#fef9c3',
border: '1px solid #fbbf24',
borderRadius: 10,
padding: '12px 16px',
display: 'flex', alignItems: 'center', gap: 10,
}}>
<span style={{ fontSize: 20 }}></span>
<div>
<div style={{ fontWeight: 700, fontSize: 13.5, color: '#92400e' }}>درگاه آزمایشی فعال است</div>
<div style={{ fontSize: 12, color: '#b45309', marginTop: 2 }}>پول واقعی کسر نخواهد شد این تراکنش آزمایشی است</div>
</div>
</div> </div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}> ) : (
{(['mellat', 'sep'] as const).map((gw) => ( <div>
<button <div style={{ fontSize: 12.5, fontWeight: 600, color: 'var(--text-2)', marginBottom: 10 }}>
key={gw} انتخاب درگاه پرداخت
onClick={() => setSelectedGateway(gw)} </div>
style={{ <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
padding: '12px 16px', borderRadius: 'var(--r-sm)', cursor: 'pointer', {(['mellat', 'sep'] as const).map((gw) => (
border: `2px solid ${selectedGateway === gw ? 'var(--primary)' : 'var(--border)'}`, <button
background: selectedGateway === gw ? 'var(--primary-soft)' : 'var(--surface)', key={gw}
color: selectedGateway === gw ? 'var(--primary-700)' : 'var(--text-2)', onClick={() => setSelectedGateway(gw)}
fontWeight: selectedGateway === gw ? 700 : 500, style={{
fontSize: 13, transition: '.14s', fontFamily: 'inherit', padding: '12px 16px', borderRadius: 'var(--r-sm)', cursor: 'pointer',
display: 'flex', alignItems: 'center', gap: 8, border: `2px solid ${selectedGateway === gw ? 'var(--primary)' : 'var(--border)'}`,
}} background: selectedGateway === gw ? 'var(--primary-soft)' : 'var(--surface)',
> color: selectedGateway === gw ? 'var(--primary-700)' : 'var(--text-2)',
<CreditCardIcon style={{ width: 17, flexShrink: 0 }} /> fontWeight: selectedGateway === gw ? 700 : 500,
{GATEWAY_LABELS[gw]} fontSize: 13, transition: '.14s', fontFamily: 'inherit',
</button> display: 'flex', alignItems: 'center', gap: 8,
))} }}
>
<CreditCardIcon style={{ width: 17, flexShrink: 0 }} />
{GATEWAY_LABELS[gw]}
</button>
))}
</div>
</div> </div>
</div> )}
{/* دکمه‌ها */} {/* دکمه‌ها */}
<div style={{ display: 'flex', gap: 10, marginTop: 4 }}> <div style={{ display: 'flex', gap: 10, marginTop: 4 }}>
@@ -338,7 +361,9 @@ export default function SubscriptionPage() {
> >
{purchaseMutation.isPending {purchaseMutation.isPending
? 'در حال انتقال...' ? 'در حال انتقال...'
: `پرداخت ${formatRial(purchaseTarget.period.price_rials)}` : isTestMode
? `پرداخت آزمایشی ${formatRial(purchaseTarget.period.price_rials)}`
: `پرداخت ${formatRial(purchaseTarget.period.price_rials)}`
} }
</button> </button>
<button className="btn ghost" style={{ height: 44 }} onClick={() => setPurchaseTarget(null)}> <button className="btn ghost" style={{ height: 44 }} onClick={() => setPurchaseTarget(null)}>
+24
View File
@@ -5,6 +5,30 @@
--- ---
## GET `/api/v1/payment/config`
دریافت تنظیمات عمومی پرداخت — برای نمایش وضعیت درگاه آزمایشی در frontend.
**Permission:** `IS_AUTHENTICATED_FULLY`
### Response `200`
```json
{
"success": true,
"data": {
"test_mode": true
}
}
```
| Field | Type | Description |
|-------|------|-------------|
| `test_mode` | boolean | `true` = درگاه آزمایشی فعال است — backend از MockGateway استفاده می‌کند و پول واقعی کسر نمی‌شود |
**نکته:** این endpoint هیچ اطلاعات حساسی (terminal_id، password، ...) را expose نمی‌کند. تنها یک boolean برای مصرف frontend است.
---
## POST `/api/v1/payment/appointment` ## POST `/api/v1/payment/appointment`
Initiate payment for an appointment. Returns a redirect URL to the payment gateway. Initiate payment for an appointment. Returns a redirect URL to the payment gateway.
@@ -504,6 +504,15 @@ class PaymentController extends BaseController
new OA\Response(response: 404, description: 'Payment not found'), new OA\Response(response: 404, description: 'Payment not found'),
] ]
)] )]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
#[Route('/api/v1/payment/config', methods: ['GET'])]
public function config(): JsonResponse
{
return $this->success([
'test_mode' => $this->configRepo->get('payment_test_mode') === '1',
]);
}
#[IsGranted('IS_AUTHENTICATED_FULLY')] #[IsGranted('IS_AUTHENTICATED_FULLY')]
#[Route('/api/v1/payment/{uuid}', methods: ['GET'])] #[Route('/api/v1/payment/{uuid}', methods: ['GET'])]
public function getStatus(string $uuid, #[CurrentUser] User $user): JsonResponse public function getStatus(string $uuid, #[CurrentUser] User $user): JsonResponse