feat(payment): unify payment flow with new pure redirect entry and update related endpoints
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
# یکسانسازی کامل پرداخت به یک Flow واحد + entry ریدایرکت خالص (Backend + Admin)
|
||||
|
||||
## پروژه
|
||||
|
||||
`clinicpro` (Backend + Admin SPA). **cross-repo** — پرامپت همتا: `nobat724_front/.claude/prompt/payment-single-flow-frontend.md` (سایت عمومی entry پرداخت را مصرف میکند؛ Backend اول اجرا شود).
|
||||
|
||||
## زمینه
|
||||
|
||||
بعد از بازطراحیهای قبلی، بخش زیادی از «flow واحد» موجود است و **نباید دوباره ساخته شود**:
|
||||
|
||||
- `PaymentManager` (`src/Payment/Service/PaymentManager.php`): تنها جایی که با درگاه صحبت میکند و verify (transaction+قفل+idempotent+post-action+log) را انجام میدهد.
|
||||
- `GatewayFactory`: Factory/Strategy انتخاب درگاه.
|
||||
- `GET /api/v1/payment/pay/{orderId}` (عمومی): ارتباط با بانک + انتقال 302/فرم POST.
|
||||
- `callback/{gateway}` → `PaymentManager::processCallback` → 302 به `frontend_address` (دامنهٔ مبدأ با `status`).
|
||||
- appointment و subscription: POST فقط `Payment` میسازد و `pay_url` میدهد؛ سپس همان pay-endpoint.
|
||||
|
||||
اما دو انحراف از «تنها یک روش پرداخت» باقی مانده که این پرامپت آنها را رفع میکند:
|
||||
|
||||
1. **`SmsWalletController::charge` یک Flow پرداخت جداگانه است** — خودش درگاه را resolve میکند (`mellat/sep/mock` با `match`)، خودش `new Payment` + `$gateway->initiate(...)` را صدا میزند و `redirect_url` بانک را برمیگرداند. این نقض «یک flow واحد» است و باید حذف و به flow واحد منتقل شود.
|
||||
2. **entry پرداخت هنوز نیازمند یک XHR است** (POST برای ساخت `Payment` سپس ریدایرکت به `pay_url`). طبق نیاز جدید باید یک entry ریدایرکتِ خالص هم وجود داشته باشد: مرورگر مستقیماً به `GET /api/v1/payment/order/{appointmentUuid}` برود و Backend همهٔ کار (اعتبارسنجی + ساخت Payment + ارتباط با بانک + ریدایرکت به شاپرک) را انجام دهد.
|
||||
|
||||
## مشکل / هدف
|
||||
|
||||
- تنها **یک** مسیر ساخت/شروع پرداخت در کل backend وجود داشته باشد: ساخت `Payment` (هر `type`) → `PaymentManager::startGatewayHandoff` → بانک → `callback` → `PaymentManager::processCallback` → ریدایرکت به دامنهٔ مبدأ.
|
||||
- هیچ کنترلری غیر از `PaymentManager` نباید `->initiate(` یا resolve مستقیم درگاه داشته باشد.
|
||||
- افزودن entry ریدایرکتِ خالص `GET /api/v1/payment/order/{appointmentUuid}` (بدون XHR) برای مسیر نوبت.
|
||||
|
||||
## فایلهای مرتبط
|
||||
|
||||
| فایل | نقش |
|
||||
|------|-----|
|
||||
| `src/Sms/Controller/SmsWalletController.php` | **حذف flow جدا**: متد `charge` باید فقط Payment بسازد و `pay_url` بدهد |
|
||||
| `src/Payment/Controller/PaymentController.php` | افزودن `GET /payment/order/{appointmentUuid}`؛ نگهداشتن pay/callback |
|
||||
| `src/Payment/Service/PaymentManager.php` | موجود — منبع واحد ارتباط با درگاه (بدون تغییر بزرگ) |
|
||||
| `src/Payment/Gateway/GatewayFactory.php` | موجود — resolve/activeGateways |
|
||||
| `src/Payment/Repository/PaymentRepository.php` | موجود `findPendingByAppointment` برای جلوگیری از pending تکراری |
|
||||
| `config/packages/security.yaml` | `^/api/v1/payment/order/` عمومی شود |
|
||||
| `assets/admin/pages/SmsWalletPage.tsx`, `SubscriptionPage.tsx` | مصرف `pay_url` (قبلاً بهروز شده؛ فقط تأیید) |
|
||||
| `docs/api/payment.md`, `docs/api/sms.md` | بهروزرسانی |
|
||||
|
||||
## وضعیت فعلی (flow جدا در SmsWallet — باید حذف شود)
|
||||
|
||||
```php
|
||||
// src/Sms/Controller/SmsWalletController.php (charge)
|
||||
if ($this->configRepo->get('payment_test_mode') === '1') {
|
||||
$gateway = $this->mock;
|
||||
} else {
|
||||
$gateway = match ($gatewayName) { 'mellat' => $this->mellat, 'sep' => $this->sep, default => null };
|
||||
}
|
||||
if ($gateway === null) { return $this->error(...); }
|
||||
$payment = new Payment($user, $amountRials, $gatewayName, Payment::TYPE_SMS_WALLET, $frontendAddress);
|
||||
$payment->setMetadata(['entity_type' => $entityType, 'entity_id' => $entityId]);
|
||||
$this->paymentRepo->save($payment);
|
||||
$callbackUrl = $this->appBaseUrl . '/api/v1/payment/callback/' . $gatewayName . '?order_id=' . $payment->getOrderId();
|
||||
$result = $gateway->initiate($amountRials, $payment->getOrderId(), $callbackUrl); // ❌ ارتباط مستقیم با درگاه
|
||||
// ... setGatewayToken ... return ['redirect_url' => $result->redirectUrl]; // ❌ redirect_url بانک
|
||||
```
|
||||
|
||||
## وظایف
|
||||
|
||||
### ۱. حذف Flow جداگانهٔ SmsWallet و انتقال به flow واحد
|
||||
|
||||
`SmsWalletController::charge` را طوری بازنویسی کن که **هیچ ارتباطی با درگاه نداشته باشد**؛ فقط `Payment` بسازد و `pay_url` بدهد (دقیقاً مثل appointment/subscription):
|
||||
|
||||
```php
|
||||
$payment = new Payment($user, $amountRials, $gatewayName, Payment::TYPE_SMS_WALLET, $frontendAddress);
|
||||
$payment->setMetadata(['entity_type' => $entityType, 'entity_id' => $entityId]);
|
||||
$this->paymentRepo->save($payment);
|
||||
|
||||
return $this->success([
|
||||
'payment_uuid' => $payment->getUuid(),
|
||||
'pay_url' => $this->appBaseUrl . '/api/v1/payment/pay/' . $payment->getOrderId(),
|
||||
'order_id' => $payment->getOrderId(),
|
||||
]);
|
||||
```
|
||||
|
||||
- فقط اعتبارسنجی درگاه با `GatewayFactory::resolve($gatewayName) === null` (بدون init). `GatewayFactory` را به کنترلر تزریق کن.
|
||||
- Dependencyهای درگاه از `SmsWalletController` حذف شوند: `MellatGateway`, `SepGateway`, `MockGateway` و منطق `match`/`configRepo` مربوط به درگاه. (init توسط `PaymentManager` در `pay` endpoint انجام میشود؛ post-action `TYPE_SMS_WALLET` از قبل در `PaymentManager::handleSmsWalletCharge` هست.)
|
||||
- Callback این نوع از قبل به `/api/v1/payment/callback/{gateway}` → `PaymentManager` میرود (چون `pay` endpoint با `PaymentManager::callbackUrl` بر اساس type میسازد؛ برای غیر-subscription پیشوند `/payment/callback/` است). تأیید کن.
|
||||
|
||||
### ۲. entry ریدایرکتِ خالص `GET /api/v1/payment/order/{appointmentUuid}`
|
||||
|
||||
در `PaymentController` یک route جدید (عمومی، بدون JWT) اضافه کن که کل مراحل ۴ تا ۶ نیاز را انجام دهد و **نیازی به XHR نداشته باشد**:
|
||||
|
||||
```php
|
||||
#[Route('/api/v1/payment/order/{appointmentUuid}', methods: ['GET'])]
|
||||
public function startOrderPayment(string $appointmentUuid, Request $request): Response
|
||||
{
|
||||
$gatewayName = trim((string) $request->query->get('gateway', ''));
|
||||
$return = trim((string) $request->query->get('return', ''));
|
||||
|
||||
$appointment = $this->appointmentRepo->findByUuid($appointmentUuid);
|
||||
if ($appointment === null) {
|
||||
return $this->redirectToReturn($return, 'notfound');
|
||||
}
|
||||
// اعتبارسنجی سفارش: قابلپرداخت بودن (pending/confirmed) و منقضی نبودن
|
||||
if (!in_array($appointment->getStatus(), [Appointment::STATUS_PENDING, Appointment::STATUS_CONFIRMED], true)) {
|
||||
return $this->redirectToReturn($return, 'invalid');
|
||||
}
|
||||
if (!empty($return) && !$this->isAllowedFrontend($return)) {
|
||||
return $this->redirectToReturn('', 'invalid'); // آدرس بازگشت مجاز نیست
|
||||
}
|
||||
if ($this->gateways->resolve($gatewayName) === null) {
|
||||
return $this->redirectToReturn($return, 'gateway');
|
||||
}
|
||||
|
||||
// جلوگیری از pending تکراری: اگر پرداخت pending برای این نوبت هست، همان را ادامه بده.
|
||||
$payment = $this->paymentRepo->findPendingByAppointment($appointment)
|
||||
?? $this->createAppointmentPayment($appointment, $gatewayName, $return);
|
||||
|
||||
// ارتباط با بانک + انتقال به شاپرک (همان مسیر واحد).
|
||||
$result = $this->paymentManager->startGatewayHandoff($payment);
|
||||
if ($result === false) {
|
||||
return $this->redirectToFrontend($payment, false);
|
||||
}
|
||||
return $result->redirectMethod === 'POST'
|
||||
? $this->autoSubmitForm(strtok($result->redirectUrl, '?'), $result->redirectParams)
|
||||
: new RedirectResponse($result->redirectUrl);
|
||||
}
|
||||
```
|
||||
|
||||
نکتهها:
|
||||
- `createAppointmentPayment()` همان منطق ساخت `Payment` در `initiateAppointment` را کپسوله کند (fee از `appointment_fee_rials`، `frontend_address = $return`).
|
||||
- اگر یک pending موجود بود ولی درگاه/گیتوی متفاوت انتخاب شده، تصمیم بگیر: یا درگاهِ pending را بهروزرسانی کن یا همان را ادامه بده (سادهترین: همان pending را ادامه بده).
|
||||
- `redirectToReturn(string $return, string $status)`: اگر `$return` مجاز بود `302` به `"$return?status=$status"`، وگرنه یک JSON خطای کوتاه.
|
||||
- **مالکیت/امنیت (مهم):** این endpoint عمومی است و بهصورت full-page redirect از دامنهٔ دیگری فراخوانی میشود؛ JWT در دسترس نیست. بنابراین `appointmentUuid` نقش capability را دارد (غیرقابلحدس/UUID). پرداخت فقط به نفع صاحب نوبت است، پس شروع پرداخت توسط دارندهٔ لینک ریسک مالی ندارد. اگر مالکیت سختگیرانه لازم است، یک پارامتر امضاشدهٔ `sig` (HMAC از uuid + secret) اضافه کن و در این endpoint verify کن؛ در غیر اینصورت همین کافی است. تصمیم را در docs ذکر کن.
|
||||
|
||||
### ۳. عمومیکردن route جدید در security
|
||||
|
||||
در `config/packages/security.yaml`:
|
||||
- الگوی firewall `payment_callback` را گسترش بده تا `^/api/v1/payment/(callback|pay|order)/` را پوشش دهد.
|
||||
- یک `access_control` برای `^/api/v1/payment/order/` با `PUBLIC_ACCESS`.
|
||||
|
||||
### ۴. تضمین «تنها یک flow»
|
||||
|
||||
- بعد از تغییرات، مطمئن شو تنها فایلی که `->initiate(` یا resolve مستقیم درگاه دارد `PaymentManager` است:
|
||||
```bash
|
||||
grep -rn "->initiate(\|new Payment(" src --include=*.php
|
||||
```
|
||||
انتظار: `new Payment(` فقط در نقاط ساختِ Payment (کنترلرها/SmsWallet برای ساخت، بدون init)؛ `->initiate(` فقط در `PaymentManager` و کلاسهای Gateway.
|
||||
- `payment_url`/`redirect_url` بانکی نباید از هیچ endpointی به کلاینت برگردد؛ فقط `pay_url` (یا ریدایرکت مستقیم در `order`).
|
||||
|
||||
### ۵. مستندسازی
|
||||
|
||||
- `docs/api/payment.md`: افزودن `GET /api/v1/payment/order/{appointmentUuid}` (پارامترهای `gateway`, `return`, رفتار، امنیت، نمودار بهروزشده).
|
||||
- `docs/api/sms.md`: بهروزرسانی `POST /api/v1/sms/wallet/charge` — حالا `pay_url` برمیگرداند و init در pay-endpoint واحد است.
|
||||
|
||||
## نکات مهم
|
||||
|
||||
- **جریان بیرونی نباید بشکند:** `pay`/`callback`/`frontend_address?status=` ثابت بمانند. `order` یک entry جدید است، نه جایگزین pay.
|
||||
- **همهٔ typeها یک مسیر:** appointment (هم XHR→pay_url و هم entry جدید order)، subscription و sms_wallet (XHR→pay_url) همگی به `pay`+`callback`+`PaymentManager` میرسند. تفاوت فقط در ساختِ اولیهٔ Payment است.
|
||||
- **علت باقیماندن یک XHR برای subscription/sms:** اینها order uuidِ ازپیشموجود ندارند و از پنل ادمین (JWT در localStorage) شروع میشوند؛ ساخت Payment نیازمند auth است. entry ریدایرکتِ خالص فقط برای نوبت (که uuid عمومی دارد) ممکن است. این موضوع در docs شفاف شود.
|
||||
- controllerها از `BaseController`؛ تاریخها Unix timestamp.
|
||||
- بعد از تغییر: `ddev exec php -l`, `ddev exec php bin/console cache:clear`, `ddev exec php vendor/bin/phpstan analyse src/Payment src/Sms`, و تست دستی `order` در `payment_test_mode=1`:
|
||||
```bash
|
||||
curl -sk -o /dev/null -w "%{http_code} %{redirect_url}\n" \
|
||||
"https://clinic-pro.ddev.site/api/v1/payment/order/<APPT_UUID>?gateway=mellat&return=http://yazd-nobat.localhost:3000/payment/result"
|
||||
```
|
||||
- بعد از تغییر API → `docs/api/payment.md` و `docs/api/sms.md` در همین session.
|
||||
@@ -0,0 +1,139 @@
|
||||
# قیمت هر پیامک در تنظیمات + رفع تنظیمات ارسال پیامک کیفپول
|
||||
|
||||
## پروژه
|
||||
|
||||
`clinicpro` (Backend + Admin SPA).
|
||||
|
||||
## زمینه
|
||||
|
||||
دو مشکل در بخش پیامک پنل ادمین:
|
||||
|
||||
1. **`/admin/settings` → بخش «پیامک»**: امکان تعیین «هزینه هر پیامک» وجود ندارد (قبلاً بود). قیمت هر پیامک اکنون در بکاند **هاردکد** است: `SmsWalletController::SMS_PRICE_RIALS = 500`. باید به یک تنظیمِ قابلویرایش در همین صفحه تبدیل شود.
|
||||
2. **`/admin/sms-wallet` → «تنظیمات ارسال پیامک»**: درست کار نمیکند — بعد از ذخیره، وضعیت واقعی سرور (بهویژه وضعیت تأیید متن پیامک بعد از ویزیت) در UI منعکس نمیشود، چون state محلی بعد از ذخیره/رفچ ریست نمیشود.
|
||||
|
||||
## مشکل / هدف
|
||||
|
||||
- افزودن کلید تنظیم `sms_price_rials` (قیمت هر پیامک) که در `/admin/settings` بخش پیامک قابل ویرایش باشد و بکاند بهجای مقدار هاردکد از آن استفاده کند.
|
||||
- رفع باگِ state کهنه در فرم تنظیمات ارسالِ `/admin/sms-wallet`.
|
||||
|
||||
## فایلهای مرتبط
|
||||
|
||||
| فایل | نقش |
|
||||
|------|-----|
|
||||
| `src/Config/Controller/SiteConfigController.php` | `ALLOWED_KEYS` تنظیمات سایت (باید `sms_price_rials` اضافه شود) |
|
||||
| `src/Sms/Controller/SmsWalletController.php` | استفاده از قیمت پیامک (هاردکد) + `resolveEntity` + endpoint تنظیمات |
|
||||
| `assets/admin/pages/SettingsPage.tsx` | فرم تنظیمات؛ بخش `sms` |
|
||||
| `assets/admin/pages/SmsWalletPage.tsx` | فرم «تنظیمات ارسال پیامک» (state محلی) |
|
||||
| `docs/api/sms.md` | مستندات (بهروزرسانی) |
|
||||
|
||||
## وضعیت فعلی
|
||||
|
||||
قیمت هاردکد و balance:
|
||||
|
||||
```php
|
||||
// src/Sms/Controller/SmsWalletController.php
|
||||
private const SMS_PRICE_RIALS = 500;
|
||||
// ...
|
||||
$smsPriceRials = self::SMS_PRICE_RIALS;
|
||||
$estimatedSms = (int) floor($balanceRials / $smsPriceRials);
|
||||
return $this->success([
|
||||
'balance_rials' => $balanceRials,
|
||||
'sms_price_rials' => $smsPriceRials,
|
||||
'estimated_sms_count' => $estimatedSms,
|
||||
]);
|
||||
```
|
||||
|
||||
کلیدهای مجاز تنظیمات (بدون `sms_price_rials`):
|
||||
|
||||
```php
|
||||
// src/Config/Controller/SiteConfigController.php
|
||||
private const ALLOWED_KEYS = [
|
||||
// ...
|
||||
'sms_panel_fee_rials',
|
||||
'appointment_fee_rials',
|
||||
// ...
|
||||
];
|
||||
```
|
||||
|
||||
بخش پیامکِ `SettingsPage.tsx` فقط وضعیت کلید API را نشان میدهد (فیلد قیمت ندارد):
|
||||
|
||||
```tsx
|
||||
{current.id === 'sms' && (
|
||||
// فقط sms_api_key_configured نمایش داده میشود
|
||||
)}
|
||||
```
|
||||
|
||||
فرم تنظیماتِ `SmsWalletPage.tsx` — state محلی بعد از ذخیره ریست نمیشود:
|
||||
|
||||
```tsx
|
||||
const [localSettings, setLocalSettings] = useState<SmsSettings | null>(null);
|
||||
const currentSettings = localSettings ?? settings;
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (body: SmsSettings) => api.patch('/api/v1/sms/settings', body),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['sms-settings'] }); toast.success('تنظیمات ذخیره شد'); },
|
||||
// ❌ localSettings ریست نمیشود → currentSettings همان نسخهٔ ویرایششده میماند،
|
||||
// دادهی تازهٔ سرور (مثل post_visit_text_status = 'pending') نمایش داده نمیشود
|
||||
});
|
||||
```
|
||||
|
||||
## وظایف
|
||||
|
||||
### ۱. افزودن کلید `sms_price_rials` به تنظیمات سایت (Backend)
|
||||
|
||||
در `SiteConfigController::ALLOWED_KEYS` کلید `'sms_price_rials'` را اضافه کن (کنار `sms_panel_fee_rials`). با این کار GET/PATCH `/api/v1/admin/settings` این کلید را برمیگرداند/ذخیره میکند.
|
||||
|
||||
### ۲. استفادهٔ بکاند از قیمتِ قابلتنظیم بهجای هاردکد
|
||||
|
||||
در `SmsWalletController`:
|
||||
- `SiteConfigRepository` را به constructor تزریق کن (اگر نیست).
|
||||
- یک helper خصوصی بساز: `private function smsPriceRials(): int { return max(1, (int) ($this->configRepo->get('sms_price_rials') ?: self::SMS_PRICE_RIALS)); }` و `SMS_PRICE_RIALS = 500` را بهعنوان **fallback** نگهدار.
|
||||
- در `balance()` بهجای `self::SMS_PRICE_RIALS` از `$this->smsPriceRials()` استفاده کن.
|
||||
- **جستجو کن** آیا جای دیگری قیمت هر پیامک برای کسر از کیفپول هنگام ارسال استفاده میشود (مثلاً `SmsWalletService` یا مسیر ارسال پیامک). اگر بله، همانجا هم از `sms_price_rials` (config) استفاده شود تا کسر و «تعداد تخمینی» همخوان باشند. اگر جایی مقدار ثابت دیگری هست، آن را هم به config متصل کن.
|
||||
|
||||
### ۳. فیلد «هزینه هر پیامک» در `SettingsPage.tsx` (Admin)
|
||||
|
||||
- به `FormValues` schema و `defaultValues` کلید `sms_price_rials` را اضافه کن (مثل `sms_panel_fee_rials`؛ نوع string، پیشفرض مثلاً `'500'`).
|
||||
- در بخش `current.id === 'sms'` یک `Field` با `input type="number"` برای `sms_price_rials` اضافه کن (الگوی دقیقاً مشابه فیلد `sms_panel_fee_rials` در بخش financial):
|
||||
```tsx
|
||||
<Field label="هزینه هر پیامک" hint="مبلغ کسرشده از کیف پول به ازای هر پیامک ارسالی (ریال).">
|
||||
<input {...register('sms_price_rials')} type="number" min={0} dir="ltr" className="input" style={{ maxWidth: 200 }} placeholder="500" />
|
||||
</Field>
|
||||
```
|
||||
- مطمئن شو مقدار اولیه از `data` (پاسخ GET `/api/v1/admin/settings`) خوانده و در PATCH ارسال میشود (چون کل `FormValues` ارسال میشود، با افزودن به schema/defaults خودکار انجام میشود).
|
||||
|
||||
### ۴. رفع state کهنهٔ فرم تنظیمات در `SmsWalletPage.tsx`
|
||||
|
||||
بعد از ذخیرهٔ موفق، `localSettings` را ریست کن تا `currentSettings` به دادهٔ تازهٔ سرور برگردد (وضعیت تأیید متن، مقادیر نرمالشده):
|
||||
|
||||
```tsx
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (body: SmsSettings) => api.patch('/api/v1/sms/settings', body),
|
||||
onSuccess: () => {
|
||||
setLocalSettings(null); // ← افزوده شود
|
||||
qc.invalidateQueries({ queryKey: ['sms-settings'] });
|
||||
toast.success('تنظیمات ذخیره شد');
|
||||
},
|
||||
onError: (e: any) => toast.error(e.message),
|
||||
});
|
||||
```
|
||||
|
||||
- همچنین اگر لازم است که با هر بار تازهشدن `settingsData` هم state محلی صفر شود (برای جلوگیری از ماندگاری ویرایشهای ذخیرهنشده پس از رفچ)، یک `useEffect(() => { setLocalSettings(null); }, [settingsData])` اضافه کن.
|
||||
- در بدنهٔ PATCH فقط فیلدهای موردنیاز فرستاده شوند (`reminder_enabled`, `reminder_hours_before`, `post_visit_enabled`, `post_visit_text`)؛ backend بقیه را نادیده میگیرد ولی برای تمیزی میتوان همینها را صریح فرستاد.
|
||||
|
||||
### ۵. دسترسی صفحهٔ کیفپول برای کاربرانِ بدون entity (بررسی و تصمیم)
|
||||
|
||||
`SmsWalletController::resolveEntity` فقط برای `ROLE_DOCTOR` و `ROLE_CLINIC` entity برمیگرداند؛ برای بقیه (از جمله ادمینِ بدون پروفایل، secretary، clinic_doctor) `['unknown', null]` → همهٔ endpointها `403 پروفایل یافت نشد` میدهند و صفحه «کار نمیکند».
|
||||
|
||||
- اگر صفحهٔ `/admin/sms-wallet` باید برای نقشهای دیگر (مثل `clinic_doctor`) هم کار کند، `resolveEntity` را گسترش بده تا آن نقشها را هم به doctor/clinic نگاشت کند.
|
||||
- اگر کیفپول فقط برای doctor/clinic معنا دارد، در `SmsWalletPage.tsx` هنگام خطای 403 یک empty-state مناسب («کیف پول پیامک فقط برای پزشک/کلینیک فعال است») نمایش بده تا صفحه سفید/خراب نشود.
|
||||
- تصمیم را بر اساس نقشهای واقعی پروژه بگیر و در گزارش ذکر کن.
|
||||
|
||||
## نکات مهم
|
||||
|
||||
- کلید تنظیم `sms_price_rials` باید همان واحد ریال باشد؛ balance→count و کسرِ هنگام ارسال باید از **یک** مقدار بخوانند تا ناسازگاری نشود.
|
||||
- `SMS_PRICE_RIALS = 500` بهعنوان fallback بماند (نصبهای بدون مقدار config نشکنند).
|
||||
- الگوهای موجود پنل: `Field` + `register` (React Hook Form)، پاسخها `data?.data`، تاریخها Unix.
|
||||
- بعد از تغییر Backend/endpoint: `ddev exec php -l ...`، `ddev exec php bin/console cache:clear`، `ddev exec php vendor/bin/phpstan analyse src/Sms src/Config`؛ و بعد از تغییر TS: `ddev exec npx tsc --noEmit --project tsconfig.json`.
|
||||
- طبق قانون پروژه، `docs/api/sms.md` (و در صورت لزوم `docs/api/admin.md` برای کلید تنظیم جدید) در همین session بهروز شود: افزودن `sms_price_rials` به لیست تنظیمات و توضیح استفادهٔ آن.
|
||||
- Entity تغییر نمیکند → migration لازم نیست (فقط یک ردیف در جدول تنظیمات سایت که با set ساخته میشود).
|
||||
@@ -32,6 +32,7 @@ const schema = z.object({
|
||||
tax_enabled: z.string(),
|
||||
tax_percent: z.string(),
|
||||
sms_panel_fee_rials: z.string(),
|
||||
sms_price_rials: z.string(),
|
||||
appointment_fee_rials: z.string(),
|
||||
// payment gateways
|
||||
payment_test_mode: z.string(),
|
||||
@@ -60,6 +61,7 @@ const toForm = (s: Partial<Settings>): FormValues => ({
|
||||
tax_enabled: s.tax_enabled ?? '0',
|
||||
tax_percent: s.tax_percent ?? '10',
|
||||
sms_panel_fee_rials: s.sms_panel_fee_rials ?? '1500000',
|
||||
sms_price_rials: s.sms_price_rials ?? '500',
|
||||
appointment_fee_rials: s.appointment_fee_rials ?? '150000',
|
||||
payment_test_mode: s.payment_test_mode ?? '0',
|
||||
mellat_enabled: s.mellat_enabled ?? '1',
|
||||
@@ -452,6 +454,9 @@ export default function SettingsPage() {
|
||||
: <><ExclamationTriangleIcon style={{ width: 18, height: 18, color: 'var(--danger)' }} /><span style={{ color: 'var(--danger)' }}>تنظیمنشده — مقدار KAVENEGAR_API_KEY را در فایل env قرار دهید</span></>}
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="هزینه هر پیامک" hint="مبلغ کسرشده از کیف پول به ازای هر پیامک ارسالی (ریال). مبنای محاسبهٔ تعداد پیامک از موجودی.">
|
||||
<input {...register('sms_price_rials')} type="number" min={0} dir="ltr" className="input" style={{ maxWidth: 200 }} placeholder="500" />
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
@@ -68,7 +68,10 @@ function SmsWalletPageInner() {
|
||||
|
||||
const chargeMutation = useMutation({
|
||||
mutationFn: ({ amount_rials }: ChargeForm) =>
|
||||
api.post<{ data: { payment_url: string } }>('/api/v1/sms/wallet/charge', { gateway, amount_rials }),
|
||||
api.post<{ data: { payment_url: string } }>('/api/v1/sms/wallet/charge', {
|
||||
gateway, amount_rials,
|
||||
frontend_address: `${window.location.origin}${window.location.pathname}`,
|
||||
}),
|
||||
onSuccess: (res: any) => {
|
||||
const url = res?.data?.pay_url ?? res?.data?.redirect_url ?? res?.data?.payment_url;
|
||||
if (url) window.location.href = url;
|
||||
@@ -80,9 +83,16 @@ function SmsWalletPageInner() {
|
||||
const [localSettings, setLocalSettings] = useState<SmsSettings | null>(null);
|
||||
const currentSettings = localSettings ?? settings;
|
||||
|
||||
// با هر بار تازهشدن دادهی سرور، ویرایش محلی صفر شود تا وضعیت واقعی (مثل وضعیت تأیید متن) نمایش داده شود.
|
||||
useEffect(() => { setLocalSettings(null); }, [settingsData]);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (body: SmsSettings) => api.patch('/api/v1/sms/settings', body),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['sms-settings'] }); toast.success('تنظیمات ذخیره شد'); },
|
||||
onSuccess: () => {
|
||||
setLocalSettings(null);
|
||||
qc.invalidateQueries({ queryKey: ['sms-settings'] });
|
||||
toast.success('تنظیمات ذخیره شد');
|
||||
},
|
||||
onError: (e: any) => toast.error(e.message),
|
||||
});
|
||||
|
||||
@@ -233,11 +243,14 @@ function SmsWalletPageInner() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`switch${currentSettings.reminder_enabled ? ' on' : ''}`}
|
||||
style={{ marginTop: 4, flexShrink: 0 }}
|
||||
onClick={() => setLocalSettings({ ...currentSettings, reminder_enabled: !currentSettings.reminder_enabled })}
|
||||
/>
|
||||
<label className="switch" style={{ marginTop: 4, flexShrink: 0 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={currentSettings.reminder_enabled}
|
||||
onChange={() => setLocalSettings({ ...currentSettings, reminder_enabled: !currentSettings.reminder_enabled })}
|
||||
/>
|
||||
<span className="switch-track"><span className="switch-thumb" /></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* ردیف: پیامک بعد از ویزیت */}
|
||||
@@ -253,11 +266,14 @@ function SmsWalletPageInner() {
|
||||
<div style={{ color: 'var(--text-3)', fontSize: 12.5, marginTop: 3 }}>متن تشکر پس از پایان مراجعه برای بیمار ارسال میشود</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`switch${currentSettings.post_visit_enabled ? ' on' : ''}`}
|
||||
style={{ flexShrink: 0 }}
|
||||
onClick={() => setLocalSettings({ ...currentSettings, post_visit_enabled: !currentSettings.post_visit_enabled })}
|
||||
/>
|
||||
<label className="switch" style={{ flexShrink: 0 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={currentSettings.post_visit_enabled}
|
||||
onChange={() => setLocalSettings({ ...currentSettings, post_visit_enabled: !currentSettings.post_visit_enabled })}
|
||||
/>
|
||||
<span className="switch-track"><span className="switch-thumb" /></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* محتوای بازشونده */}
|
||||
|
||||
@@ -83,7 +83,10 @@ export default function SubscriptionPage() {
|
||||
|
||||
const purchaseMutation = useMutation({
|
||||
mutationFn: ({ period_uuid, gateway, amount_rials }: { period_uuid: string; gateway: string; amount_rials: number }) =>
|
||||
api.post<{ data: { payment_url: string } }>('/api/v1/subscription-payment', { period_uuid, gateway, amount_rials }),
|
||||
api.post<{ data: { payment_url: string } }>('/api/v1/subscription-payment', {
|
||||
period_uuid, gateway, amount_rials,
|
||||
frontend_address: `${window.location.origin}/admin/subscription`,
|
||||
}),
|
||||
onSuccess: (res: any) => {
|
||||
const url = res?.data?.pay_url ?? res?.data?.redirect_url ?? res?.data?.payment_url;
|
||||
if (url) window.location.href = url;
|
||||
|
||||
@@ -38,7 +38,7 @@ security:
|
||||
security: false
|
||||
|
||||
payment_callback:
|
||||
pattern: ^/api/v1/(payment/(callback|pay)/|subscription-payment/callback/)
|
||||
pattern: ^/api/v1/(payment/(callback|pay|order)/|subscription-payment/callback/)
|
||||
stateless: true
|
||||
security: false
|
||||
|
||||
@@ -75,6 +75,7 @@ security:
|
||||
- { path: ^/session/token, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/api/v1/payment/callback/, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/api/v1/payment/pay/, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/api/v1/payment/order/, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/api/v1/subscription-payment/callback/, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/api/v1/categorys/, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/api/v1/doctors$, roles: PUBLIC_ACCESS }
|
||||
|
||||
@@ -1033,6 +1033,7 @@ Reject a pending request. **Permission:** `ROLE_ADMIN`
|
||||
| `tax_enabled` | `0` | فعالسازی مالیات بر ارزش افزوده |
|
||||
| `tax_percent` | `10` | درصد مالیات |
|
||||
| `sms_panel_fee_rials` | `1500000` | هزینه ثابت پنل پیامک به ریال (از نوبت و اشتراک کسر میشود) |
|
||||
| `sms_price_rials` | `500` | هزینه هر پیامک ارسالی به ریال؛ مبنای محاسبهٔ تعداد پیامک از موجودی کیفپول (`GET /api/v1/sms/wallet/balance`). قابل ویرایش در `/admin/settings` → بخش پیامک |
|
||||
| `appointment_fee_rials` | `150000` | مبلغ هر نوبت به ریال؛ مبلغی که بیمار هنگام رزرو آنلاین پرداخت میکند. backend از همین کلید میخواند و در `GET /api/v1/payment/config` expose میشود |
|
||||
| `log_retention_days` | `90` | مدت نگهداری لاگها (روز)؛ کاماند روزانه `app:prune-logs` لاگهای قدیمیتر را حذف میکند. `0` = نگهداری نامحدود |
|
||||
|
||||
|
||||
@@ -185,6 +185,31 @@ Initiate payment for an appointment. Returns a redirect URL to the payment gatew
|
||||
|
||||
---
|
||||
|
||||
## GET `/api/v1/payment/order/{appointmentUuid}`
|
||||
|
||||
**entry ریدایرکتِ خالص (بدون XHR)** برای شروع پرداخت نوبت. مرورگر مستقیماً به این آدرس هدایت میشود؛ Backend همهٔ کار را انجام میدهد: اعتبارسنجی سفارش → ساخت `Payment` → ارتباط با بانک → ریدایرکت به شاپرک.
|
||||
|
||||
**Permission:** `PUBLIC` (بدون JWT).
|
||||
|
||||
### Query Parameters
|
||||
| Param | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `gateway` | string | ✅ | نام درگاه فعال (`mellat`/`sep`) — از `GET /payment/config` |
|
||||
| `return` | string | ❌ | آدرس بازگشت (دامنهٔ مبدأ)؛ باید در `payment_allowed_frontend_hosts` مجاز باشد |
|
||||
|
||||
### رفتار
|
||||
- نوبت یافت نشد → `302` به `return?status=notfound` (یا `422` اگر return نبود/نامجاز).
|
||||
- نوبت قابلپرداخت نیست (نه `pending`/`confirmed`) → `302` `return?status=invalid`.
|
||||
- درگاه نامعتبر/غیرفعال → `302` `return?status=gateway`.
|
||||
- موفق → ساخت/ادامهٔ `Payment` pending (بدون pending تکراری via `findPendingByAppointment`) و `302` به بانک (یا فرم auto-submit POST برای ملت).
|
||||
|
||||
### امنیت مالکیت
|
||||
چون entry عمومی و full-page cross-domain است، JWT در دسترس نیست؛ `appointmentUuid` (UUID غیرقابلحدس) نقش capability را دارد و پرداخت فقط به نفع صاحب نوبت است. برای مالکیت سختگیرانه میتوان پارامتر امضاشدهٔ `sig` (HMAC) افزود.
|
||||
|
||||
> **صفحهٔ نتیجه:** این endpointهای مرورگرمحور (`order`/`pay`/`callback`) هرگز JSON به کاربر نمیدهند. اگر `return` معتبر باشد → `302` به همان دامنه با `?status=`؛ در غیر اینصورت (نبود/نامعتبر بودن `return`، یافتنشدن سفارش، نبود آدرس بازگشت) یک صفحهٔ **Twig** (`templates/payment/result.html.twig`, RTL، `noindex`) با پیام وضعیت رندر میشود.
|
||||
|
||||
---
|
||||
|
||||
## GET `/api/v1/payment/pay/{orderId}`
|
||||
|
||||
انتقال مرورگر به درگاه پرداخت برای یک پرداختِ `pending`. **عمومی (بدون JWT)** — مرورگر مستقیماً به این آدرس هدایت میشود. صلاحیت اوردر قبلاً در `POST /api/v1/payment/appointment` (نیازمند JWT) بررسی و پرداخت ساخته شده است. **ارتباط با بانک (init درگاه) در همین endpoint انجام میشود.**
|
||||
|
||||
+4
-4
@@ -9,8 +9,8 @@
|
||||
- **کلید API کاوهنگار فقط از متغیر محیطی `KAVENEGAR_API_KEY` خوانده میشود** — نه از دیتابیس و نه از پنل. در پنل ادمین فقط وضعیت read-only «تنظیمشده/نشده» نمایش داده میشود.
|
||||
- شماره فرستنده تنظیم نمیشود؛ کاوهنگار از خط پیشفرض حساب استفاده میکند.
|
||||
- endpoint `GET /api/v1/admin/settings` یک فیلد read-only به نام `sms_api_key_configured` (boolean) برمیگرداند.
|
||||
- `PATCH /api/v1/admin/settings` دیگر کلیدهای `sms_provider`، `kavenegar_api_key`، `kavenegar_sender`، `rangineh_api_key`، `rangineh_sender`، `sms_price_rials` را نمیپذیرد (از `ALLOWED_KEYS` حذف شدهاند).
|
||||
- قیمت هر پیامک ثابت است: `SmsWalletController::SMS_PRICE_RIALS = 500` ریال.
|
||||
- `PATCH /api/v1/admin/settings` کلیدهای `sms_provider`، `kavenegar_api_key`، `kavenegar_sender`، `rangineh_api_key`، `rangineh_sender` را نمیپذیرد (از `ALLOWED_KEYS` حذف شدهاند).
|
||||
- **قیمت هر پیامک** از کلید تنظیمات `sms_price_rials` خوانده میشود (قابل ویرایش در `/admin/settings` → بخش پیامک، و از طریق `PATCH /api/v1/admin/settings`). اگر تنظیم نشده باشد، مقدار پیشفرض `SmsWalletController::SMS_PRICE_RIALS = 500` ریال بهعنوان fallback استفاده میشود. `GET /api/v1/sms/wallet/balance` این مقدار را در `sms_price_rials` و تعداد تخمینی پیامک را در `estimated_sms_count` برمیگرداند.
|
||||
|
||||
---
|
||||
|
||||
@@ -348,13 +348,13 @@ Updated template with `status: "rejected"`.
|
||||
"success": true,
|
||||
"data": {
|
||||
"payment_uuid": "...",
|
||||
"redirect_url": "https://gateway...",
|
||||
"pay_url": "{APP_BASE_URL}/api/v1/payment/pay/ORD-...",
|
||||
"order_id": "ORD-..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
پس از پرداخت موفق، موجودی کیف خودکار شارژ میشود.
|
||||
> این endpoint فقط `Payment` (type=`sms_wallet`) میسازد و `pay_url` میدهد؛ **ارتباط با بانک اینجا انجام نمیشود** و از flow واحد پرداخت (`GET /payment/pay/{orderId}` → callback → `PaymentManager`) عبور میکند. کلاینت باید مرورگر را به `pay_url` هدایت کند. پس از پرداخت موفق، `PaymentManager` موجودی کیف را خودکار شارژ میکند.
|
||||
|
||||
### GET /api/v1/sms/wallet/logs
|
||||
|
||||
|
||||
@@ -144,12 +144,6 @@ parameters:
|
||||
count: 1
|
||||
path: src/Sms/Command/SeedSmsMessageTemplatesCommand.php
|
||||
|
||||
-
|
||||
message: '#^Property App\\Payment\\Gateway\\PaymentInitResult\:\:\$errorMessage \(string\) on left side of \?\? is not nullable\.$#'
|
||||
identifier: nullCoalesce.property
|
||||
count: 1
|
||||
path: src/Sms/Controller/SmsWalletController.php
|
||||
|
||||
-
|
||||
message: '#^Property App\\Subscription\\Controller\\SubscriptionController\:\:\$subscriptionRepo is never read, only written\.$#'
|
||||
identifier: property.onlyWritten
|
||||
|
||||
@@ -23,6 +23,7 @@ class SiteConfigController extends BaseController
|
||||
'tax_enabled',
|
||||
'tax_percent',
|
||||
'sms_panel_fee_rials',
|
||||
'sms_price_rials',
|
||||
'appointment_fee_rials',
|
||||
'site_name',
|
||||
'support_phone',
|
||||
|
||||
@@ -126,6 +126,83 @@ class PaymentController extends BaseController
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Pure-redirect entry (no XHR) — browser navigates here to start payment ──
|
||||
|
||||
#[OA\Get(
|
||||
path: '/api/v1/payment/order/{appointmentUuid}',
|
||||
summary: 'Start an appointment payment via a pure browser redirect (no XHR)',
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'appointmentUuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'gateway', in: 'query', required: true, schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'return', in: 'query', required: false, schema: new OA\Schema(type: 'string', format: 'uri')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(response: 302, description: 'Redirect to the bank, or back to return URL with status on validation failure'),
|
||||
]
|
||||
)]
|
||||
#[Route('/api/v1/payment/order/{appointmentUuid}', methods: ['GET'])]
|
||||
public function startOrderPayment(string $appointmentUuid, Request $request): \Symfony\Component\HttpFoundation\Response
|
||||
{
|
||||
$gatewayName = trim((string) $request->query->get('gateway', ''));
|
||||
$return = trim((string) $request->query->get('return', ''));
|
||||
|
||||
if ($return !== '' && !$this->isAllowedFrontend($return)) {
|
||||
return $this->renderPaymentResult('invalid_return');
|
||||
}
|
||||
|
||||
$appointment = $this->appointmentRepo->findByUuid($appointmentUuid);
|
||||
if ($appointment === null) {
|
||||
return $this->redirectToReturn($return, 'notfound');
|
||||
}
|
||||
|
||||
// اعتبارسنجی سفارش: فقط نوبت قابلپرداخت.
|
||||
if (!in_array($appointment->getStatus(), [Appointment::STATUS_PENDING, Appointment::STATUS_CONFIRMED], true)) {
|
||||
return $this->redirectToReturn($return, 'invalid');
|
||||
}
|
||||
|
||||
if ($this->gateways->resolve($gatewayName) === null) {
|
||||
return $this->redirectToReturn($return, 'gateway');
|
||||
}
|
||||
|
||||
// جلوگیری از pending تکراری: پرداخت pending موجود را ادامه بده وگرنه بساز.
|
||||
$payment = $this->paymentRepo->findPendingByAppointment($appointment)
|
||||
?? $this->createAppointmentPayment($appointment, $gatewayName, $return);
|
||||
|
||||
// آدرس بازگشت را روی پرداختِ بازاستفادهشده هم بهروزرسانی کن تا بازگشت درست باشد.
|
||||
if ($return !== '' && $payment->getFrontendAddress() !== $return) {
|
||||
$payment->setFrontendAddress($return);
|
||||
$this->paymentRepo->save($payment);
|
||||
}
|
||||
|
||||
// ارتباط با بانک + انتقال به شاپرک — همان مسیر واحد.
|
||||
$result = $this->paymentManager->startGatewayHandoff($payment);
|
||||
if ($result === false) {
|
||||
return $this->redirectToFrontend($payment, false);
|
||||
}
|
||||
|
||||
return $result->redirectMethod === 'POST'
|
||||
? $this->autoSubmitForm(strtok($result->redirectUrl, '?'), $result->redirectParams)
|
||||
: new RedirectResponse($result->redirectUrl);
|
||||
}
|
||||
|
||||
private function createAppointmentPayment(Appointment $appointment, string $gatewayName, string $return): Payment
|
||||
{
|
||||
$feeRials = (int) $this->configRepo->get('appointment_fee_rials');
|
||||
$payment = new Payment($appointment->getUser(), $feeRials, $gatewayName, Payment::TYPE_APPOINTMENT, $return);
|
||||
$payment->setAppointment($appointment);
|
||||
$this->paymentRepo->save($payment);
|
||||
return $payment;
|
||||
}
|
||||
|
||||
private function redirectToReturn(string $return, string $status): \Symfony\Component\HttpFoundation\Response
|
||||
{
|
||||
if ($return !== '' && $this->isAllowedFrontend($return)) {
|
||||
$sep = str_contains($return, '?') ? '&' : '?';
|
||||
return new RedirectResponse($return . $sep . 'status=' . $status);
|
||||
}
|
||||
return $this->renderPaymentResult($status);
|
||||
}
|
||||
|
||||
// ── Gateway hand-off (public — browser redirect to the bank) ───────────────
|
||||
|
||||
#[OA\Get(
|
||||
@@ -144,7 +221,7 @@ class PaymentController extends BaseController
|
||||
{
|
||||
$payment = $this->paymentRepo->findByOrderId($orderId);
|
||||
if ($payment === null) {
|
||||
return new JsonResponse(['success' => false, 'message' => 'payment not found'], 404);
|
||||
return $this->renderPaymentResult('notfound');
|
||||
}
|
||||
|
||||
// فقط پرداخت در انتظار قابل انتقال به درگاه است (جلوگیری از پرداخت تکراری/replay).
|
||||
@@ -166,24 +243,13 @@ class PaymentController extends BaseController
|
||||
return new RedirectResponse($result->redirectUrl);
|
||||
}
|
||||
|
||||
/** یک صفحهٔ HTML با فرمی که بهصورت خودکار (POST) به درگاه ارسال میشود. */
|
||||
/** صفحهٔ انتقال به درگاه (Twig) با فرمِ auto-submit بهصورت POST. */
|
||||
private function autoSubmitForm(string $action, array $params): \Symfony\Component\HttpFoundation\Response
|
||||
{
|
||||
$fields = '';
|
||||
foreach ($params as $name => $value) {
|
||||
$fields .= sprintf(
|
||||
'<input type="hidden" name="%s" value="%s">',
|
||||
htmlspecialchars((string) $name, ENT_QUOTES),
|
||||
htmlspecialchars((string) $value, ENT_QUOTES)
|
||||
);
|
||||
}
|
||||
$safeAction = htmlspecialchars($action, ENT_QUOTES);
|
||||
$html = <<<HTML
|
||||
<!doctype html><html lang="fa" dir="rtl"><head><meta charset="utf-8"><title>در حال انتقال به درگاه پرداخت…</title></head>
|
||||
<body onload="document.forms[0].submit()"><p style="font-family:Tahoma,sans-serif;text-align:center;margin-top:40px">در حال انتقال به درگاه پرداخت…</p>
|
||||
<form method="POST" action="{$safeAction}">{$fields}<noscript><button type="submit">ادامه</button></noscript></form></body></html>
|
||||
HTML;
|
||||
return new \Symfony\Component\HttpFoundation\Response($html, 200, ['Content-Type' => 'text/html; charset=utf-8']);
|
||||
return $this->render('payment/redirect.html.twig', [
|
||||
'action' => $action,
|
||||
'params' => $params,
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Payment Callback (public — no JWT) ───────────────────────────────────
|
||||
@@ -229,7 +295,7 @@ HTML;
|
||||
// verify امن (transaction + قفل + idempotent + post-action + log) در سرویس.
|
||||
$payment = $this->paymentManager->processCallback($gateway, $callbackData, $clientIp, $orderId);
|
||||
if ($payment === null) {
|
||||
return new JsonResponse(['success' => false, 'message' => 'payment not found'], 404);
|
||||
return $this->renderPaymentResult('notfound');
|
||||
}
|
||||
|
||||
return $this->redirectToFrontend($payment, $payment->getStatus() === Payment::STATUS_SUCCESS);
|
||||
@@ -470,18 +536,45 @@ HTML;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** ثانیهٔ تأخیر پیش از ریدایرکت خودکار به فرانتاند در صفحهٔ نتیجه. */
|
||||
private const RESULT_REDIRECT_DELAY = 5;
|
||||
|
||||
/** صفحهٔ نتیجهٔ پرداخت (Twig). اگر $redirectTo داده شود، بعد از چند ثانیه به آن میرود. */
|
||||
private function renderPaymentResult(string $status, ?Payment $payment = null, string $redirectTo = ''): \Symfony\Component\HttpFoundation\Response
|
||||
{
|
||||
$labels = [
|
||||
'success' => ['پرداخت موفق', 'پرداخت شما با موفقیت انجام شد.'],
|
||||
'failed' => ['پرداخت ناموفق', 'پرداخت انجام نشد. در صورت کسر وجه، مبلغ طی ۷۲ ساعت بازمیگردد.'],
|
||||
'canceled' => ['پرداخت لغو شد', 'پرداخت توسط شما لغو شد.'],
|
||||
'pending' => ['در انتظار پرداخت', 'این پرداخت هنوز نهایی نشده است.'],
|
||||
'notfound' => ['سفارش یافت نشد', 'سفارش موردنظر یافت نشد.'],
|
||||
'invalid' => ['قابل پرداخت نیست', 'این سفارش در وضعیت قابل پرداخت نیست.'],
|
||||
'gateway' => ['درگاه نامعتبر', 'درگاه پرداخت انتخابی نامعتبر یا غیرفعال است.'],
|
||||
'invalid_return' => ['آدرس بازگشت نامعتبر', 'آدرس بازگشت مجاز نیست.'],
|
||||
];
|
||||
[$title, $message] = $labels[$status] ?? ['خطا در پرداخت', 'خطایی در فرآیند پرداخت رخ داد.'];
|
||||
|
||||
return $this->render('payment/result.html.twig', [
|
||||
'status' => $status,
|
||||
'title' => $title,
|
||||
'message' => $message,
|
||||
'payment' => $payment?->toArray(),
|
||||
'redirect_to' => $redirectTo,
|
||||
'delay' => self::RESULT_REDIRECT_DELAY,
|
||||
]);
|
||||
}
|
||||
|
||||
private function redirectToFrontend(Payment $payment, bool $success): \Symfony\Component\HttpFoundation\Response
|
||||
{
|
||||
$base = $payment->getFrontendAddress();
|
||||
if (empty($base)) {
|
||||
return new JsonResponse([
|
||||
'success' => $success,
|
||||
'payment' => $payment->toArray(),
|
||||
]);
|
||||
// آدرس بازگشتی نداریم → فقط صفحهٔ نتیجه (بدون ریدایرکت خودکار).
|
||||
return $this->renderPaymentResult($payment->getStatus(), $payment);
|
||||
}
|
||||
|
||||
$sep = str_contains($base, '?') ? '&' : '?';
|
||||
$url = $base . $sep . 'payment_uuid=' . $payment->getUuid() . '&status=' . $payment->getStatus();
|
||||
return new RedirectResponse($url);
|
||||
// صفحهٔ نتیجه را نشان بده و بعد از چند ثانیه به همان فرانتاندِ مبدأ برگرد.
|
||||
$sep = str_contains($base, '?') ? '&' : '?';
|
||||
$url = $base . $sep . 'payment_uuid=' . $payment->getUuid() . '&status=' . $payment->getStatus();
|
||||
return $this->renderPaymentResult($payment->getStatus(), $payment, $url);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,6 +110,7 @@ class Payment
|
||||
public function setReferenceId(?string $r): self { $this->referenceId = $r; $this->touch(); return $this; }
|
||||
public function setStatus(string $s): self { $this->status = $s; $this->touch(); return $this; }
|
||||
public function setCallbackIp(?string $ip): self { $this->callbackIp = $ip; $this->touch(); return $this; }
|
||||
public function setFrontendAddress(?string $a): self { $this->frontendAddress = $a ?: null; $this->touch(); return $this; }
|
||||
|
||||
private function touch(): void { $this->updatedAt = time(); }
|
||||
|
||||
|
||||
@@ -27,7 +27,23 @@ class SecurityHeadersSubscriber implements EventSubscriberInterface
|
||||
|
||||
$path = $event->getRequest()->getPathInfo();
|
||||
if (str_starts_with($path, '/api') && !str_starts_with($path, '/api/doc')) {
|
||||
$response->headers->set('Content-Security-Policy', "default-src 'none'");
|
||||
// صفحات مرورگرمحورِ پرداخت (order/pay/callback) HTML برمیگردانند و به
|
||||
// inline style + فونت + فرمِ انتقال به شاپرک نیاز دارند؛ بقیهٔ API (JSON)
|
||||
// همان سیاست سختگیرانه را میگیرد.
|
||||
$isPaymentPage =
|
||||
str_starts_with($path, '/api/v1/payment/order/')
|
||||
|| str_starts_with($path, '/api/v1/payment/pay/')
|
||||
|| str_starts_with($path, '/api/v1/payment/callback/')
|
||||
|| str_starts_with($path, '/api/v1/subscription-payment/callback/');
|
||||
|
||||
$response->headers->set(
|
||||
'Content-Security-Policy',
|
||||
$isPaymentPage
|
||||
? "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; "
|
||||
. "font-src https://cdn.jsdelivr.net data:; img-src data:; "
|
||||
. "form-action https://*.shaparak.ir; base-uri 'none'"
|
||||
: "default-src 'none'"
|
||||
);
|
||||
}
|
||||
|
||||
if (str_starts_with($path, '/admin') || str_starts_with($path, '/api')) {
|
||||
|
||||
@@ -4,12 +4,9 @@ namespace App\Sms\Controller;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Clinic\Repository\ClinicRepository;
|
||||
use App\Config\Repository\SiteConfigRepository;
|
||||
use App\Doctor\Repository\DoctorRepository;
|
||||
use App\Payment\Entity\Payment;
|
||||
use App\Payment\Gateway\MellatGateway;
|
||||
use App\Payment\Gateway\MockGateway;
|
||||
use App\Payment\Gateway\SepGateway;
|
||||
use App\Payment\Gateway\GatewayFactory;
|
||||
use App\Payment\Repository\PaymentRepository;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Controller\BaseController;
|
||||
@@ -37,15 +34,19 @@ class SmsWalletController extends BaseController
|
||||
private readonly SmsWalletTransactionRepository $txRepo,
|
||||
private readonly SmsSettingsRepository $settingsRepo,
|
||||
private readonly PaymentRepository $paymentRepo,
|
||||
private readonly SiteConfigRepository $configRepo,
|
||||
private readonly MellatGateway $mellat,
|
||||
private readonly SepGateway $sep,
|
||||
private readonly MockGateway $mock,
|
||||
private readonly GatewayFactory $gateways,
|
||||
private readonly DoctorRepository $doctorRepo,
|
||||
private readonly ClinicRepository $clinicRepo,
|
||||
private readonly \App\Config\Repository\SiteConfigRepository $configRepo,
|
||||
private readonly string $appBaseUrl,
|
||||
) {}
|
||||
|
||||
/** قیمت هر پیامک از تنظیمات سایت؛ با fallback به مقدار پیشفرض. */
|
||||
private function smsPriceRials(): int
|
||||
{
|
||||
return max(1, (int) ($this->configRepo->get('sms_price_rials') ?: self::SMS_PRICE_RIALS));
|
||||
}
|
||||
|
||||
#[Route('/api/v1/sms/wallet/balance', methods: ['GET'])]
|
||||
public function balance(#[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
@@ -55,7 +56,7 @@ class SmsWalletController extends BaseController
|
||||
}
|
||||
|
||||
$balanceRials = $this->walletService->getBalance($entityType, $entityId);
|
||||
$smsPriceRials = self::SMS_PRICE_RIALS;
|
||||
$smsPriceRials = $this->smsPriceRials();
|
||||
$estimatedSms = (int) floor($balanceRials / $smsPriceRials);
|
||||
|
||||
return $this->success([
|
||||
@@ -81,17 +82,8 @@ class SmsWalletController extends BaseController
|
||||
return $this->error(ErrorCodes::ERR_PAYMENT_002, 'مبلغ شارژ نامعتبر است', 422);
|
||||
}
|
||||
|
||||
if ($this->configRepo->get('payment_test_mode') === '1') {
|
||||
$gateway = $this->mock;
|
||||
} else {
|
||||
$gateway = match ($gatewayName) {
|
||||
'mellat' => $this->mellat,
|
||||
'sep' => $this->sep,
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
if ($gateway === null) {
|
||||
// فقط اعتبارسنجی درگاه؛ ارتباط با بانک در flow واحد (GET /payment/pay) انجام میشود.
|
||||
if ($this->gateways->resolve($gatewayName) === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'درگاه پرداخت نامعتبر است', 422);
|
||||
}
|
||||
|
||||
@@ -100,19 +92,9 @@ class SmsWalletController extends BaseController
|
||||
$payment->setMetadata(['entity_type' => $entityType, 'entity_id' => $entityId]);
|
||||
$this->paymentRepo->save($payment);
|
||||
|
||||
$callbackUrl = $this->appBaseUrl . '/api/v1/payment/callback/' . $gatewayName . '?order_id=' . $payment->getOrderId();
|
||||
$result = $gateway->initiate($amountRials, $payment->getOrderId(), $callbackUrl);
|
||||
|
||||
if (!$result->success) {
|
||||
return $this->error(ErrorCodes::ERR_PAYMENT_001, $result->errorMessage ?? 'درگاه در دسترس نیست', 503);
|
||||
}
|
||||
|
||||
$payment->setGatewayToken($result->token);
|
||||
$this->paymentRepo->save($payment);
|
||||
|
||||
return $this->success([
|
||||
'payment_uuid' => $payment->getUuid(),
|
||||
'redirect_url' => $result->redirectUrl,
|
||||
'pay_url' => $this->appBaseUrl . '/api/v1/payment/pay/' . $payment->getOrderId(),
|
||||
'order_id' => $payment->getOrderId(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fa" dir="rtl">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex,nofollow">
|
||||
<title>در حال انتقال به درگاه پرداخت…</title>
|
||||
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin>
|
||||
<style>
|
||||
@font-face {
|
||||
font-family:'Vazirmatn';
|
||||
src:url('https://cdn.jsdelivr.net/gh/rastikerdar/vazirmatn@v33.003/fonts/webfonts/Vazirmatn-Regular.woff2') format('woff2');
|
||||
font-weight:400; font-display:swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family:'Vazirmatn';
|
||||
src:url('https://cdn.jsdelivr.net/gh/rastikerdar/vazirmatn@v33.003/fonts/webfonts/Vazirmatn-Bold.woff2') format('woff2');
|
||||
font-weight:700; font-display:swap;
|
||||
}
|
||||
:root {
|
||||
--ink:#0f1b2e; --muted:#56657c; --line:#e4e9f1; --primary:#5457dd;
|
||||
--shadow-lg:0 12px 32px rgba(15,27,46,.12), 0 4px 10px rgba(15,27,46,.06);
|
||||
}
|
||||
* { box-sizing:border-box; }
|
||||
body { margin:0; font-family:'Vazirmatn', ui-sans-serif, system-ui, sans-serif; color:var(--ink);
|
||||
background:radial-gradient(1200px 600px at 50% -10%, #e9eefb 0%, #eef2f8 45%, #e6ecf4 100%);
|
||||
min-height:100vh; display:flex; align-items:center; justify-content:center; padding:20px; }
|
||||
.card { background:#fff; border:1px solid var(--line); border-radius:24px; box-shadow:var(--shadow-lg);
|
||||
width:100%; max-width:400px; padding:40px 28px; text-align:center;
|
||||
animation:rise .45s cubic-bezier(.22,.61,.36,1) both; }
|
||||
@keyframes rise { from { opacity:0; transform:translateY(14px) scale(.98); } }
|
||||
.spinner { width:58px; height:58px; margin:0 auto 22px; border:5px solid #ecedfb;
|
||||
border-top-color:var(--primary); border-radius:50%; animation:spin .9s linear infinite; }
|
||||
@keyframes spin { to { transform:rotate(360deg); } }
|
||||
h1 { font-size:19px; font-weight:700; margin:0 0 8px; }
|
||||
p { color:var(--muted); font-size:14px; margin:0; line-height:2; }
|
||||
.noscript-btn { display:inline-block; margin-top:18px; padding:12px 22px; border-radius:12px;
|
||||
background:var(--primary); color:#fff; font-weight:700; border:none; cursor:pointer; font-size:15px; }
|
||||
.brand { margin-top:22px; font-size:11.5px; color:#aeb4c0; letter-spacing:.2px; }
|
||||
</style>
|
||||
</head>
|
||||
<body onload="document.forms[0] && document.forms[0].submit()">
|
||||
<div class="card">
|
||||
<div class="spinner"></div>
|
||||
<h1>در حال انتقال به درگاه پرداخت</h1>
|
||||
<p>لطفاً چند لحظه صبر کنید…<br>در صورت انتقالنشدن خودکار، روی دکمهٔ زیر بزنید.</p>
|
||||
<form method="POST" action="{{ action }}">
|
||||
{% for name, value in params %}
|
||||
<input type="hidden" name="{{ name }}" value="{{ value }}">
|
||||
{% endfor %}
|
||||
<noscript><button type="submit" class="noscript-btn">ادامه و پرداخت</button></noscript>
|
||||
</form>
|
||||
<div class="brand">پرداخت امن از طریق درگاه بانکی</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,146 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fa" dir="rtl">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex,nofollow">
|
||||
{% if redirect_to %}<meta http-equiv="refresh" content="{{ delay }};url={{ redirect_to }}">{% endif %}
|
||||
<title>نتیجه پرداخت</title>
|
||||
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin>
|
||||
<style>
|
||||
@font-face {
|
||||
font-family: 'Vazirmatn';
|
||||
src: url('https://cdn.jsdelivr.net/gh/rastikerdar/vazirmatn@v33.003/fonts/webfonts/Vazirmatn-Regular.woff2') format('woff2');
|
||||
font-weight: 400; font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Vazirmatn';
|
||||
src: url('https://cdn.jsdelivr.net/gh/rastikerdar/vazirmatn@v33.003/fonts/webfonts/Vazirmatn-Bold.woff2') format('woff2');
|
||||
font-weight: 700; font-display: swap;
|
||||
}
|
||||
:root {
|
||||
/* همتراز با تم پنل clinic-pro (assets/admin/styles.css) */
|
||||
--ok:#15a35a; --ok-soft:#e6f6ed;
|
||||
--err:#e0394a; --err-soft:#fdebed;
|
||||
--warn:#d98a09; --warn-soft:#fcf2df;
|
||||
--ink:#0f1b2e; --muted:#56657c; --line:#e4e9f1;
|
||||
--primary:#5457dd; --primary-600:#464ac9;
|
||||
--shadow-lg:0 12px 32px rgba(15,27,46,.12), 0 4px 10px rgba(15,27,46,.06);
|
||||
--ease:cubic-bezier(.22,.61,.36,1);
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { height: 100%; }
|
||||
body {
|
||||
margin:0; font-family:'Vazirmatn', ui-sans-serif, system-ui, sans-serif; color:var(--ink);
|
||||
background: radial-gradient(1200px 600px at 50% -10%, #e9eefb 0%, #eef2f8 45%, #e6ecf4 100%);
|
||||
min-height:100vh; display:flex; align-items:center; justify-content:center; padding:20px;
|
||||
}
|
||||
.card {
|
||||
position:relative; overflow:hidden; background:var(--surface,#fff); border:1px solid var(--line);
|
||||
border-radius:24px; box-shadow:var(--shadow-lg); width:100%; max-width:420px;
|
||||
padding:40px 28px 30px; text-align:center; animation:rise .45s var(--ease) both;
|
||||
}
|
||||
@keyframes rise { from { opacity:0; transform:translateY(14px) scale(.98); } }
|
||||
.accent { position:absolute; inset:0 0 auto 0; height:6px; }
|
||||
.accent.ok { background:linear-gradient(90deg,#15a35a,#3fca7d); }
|
||||
.accent.err { background:linear-gradient(90deg,#e0394a,#f07a86); }
|
||||
.accent.warn { background:linear-gradient(90deg,#d98a09,#f2b545); }
|
||||
|
||||
.badge { width:96px; height:96px; margin:6px auto 20px; position:relative; }
|
||||
.badge .ring {
|
||||
position:absolute; inset:0; border-radius:50%;
|
||||
animation:pop .5s cubic-bezier(.2,.8,.2,1.2) .05s both;
|
||||
}
|
||||
.badge.ok .ring { background:var(--ok-soft); }
|
||||
.badge.err .ring { background:var(--err-soft); }
|
||||
.badge.warn .ring { background:var(--warn-soft); }
|
||||
.badge svg { position:absolute; inset:0; width:96px; height:96px; }
|
||||
@keyframes pop { from { transform:scale(.4); opacity:0; } }
|
||||
.draw { fill:none; stroke-width:6; stroke-linecap:round; stroke-linejoin:round;
|
||||
stroke-dasharray:80; stroke-dashoffset:80; animation:draw .6s ease .35s forwards; }
|
||||
.badge.ok .draw { stroke:var(--ok); }
|
||||
.badge.err .draw { stroke:var(--err); }
|
||||
.badge.warn .draw { stroke:var(--warn); }
|
||||
@keyframes draw { to { stroke-dashoffset:0; } }
|
||||
|
||||
h1 { font-size:21px; font-weight:700; margin:0 0 8px; }
|
||||
p.msg { color:var(--muted); font-size:14px; margin:0 auto 22px; line-height:2; max-width:320px; }
|
||||
|
||||
.amount { font-size:15px; color:var(--muted); margin-bottom:22px; }
|
||||
.amount b { display:block; font-size:26px; color:var(--ink); margin-top:4px; font-weight:700; }
|
||||
.amount b small { font-size:14px; font-weight:400; color:var(--muted); }
|
||||
|
||||
.rows { text-align:right; background:#f6f8fc; border:1px solid var(--line); border-radius:14px;
|
||||
padding:6px 14px; margin-bottom:22px; }
|
||||
.row { display:flex; justify-content:space-between; align-items:center; padding:11px 0; font-size:13.5px; }
|
||||
.row + .row { border-top:1px dashed var(--line); }
|
||||
.row span { color:var(--muted); }
|
||||
.row b { font-weight:700; font-variant-numeric:tabular-nums; }
|
||||
.btn { display:block; width:100%; padding:13px; border-radius:12px; text-decoration:none;
|
||||
font-weight:700; font-size:15px; background:var(--primary); color:#fff; border:none; cursor:pointer;
|
||||
transition:filter .15s, transform .05s; }
|
||||
.btn:hover { background:var(--primary-600); }
|
||||
.btn:active { transform:translateY(1px); }
|
||||
.hint { color:#9aa1ad; font-size:12.5px; margin:6px 0 0; }
|
||||
.brand { margin-top:20px; font-size:11.5px; color:#aeb4c0; letter-spacing:.2px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{% set kind = status == 'success' ? 'ok' : (status == 'pending' ? 'warn' : 'err') %}
|
||||
<div class="card">
|
||||
<div class="accent {{ kind }}"></div>
|
||||
|
||||
<div class="badge {{ kind }}">
|
||||
<div class="ring"></div>
|
||||
<svg viewBox="0 0 96 96" aria-hidden="true">
|
||||
{% if status == 'success' %}
|
||||
<path class="draw" d="M30 50 L44 63 L68 35"/>
|
||||
{% elseif status == 'pending' %}
|
||||
<path class="draw" d="M48 30 L48 50 L62 58"/>
|
||||
<circle class="draw" cx="48" cy="48" r="0.5"/>
|
||||
{% else %}
|
||||
<path class="draw" d="M36 36 L60 60"/>
|
||||
<path class="draw" d="M60 36 L36 60"/>
|
||||
{% endif %}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h1>{{ title }}</h1>
|
||||
<p class="msg">{{ message }}</p>
|
||||
|
||||
{% if payment %}
|
||||
<div class="amount">
|
||||
مبلغ
|
||||
<b>{{ payment.amount_rials|number_format(0, '.', ',') }} <small>ریال</small></b>
|
||||
</div>
|
||||
<div class="rows">
|
||||
<div class="row"><span>شماره سفارش</span><b dir="ltr">{{ payment.order_id }}</b></div>
|
||||
{% if payment.reference_id %}<div class="row"><span>شماره مرجع</span><b dir="ltr">{{ payment.reference_id }}</b></div>{% endif %}
|
||||
<div class="row"><span>درگاه</span><b>{{ payment.gateway == 'sep' ? 'سپ (سامان کیش)' : (payment.gateway == 'mellat' ? 'بانک ملت' : payment.gateway) }}</b></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if redirect_to %}
|
||||
<a class="btn" href="{{ redirect_to }}">بازگشت به سایت</a>
|
||||
<p class="hint">در حال انتقال خودکار طی <b id="cd">{{ delay }}</b> ثانیه…</p>
|
||||
{% else %}
|
||||
<p class="hint">میتوانید این صفحه را ببندید و به برنامه بازگردید.</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="brand">پرداخت امن از طریق درگاه بانکی</div>
|
||||
</div>
|
||||
|
||||
{% if redirect_to %}
|
||||
<script>
|
||||
(function () {
|
||||
var n = {{ delay }}, el = document.getElementById('cd');
|
||||
var t = setInterval(function () {
|
||||
n -= 1;
|
||||
if (el) el.textContent = n > 0 ? n : 0;
|
||||
if (n <= 0) { clearInterval(t); window.location.href = {{ redirect_to|json_encode|raw }}; }
|
||||
}, 1000);
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user