feat(payment): canceled status, manageable origin allowlist, CORS subdomains
Unify and harden the payment flow (same API for the main site and all consumer sites; per-client difference is only frontend_address). - Payment gains STATUS_CANCELED. Gateways distinguish user-cancel from failure (Mellat ResCode=17, SEP CanceledByUser, mock cancel=1) via a new PaymentVerifyResult::canceled flag; callback sets canceled vs failed and skips the circuit-breaker on cancel. - Expiry job now cancels the pending payment when a booking lapses (AppointmentExpiryService + PaymentRepository::findPendingByAppointment). - frontend_address allowlist is read from the payment_allowed_frontend_hosts site setting (manageable via PATCH /api/v1/admin/settings), falling back to the ALLOWED_FRONTEND_HOSTS env var — so a new consumer site needs no code change. - .env: broaden CORS_ALLOW_ORIGIN to city subdomains (*.localhost / *.clinic-pro.ddev.site) and add yazd-nobat.localhost to ALLOWED_FRONTEND_HOSTS. - Update docs/api/payment.md and docs/api/admin.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
# یکپارچهسازی منطق پرداخت: وضعیت canceled + allowlist دامنهی مدیریتپذیر + مستندسازی
|
||||
|
||||
## پروژه
|
||||
|
||||
`clinicpro` (Backend — منبع حقیقت).
|
||||
|
||||
> **Cross-repo (مصرف):** سایت عمومی `nobat724_front` و هر سایت سرویسگیرندهی دیگر، همین API پرداخت را مصرف میکنند. قرارداد عمومی: `POST /api/v1/payment/appointment`، `POST|GET /api/v1/payment/callback/{gateway}`، `GET /api/v1/payment/{uuid}`. سایت مبدأ با `frontend_address` تعیین میشود و کاربر پس از پرداخت با `?payment_uuid=<uuid>&status=<status>` به همان آدرس بازگردانده میشود. تغییرات این پرامپت قرارداد را **نمیشکند** (فقط وضعیت `canceled` و allowlist مدیریتپذیر اضافه میشود).
|
||||
|
||||
## زمینه
|
||||
|
||||
زیرساخت پرداخت بکاند از قبل یکپارچه و کامل است: هر تراکنش یک `order_id` یکتا دارد؛ در حالت تست (`payment_test_mode=1`) درگاه `MockGateway` بدون ارتباط با بانک پرداخت را شبیهسازی و `success` ثبت میکند؛ `callback` با `verify` نتیجه را تأیید و بسته به `type` (appointment/subscription/sms_wallet) عملیات بعدی را انجام میدهد و سپس کاربر را با `redirectToFrontend` به سایت مبدأ بازمیگرداند. منطق برای همهی کلاینتها یکسان است چون همه همین endpointها را صدا میزنند.
|
||||
|
||||
سه فاصله با خواستهی محصول باقی مانده:
|
||||
1. **وضعیت `canceled`** وجود ندارد (فقط `pending`, `success`, `failed`, `refunded`). انصراف کاربر از درگاه و انقضای مهلت پرداخت باید `canceled` ثبت شود.
|
||||
2. **allowlist دامنهی سایت مبدأ** (`ALLOWED_FRONTEND_HOSTS`) یک رشتهی ثابت در `.env` است؛ افزودن هر سرویسگیرندهی جدید نیازمند تغییر `.env` و ریاستارت است. باید به سیستم `SiteConfig` (که از پنل ادمین قابلویرایش است) منتقل شود.
|
||||
3. مستندات `docs/api/payment.md` باید با وضعیتها و قرارداد نهایی همخوان شود.
|
||||
|
||||
## مشکل / هدف
|
||||
|
||||
۱. افزودن `Payment::STATUS_CANCELED` و ثبت آن در دو نقطه: (الف) انقضای مهلت پرداخت نوبت (در `AppointmentExpiryService`)، (ب) callbackِ انصراف کاربر از درگاه (وقتی gateway کد انصراف برمیگرداند، نه خطا).
|
||||
۲. انتقال `allowedFrontendHosts` از `.env` به `SiteConfig` با کلید `payment_allowed_frontend_hosts`، با fallback به مقدار `.env` فعلی؛ قابلویرایش از `PATCH /api/v1/admin/settings`.
|
||||
۳. بهروزرسانی `docs/api/payment.md`.
|
||||
|
||||
## فایلهای مرتبط
|
||||
|
||||
| فایل | نقش |
|
||||
|------|-----|
|
||||
| `src/Payment/Entity/Payment.php` | افزودن `STATUS_CANCELED` + متد `cancel()` (در صورت نیاز) |
|
||||
| `src/Appointment/Service/AppointmentExpiryService.php` | هنگام expire نوبت، payment ـِ pending مرتبط را `canceled` کن |
|
||||
| `src/Appointment/Repository/PaymentRepository.php` یا `src/Payment/Repository/PaymentRepository.php` | متد یافتن payment ـِ pending یک نوبت |
|
||||
| `src/Payment/Controller/PaymentController.php` | خواندن allowlist از `SiteConfig`؛ ثبت `canceled` در callbackِ انصراف |
|
||||
| `src/Payment/Gateway/MellatGateway.php` / `SepGateway.php` / `MockGateway.php` | تشخیص «انصراف کاربر» در `verify` و تمایز آن از «خطا» |
|
||||
| `src/Config/Repository/SiteConfigRepository.php` | کلید جدید `payment_allowed_frontend_hosts` |
|
||||
| `docs/api/payment.md` | مستندسازی وضعیتها + allowlist |
|
||||
|
||||
## وضعیت فعلی (کد واقعی)
|
||||
|
||||
### وضعیتهای Payment (بدون canceled)
|
||||
```php
|
||||
public const STATUS_PENDING = 'pending';
|
||||
public const STATUS_SUCCESS = 'success';
|
||||
public const STATUS_FAILED = 'failed';
|
||||
public const STATUS_REFUNDED = 'refunded';
|
||||
```
|
||||
|
||||
### allowlist ثابت از .env (services.yaml: `$allowedFrontendHosts: '%env(ALLOWED_FRONTEND_HOSTS)%'`)
|
||||
```php
|
||||
// PaymentController
|
||||
private function isAllowedFrontend(string $url): bool
|
||||
{
|
||||
$hosts = array_filter(array_map('trim', explode(',', $this->allowedFrontendHosts)));
|
||||
if (empty($hosts)) return false;
|
||||
$host = parse_url($url, PHP_URL_HOST);
|
||||
return in_array($host, $hosts, true);
|
||||
}
|
||||
```
|
||||
|
||||
### expire نوبت — payment را دست نمیزند
|
||||
```php
|
||||
// AppointmentExpiryService::expireStale()
|
||||
foreach ([...findPaymentExpired($now), ...findExpiredPending($now)] as $appointment) {
|
||||
$appointment->transitionTo(Appointment::STATUS_EXPIRED);
|
||||
$this->appointmentRepo->save($appointment, false);
|
||||
// ❌ payment ـِ pending این نوبت همچنان pending میماند
|
||||
}
|
||||
```
|
||||
|
||||
### callback — انصراف کاربر معادل خطا گرفته میشود
|
||||
```php
|
||||
$result = $gw?->verify($callbackData) ?? null;
|
||||
if ($result === null || !$result->success) {
|
||||
$payment->setStatus(Payment::STATUS_FAILED); // ❌ انصراف هم failed ثبت میشود، نه canceled
|
||||
...
|
||||
return $this->redirectToFrontend($payment, false);
|
||||
}
|
||||
```
|
||||
|
||||
> `SiteConfig` یک ذخیرهی key-value است: `SiteConfigRepository::get($key)` / `set($key, $value)` / `getAll()`. کلید `payment_test_mode` همینجاست و از پنل ادمین (`GET/PATCH /api/v1/admin/settings`) قابلویرایش است.
|
||||
|
||||
## وظایف
|
||||
|
||||
### ۱. افزودن وضعیت `canceled`
|
||||
در `src/Payment/Entity/Payment.php`:
|
||||
```php
|
||||
public const STATUS_CANCELED = 'canceled';
|
||||
```
|
||||
- در صورت وجود متدهای transition/setStatus، مطمئن شو `canceled` معتبر است. اگر متد `cancel()` کمکی منطقی است اضافه کن (`$this->status = self::STATUS_CANCELED;`).
|
||||
- نیازی به migration نیست (ستون `status` رشته است)، مگر اینکه enum/check-constraint داشته باشد — بررسی کن.
|
||||
|
||||
### ۲. تمایز «انصراف» از «خطا» در gatewayها و callback
|
||||
- در `verify` هر gateway، یک علامت برای «کاربر انصراف داد» اضافه کن. الگوها:
|
||||
- **Mellat:** `ResCode === '17'` (انصراف کاربر) → canceled؛ سایر کدهای ناموفق → failed.
|
||||
- **Sep:** `State === 'CanceledByUser'` → canceled.
|
||||
- **Mock:** اگر `ResCode === '17'` یا پارامتر `cancel=1` بود → canceled (برای تست).
|
||||
سادهترین راه بدون شکستن `PaymentVerifyResult`: یک فیلد `canceled: bool` به `PaymentVerifyResult` اضافه کن (پیشفرض false)، یا یک `errorCode` که controller بر اساسش تصمیم بگیرد.
|
||||
- در `PaymentController::callback`، شاخهی ناموفق را به دو حالت تقسیم کن:
|
||||
```php
|
||||
if ($result === null || !$result->success) {
|
||||
$payment->setStatus($result?->canceled ? Payment::STATUS_CANCELED : Payment::STATUS_FAILED);
|
||||
$this->paymentRepo->save($payment);
|
||||
if (!$result?->canceled) $this->circuitBreaker->recordFailure($gateway); // انصراف کاربر، خطای درگاه نیست
|
||||
return $this->redirectToFrontend($payment, false);
|
||||
}
|
||||
```
|
||||
|
||||
### ۳. canceled هنگام انقضای مهلت پرداخت
|
||||
در `AppointmentExpiryService::expireStale()`، هنگام expire هر نوبت، payment ـِ `pending` مرتبط را `canceled` کن:
|
||||
```php
|
||||
$appointment->transitionTo(Appointment::STATUS_EXPIRED);
|
||||
$this->appointmentRepo->save($appointment, false);
|
||||
|
||||
$payment = $this->paymentRepo->findPendingByAppointment($appointment);
|
||||
if ($payment !== null) {
|
||||
$payment->setStatus(Payment::STATUS_CANCELED);
|
||||
$this->paymentRepo->save($payment, false);
|
||||
}
|
||||
```
|
||||
- متد `findPendingByAppointment(Appointment): ?Payment` را به `PaymentRepository` اضافه کن (status=pending AND appointment=...).
|
||||
- `AppointmentExpiryService` را با `PaymentRepository` تزریق کن.
|
||||
- این سرویس از طریق Scheduler هر ۱ دقیقه اجرا میشود (همان مکانیزم موجود)، پس canceledها خودکار ثبت میشوند.
|
||||
|
||||
### ۴. allowlist مدیریتپذیر از SiteConfig
|
||||
- در `PaymentController::isAllowedFrontend`، منبع hostها را اول از `SiteConfig` بخوان و اگر خالی بود به `.env` (`$this->allowedFrontendHosts`) fallback کن:
|
||||
```php
|
||||
private function allowedHosts(): array
|
||||
{
|
||||
$fromConfig = (string) ($this->configRepo->get('payment_allowed_frontend_hosts') ?? '');
|
||||
$raw = $fromConfig !== '' ? $fromConfig : $this->allowedFrontendHosts;
|
||||
return array_filter(array_map('trim', explode(',', $raw)));
|
||||
}
|
||||
|
||||
private function isAllowedFrontend(string $url): bool
|
||||
{
|
||||
$hosts = $this->allowedHosts();
|
||||
if (empty($hosts)) return false;
|
||||
return in_array(parse_url($url, PHP_URL_HOST), $hosts, true);
|
||||
}
|
||||
```
|
||||
- مطمئن شو کلید `payment_allowed_frontend_hosts` در فهرست کلیدهای مجازِ `PATCH /api/v1/admin/settings` هست (اگر آن endpoint allowlist کلید دارد، این کلید را اضافه کن). بررسی کن `SiteConfigController::patch` چطور کلیدهای مجاز را محدود میکند.
|
||||
- مقدار اولیه را در `.env` نگهدار (fallback)؛ مدیر میتواند از پنل override کند.
|
||||
|
||||
### ۵. مستندسازی `docs/api/payment.md`
|
||||
- وضعیتهای تراکنش: `pending` / `success` / `failed` / `canceled` / `refunded` با توضیح هر کدام (canceled = انصراف کاربر یا انقضای مهلت).
|
||||
- جریان callback و redirect به سایت مبدأ با `?payment_uuid=<uuid>&status=<status>`.
|
||||
- حالت تست (Sandbox) با `MockGateway` و نحوهی فعالسازی (`payment_test_mode` در تنظیمات).
|
||||
- allowlist دامنهی سایت مبدأ و اینکه از پنل ادمین (`payment_allowed_frontend_hosts`) قابلویرایش است.
|
||||
|
||||
## نکات مهم
|
||||
|
||||
- **منطق برای همهی کلاینتها یکسان است** و باید بماند؛ هیچ شاخهی if خاصِ «سایت اصلی» در برابر «سرویسگیرنده» اضافه نکن. تنها تفاوت، `frontend_address`ِ هر کلاینت است که در تراکنش ذخیره و در پایان برای redirect استفاده میشود.
|
||||
- `frontend_address` همان «سایت مبدأ» است؛ allowlist فقط برای جلوگیری از Open Redirect است — منطق redirect عوض نمیشود.
|
||||
- وضعیت `canceled` نباید circuit-breaker درگاه را بهعنوان failure ثبت کند (انصراف کاربر، نقص درگاه نیست).
|
||||
- تاریخها Unix timestamp؛ پاسخها از `BaseController` (`$this->success/$this->error`)؛ مبالغ بر حسب ریال.
|
||||
- بعد از تغییر: `ddev exec php -l` روی فایلهای PHP؛ `cache:clear`؛ اگر `status` ستون enum/constraint داشت `migrations:diff`/`migrate`.
|
||||
- تست رفتاری: (الف) جریان تست موفق (mock) → `success` + نوبت confirmed؛ (ب) انصراف (mock با `cancel=1`/`ResCode=17`) → `canceled` + نوبت دستنخورده؛ (ج) انقضای مهلت → Scheduler نوبت را `expired` و payment را `canceled` کند؛ (د) `frontend_address` با دامنهی اضافهشده در `SiteConfig` پذیرفته شود و با دامنهی نامجاز ۴۲۲ بدهد.
|
||||
- طبق Standing Rule، `docs/api/payment.md` در همین session بهروز شود.
|
||||
@@ -19,7 +19,7 @@ JWT_PASSPHRASE=5778180ab122fbb3253d84f4137dbc1672109bab9ad051d3d40fb1c2be3e242d
|
||||
###< lexik/jwt-authentication-bundle ###
|
||||
|
||||
###> nelmio/cors-bundle ###
|
||||
CORS_ALLOW_ORIGIN='^https?://(clinic-pro\.ddev\.site|localhost|127\.0\.0\.1)(:[0-9]+)?$'
|
||||
CORS_ALLOW_ORIGIN='^https?://([a-z0-9-]+\.)*(clinic-pro\.ddev\.site|localhost|127\.0\.0\.1)(:[0-9]+)?$'
|
||||
###< nelmio/cors-bundle ###
|
||||
|
||||
###> symfony/messenger ###
|
||||
@@ -47,7 +47,7 @@ UPLOAD_DIR=var/uploads
|
||||
###< File Upload ###
|
||||
|
||||
###> Payment ###
|
||||
ALLOWED_FRONTEND_HOSTS=clinic-pro.ddev.site,localhost
|
||||
ALLOWED_FRONTEND_HOSTS=clinic-pro.ddev.site,localhost,yazd-nobat.localhost
|
||||
###< Payment ###
|
||||
|
||||
# Payment
|
||||
|
||||
+2
-1
@@ -824,8 +824,9 @@ Update one or more settings. Unknown keys are silently ignored.
|
||||
|
||||
**Payment gateway rules:**
|
||||
- `payment_test_mode` — `"1"` = all payments use MockGateway (no real bank calls), `"0"` = real gateways
|
||||
- `payment_allowed_frontend_hosts` — comma-separated hosts allowed as a payment `frontend_address` (origin site to return to). Falls back to the `ALLOWED_FRONTEND_HOSTS` env var when empty. Add a consumer site's host here to permit its payments.
|
||||
- Gateway credentials (mellat/sep) read from DB first, fallback to env vars if DB value is empty
|
||||
- MockGateway callback: same URL pattern + `&mock=1&ResCode=0&RefId=MOCK-{orderId}`
|
||||
- MockGateway callback: same URL pattern + `&mock=1&ResCode=0&RefId=MOCK-{orderId}` (add `&cancel=1` to simulate a user cancellation → `canceled`)
|
||||
|
||||
**SMS provider rules:**
|
||||
- `sms_provider` — `"kavenegar"` or `"rangineh"`
|
||||
|
||||
+27
-9
@@ -40,7 +40,7 @@ List the **authenticated user's own** payments (derived from the token — there
|
||||
|-------|------|---------|-------------|
|
||||
| `page` | integer | 1 | Page number |
|
||||
| `limit` | integer | 20 | Items per page (max 100) |
|
||||
| `status` | string | — | Optional filter: `pending` / `success` / `failed` / `cancelled` / `refunded` |
|
||||
| `status` | string | — | Optional filter: `pending` / `success` / `failed` / `canceled` / `refunded` |
|
||||
|
||||
### Response `200` (paginated)
|
||||
```json
|
||||
@@ -142,12 +142,20 @@ Status=2&RRN=...&RefNum=...&TerminalId=...&TraceNo=...
|
||||
```
|
||||
|
||||
### Response
|
||||
- If `ResCode=0` (success): appointment confirmed, redirect to `frontend_address?success=1&uuid=...`
|
||||
- If failed: redirect to `frontend_address?success=0&error=...`
|
||||
After verifying the gateway result, the backend redirects the user **back to the origin site** (`frontend_address`) with the outcome appended as query params:
|
||||
```
|
||||
{frontend_address}?payment_uuid={uuid}&status={status}
|
||||
```
|
||||
- **Success** (`verify` ok): payment → `success`, then the type-specific action runs (appointment → `confirmed`, subscription → activated, sms_wallet → credited).
|
||||
- **User canceled** (e.g. Mellat `ResCode=17`, SEP `State=CanceledByUser`, mock `cancel=1`): payment → `canceled`. The gateway circuit-breaker is **not** marked as failed (it's a user choice, not a gateway fault).
|
||||
- **Failed** (any other unsuccessful verify): payment → `failed`, circuit-breaker records a failure.
|
||||
|
||||
If `frontend_address` is empty, a JSON body `{ success, payment }` is returned instead of a redirect.
|
||||
|
||||
### Notes
|
||||
- On success: appointment status → `confirmed`, wallet credited with doctor's share
|
||||
- Payment record stored with: `order_id`, `amount_rials`, `status`, `gateway`, `ref_id`
|
||||
- On appointment success: status → `confirmed`, its 15-minute `expires_at` cleared, confirmation SMS dispatched.
|
||||
- Payment record stores: `order_id`, `amount_rials`, `status`, `gateway`, `reference_id`, `frontend_address`, `callback_ip`.
|
||||
- Same flow for **all clients** (the main site and every consumer site) — the only per-client difference is `frontend_address`, which is validated against an allowlist (see below) to prevent open redirects.
|
||||
|
||||
---
|
||||
|
||||
@@ -233,15 +241,25 @@ Get payment status and details.
|
||||
**Payment Status Values:**
|
||||
| Value | Description |
|
||||
|-------|-------------|
|
||||
| `pending` | Created, not paid yet |
|
||||
| `paid` | Successfully paid |
|
||||
| `failed` | Gateway returned failure |
|
||||
| `cancelled` | User cancelled at gateway |
|
||||
| `pending` | Transaction created, awaiting payment |
|
||||
| `success` | Successfully paid and verified |
|
||||
| `failed` | Gateway returned a failure |
|
||||
| `canceled` | User canceled at the gateway, or the payment window lapsed (booking expired) |
|
||||
| `refunded` | Refunded |
|
||||
|
||||
> `canceled` is set in two cases: (1) the gateway callback reports a user cancellation, and (2) the appointment's 15-minute payment window lapses — the scheduled expiry job marks the booking `expired` and its pending payment `canceled`.
|
||||
|
||||
### Errors
|
||||
| Code | HTTP | Description |
|
||||
|------|------|-------------|
|
||||
| `ERR_AUTH_001` | 401 | Missing token |
|
||||
| `ERR_FORBIDDEN_001` | 403 | Not the owner |
|
||||
| `ERR_NOT_FOUND_001` | 404 | Payment not found |
|
||||
|
||||
---
|
||||
|
||||
## Sandbox (test) mode & origin allowlist
|
||||
|
||||
**Test mode:** when `payment_test_mode` (in admin settings) is `1`, every initiation resolves to the internal `MockGateway` — no bank call is made, the transaction is simulated and verified inside the system, and the user is redirected back to `frontend_address` exactly like a real payment. `GET /api/v1/payment/config` exposes this as `test_mode`.
|
||||
|
||||
**Origin allowlist:** `frontend_address` (the origin site the user returns to) must match an allowed host, to prevent open redirects. The allowlist is read from the `payment_allowed_frontend_hosts` site setting (comma-separated hosts), falling back to the `ALLOWED_FRONTEND_HOSTS` env var when the setting is empty. Manage it via `PATCH /api/v1/admin/settings` — so a new consumer site can be allowed without a code or `.env` change. A non-allowed host yields `422 ERR_VALIDATION_001` (`field: frontend_address`).
|
||||
|
||||
@@ -4,10 +4,15 @@ namespace App\Appointment\Service;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Appointment\Repository\AppointmentRepository;
|
||||
use App\Payment\Entity\Payment;
|
||||
use App\Payment\Repository\PaymentRepository;
|
||||
|
||||
class AppointmentExpiryService
|
||||
{
|
||||
public function __construct(private readonly AppointmentRepository $appointmentRepo) {}
|
||||
public function __construct(
|
||||
private readonly AppointmentRepository $appointmentRepo,
|
||||
private readonly PaymentRepository $paymentRepo,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Expire pending bookings whose payment window has lapsed or whose slot
|
||||
@@ -28,6 +33,13 @@ class AppointmentExpiryService
|
||||
foreach ($expired as $appointment) {
|
||||
$appointment->transitionTo(Appointment::STATUS_EXPIRED);
|
||||
$this->appointmentRepo->save($appointment, false);
|
||||
|
||||
$payment = $this->paymentRepo->findPendingByAppointment($appointment);
|
||||
if ($payment !== null) {
|
||||
$payment->setStatus(Payment::STATUS_CANCELED);
|
||||
$this->paymentRepo->save($payment, false);
|
||||
}
|
||||
|
||||
$count++;
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ class SiteConfigController extends BaseController
|
||||
'appointment_reminder_hours',
|
||||
// payment gateways
|
||||
'payment_test_mode',
|
||||
'payment_allowed_frontend_hosts',
|
||||
'mellat_terminal_id',
|
||||
'mellat_username',
|
||||
'mellat_password',
|
||||
|
||||
@@ -259,9 +259,12 @@ class PaymentController extends BaseController
|
||||
$result = $gw?->verify($callbackData) ?? null;
|
||||
|
||||
if ($result === null || !$result->success) {
|
||||
$payment->setStatus(Payment::STATUS_FAILED);
|
||||
$canceled = $result?->canceled ?? false;
|
||||
$payment->setStatus($canceled ? Payment::STATUS_CANCELED : Payment::STATUS_FAILED);
|
||||
$this->paymentRepo->save($payment);
|
||||
$this->circuitBreaker->recordFailure($gateway);
|
||||
if (!$canceled) {
|
||||
$this->circuitBreaker->recordFailure($gateway);
|
||||
}
|
||||
|
||||
return $this->redirectToFrontend($payment, false);
|
||||
}
|
||||
@@ -566,9 +569,17 @@ class PaymentController extends BaseController
|
||||
};
|
||||
}
|
||||
|
||||
/** @return string[] allowed frontend hosts — from SiteConfig, falling back to env. */
|
||||
private function allowedHosts(): array
|
||||
{
|
||||
$fromConfig = (string) ($this->configRepo->get('payment_allowed_frontend_hosts') ?? '');
|
||||
$raw = $fromConfig !== '' ? $fromConfig : $this->allowedFrontendHosts;
|
||||
return array_filter(array_map('trim', explode(',', $raw)));
|
||||
}
|
||||
|
||||
private function isAllowedFrontend(string $url): bool
|
||||
{
|
||||
$hosts = array_filter(array_map('trim', explode(',', $this->allowedFrontendHosts)));
|
||||
$hosts = $this->allowedHosts();
|
||||
if (empty($hosts)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ class Payment
|
||||
public const STATUS_PENDING = 'pending';
|
||||
public const STATUS_SUCCESS = 'success';
|
||||
public const STATUS_FAILED = 'failed';
|
||||
public const STATUS_CANCELED = 'canceled';
|
||||
public const STATUS_REFUNDED = 'refunded';
|
||||
|
||||
public const TYPE_APPOINTMENT = 'appointment';
|
||||
|
||||
@@ -55,6 +55,10 @@ class MellatGateway implements PaymentGatewayInterface
|
||||
$refId = $callbackData['RefId'] ?? '';
|
||||
$resCode = $callbackData['ResCode'] ?? '';
|
||||
|
||||
if ($resCode === '17') {
|
||||
return new PaymentVerifyResult(false, errorMessage: 'پرداخت توسط کاربر لغو شد', canceled: true);
|
||||
}
|
||||
|
||||
if ($resCode !== '0') {
|
||||
return new PaymentVerifyResult(false, errorMessage: "Payment failed: $resCode");
|
||||
}
|
||||
|
||||
@@ -21,6 +21,10 @@ class MockGateway implements PaymentGatewayInterface
|
||||
return new PaymentVerifyResult(false, errorMessage: 'mock callback مجاز نیست');
|
||||
}
|
||||
|
||||
if (($callbackData['cancel'] ?? '0') === '1' || $resCode === '17') {
|
||||
return new PaymentVerifyResult(false, errorMessage: 'پرداخت توسط کاربر لغو شد', canceled: true);
|
||||
}
|
||||
|
||||
$refId = $callbackData['RefId'] ?? $callbackData['order_id'] ?? 'MOCK-REF';
|
||||
return new PaymentVerifyResult(true, referenceId: $refId);
|
||||
}
|
||||
|
||||
@@ -9,5 +9,6 @@ final class PaymentVerifyResult
|
||||
public readonly string $referenceId = '',
|
||||
public readonly string $errorMessage = '',
|
||||
public readonly int $amountRials = 0,
|
||||
public readonly bool $canceled = false,
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,9 @@ class SepGateway implements PaymentGatewayInterface
|
||||
public function verify(array $callbackData): PaymentVerifyResult
|
||||
{
|
||||
$state = $callbackData['State'] ?? '';
|
||||
if (strtolower($state) === 'canceledbyuser') {
|
||||
return new PaymentVerifyResult(false, errorMessage: 'پرداخت توسط کاربر لغو شد', canceled: true);
|
||||
}
|
||||
if (strtolower($state) !== 'ok') {
|
||||
return new PaymentVerifyResult(false, errorMessage: "Payment state: $state");
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Payment\Repository;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Auth\Entity\User;
|
||||
use App\Payment\Entity\Payment;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
@@ -53,6 +54,14 @@ class PaymentRepository extends ServiceEntityRepository
|
||||
return $this->findOneBy(['orderId' => $orderId]);
|
||||
}
|
||||
|
||||
public function findPendingByAppointment(Appointment $appointment): ?Payment
|
||||
{
|
||||
return $this->findOneBy([
|
||||
'appointment' => $appointment,
|
||||
'status' => Payment::STATUS_PENDING,
|
||||
]);
|
||||
}
|
||||
|
||||
public function save(Payment $entity, bool $flush = true): void
|
||||
{
|
||||
$this->getEntityManager()->persist($entity);
|
||||
|
||||
Reference in New Issue
Block a user