From 492a7df989809eb53f5cd0984dd8ff3bfb7d16ba Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Tue, 16 Jun 2026 10:06:43 +0330 Subject: [PATCH] feat(payment): canceled status, manageable origin allowlist, CORS subdomains MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ...ified-status-canceled-and-managed-hosts.md | 156 ++++++++++++++++++ .env | 4 +- docs/api/admin.md | 3 +- docs/api/payment.md | 36 +++- .../Service/AppointmentExpiryService.php | 14 +- .../Controller/SiteConfigController.php | 1 + src/Payment/Controller/PaymentController.php | 17 +- src/Payment/Entity/Payment.php | 1 + src/Payment/Gateway/MellatGateway.php | 4 + src/Payment/Gateway/MockGateway.php | 4 + src/Payment/Gateway/PaymentVerifyResult.php | 1 + src/Payment/Gateway/SepGateway.php | 3 + src/Payment/Repository/PaymentRepository.php | 9 + 13 files changed, 237 insertions(+), 16 deletions(-) create mode 100644 .claude/prompt/payment-unified-status-canceled-and-managed-hosts.md diff --git a/.claude/prompt/payment-unified-status-canceled-and-managed-hosts.md b/.claude/prompt/payment-unified-status-canceled-and-managed-hosts.md new file mode 100644 index 00000000..4b60cb67 --- /dev/null +++ b/.claude/prompt/payment-unified-status-canceled-and-managed-hosts.md @@ -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=&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=&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 به‌روز شود. diff --git a/.env b/.env index 7425edd8..6c2a3902 100644 --- a/.env +++ b/.env @@ -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 diff --git a/docs/api/admin.md b/docs/api/admin.md index 9aea6be2..54288929 100644 --- a/docs/api/admin.md +++ b/docs/api/admin.md @@ -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"` diff --git a/docs/api/payment.md b/docs/api/payment.md index a265e60c..9e67952f 100644 --- a/docs/api/payment.md +++ b/docs/api/payment.md @@ -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`). diff --git a/src/Appointment/Service/AppointmentExpiryService.php b/src/Appointment/Service/AppointmentExpiryService.php index 34201f86..639ba3b0 100644 --- a/src/Appointment/Service/AppointmentExpiryService.php +++ b/src/Appointment/Service/AppointmentExpiryService.php @@ -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++; } diff --git a/src/Config/Controller/SiteConfigController.php b/src/Config/Controller/SiteConfigController.php index ed6b8633..4c0e8c8b 100644 --- a/src/Config/Controller/SiteConfigController.php +++ b/src/Config/Controller/SiteConfigController.php @@ -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', diff --git a/src/Payment/Controller/PaymentController.php b/src/Payment/Controller/PaymentController.php index 80b1a1e2..220ce6f7 100644 --- a/src/Payment/Controller/PaymentController.php +++ b/src/Payment/Controller/PaymentController.php @@ -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; } diff --git a/src/Payment/Entity/Payment.php b/src/Payment/Entity/Payment.php index 4fe88913..8b1ebf4e 100644 --- a/src/Payment/Entity/Payment.php +++ b/src/Payment/Entity/Payment.php @@ -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'; diff --git a/src/Payment/Gateway/MellatGateway.php b/src/Payment/Gateway/MellatGateway.php index c24a32ba..cee88ef0 100644 --- a/src/Payment/Gateway/MellatGateway.php +++ b/src/Payment/Gateway/MellatGateway.php @@ -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"); } diff --git a/src/Payment/Gateway/MockGateway.php b/src/Payment/Gateway/MockGateway.php index 8e1171e0..25318008 100644 --- a/src/Payment/Gateway/MockGateway.php +++ b/src/Payment/Gateway/MockGateway.php @@ -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); } diff --git a/src/Payment/Gateway/PaymentVerifyResult.php b/src/Payment/Gateway/PaymentVerifyResult.php index 0a0c9ca1..ec92aca4 100644 --- a/src/Payment/Gateway/PaymentVerifyResult.php +++ b/src/Payment/Gateway/PaymentVerifyResult.php @@ -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, ) {} } diff --git a/src/Payment/Gateway/SepGateway.php b/src/Payment/Gateway/SepGateway.php index e249f110..0db8663f 100644 --- a/src/Payment/Gateway/SepGateway.php +++ b/src/Payment/Gateway/SepGateway.php @@ -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"); } diff --git a/src/Payment/Repository/PaymentRepository.php b/src/Payment/Repository/PaymentRepository.php index a9d83477..283c0e54 100644 --- a/src/Payment/Repository/PaymentRepository.php +++ b/src/Payment/Repository/PaymentRepository.php @@ -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);