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>
12 KiB
یکپارچهسازی منطق پرداخت: وضعیت 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ها را صدا میزنند.
سه فاصله با خواستهی محصول باقی مانده:
- وضعیت
canceledوجود ندارد (فقطpending,success,failed,refunded). انصراف کاربر از درگاه و انقضای مهلت پرداخت بایدcanceledثبت شود. - allowlist دامنهی سایت مبدأ (
ALLOWED_FRONTEND_HOSTS) یک رشتهی ثابت در.envاست؛ افزودن هر سرویسگیرندهی جدید نیازمند تغییر.envو ریاستارت است. باید به سیستمSiteConfig(که از پنل ادمین قابلویرایش است) منتقل شود. - مستندات
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)
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)%')
// 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 را دست نمیزند
// AppointmentExpiryService::expireStale()
foreach ([...findPaymentExpired($now), ...findExpiredPending($now)] as $appointment) {
$appointment->transitionTo(Appointment::STATUS_EXPIRED);
$this->appointmentRepo->save($appointment, false);
// ❌ payment ـِ pending این نوبت همچنان pending میماند
}
callback — انصراف کاربر معادل خطا گرفته میشود
$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:
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 بر اساسش تصمیم بگیرد.
- Mellat:
- در
PaymentController::callback، شاخهی ناموفق را به دو حالت تقسیم کن:
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 کن:
$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 کن:
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 بهروز شود.