Files
clinicpro/docs/tasks/task-15-payment/task.md
T
hamed de1a78a235 feat: Implement SMS sending functionality with KavehNegar and Rangineh providers
- Add SendSmsMessage class for encapsulating SMS message data.
- Create KavehNegarProvider and RanginehProvider classes implementing SmsProviderInterface for sending SMS.
- Implement SmsLogRepository and SmsTemplateRepository for managing SMS logs and templates.
- Develop SendSmsHandler for handling SMS sending messages.
- Create SmsService to manage SMS dispatching and logging.
- Add UserProfileController for managing user profiles with CRUD operations.
- Implement UserProfile entity and repository for user profile data management.
- Update symfony.lock and bootstrap.php for project dependencies and environment setup.
2026-06-09 22:00:34 +03:30

8.7 KiB
Raw Blame History

تسک ۱۵: ماژول پرداخت

توضیح

مدیریت پرداخت نوبت‌ها از طریق درگاه‌های 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 برای درگاه‌ها

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

// 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}

{
  "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

// 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 دامنه‌های مجاز:

// 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

// 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');
        }
    }
}