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

286 lines
8.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# تسک ۱۵: ماژول پرداخت
## توضیح
مدیریت پرداخت نوبت‌ها از طریق درگاه‌های 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');
}
}
}
```