- 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.
286 lines
8.7 KiB
Markdown
286 lines
8.7 KiB
Markdown
# تسک ۱۵: ماژول پرداخت
|
||
|
||
## توضیح
|
||
مدیریت پرداخت نوبتها از طریق درگاههای 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');
|
||
}
|
||
}
|
||
}
|
||
```
|