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

## توضیح
مدیریت پرداخت نوبت‌ها از طریق درگاه‌های 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');
        }
    }
}
```
