Files
clinicpro/.claude/prompt/payment-unified-status-canceled-and-managed-hosts.md
hamedandClaude Opus 4.8 492a7df989 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>
2026-06-16 10:06:43 +03:30

12 KiB
Raw Permalink Blame History

یکپارچه‌سازی منطق پرداخت: وضعیت 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)

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 بر اساسش تصمیم بگیرد.
  • در 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 به‌روز شود.