# تسک ۱۵: ماژول پرداخت ## توضیح مدیریت پرداخت نوبت‌ها از طریق درگاه‌های Mellat و SEP، callback پرداخت، refund و مشاهده تاریخچه. ## Endpoint ها | متد | مسیر | توضیح | نیاز به Auth | |-----|------|-------|-------------| | POST | `/api/v1/payment` | شروع فرآیند پرداخت | بله | | GET | `/api/v1/payment/{uuid}` | دریافت اطلاعات پرداخت | بله | | GET | `/api/v1/payment/my-payments/{userId}` | تاریخچه پرداخت‌های من | بله | | POST | `/api/v1/payment/callback/mellat` | Callback از درگاه ملت | خیر (IP whitelist) | | POST | `/api/v1/payment/callback/sep` | Callback از درگاه سامان | خیر (IP whitelist) | | POST | `/api/v1/subscription-payment` | شروع پرداخت اشتراک | بله | | GET | `/api/v1/subscription-payment/{uuid}` | اطلاعات پرداخت اشتراک | بله | | POST | `/api/v1/subscription-payment/callback/mellat` | Callback اشتراک ملت | خیر | | POST | `/api/v1/subscription-payment/callback/sep` | Callback اشتراک سامان | خیر | ## پیش‌نیازها - تسک ۰۱، ۰۲، ۱۰ (Appointment) ## زمان تخمینی ۱۰ تا ۱۲ ساعت --- ## فلوی کامل پرداخت نوبت ``` ۱. POST /api/v1/payment ↓ ۲. بررسی: appointment.status == 'waiting_for_payment' ؟ ↓ (بله) ۳. ایجاد رکورد payment با status=pending ↓ ۴. فراخوانی PaymentGatewayInterface::initiate(amount, callback_url) ↓ ┌──────────────────┬──────────────────┐ Mellat (SOAP) SEP (REST) → bpPayRequest → MerchantSendTransaction → دریافت RefId → دریافت token ↓ ۵. بازگشت payment_url به کلاینت ↓ ۶. Redirect کاربر به درگاه بانک ↓ ۷. [Callback از بانک] ↓ ۸. POST /api/v1/payment/callback/{gateway} ↓ ۹. تأیید تراکنش با درگاه (VerifyRequest) ↓ ┌─────────────────────────────────────┐ پرداخت موفق پرداخت ناموفق ↓ ↓ payments.status=received payments.status=canceled appointments.status=reserved appointments.status=waiting_for_payment واریز کمیسیون نماینده (کاربر می‌تواند مجدداً تلاش کند) ↓ Redirect به frontend_address ``` --- ## Strategy Pattern برای درگاه‌ها ```php interface PaymentGatewayInterface { public function initiate(int $amount, string $callbackUrl, string $description): GatewayInitResult; public function verify(string $refId, int $amount): GatewayVerifyResult; public function getName(): string; // 'mellat' | 'sep' } class MellatGateway implements PaymentGatewayInterface { ... } class SepGateway implements PaymentGatewayInterface { ... } ``` --- ## POST /api/v1/payment ```json // Request { "appointment_uuid": "7b759d2a-...", "payment_method": "mellat", "frontend_address": "https://yasuj-nobat.localhost:3000/" } // Response 200 { "success": true, "data": { "uuid": "...", "payment_url": "https://bpm.shaparak.ir/pgwchannel/startpay.mellat?RefId=xxx", "amount": 500000, "status": "pending", "expires_at": 1748001800 } } // Response 400 — نوبت در وضعیت نامناسب { "success": false, "errors": [{ "code": "ERR_PAYMENT_003", "message": "وضعیت نوبت برای پرداخت مناسب نیست" }] } // Response 503 — درگاه در دسترس نیست { "success": false, "errors": [{ "code": "ERR_PAYMENT_001", "message": "درگاه پرداخت در حال حاضر در دسترس نیست" }] } ``` --- ## POST /api/v1/payment/callback/mellat ``` // form-data از بانک ResCode=0 SaleOrderId=... SaleReferenceId=12345678 ``` **منطق:** ``` 1. پیدا کردن payment با ref_id مربوطه 2. فراخوانی MellatGateway::verify(SaleReferenceId, amount) 3. اگر موفق: - payments.status = 'received' - payments.ref_id = SaleReferenceId - payments.payment_time = now() - appointments.status = 'reserved' - محاسبه و واریز کمیسیون نماینده (async) 4. Redirect به frontend_address + ?status=success 5. اگر ناموفق: - payments.status = 'canceled' - Redirect به frontend_address + ?status=failed ``` --- ## GET /api/v1/payment/{uuid} ```json { "success": true, "data": { "uuid": "...", "appointment": { "uuid": "...", "date": "2024-03-20", "time": "09:00", "doctor": { "name": "دکتر احمدی" } }, "amount": 500000, "status": "received", "payment_method": "mellat", "ref_id": "12345678", "payment_time": 1748000000, "created_at": 1748000000 } } ``` --- ## فلوی Refund (لغو نوبت بعد از پرداخت) ``` PATCH /api/v1/appointment/{uuid}/cancel ↓ appointment.status = 'cancelled_by_patient' ↓ payment.status = 'refund' ↓ ثبت در سیستم — refund واقعی دستی توسط ادمین انجام می‌شود ↓ log در سیستم برای پیگیری ادمین ``` > **نکته:** Refund خودکار از درگاه در این پروژه پیاده‌سازی نمی‌شود — ادمین به صورت دستی مبلغ را برمی‌گرداند. --- ## Subscription Payment — POST /api/v1/subscription-payment ```json // Request { "reference_type": "doctor", "reference_id": 29, "plan": "advanced", "payment_method": "mellat", "frontend_address": "https://yasuj-nobat.localhost:3000/" } // Response 200 { "success": true, "data": { "uuid": "...", "payment_url": "https://bpm.shaparak.ir/...", "amount": 5000000, "plan": "advanced", "status": "pending" } } ``` **بعد از تأیید پرداخت اشتراک:** ``` subscription_payments.status = 'received' subscription_payments.start_date = now() subscription_payments.expiration_date = now() + 30 روز (یا 365 روز) واریز کمیسیون به کیف پول نماینده (اگر از طریق نماینده) ``` --- ## نکات مهم - **مبلغ در ریال ذخیره می‌شود** (نه تومان) — مثال: ۵۰,۰۰۰ تومان = ۵۰۰,۰۰۰ ریال - **وضعیت 'received'** — نه 'paid' (مستقیم از Drupal) - **Circuit Breaker:** اگر درگاه ۳ بار پشت سر هم fail داشت → به مدت ۵ دقیقه blocked شود - **Idempotency:** Callback ممکن است چند بار فراخوانی شود — بررسی کنید payment قبلاً verified نشده باشد - **IP Whitelist:** Callback endpoint ها باید فقط از IP های بانک قابل دسترس باشند --- ## ⚠ امنیت: جلوگیری از Open Redirect فیلد `frontend_address` در request می‌تواند توسط مهاجم دستکاری شود تا Callback به یک سایت مخرب redirect کند. **راه‌حل — Whitelist دامنه‌های مجاز:** ```php // config/packages/payment.yaml (یا .env) ALLOWED_FRONTEND_HOSTS=yasuj-nobat.localhost,clinicpro.ir,app.clinicpro.ir // در PaymentService قبل از ذخیره frontend_address: private function validateFrontendAddress(string $url): void { $parsed = parse_url($url); $host = $parsed['host'] ?? ''; $allowed = explode(',', $this->params->get('allowed_frontend_hosts')); if (!in_array($host, $allowed, true)) { throw new \InvalidArgumentException('آدرس بازگشت مجاز نیست'); } } ``` **یا روش ساده‌تر:** `frontend_address` را از JWT کاربر یا از `representations.domain_name` بخوان — نه از request body. --- ## ⚠ امنیت: IP Whitelist برای Callback ```php // src/Payment/EventSubscriber/PaymentCallbackGuard.php class PaymentCallbackGuard implements EventSubscriberInterface { private const MELLAT_IPS = ['185.143.233.0/24', '79.175.148.0/24']; private const SEP_IPS = ['195.146.48.0/24']; public function onKernelRequest(RequestEvent $event): void { $path = $event->getRequest()->getPathInfo(); if (!str_contains($path, '/payment/callback/')) return; $clientIp = $event->getRequest()->getClientIp(); $gateway = str_contains($path, 'mellat') ? 'mellat' : 'sep'; $allowed = $gateway === 'mellat' ? self::MELLAT_IPS : self::SEP_IPS; if (!$this->ipInRanges($clientIp, $allowed)) { throw new AccessDeniedHttpException('IP not allowed for payment callback'); } } } ```