feat(payment): implement PaymentManager for handling payment logic and callbacks

- Refactor PaymentController to delegate payment processing to PaymentManager.
- Add findByOrderIdForUpdate method in PaymentRepository for pessimistic locking.
- Create PaymentLog entity and repository for auditing payment actions.
- Implement startGatewayHandoff and processCallback methods in PaymentManager.
- Introduce transaction handling and logging for payment verification.
- Update payment flow to ensure idempotency and prevent race conditions.
- Enhance security by logging sensitive actions without exposing credentials.
- Update database schema with migration for payment_logs table.
- Document changes in payment flow architecture.
This commit is contained in:
hamed
2026-07-02 15:36:08 +03:30
parent ca71c49451
commit c247ac2c80
12 changed files with 761 additions and 378 deletions
+57 -2
View File
@@ -5,6 +5,59 @@
---
## معماری (Flow & مسئولیت‌ها)
**اصل:** Frontend هرگز مستقیم با بانک صحبت نمی‌کند و منطق پرداخت را نگه نمی‌دارد. Backend تنها مرجع معتبر (Source of Truth) است.
```
[Frontend] کلیک پرداخت
│ (۱) XHR authenticated: POST /api/v1/payment/appointment {appointment_uuid, gateway, frontend_address}
[PaymentController::initiateAppointment] ← فقط Orchestration + Validation
│ اعتبارسنجی: وجود نوبت، مالکیت کاربر، وضعیت قابل‌پرداخت، آدرس بازگشت مجاز، فعال بودن درگاه (GatewayFactory)
│ ساخت Payment(pending) + ذخیره؛ برمی‌گرداند pay_url (هیچ ارتباطی با بانک اینجا نیست)
[Frontend] مرورگر full-page redirect → pay_url
│ (۲) GET /api/v1/payment/pay/{orderId} (عمومی)
[PaymentController::pay]
│ GatewayFactory::resolve(نام درگاه) → درگاه (تست→Mock)
│ CircuitBreaker چک؛ gateway->initiate(amount, orderId, callbackUrl) ← ارتباط با بانک
│ ذخیرهٔ token؛ انتقال به بانک: 302 (GET) یا فرم auto-submit POST (ملت)
[درگاه بانک / شاپرک] پرداخت کاربر
│ (۳) بازگشت به callbackUrl بک‌اند
[PaymentController::callback] (عمومی، محدود به IP شاپرک مگر تست)
│ gateway->verify()؛ بررسی مبلغ؛ جلوگیری از replay (reference_id یکتا)؛ ست وضعیت
│ post-action: confirm نوبت / فعال‌سازی اشتراک / شارژ کیف‌پول + کمیسیون + پیامک
│ (۴) RedirectResponse → frontend_address?payment_uuid=..&status=.. (همان دامنهٔ مبدأ)
[Frontend] /payment/result → نمایش وضعیت
```
**کلاس‌ها و مسئولیت‌ها:**
| کلاس | مسئولیت (SRP) |
|------|----------------|
| `PaymentController` | فقط Orchestration: دریافت request، اعتبارسنجی مالکیت/سفارش، فراخوانی `PaymentManager`، ساخت پاسخ/redirect HTTP (`autoSubmitForm`, `redirectToFrontend`, IP-check, Open-Redirect guard). بدون منطق درگاه/verify. |
| `PaymentManager` (`src/Payment/Service/`) | **منطق پرداخت**: `startGatewayHandoff()` (init درگاه + CircuitBreaker + ذخیرهٔ token) و `processCallback()` (verify داخل **transaction + قفل بدبینانه**، بررسی مبلغ، ضد-replay، idempotent، post-action، لاگ). |
| `GatewayFactory` (`src/Payment/Gateway/`) | **Factory + Strategy**: انتخاب درگاه بر اساس نام + حالت تست + فعال بودن؛ فهرست درگاه‌های فعال. |
| `PaymentGatewayInterface` | قرارداد درگاه: `initiate()`, `verify()`, `isConfigured()`, `getName()`. |
| `MellatGateway` / `SepGateway` / `MockGateway` | پیاده‌سازی هر درگاه (SOAP/REST/mock). ملت با POST به بانک، سپ با GET. |
| `PaymentInitResult` / `PaymentVerifyResult` | DTO نتیجهٔ init/verify (شامل `redirectMethod`/`redirectParams`). |
| `CircuitBreakerService` | جلوگیری از فشار روی درگاهِ خراب. |
| `Payment` (Entity) | وضعیت پرداخت، `orderId` یکتا، `referenceId` یکتا (backstop برای replay)، `frontendAddress` (دامنهٔ مبدأ). |
| `PaymentLog` (Entity) + `PaymentLogRepository` | **audit trail**: هر گام (`initiate`/`verify`) با نتیجه، authority، IP، payload کال‌بک (بدون اعتبارنامه). |
**امنیت verify:** `processCallback` داخل `EntityManager::wrapInTransaction` با `findByOrderIdForUpdate` (SELECT … FOR UPDATE) اجرا می‌شود؛ گاردِ «فقط `pending`» آن را **idempotent** می‌کند (verify تکراری/race بی‌اثر).
**یکدستیِ typeها:** هر سه نوع (`appointment`/`subscription`/`sms_wallet`) از همان `GET /payment/pay/{orderId}` عبور می‌کنند؛ `PaymentManager::callbackUrl()` پیشوند callback را بر اساس `type` انتخاب می‌کند. POST این endpointها فقط `Payment` pending می‌سازد و `pay_url` برمی‌گرداند (نه `redirect_url`).
**افزودن درگاه جدید (Open/Closed):** یک کلاس جدید implements `PaymentGatewayInterface` بساز، در `GatewayFactory::$gateways` + `LABELS` ثبت کن. `PaymentController`/`PaymentManager` تغییر نمی‌کنند.
---
## GET `/api/v1/payment/config`
دریافت تنظیمات عمومی پرداخت — برای نمایش وضعیت درگاه آزمایشی در frontend.
@@ -231,12 +284,14 @@ Initiate a subscription / wallet top-up payment (not tied to a specific appointm
"success": true,
"data": {
"payment_uuid": "pay-uuid-...",
"redirect_url": "https://bpm.shaparak.ir/pgwchannel/...",
"order_id": "CLINICPRO-SUB-1717000000-XYZ"
"pay_url": "{APP_BASE_URL}/api/v1/payment/pay/ORD-XXXX",
"order_id": "ORD-XXXX"
}
}
```
> مثل appointment: کلاینت مرورگر را به `pay_url` هدایت می‌کند؛ init درگاه در `GET /api/v1/payment/pay/{orderId}` انجام می‌شود (نه در این POST). Callback این نوع به `/api/v1/subscription-payment/callback/` می‌رود.
### Errors
| Code | HTTP | Description |
|------|------|-------------|