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:
@@ -0,0 +1,179 @@
|
|||||||
|
# بازطراحی معماری پرداخت — سرویسمحور، امن، توسعهپذیر (Backend)
|
||||||
|
|
||||||
|
## پروژه
|
||||||
|
|
||||||
|
`clinicpro` (Backend). **cross-repo** — پرامپت همتا: `nobat724_front/.claude/prompt/payment-flow-frontend.md` (کلاینت این قرارداد را مصرف میکند؛ Backend اول اجرا شود).
|
||||||
|
|
||||||
|
## زمینه
|
||||||
|
|
||||||
|
بخش بزرگی از معماری هدف **قبلاً پیاده شده** و نباید دوباره ساخته شود:
|
||||||
|
|
||||||
|
- جریان backend-driven: `POST /api/v1/payment/appointment` (اعتبارسنجی سفارش + ساخت `Payment` در وضعیت pending، بدون تماس با بانک) → `pay_url` → `GET /api/v1/payment/pay/{orderId}` (تماس با بانک + انتقال 302 یا فرم auto-submit POST) → `callback` (verify) → `RedirectResponse` به `frontend_address` (همان دامنهٔ مبدأ) با `?payment_uuid=..&status=..`.
|
||||||
|
- `GatewayFactory` (`src/Payment/Gateway/GatewayFactory.php`) الگوی Factory/Strategy را دارد؛ `resolve()`, `isEnabled()`, `isTestMode()`, `activeGateways()`. کنترلر دیگر منطق انتخاب درگاه ندارد.
|
||||||
|
- امنیت موجود: Open-Redirect guard (`payment_allowed_frontend_hosts`)، IP-restrict شاپرک در callback، جلوگیری از replay با یکتایی `reference_id`، بررسی مبلغ (`amountRials !== result->amountRials`)، CircuitBreaker.
|
||||||
|
|
||||||
|
این پرامپت فقط **شکافهای باقیماندهٔ معماری/امنیت** را میبندد؛ رفتار جریان بیرونی نباید بشکند.
|
||||||
|
|
||||||
|
## مشکل / هدف
|
||||||
|
|
||||||
|
طبق spec معمار، این موارد هنوز رعایت نشدهاند:
|
||||||
|
|
||||||
|
1. **کنترلر هنوز چاق است** — منطق تماس با بانک (در `pay`) و کل verify + post-actions (در `callback`: confirm نوبت، فعالسازی اشتراک، شارژ کیفپول، کمیسیون، پیامک) داخل `PaymentController` است. طبق spec: `Controller → PaymentManager → GatewayFactory → PaymentGatewayInterface`. باید یک سرویس `PaymentManager` این منطق را بگیرد و کنترلر فقط Orchestration کند.
|
||||||
|
2. **نبود قفل/تراکنش در verify** — هنگام verify همزمانِ دو callback (race)، فقط یکتاییِ `reference_id` جلوگیری میکند؛ باید در یک DB transaction با قفل بدبینانه روی ردیف `Payment` انجام شود.
|
||||||
|
3. **لاگ کامل تراکنش وجود ندارد** — spec «ثبت کامل Logها + Gateway Response + Request Time + Authority» میخواهد. الان فقط `gatewayToken` و `reference_id` ذخیره میشود.
|
||||||
|
4. **`getStatus` پاسخ double-nested دارد** (`success(['data'=>...])`).
|
||||||
|
5. **subscription-payment و sms-wallet هنوز init را داخل POST انجام میدهند** (فقط appointment به الگوی pay-endpoint منتقل شده) — ناسازگاری معماری و همان باگ درگاه POSTیِ ملت.
|
||||||
|
|
||||||
|
## فایلهای مرتبط
|
||||||
|
|
||||||
|
| فایل | نقش |
|
||||||
|
|------|-----|
|
||||||
|
| `src/Payment/Controller/PaymentController.php` | کنترلر فعلی؛ باید به Orchestration خالص کاهش یابد |
|
||||||
|
| `src/Payment/Service/PaymentManager.php` | **جدید** — منطق start/verify/post-actions |
|
||||||
|
| `src/Payment/Gateway/GatewayFactory.php` | موجود (Factory) — بدون تغییر بزرگ |
|
||||||
|
| `src/Payment/Gateway/PaymentGatewayInterface.php` | قرارداد درگاه |
|
||||||
|
| `src/Payment/Entity/Payment.php` | افزودن فیلدهای authority/response/requested_at یا metadata |
|
||||||
|
| `src/Payment/Entity/PaymentLog.php` | **جدید (اختیاری ولی توصیهشده)** — audit trail |
|
||||||
|
| `src/Payment/Repository/PaymentRepository.php` | افزودن `findByOrderIdForUpdate()` (قفل) |
|
||||||
|
| `docs/api/payment.md` | بهروزرسانی |
|
||||||
|
|
||||||
|
## وضعیت فعلی
|
||||||
|
|
||||||
|
کنترلر `pay()` مستقیماً با درگاه و repo کار میکند:
|
||||||
|
|
||||||
|
```php
|
||||||
|
#[Route('/api/v1/payment/pay/{orderId}', methods: ['GET'])]
|
||||||
|
public function pay(string $orderId): Response
|
||||||
|
{
|
||||||
|
$payment = $this->paymentRepo->findByOrderId($orderId);
|
||||||
|
// ... resolve gateway, circuitBreaker, $gateway->initiate(...), setGatewayToken, redirect/autoSubmitForm
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`callback()` کل verify + post-action را دارد:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$result = $gw?->verify($callbackData);
|
||||||
|
// ست وضعیت، بررسی مبلغ، بررسی replay (reference_id)، سپس:
|
||||||
|
if ($payment->getType() === Payment::TYPE_SUBSCRIPTION) { $this->handleSubscriptionActivation($payment); }
|
||||||
|
elseif (... SMS_WALLET) { $this->handleSmsWalletCharge($payment); }
|
||||||
|
elseif (... APPOINTMENT) { $this->handleAppointmentConfirmation($payment); }
|
||||||
|
return $this->redirectToFrontend($payment, true);
|
||||||
|
```
|
||||||
|
|
||||||
|
`getStatus()` double-nested:
|
||||||
|
|
||||||
|
```php
|
||||||
|
return $this->success(['data' => $payment->toArray()]); // ❌ data.data.data
|
||||||
|
```
|
||||||
|
|
||||||
|
## وظایف
|
||||||
|
|
||||||
|
### ۱. ساخت سرویس `PaymentManager` و لاغر کردن کنترلر
|
||||||
|
|
||||||
|
`src/Payment/Service/PaymentManager.php` بساز که این متدها را داشته باشد و از `GatewayFactory`, `PaymentRepository`, `CircuitBreakerService`, `EntityManagerInterface`, `LoggerInterface` و سرویسهای post-action (SubscriptionService, SmsWalletService, CommissionService, SmsService, AppointmentRepository, ...) از طریق **DI** استفاده کند:
|
||||||
|
|
||||||
|
```php
|
||||||
|
final class PaymentManager
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private GatewayFactory $gateways,
|
||||||
|
private PaymentRepository $paymentRepo,
|
||||||
|
private CircuitBreakerService $circuitBreaker,
|
||||||
|
private EntityManagerInterface $em,
|
||||||
|
private LoggerInterface $logger,
|
||||||
|
private string $appBaseUrl,
|
||||||
|
// + سرویسهای post-action
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** init درگاه برای پرداخت pending و بازگرداندن نتیجه انتقال (redirectMethod/url/params). */
|
||||||
|
public function startGatewayHandoff(Payment $payment): PaymentInitResult|false { ... }
|
||||||
|
|
||||||
|
/** verify امنِ callback داخل transaction + قفل ردیف؛ اجرای post-action؛ بازگرداندن success bool. */
|
||||||
|
public function processCallback(string $orderId, array $callbackData, string $clientIp): Payment|null { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- منطق فعلیِ `pay()` (resolve + circuitBreaker + initiate + setGatewayToken) → `startGatewayHandoff()`.
|
||||||
|
- منطق فعلیِ `callback()` (verify + amount + replay + status + post-actions) → `processCallback()`.
|
||||||
|
- متدهای `handleAppointmentConfirmation`, `handleSubscriptionActivation`, `handleSmsWalletCharge` از کنترلر به `PaymentManager` منتقل شوند.
|
||||||
|
- کنترلر فقط: خواندن request، فراخوانی manager، ساخت `RedirectResponse`/`autoSubmitForm`/`error`. متد `autoSubmitForm` و `redirectToFrontend` و `isAllowedFrontend`/`allowedHosts` میتوانند در کنترلر بمانند (لایهٔ HTTP) یا به یک `PaymentRedirectResponder` منتقل شوند — یکی را انتخاب و مستند کن.
|
||||||
|
|
||||||
|
### ۲. transaction + قفل بدبینانه در verify (جلوگیری از race / double-verify)
|
||||||
|
|
||||||
|
در `processCallback`، پرداخت را با قفل بخوان و کل verify+status+post-action را در یک تراکنش انجام بده:
|
||||||
|
|
||||||
|
```php
|
||||||
|
return $this->em->wrapInTransaction(function () use ($orderId, $callbackData, $clientIp) {
|
||||||
|
$payment = $this->paymentRepo->findByOrderIdForUpdate($orderId); // SELECT ... FOR UPDATE
|
||||||
|
if ($payment === null) return null;
|
||||||
|
if ($payment->getStatus() !== Payment::STATUS_PENDING) return $payment; // قبلاً پردازش شده → idempotent
|
||||||
|
// verify، amount check، replay، setStatus، post-action
|
||||||
|
return $payment;
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
`PaymentRepository::findByOrderIdForUpdate()`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function findByOrderIdForUpdate(string $orderId): ?Payment
|
||||||
|
{
|
||||||
|
return $this->createQueryBuilder('p')
|
||||||
|
->where('p.orderId = :o')->setParameter('o', $orderId)
|
||||||
|
->getQuery()
|
||||||
|
->setLockMode(\Doctrine\DBAL\LockMode::PESSIMISTIC_WRITE)
|
||||||
|
->getOneOrNullResult();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> نکته: قفل فقط داخل تراکنش معتبر است. گارد `status !== pending → return` باعث idempotent شدن verify تکراری میشود.
|
||||||
|
|
||||||
|
### ۳. لاگ کامل تراکنش (`PaymentLog`)
|
||||||
|
|
||||||
|
Entity جدید `src/Payment/Entity/PaymentLog.php` با فیلدها: `id`, `paymentId` (FK)، `action` (`initiate`/`verify`/`callback`)، `gateway`، `authority`/`token`، `requestPayload` (json, بدون افشای اعتبارنامه)، `responsePayload` (json)، `clientIp`، `createdAt` (Unix ts). در `PaymentManager` روی init و verify یک رکورد لاگ ثبت شود.
|
||||||
|
|
||||||
|
- **migration:** بعد از ساخت Entity، `doctrine:migrations:diff` + `migrate`.
|
||||||
|
- **عدم افشای اطلاعات حساس:** اعتبارنامهٔ درگاه (username/password/terminal) هرگز در لاگ ذخیره نشود.
|
||||||
|
|
||||||
|
### ۴. ذخیرهٔ authority/response/request-time روی Payment
|
||||||
|
|
||||||
|
اگر `PaymentLog` را پیاده کردی، اینها آنجا ثبت میشوند و کافی است. در غیر اینصورت در `Payment::$metadata` کلیدهای `authority`, `gateway_response`, `requested_at` را ذخیره کن. یکی را انتخاب کن (ترجیحاً `PaymentLog`).
|
||||||
|
|
||||||
|
### ۵. رفع double-nesting در `getStatus`
|
||||||
|
|
||||||
|
```php
|
||||||
|
return $this->success($payment->toArray()); // بهجای ['data'=>...]
|
||||||
|
```
|
||||||
|
|
||||||
|
مصرفکنندهها را چک کن: پنل ادمین از `GET /api/v1/admin/payments/{uuid}` استفاده میکند (تخت، مستقل). `nobat724_front` `app/payment/[uuid]/page.js` از `getPayment` استفاده میکند — اگر به double-nest وابسته است، همانجا هم اصلاح کن (در پرامپت frontend ذکر شده).
|
||||||
|
|
||||||
|
### ۶. یکسانسازی subscription و sms-wallet با الگوی pay-endpoint
|
||||||
|
|
||||||
|
`POST /api/v1/subscription-payment` و مسیر sms-wallet را مثل appointment بازطراحی کن: POST فقط `Payment` pending بسازد و `pay_url` برگرداند؛ init واقعی در `GET /payment/pay/{orderId}` (که عمومی است و بر اساس `payment->getType()` کار میکند). این هم درگاه POSTیِ ملت را برای این جریانها درست میکند و هم معماری را یکدست.
|
||||||
|
|
||||||
|
- **توجه:** مصرفکنندهٔ subscription، پنل ادمین `clinicpro` است — بعد از تغییر قرارداد، `assets/admin/` جایی که subscription-payment را صدا میزند به `pay_url` سوییچ کن (مثل frontend).
|
||||||
|
|
||||||
|
### ۷. مستندسازی
|
||||||
|
|
||||||
|
`docs/api/payment.md` را با معماری نهایی (سرویسها، PaymentManager، PaymentLog، جریان یکدست همهٔ typeها) بهروز کن.
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
|
||||||
|
- **جریان بیرونی نباید بشکند:** endpointها و قرارداد (`pay_url`, `frontend_address?status=`) ثابت بمانند؛ فقط لایهبندی داخلی عوض میشود. بعد از هر مرحله با تستهای واقعی (زیر) صحت را بررسی کن.
|
||||||
|
- **step-1 معماری (کلیک → بکاند):** بهدلیل اینکه JWT سایت در کوکیِ همان دامنه است و redirect full-page کوکی cross-domain نمیبرد، ساخت `Payment` نیازمند **XHR authenticated** است؛ سپس مرورگر full-page به `pay_url` میرود. این استانداردِ امنِ چند-دامنه است. اگر «بدون هیچ XHR» الزامی است، بهجای آن یک **توکن یکبارمصرفِ امضاشده** در URL لازم است — در این صورت آن را پیاده کن؛ در غیر اینصورت الگوی XHR+redirect را حفظ و مستند کن.
|
||||||
|
- همه controllerها از `BaseController` ارث میبرند؛ پاسخها `success/error`. تاریخها Unix timestamp.
|
||||||
|
- Entity جدید (`PaymentLog`) → **migration الزامی**.
|
||||||
|
- بعد از تغییر: `ddev exec php -l ...`، `ddev exec php bin/console cache:clear`، `ddev exec php vendor/bin/phpstan analyse`، و تست دستی زیر.
|
||||||
|
- بعد از تغییر API → `docs/api/payment.md` در همین session.
|
||||||
|
|
||||||
|
## تست دستی (ddev، در حالت `payment_test_mode=1`)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# pay endpoint یک پرداخت pending باید 302 به callback بدهد (Mock)
|
||||||
|
curl -sk -o /dev/null -w "%{http_code} %{redirect_url}\n" "https://clinic-pro.ddev.site/api/v1/payment/pay/ORD-XXXX"
|
||||||
|
# callback موفق باید 302 به frontend_address?status=success بدهد
|
||||||
|
# verify تکراری (دوبار زدن callback) نباید وضعیت را دوباره پردازش کند (idempotent)
|
||||||
|
```
|
||||||
|
|
||||||
|
## خروجی نهایی (طبق spec — در گزارش اجرا ارائه شود)
|
||||||
|
۱ Flow ۲ کلاسها ۳ سرویسها ۴ کنترلرها ۵ مسئولیت هر کلاس ۶ نقاط ضعف ۷ بهبود ۸ امنیت ۹ Performance ۱۰ افزودن درگاه جدید (Open/Closed via GatewayFactory).
|
||||||
@@ -70,7 +70,7 @@ function SmsWalletPageInner() {
|
|||||||
mutationFn: ({ amount_rials }: ChargeForm) =>
|
mutationFn: ({ amount_rials }: ChargeForm) =>
|
||||||
api.post<{ data: { payment_url: string } }>('/api/v1/sms/wallet/charge', { gateway, amount_rials }),
|
api.post<{ data: { payment_url: string } }>('/api/v1/sms/wallet/charge', { gateway, amount_rials }),
|
||||||
onSuccess: (res: any) => {
|
onSuccess: (res: any) => {
|
||||||
const url = res?.data?.redirect_url ?? res?.data?.payment_url;
|
const url = res?.data?.pay_url ?? res?.data?.redirect_url ?? res?.data?.payment_url;
|
||||||
if (url) window.location.href = url;
|
if (url) window.location.href = url;
|
||||||
else toast.error('خطا در دریافت لینک پرداخت');
|
else toast.error('خطا در دریافت لینک پرداخت');
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ export default function SubscriptionPage() {
|
|||||||
mutationFn: ({ period_uuid, gateway, amount_rials }: { period_uuid: string; gateway: string; amount_rials: number }) =>
|
mutationFn: ({ period_uuid, gateway, amount_rials }: { period_uuid: string; gateway: string; amount_rials: number }) =>
|
||||||
api.post<{ data: { payment_url: string } }>('/api/v1/subscription-payment', { period_uuid, gateway, amount_rials }),
|
api.post<{ data: { payment_url: string } }>('/api/v1/subscription-payment', { period_uuid, gateway, amount_rials }),
|
||||||
onSuccess: (res: any) => {
|
onSuccess: (res: any) => {
|
||||||
const url = res?.data?.redirect_url ?? res?.data?.payment_url;
|
const url = res?.data?.pay_url ?? res?.data?.redirect_url ?? res?.data?.payment_url;
|
||||||
if (url) window.location.href = url;
|
if (url) window.location.href = url;
|
||||||
else toast.error('خطا در دریافت لینک پرداخت');
|
else toast.error('خطا در دریافت لینک پرداخت');
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -88,6 +88,10 @@ services:
|
|||||||
$appBaseUrl: '%env(APP_BASE_URL)%'
|
$appBaseUrl: '%env(APP_BASE_URL)%'
|
||||||
$allowedFrontendHosts: '%env(ALLOWED_FRONTEND_HOSTS)%'
|
$allowedFrontendHosts: '%env(ALLOWED_FRONTEND_HOSTS)%'
|
||||||
|
|
||||||
|
App\Payment\Service\PaymentManager:
|
||||||
|
arguments:
|
||||||
|
$appBaseUrl: '%env(APP_BASE_URL)%'
|
||||||
|
|
||||||
App\Sms\Provider\KavehNegarProvider:
|
App\Sms\Provider\KavehNegarProvider:
|
||||||
arguments:
|
arguments:
|
||||||
$apiKey: '%env(default::KAVENEGAR_API_KEY)%'
|
$apiKey: '%env(default::KAVENEGAR_API_KEY)%'
|
||||||
|
|||||||
+57
-2
@@ -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`
|
## GET `/api/v1/payment/config`
|
||||||
|
|
||||||
دریافت تنظیمات عمومی پرداخت — برای نمایش وضعیت درگاه آزمایشی در frontend.
|
دریافت تنظیمات عمومی پرداخت — برای نمایش وضعیت درگاه آزمایشی در frontend.
|
||||||
@@ -231,12 +284,14 @@ Initiate a subscription / wallet top-up payment (not tied to a specific appointm
|
|||||||
"success": true,
|
"success": true,
|
||||||
"data": {
|
"data": {
|
||||||
"payment_uuid": "pay-uuid-...",
|
"payment_uuid": "pay-uuid-...",
|
||||||
"redirect_url": "https://bpm.shaparak.ir/pgwchannel/...",
|
"pay_url": "{APP_BASE_URL}/api/v1/payment/pay/ORD-XXXX",
|
||||||
"order_id": "CLINICPRO-SUB-1717000000-XYZ"
|
"order_id": "ORD-XXXX"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> مثل appointment: کلاینت مرورگر را به `pay_url` هدایت میکند؛ init درگاه در `GET /api/v1/payment/pay/{orderId}` انجام میشود (نه در این POST). Callback این نوع به `/api/v1/subscription-payment/callback/` میرود.
|
||||||
|
|
||||||
### Errors
|
### Errors
|
||||||
| Code | HTTP | Description |
|
| Code | HTTP | Description |
|
||||||
|------|------|-------------|
|
|------|------|-------------|
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated Migration: Please modify to your needs!
|
||||||
|
*/
|
||||||
|
final class Version20260702115209 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Create payment_logs audit table';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('CREATE TABLE payment_logs (id INT AUTO_INCREMENT NOT NULL, payment_id INT NOT NULL, action VARCHAR(20) NOT NULL, gateway VARCHAR(20) NOT NULL, result VARCHAR(20) NOT NULL, authority VARCHAR(255) DEFAULT NULL, client_ip VARCHAR(45) DEFAULT NULL, payload JSON DEFAULT NULL, created_at INT NOT NULL, INDEX idx_payment_logs_payment (payment_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('DROP TABLE payment_logs');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,20 +5,13 @@ namespace App\Payment\Controller;
|
|||||||
use App\Appointment\Entity\Appointment;
|
use App\Appointment\Entity\Appointment;
|
||||||
use App\Appointment\Repository\AppointmentRepository;
|
use App\Appointment\Repository\AppointmentRepository;
|
||||||
use App\Auth\Entity\User;
|
use App\Auth\Entity\User;
|
||||||
use App\Clinic\Repository\ClinicRepository;
|
|
||||||
use App\Doctor\Repository\DoctorRepository;
|
|
||||||
use App\Payment\Entity\Payment;
|
use App\Payment\Entity\Payment;
|
||||||
use App\Config\Repository\SiteConfigRepository;
|
use App\Config\Repository\SiteConfigRepository;
|
||||||
use App\Payment\Gateway\MellatGateway;
|
use App\Payment\Gateway\GatewayFactory;
|
||||||
use App\Payment\Gateway\MockGateway;
|
|
||||||
use App\Payment\Gateway\SepGateway;
|
|
||||||
use App\Payment\Repository\PaymentRepository;
|
use App\Payment\Repository\PaymentRepository;
|
||||||
use App\Payment\Service\CircuitBreakerService;
|
use App\Payment\Service\PaymentManager;
|
||||||
use App\Shared\Constant\ErrorCodes;
|
use App\Shared\Constant\ErrorCodes;
|
||||||
use App\Shared\Controller\BaseController;
|
use App\Shared\Controller\BaseController;
|
||||||
use App\Sms\Service\SmsService;
|
|
||||||
use App\Sms\Service\SmsWalletService;
|
|
||||||
use App\Subscription\Service\SubscriptionService;
|
|
||||||
use OpenApi\Attributes as OA;
|
use OpenApi\Attributes as OA;
|
||||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||||
@@ -39,19 +32,9 @@ class PaymentController extends BaseController
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly PaymentRepository $paymentRepo,
|
private readonly PaymentRepository $paymentRepo,
|
||||||
private readonly AppointmentRepository $appointmentRepo,
|
private readonly AppointmentRepository $appointmentRepo,
|
||||||
private readonly MellatGateway $mellat,
|
private readonly GatewayFactory $gateways,
|
||||||
private readonly SepGateway $sep,
|
private readonly PaymentManager $paymentManager,
|
||||||
private readonly MockGateway $mock,
|
|
||||||
private readonly CircuitBreakerService $circuitBreaker,
|
|
||||||
private readonly SubscriptionService $subscriptionService,
|
|
||||||
private readonly SmsWalletService $smsWalletService,
|
|
||||||
private readonly SmsService $smsService,
|
|
||||||
private readonly \App\Sms\Service\SmsTextResolver $smsText,
|
|
||||||
private readonly DoctorRepository $doctorRepo,
|
|
||||||
private readonly ClinicRepository $clinicRepo,
|
|
||||||
private readonly SiteConfigRepository $configRepo,
|
private readonly SiteConfigRepository $configRepo,
|
||||||
private readonly \App\Settlement\Service\CommissionService $commissionService,
|
|
||||||
private readonly \App\Representation\Service\JalaliDateService $jalali,
|
|
||||||
private readonly string $appBaseUrl,
|
private readonly string $appBaseUrl,
|
||||||
private readonly string $allowedFrontendHosts = '',
|
private readonly string $allowedFrontendHosts = '',
|
||||||
) {}
|
) {}
|
||||||
@@ -84,7 +67,7 @@ class PaymentController extends BaseController
|
|||||||
property: 'data',
|
property: 'data',
|
||||||
properties: [
|
properties: [
|
||||||
new OA\Property(property: 'payment_uuid', type: 'string', format: 'uuid'),
|
new OA\Property(property: 'payment_uuid', type: 'string', format: 'uuid'),
|
||||||
new OA\Property(property: 'redirect_url', type: 'string', format: 'uri'),
|
new OA\Property(property: 'pay_url', type: 'string', format: 'uri', description: 'مرورگر به این آدرس بکاند هدایت شود؛ بکاند به درگاه منتقل میکند'),
|
||||||
new OA\Property(property: 'order_id', type: 'string'),
|
new OA\Property(property: 'order_id', type: 'string'),
|
||||||
],
|
],
|
||||||
type: 'object'
|
type: 'object'
|
||||||
@@ -92,63 +75,8 @@ class PaymentController extends BaseController
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
new OA\Response(
|
new OA\Response(response: 401, description: 'Unauthorized'),
|
||||||
response: 401,
|
new OA\Response(response: 422, description: 'Validation error'),
|
||||||
description: 'Unauthorized',
|
|
||||||
content: new OA\JsonContent(
|
|
||||||
properties: [
|
|
||||||
new OA\Property(property: 'success', type: 'boolean', example: false),
|
|
||||||
new OA\Property(
|
|
||||||
property: 'errors',
|
|
||||||
type: 'array',
|
|
||||||
items: new OA\Items(
|
|
||||||
properties: [
|
|
||||||
new OA\Property(property: 'code', type: 'string'),
|
|
||||||
new OA\Property(property: 'message', type: 'string'),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
),
|
|
||||||
new OA\Response(
|
|
||||||
response: 422,
|
|
||||||
description: 'Validation error',
|
|
||||||
content: new OA\JsonContent(
|
|
||||||
properties: [
|
|
||||||
new OA\Property(property: 'success', type: 'boolean', example: false),
|
|
||||||
new OA\Property(
|
|
||||||
property: 'errors',
|
|
||||||
type: 'array',
|
|
||||||
items: new OA\Items(
|
|
||||||
properties: [
|
|
||||||
new OA\Property(property: 'code', type: 'string'),
|
|
||||||
new OA\Property(property: 'message', type: 'string'),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
),
|
|
||||||
new OA\Response(
|
|
||||||
response: 503,
|
|
||||||
description: 'Gateway unavailable',
|
|
||||||
content: new OA\JsonContent(
|
|
||||||
properties: [
|
|
||||||
new OA\Property(property: 'success', type: 'boolean', example: false),
|
|
||||||
new OA\Property(
|
|
||||||
property: 'errors',
|
|
||||||
type: 'array',
|
|
||||||
items: new OA\Items(
|
|
||||||
properties: [
|
|
||||||
new OA\Property(property: 'code', type: 'string'),
|
|
||||||
new OA\Property(property: 'message', type: 'string'),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
),
|
|
||||||
]
|
]
|
||||||
)]
|
)]
|
||||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||||
@@ -180,7 +108,7 @@ class PaymentController extends BaseController
|
|||||||
|
|
||||||
// اعتبارسنجی اولیهٔ درگاه (فعال/معتبر بودن)؛ ارتباط با بانک اینجا انجام
|
// اعتبارسنجی اولیهٔ درگاه (فعال/معتبر بودن)؛ ارتباط با بانک اینجا انجام
|
||||||
// نمیشود — در GET /payment/pay هنگام انتقال مرورگر به درگاه انجام میگیرد.
|
// نمیشود — در GET /payment/pay هنگام انتقال مرورگر به درگاه انجام میگیرد.
|
||||||
if ($this->resolveGateway($gatewayName) === null) {
|
if ($this->gateways->resolve($gatewayName) === null) {
|
||||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'درگاه پرداخت نامعتبر یا غیرفعال است', 422, 'gateway');
|
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'درگاه پرداخت نامعتبر یا غیرفعال است', 422, 'gateway');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,35 +152,12 @@ class PaymentController extends BaseController
|
|||||||
return $this->redirectToFrontend($payment, $payment->getStatus() === Payment::STATUS_SUCCESS);
|
return $this->redirectToFrontend($payment, $payment->getStatus() === Payment::STATUS_SUCCESS);
|
||||||
}
|
}
|
||||||
|
|
||||||
$gatewayName = $payment->getGateway();
|
// ارتباط با بانک + init در سرویس انجام میشود؛ کنترلر فقط انتقال HTTP را میسازد.
|
||||||
$gateway = $this->resolveGateway($gatewayName);
|
$result = $this->paymentManager->startGatewayHandoff($payment);
|
||||||
$testMode = $this->configRepo->get('payment_test_mode') === '1';
|
if ($result === false) {
|
||||||
|
|
||||||
if ($gateway === null || (!$testMode && $this->circuitBreaker->isOpen($gatewayName))) {
|
|
||||||
$payment->setStatus(Payment::STATUS_FAILED);
|
|
||||||
$this->paymentRepo->save($payment);
|
|
||||||
return $this->redirectToFrontend($payment, false);
|
return $this->redirectToFrontend($payment, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ارتباط با بانک (init) از سمت بکاند انجام میشود.
|
|
||||||
$callbackUrl = $this->appBaseUrl . '/api/v1/payment/callback/' . $gatewayName . '?order_id=' . $payment->getOrderId();
|
|
||||||
$result = $gateway->initiate($payment->getAmountRials(), $payment->getOrderId(), $callbackUrl);
|
|
||||||
|
|
||||||
if (!$result->success) {
|
|
||||||
if (!$testMode) {
|
|
||||||
$this->circuitBreaker->recordFailure($gatewayName);
|
|
||||||
}
|
|
||||||
$payment->setStatus(Payment::STATUS_FAILED);
|
|
||||||
$this->paymentRepo->save($payment);
|
|
||||||
return $this->redirectToFrontend($payment, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!$testMode) {
|
|
||||||
$this->circuitBreaker->recordSuccess($gatewayName);
|
|
||||||
}
|
|
||||||
$payment->setGatewayToken($result->token);
|
|
||||||
$this->paymentRepo->save($payment);
|
|
||||||
|
|
||||||
// انتقال مرورگر به درگاه: 302 برای درگاه GET (سپ/mock) یا فرم auto-submit POST (ملت).
|
// انتقال مرورگر به درگاه: 302 برای درگاه GET (سپ/mock) یا فرم auto-submit POST (ملت).
|
||||||
if ($result->redirectMethod === 'POST') {
|
if ($result->redirectMethod === 'POST') {
|
||||||
return $this->autoSubmitForm(strtok($result->redirectUrl, '?'), $result->redirectParams);
|
return $this->autoSubmitForm(strtok($result->redirectUrl, '?'), $result->redirectParams);
|
||||||
@@ -313,75 +218,21 @@ HTML;
|
|||||||
#[Route('/api/v1/payment/callback/{gateway}', methods: ['POST', 'GET'])]
|
#[Route('/api/v1/payment/callback/{gateway}', methods: ['POST', 'GET'])]
|
||||||
public function callback(string $gateway, Request $request): \Symfony\Component\HttpFoundation\Response
|
public function callback(string $gateway, Request $request): \Symfony\Component\HttpFoundation\Response
|
||||||
{
|
{
|
||||||
$clientIp = $request->getClientIp() ?? '';
|
$clientIp = $request->getClientIp() ?? '';
|
||||||
$isTestMode = $this->configRepo->get('payment_test_mode') === '1';
|
if (!$this->gateways->isTestMode() && !$this->isAllowedCallbackIp($clientIp)) {
|
||||||
if (!$isTestMode && !$this->isAllowedCallbackIp($clientIp)) {
|
|
||||||
return new JsonResponse(['success' => false, 'message' => 'دسترسی ممنوع'], 403);
|
return new JsonResponse(['success' => false, 'message' => 'دسترسی ممنوع'], 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
$callbackData = array_merge($request->query->all(), $request->request->all());
|
$callbackData = array_merge($request->query->all(), $request->request->all());
|
||||||
$orderId = $callbackData['order_id'] ?? $callbackData['ResNum'] ?? '';
|
$orderId = $callbackData['order_id'] ?? $callbackData['ResNum'] ?? '';
|
||||||
|
|
||||||
$payment = $this->paymentRepo->findByOrderId($orderId);
|
// verify امن (transaction + قفل + idempotent + post-action + log) در سرویس.
|
||||||
|
$payment = $this->paymentManager->processCallback($gateway, $callbackData, $clientIp, $orderId);
|
||||||
if ($payment === null) {
|
if ($payment === null) {
|
||||||
return new JsonResponse(['success' => false, 'message' => 'payment not found'], 404);
|
return new JsonResponse(['success' => false, 'message' => 'payment not found'], 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
$payment->setCallbackIp($clientIp);
|
return $this->redirectToFrontend($payment, $payment->getStatus() === Payment::STATUS_SUCCESS);
|
||||||
|
|
||||||
$gw = $this->resolveGateway($gateway);
|
|
||||||
$result = $gw?->verify($callbackData) ?? null;
|
|
||||||
|
|
||||||
if ($result === null || !$result->success) {
|
|
||||||
$canceled = $result !== null && $result->canceled;
|
|
||||||
$payment->setStatus($canceled ? Payment::STATUS_CANCELED : Payment::STATUS_FAILED);
|
|
||||||
$this->paymentRepo->save($payment);
|
|
||||||
if (!$canceled) {
|
|
||||||
$this->circuitBreaker->recordFailure($gateway);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->redirectToFrontend($payment, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->circuitBreaker->recordSuccess($gateway);
|
|
||||||
|
|
||||||
// Gateway-confirmed amount must match the amount we charged. Gateways that
|
|
||||||
// report the settled amount (SEP: AffectiveAmount) let us catch an
|
|
||||||
// underpayment / RefNum-replay; gateways that don't report it bind the
|
|
||||||
// amount server-side to the original request, so amountRials is 0 here.
|
|
||||||
if ($result->amountRials > 0 && $result->amountRials !== $payment->getAmountRials()) {
|
|
||||||
$payment->setStatus(Payment::STATUS_FAILED);
|
|
||||||
$this->paymentRepo->save($payment);
|
|
||||||
|
|
||||||
return $this->redirectToFrontend($payment, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
// A gateway reference identifies exactly one settled transaction. If it
|
|
||||||
// already belongs to another payment, this is a replay — reject it. The
|
|
||||||
// unique DB index on reference_id is the hard backstop behind this check.
|
|
||||||
if ($result->referenceId !== '') {
|
|
||||||
$owner = $this->paymentRepo->findByReferenceId($result->referenceId);
|
|
||||||
if ($owner !== null && $owner->getId() !== $payment->getId()) {
|
|
||||||
$payment->setStatus(Payment::STATUS_FAILED);
|
|
||||||
$this->paymentRepo->save($payment);
|
|
||||||
|
|
||||||
return $this->redirectToFrontend($payment, false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$payment->setStatus(Payment::STATUS_SUCCESS);
|
|
||||||
$payment->setReferenceId($result->referenceId);
|
|
||||||
$this->paymentRepo->save($payment);
|
|
||||||
|
|
||||||
if ($payment->getType() === Payment::TYPE_SUBSCRIPTION) {
|
|
||||||
$this->handleSubscriptionActivation($payment);
|
|
||||||
} elseif ($payment->getType() === Payment::TYPE_SMS_WALLET) {
|
|
||||||
$this->handleSmsWalletCharge($payment);
|
|
||||||
} elseif ($payment->getType() === Payment::TYPE_APPOINTMENT) {
|
|
||||||
$this->handleAppointmentConfirmation($payment);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->redirectToFrontend($payment, true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Subscription Payment ──────────────────────────────────────────────────
|
// ── Subscription Payment ──────────────────────────────────────────────────
|
||||||
@@ -412,7 +263,7 @@ HTML;
|
|||||||
property: 'data',
|
property: 'data',
|
||||||
properties: [
|
properties: [
|
||||||
new OA\Property(property: 'payment_uuid', type: 'string', format: 'uuid'),
|
new OA\Property(property: 'payment_uuid', type: 'string', format: 'uuid'),
|
||||||
new OA\Property(property: 'redirect_url', type: 'string', format: 'uri'),
|
new OA\Property(property: 'pay_url', type: 'string', format: 'uri'),
|
||||||
new OA\Property(property: 'order_id', type: 'string'),
|
new OA\Property(property: 'order_id', type: 'string'),
|
||||||
],
|
],
|
||||||
type: 'object'
|
type: 'object'
|
||||||
@@ -420,63 +271,8 @@ HTML;
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
new OA\Response(
|
new OA\Response(response: 401, description: 'Unauthorized'),
|
||||||
response: 401,
|
new OA\Response(response: 422, description: 'Validation error'),
|
||||||
description: 'Unauthorized',
|
|
||||||
content: new OA\JsonContent(
|
|
||||||
properties: [
|
|
||||||
new OA\Property(property: 'success', type: 'boolean', example: false),
|
|
||||||
new OA\Property(
|
|
||||||
property: 'errors',
|
|
||||||
type: 'array',
|
|
||||||
items: new OA\Items(
|
|
||||||
properties: [
|
|
||||||
new OA\Property(property: 'code', type: 'string'),
|
|
||||||
new OA\Property(property: 'message', type: 'string'),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
),
|
|
||||||
new OA\Response(
|
|
||||||
response: 422,
|
|
||||||
description: 'Validation error',
|
|
||||||
content: new OA\JsonContent(
|
|
||||||
properties: [
|
|
||||||
new OA\Property(property: 'success', type: 'boolean', example: false),
|
|
||||||
new OA\Property(
|
|
||||||
property: 'errors',
|
|
||||||
type: 'array',
|
|
||||||
items: new OA\Items(
|
|
||||||
properties: [
|
|
||||||
new OA\Property(property: 'code', type: 'string'),
|
|
||||||
new OA\Property(property: 'message', type: 'string'),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
),
|
|
||||||
new OA\Response(
|
|
||||||
response: 503,
|
|
||||||
description: 'Gateway unavailable',
|
|
||||||
content: new OA\JsonContent(
|
|
||||||
properties: [
|
|
||||||
new OA\Property(property: 'success', type: 'boolean', example: false),
|
|
||||||
new OA\Property(
|
|
||||||
property: 'errors',
|
|
||||||
type: 'array',
|
|
||||||
items: new OA\Items(
|
|
||||||
properties: [
|
|
||||||
new OA\Property(property: 'code', type: 'string'),
|
|
||||||
new OA\Property(property: 'message', type: 'string'),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
),
|
|
||||||
]
|
]
|
||||||
)]
|
)]
|
||||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||||
@@ -496,15 +292,10 @@ HTML;
|
|||||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'آدرس بازگشت مجاز نیست', 422, 'frontend_address');
|
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'آدرس بازگشت مجاز نیست', 422, 'frontend_address');
|
||||||
}
|
}
|
||||||
|
|
||||||
$gateway = $this->resolveGateway($gatewayName);
|
if ($this->gateways->resolve($gatewayName) === null) {
|
||||||
if ($gateway === null) {
|
|
||||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'درگاه پرداخت نامعتبر یا غیرفعال است', 422, 'gateway');
|
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'درگاه پرداخت نامعتبر یا غیرفعال است', 422, 'gateway');
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->circuitBreaker->isOpen($gatewayName)) {
|
|
||||||
return $this->error(ErrorCodes::ERR_PAYMENT_001, ErrorCodes::message(ErrorCodes::ERR_PAYMENT_001), 503);
|
|
||||||
}
|
|
||||||
|
|
||||||
$periodUuid = trim($data['period_uuid'] ?? '');
|
$periodUuid = trim($data['period_uuid'] ?? '');
|
||||||
$payment = new Payment($user, $amountRials, $gatewayName, Payment::TYPE_SUBSCRIPTION, $frontendAddress);
|
$payment = new Payment($user, $amountRials, $gatewayName, Payment::TYPE_SUBSCRIPTION, $frontendAddress);
|
||||||
if ($periodUuid !== '') {
|
if ($periodUuid !== '') {
|
||||||
@@ -512,21 +303,10 @@ HTML;
|
|||||||
}
|
}
|
||||||
$this->paymentRepo->save($payment);
|
$this->paymentRepo->save($payment);
|
||||||
|
|
||||||
$callbackUrl = $this->appBaseUrl . '/api/v1/subscription-payment/callback/' . $gatewayName . '?order_id=' . $payment->getOrderId();
|
// مثل appointment: ارتباط با بانک اینجا نیست؛ در GET /payment/pay انجام میشود.
|
||||||
$result = $gateway->initiate($amountRials, $payment->getOrderId(), $callbackUrl);
|
|
||||||
|
|
||||||
if (!$result->success) {
|
|
||||||
$this->circuitBreaker->recordFailure($gatewayName);
|
|
||||||
return $this->error(ErrorCodes::ERR_PAYMENT_001, $result->errorMessage, 503);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->circuitBreaker->recordSuccess($gatewayName);
|
|
||||||
$payment->setGatewayToken($result->token);
|
|
||||||
$this->paymentRepo->save($payment);
|
|
||||||
|
|
||||||
return $this->success([
|
return $this->success([
|
||||||
'payment_uuid' => $payment->getUuid(),
|
'payment_uuid' => $payment->getUuid(),
|
||||||
'redirect_url' => $result->redirectUrl,
|
'pay_url' => $this->appBaseUrl . '/api/v1/payment/pay/' . $payment->getOrderId(),
|
||||||
'order_id' => $payment->getOrderId(),
|
'order_id' => $payment->getOrderId(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -588,18 +368,12 @@ HTML;
|
|||||||
new OA\Property(
|
new OA\Property(
|
||||||
property: 'data',
|
property: 'data',
|
||||||
properties: [
|
properties: [
|
||||||
new OA\Property(
|
new OA\Property(property: 'uuid', type: 'string', format: 'uuid'),
|
||||||
property: 'data',
|
new OA\Property(property: 'status', type: 'string'),
|
||||||
properties: [
|
new OA\Property(property: 'amount_rials', type: 'integer'),
|
||||||
new OA\Property(property: 'uuid', type: 'string', format: 'uuid'),
|
new OA\Property(property: 'gateway', type: 'string'),
|
||||||
new OA\Property(property: 'status', type: 'string'),
|
new OA\Property(property: 'reference_id', type: 'string', nullable: true),
|
||||||
new OA\Property(property: 'amount_rials', type: 'integer'),
|
new OA\Property(property: 'created_at', type: 'integer'),
|
||||||
new OA\Property(property: 'gateway', type: 'string'),
|
|
||||||
new OA\Property(property: 'reference_id', type: 'string', nullable: true),
|
|
||||||
new OA\Property(property: 'created_at', type: 'string', format: 'date-time'),
|
|
||||||
],
|
|
||||||
type: 'object'
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
type: 'object'
|
type: 'object'
|
||||||
),
|
),
|
||||||
@@ -615,36 +389,13 @@ HTML;
|
|||||||
#[Route('/api/v1/payment/config', methods: ['GET'])]
|
#[Route('/api/v1/payment/config', methods: ['GET'])]
|
||||||
public function config(): JsonResponse
|
public function config(): JsonResponse
|
||||||
{
|
{
|
||||||
$testMode = $this->configRepo->get('payment_test_mode') === '1';
|
|
||||||
|
|
||||||
return $this->success([
|
return $this->success([
|
||||||
'test_mode' => $testMode,
|
'test_mode' => $this->gateways->isTestMode(),
|
||||||
'appointment_fee_rials' => (int) ($this->configRepo->get('appointment_fee_rials') ?: 0),
|
'appointment_fee_rials' => (int) ($this->configRepo->get('appointment_fee_rials') ?: 0),
|
||||||
'gateways' => $this->activeGateways($testMode),
|
'gateways' => $this->gateways->activeGateways(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* درگاههای قابلانتخاب: در حالت تست فقط درگاه آزمایشی، در غیر این صورت هر درگاهی که اعتبارنامهاش ست شده.
|
|
||||||
* @return array<int, array{name: string, label: string}>
|
|
||||||
*/
|
|
||||||
private function activeGateways(bool $testMode): array
|
|
||||||
{
|
|
||||||
if ($testMode) {
|
|
||||||
return [['name' => 'mellat', 'label' => 'بانک ملت (آزمایشی)']];
|
|
||||||
}
|
|
||||||
|
|
||||||
$labels = ['mellat' => 'بانک ملت', 'sep' => 'سپ (سامان کیش)'];
|
|
||||||
$gateways = [];
|
|
||||||
foreach ([$this->mellat, $this->sep] as $gateway) {
|
|
||||||
$name = $gateway->getName();
|
|
||||||
if ($gateway->isConfigured() && $this->isGatewayEnabled($name)) {
|
|
||||||
$gateways[] = ['name' => $name, 'label' => $labels[$name] ?? $name];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return $gateways;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||||
#[Route('/api/v1/my/payments', methods: ['GET'])]
|
#[Route('/api/v1/my/payments', methods: ['GET'])]
|
||||||
public function myPayments(Request $request, #[CurrentUser] User $user): JsonResponse
|
public function myPayments(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||||
@@ -675,35 +426,11 @@ HTML;
|
|||||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->success(['data' => $payment->toArray()]);
|
return $this->success($payment->toArray());
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Private helpers ───────────────────────────────────────────────────────
|
// ── Private helpers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
private function resolveGateway(string $name): \App\Payment\Gateway\PaymentGatewayInterface|null
|
|
||||||
{
|
|
||||||
if ($this->configRepo->get('payment_test_mode') === '1') {
|
|
||||||
return $this->mock;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!$this->isGatewayEnabled($name)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return match ($name) {
|
|
||||||
'mellat' => $this->mellat,
|
|
||||||
'sep' => $this->sep,
|
|
||||||
default => null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** آیا این درگاه در تنظیمات فعال است؟ کلید تنظیمنشده = فعال (سازگاری با نصبهای قبلی). */
|
|
||||||
private function isGatewayEnabled(string $name): bool
|
|
||||||
{
|
|
||||||
$v = $this->configRepo->get($name . '_enabled');
|
|
||||||
return $v === null || $v === '1';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return string[] allowed frontend hosts — from SiteConfig, falling back to env. */
|
/** @return string[] allowed frontend hosts — from SiteConfig, falling back to env. */
|
||||||
private function allowedHosts(): array
|
private function allowedHosts(): array
|
||||||
{
|
{
|
||||||
@@ -743,76 +470,6 @@ HTML;
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function handleSmsWalletCharge(Payment $payment): void
|
|
||||||
{
|
|
||||||
$meta = $payment->getMetadata() ?? [];
|
|
||||||
$entityType = $meta['entity_type'] ?? null;
|
|
||||||
$entityId = isset($meta['entity_id']) ? (int) $meta['entity_id'] : null;
|
|
||||||
|
|
||||||
if ($entityType === null || $entityId === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$wallet = $this->smsWalletService->getOrCreate($entityType, $entityId);
|
|
||||||
$this->smsWalletService->charge($wallet, $payment->getAmountRials(), $payment);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function handleAppointmentConfirmation(Payment $payment): void
|
|
||||||
{
|
|
||||||
$appointment = $payment->getAppointment();
|
|
||||||
if ($appointment === null || !$appointment->canTransitionTo(Appointment::STATUS_CONFIRMED)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$appointment->transitionTo(Appointment::STATUS_CONFIRMED);
|
|
||||||
$this->appointmentRepo->save($appointment);
|
|
||||||
|
|
||||||
$doctor = $appointment->getDoctor();
|
|
||||||
$this->commissionService->processAppointment(
|
|
||||||
$payment,
|
|
||||||
$doctor->getRepresentationId(),
|
|
||||||
$appointment->getBookingRepresentationId(),
|
|
||||||
$doctor->getId(),
|
|
||||||
);
|
|
||||||
|
|
||||||
$mobile = $appointment->getPatientMobile();
|
|
||||||
if ($mobile) {
|
|
||||||
$when = $this->jalali->formatDateTime($appointment->getSlotStart());
|
|
||||||
$message = $this->smsText->resolve(\App\Sms\Entity\SmsLog::TAG_PAYMENT, [
|
|
||||||
'doctor' => $appointment->getDoctor()->getName(),
|
|
||||||
'date' => $when,
|
|
||||||
]);
|
|
||||||
$this->smsService->dispatchAsync(
|
|
||||||
$mobile,
|
|
||||||
$message,
|
|
||||||
tag: \App\Sms\Entity\SmsLog::TAG_PAYMENT,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function handleSubscriptionActivation(Payment $payment): void
|
|
||||||
{
|
|
||||||
$meta = $payment->getMetadata() ?? [];
|
|
||||||
$periodUuid = $meta['period_uuid'] ?? null;
|
|
||||||
if ($periodUuid === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$user = $payment->getUser();
|
|
||||||
$doctor = $this->doctorRepo->findByUser($user);
|
|
||||||
if ($doctor !== null) {
|
|
||||||
$this->subscriptionService->createFromPayment($payment, 'doctor', $doctor->getId(), $periodUuid);
|
|
||||||
$this->commissionService->processSubscription($payment, $doctor->getRepresentationId(), $doctor->getId(), null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$clinic = $this->clinicRepo->findByUser($user);
|
|
||||||
if ($clinic !== null) {
|
|
||||||
$this->subscriptionService->createFromPayment($payment, 'clinic', $clinic->getId(), $periodUuid);
|
|
||||||
$this->commissionService->processSubscription($payment, $clinic->getRepresentationId(), null, $clinic->getId());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function redirectToFrontend(Payment $payment, bool $success): \Symfony\Component\HttpFoundation\Response
|
private function redirectToFrontend(Payment $payment, bool $success): \Symfony\Component\HttpFoundation\Response
|
||||||
{
|
{
|
||||||
$base = $payment->getFrontendAddress();
|
$base = $payment->getFrontendAddress();
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Payment\Entity;
|
||||||
|
|
||||||
|
use App\Payment\Repository\PaymentLogRepository;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ردپای حسابرسی (audit trail) هر گام از چرخهٔ پرداخت.
|
||||||
|
* اعتبارنامهٔ درگاه هرگز اینجا ذخیره نمیشود.
|
||||||
|
*/
|
||||||
|
#[ORM\Entity(repositoryClass: PaymentLogRepository::class)]
|
||||||
|
#[ORM\Table(name: 'payment_logs')]
|
||||||
|
#[ORM\Index(columns: ['payment_id'], name: 'idx_payment_logs_payment')]
|
||||||
|
class PaymentLog
|
||||||
|
{
|
||||||
|
public const ACTION_INITIATE = 'initiate';
|
||||||
|
public const ACTION_VERIFY = 'verify';
|
||||||
|
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column(type: 'integer')]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\Column(name: 'payment_id', type: 'integer')]
|
||||||
|
private int $paymentId;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'string', length: 20)]
|
||||||
|
private string $action;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'string', length: 20)]
|
||||||
|
private string $gateway;
|
||||||
|
|
||||||
|
/** نتیجهٔ گام: success | failed | canceled | pending */
|
||||||
|
#[ORM\Column(type: 'string', length: 20)]
|
||||||
|
private string $result;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||||
|
private ?string $authority = null;
|
||||||
|
|
||||||
|
#[ORM\Column(name: 'client_ip', type: 'string', length: 45, nullable: true)]
|
||||||
|
private ?string $clientIp = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'json', nullable: true)]
|
||||||
|
private ?array $payload = null;
|
||||||
|
|
||||||
|
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||||
|
private int $createdAt;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
int $paymentId,
|
||||||
|
string $action,
|
||||||
|
string $gateway,
|
||||||
|
string $result,
|
||||||
|
?string $authority = null,
|
||||||
|
?string $clientIp = null,
|
||||||
|
?array $payload = null,
|
||||||
|
) {
|
||||||
|
$this->paymentId = $paymentId;
|
||||||
|
$this->action = $action;
|
||||||
|
$this->gateway = $gateway;
|
||||||
|
$this->result = $result;
|
||||||
|
$this->authority = $authority;
|
||||||
|
$this->clientIp = $clientIp;
|
||||||
|
$this->payload = $payload;
|
||||||
|
$this->createdAt = time();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getId(): ?int { return $this->id; }
|
||||||
|
public function getPaymentId(): int { return $this->paymentId; }
|
||||||
|
public function getAction(): string { return $this->action; }
|
||||||
|
public function getResult(): string { return $this->result; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Payment\Gateway;
|
||||||
|
|
||||||
|
use App\Config\Repository\SiteConfigRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* انتخاب/ساخت درگاه پرداخت (Factory + Strategy).
|
||||||
|
*
|
||||||
|
* تمام منطقِ «کدام درگاه، فعال است یا نه، حالت تست» اینجا متمرکز است تا کنترلر
|
||||||
|
* فقط Orchestration کند. افزودن درگاه جدید = ثبت آن بهعنوان سرویس و اضافهکردن
|
||||||
|
* یک case در map + یک برچسب، بدون تغییر کنترلر.
|
||||||
|
*/
|
||||||
|
class GatewayFactory
|
||||||
|
{
|
||||||
|
/** @var array<string, PaymentGatewayInterface> */
|
||||||
|
private array $gateways;
|
||||||
|
|
||||||
|
/** @var array<string, string> برچسب فارسی هر درگاه */
|
||||||
|
private const LABELS = [
|
||||||
|
'mellat' => 'بانک ملت',
|
||||||
|
'sep' => 'سپ (سامان کیش)',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
MellatGateway $mellat,
|
||||||
|
SepGateway $sep,
|
||||||
|
private readonly MockGateway $mock,
|
||||||
|
private readonly SiteConfigRepository $configRepo,
|
||||||
|
) {
|
||||||
|
$this->gateways = [
|
||||||
|
$mellat->getName() => $mellat,
|
||||||
|
$sep->getName() => $sep,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isTestMode(): bool
|
||||||
|
{
|
||||||
|
return $this->configRepo->get('payment_test_mode') === '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* درگاهِ قابلاستفاده برای این نام؛ در حالت تست همیشه Mock، در غیر اینصورت
|
||||||
|
* درگاه واقعی در صورت فعال بودن. null یعنی نامعتبر/غیرفعال.
|
||||||
|
*/
|
||||||
|
public function resolve(string $name): ?PaymentGatewayInterface
|
||||||
|
{
|
||||||
|
if ($this->isTestMode()) {
|
||||||
|
return $this->mock;
|
||||||
|
}
|
||||||
|
if (!$this->isEnabled($name)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return $this->gateways[$name] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** آیا درگاه در تنظیمات فعال است؟ کلید تنظیمنشده = فعال (سازگاری با نصبهای قبلی). */
|
||||||
|
public function isEnabled(string $name): bool
|
||||||
|
{
|
||||||
|
$v = $this->configRepo->get($name . '_enabled');
|
||||||
|
return $v === null || $v === '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* درگاههای قابلانتخاب برای نمایش به کاربر: در حالت تست فقط درگاه آزمایشی،
|
||||||
|
* در غیر اینصورت هر درگاهی که اعتبارنامهاش ست شده و فعال است.
|
||||||
|
*
|
||||||
|
* @return array<int, array{name: string, label: string}>
|
||||||
|
*/
|
||||||
|
public function activeGateways(): array
|
||||||
|
{
|
||||||
|
if ($this->isTestMode()) {
|
||||||
|
return [['name' => 'mellat', 'label' => 'بانک ملت (آزمایشی)']];
|
||||||
|
}
|
||||||
|
|
||||||
|
$out = [];
|
||||||
|
foreach ($this->gateways as $name => $gateway) {
|
||||||
|
if ($gateway->isConfigured() && $this->isEnabled($name)) {
|
||||||
|
$out[] = ['name' => $name, 'label' => self::LABELS[$name] ?? $name];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Payment\Repository;
|
||||||
|
|
||||||
|
use App\Payment\Entity\PaymentLog;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
class PaymentLogRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, PaymentLog::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function save(PaymentLog $entity, bool $flush = true): void
|
||||||
|
{
|
||||||
|
$this->getEntityManager()->persist($entity);
|
||||||
|
if ($flush) $this->getEntityManager()->flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,6 +59,19 @@ class PaymentRepository extends ServiceEntityRepository
|
|||||||
return $this->findOneBy(['referenceId' => $referenceId]);
|
return $this->findOneBy(['referenceId' => $referenceId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* قفل بدبینانه روی ردیف پرداخت (باید داخل یک transaction فعال صدا زده شود).
|
||||||
|
* برای جلوگیری از verify همزمانِ دو callback (race / double-verify).
|
||||||
|
*/
|
||||||
|
public function findByOrderIdForUpdate(string $orderId): ?Payment
|
||||||
|
{
|
||||||
|
return $this->createQueryBuilder('p')
|
||||||
|
->where('p.orderId = :o')->setParameter('o', $orderId)
|
||||||
|
->getQuery()
|
||||||
|
->setLockMode(\Doctrine\DBAL\LockMode::PESSIMISTIC_WRITE)
|
||||||
|
->getOneOrNullResult();
|
||||||
|
}
|
||||||
|
|
||||||
public function findPendingByAppointment(Appointment $appointment): ?Payment
|
public function findPendingByAppointment(Appointment $appointment): ?Payment
|
||||||
{
|
{
|
||||||
return $this->findOneBy([
|
return $this->findOneBy([
|
||||||
|
|||||||
@@ -0,0 +1,268 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Payment\Service;
|
||||||
|
|
||||||
|
use App\Appointment\Entity\Appointment;
|
||||||
|
use App\Appointment\Repository\AppointmentRepository;
|
||||||
|
use App\Clinic\Repository\ClinicRepository;
|
||||||
|
use App\Doctor\Repository\DoctorRepository;
|
||||||
|
use App\Payment\Entity\Payment;
|
||||||
|
use App\Payment\Entity\PaymentLog;
|
||||||
|
use App\Payment\Gateway\GatewayFactory;
|
||||||
|
use App\Payment\Gateway\PaymentInitResult;
|
||||||
|
use App\Payment\Repository\PaymentLogRepository;
|
||||||
|
use App\Payment\Repository\PaymentRepository;
|
||||||
|
use App\Representation\Service\JalaliDateService;
|
||||||
|
use App\Settlement\Service\CommissionService;
|
||||||
|
use App\Sms\Entity\SmsLog;
|
||||||
|
use App\Sms\Service\SmsService;
|
||||||
|
use App\Sms\Service\SmsTextResolver;
|
||||||
|
use App\Sms\Service\SmsWalletService;
|
||||||
|
use App\Subscription\Service\SubscriptionService;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* تمام منطق پرداخت (ارتباط با بانک + verify + post-action) اینجاست تا کنترلر فقط
|
||||||
|
* Orchestration کند. verify امن داخل transaction با قفل بدبینانه انجام میشود.
|
||||||
|
*/
|
||||||
|
final class PaymentManager
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly GatewayFactory $gateways,
|
||||||
|
private readonly PaymentRepository $paymentRepo,
|
||||||
|
private readonly PaymentLogRepository $paymentLogRepo,
|
||||||
|
private readonly CircuitBreakerService $circuitBreaker,
|
||||||
|
private readonly EntityManagerInterface $em,
|
||||||
|
private readonly LoggerInterface $logger,
|
||||||
|
private readonly SubscriptionService $subscriptionService,
|
||||||
|
private readonly SmsWalletService $smsWalletService,
|
||||||
|
private readonly SmsService $smsService,
|
||||||
|
private readonly SmsTextResolver $smsText,
|
||||||
|
private readonly DoctorRepository $doctorRepo,
|
||||||
|
private readonly ClinicRepository $clinicRepo,
|
||||||
|
private readonly CommissionService $commissionService,
|
||||||
|
private readonly JalaliDateService $jalali,
|
||||||
|
private readonly string $appBaseUrl,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* درگاه را برای یک پرداخت pending init میکند (ارتباط با بانک).
|
||||||
|
* موفق → PaymentInitResult؛ ناموفق → false (پرداخت failed و ذخیرهشده).
|
||||||
|
*/
|
||||||
|
public function startGatewayHandoff(Payment $payment): PaymentInitResult|false
|
||||||
|
{
|
||||||
|
$gatewayName = $payment->getGateway();
|
||||||
|
$gateway = $this->gateways->resolve($gatewayName);
|
||||||
|
$testMode = $this->gateways->isTestMode();
|
||||||
|
|
||||||
|
if ($gateway === null || (!$testMode && $this->circuitBreaker->isOpen($gatewayName))) {
|
||||||
|
$this->failPayment($payment, PaymentLog::ACTION_INITIATE, ['reason' => 'gateway_unavailable']);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$callbackUrl = $this->callbackUrl($payment);
|
||||||
|
$result = $gateway->initiate($payment->getAmountRials(), $payment->getOrderId(), $callbackUrl);
|
||||||
|
|
||||||
|
if (!$result->success) {
|
||||||
|
if (!$testMode) {
|
||||||
|
$this->circuitBreaker->recordFailure($gatewayName);
|
||||||
|
}
|
||||||
|
$this->failPayment($payment, PaymentLog::ACTION_INITIATE, ['error' => $result->errorMessage]);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$testMode) {
|
||||||
|
$this->circuitBreaker->recordSuccess($gatewayName);
|
||||||
|
}
|
||||||
|
$payment->setGatewayToken($result->token);
|
||||||
|
$this->paymentRepo->save($payment);
|
||||||
|
$this->log($payment, PaymentLog::ACTION_INITIATE, 'success', $result->token, null, ['token' => $result->token]);
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* verify امنِ callback داخل transaction + قفل ردیف. idempotent: اگر پرداخت
|
||||||
|
* قبلاً نهایی شده باشد بدون پردازش دوباره همان را برمیگرداند.
|
||||||
|
* null یعنی پرداخت یافت نشد.
|
||||||
|
*/
|
||||||
|
public function processCallback(string $gatewayName, array $callbackData, string $clientIp, string $orderId): ?Payment
|
||||||
|
{
|
||||||
|
return $this->em->wrapInTransaction(function () use ($gatewayName, $callbackData, $clientIp, $orderId): ?Payment {
|
||||||
|
$payment = $this->paymentRepo->findByOrderIdForUpdate($orderId);
|
||||||
|
if ($payment === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// جلوگیری از verify تکراری / race: فقط پرداخت pending پردازش میشود.
|
||||||
|
if ($payment->getStatus() !== Payment::STATUS_PENDING) {
|
||||||
|
return $payment;
|
||||||
|
}
|
||||||
|
|
||||||
|
$payment->setCallbackIp($clientIp);
|
||||||
|
|
||||||
|
$gateway = $this->gateways->resolve($gatewayName);
|
||||||
|
$result = $gateway?->verify($callbackData);
|
||||||
|
|
||||||
|
if ($result === null || !$result->success) {
|
||||||
|
$canceled = $result !== null && $result->canceled;
|
||||||
|
$payment->setStatus($canceled ? Payment::STATUS_CANCELED : Payment::STATUS_FAILED);
|
||||||
|
$this->em->persist($payment);
|
||||||
|
if (!$canceled) {
|
||||||
|
$this->circuitBreaker->recordFailure($gatewayName);
|
||||||
|
}
|
||||||
|
$this->log($payment, PaymentLog::ACTION_VERIFY, $payment->getStatus(), null, $clientIp, $this->sanitize($callbackData));
|
||||||
|
return $payment;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->circuitBreaker->recordSuccess($gatewayName);
|
||||||
|
|
||||||
|
// مبلغ تأییدشدهٔ درگاه باید با مبلغِ ثبتشده برابر باشد (ضد underpayment/دستکاری).
|
||||||
|
if ($result->amountRials > 0 && $result->amountRials !== $payment->getAmountRials()) {
|
||||||
|
$payment->setStatus(Payment::STATUS_FAILED);
|
||||||
|
$this->em->persist($payment);
|
||||||
|
$this->log($payment, PaymentLog::ACTION_VERIFY, 'failed', $result->referenceId, $clientIp, ['reason' => 'amount_mismatch']);
|
||||||
|
return $payment;
|
||||||
|
}
|
||||||
|
|
||||||
|
// مرجع درگاه یکتاست؛ اگر متعلق به پرداخت دیگری باشد replay است.
|
||||||
|
if ($result->referenceId !== '') {
|
||||||
|
$owner = $this->paymentRepo->findByReferenceId($result->referenceId);
|
||||||
|
if ($owner !== null && $owner->getId() !== $payment->getId()) {
|
||||||
|
$payment->setStatus(Payment::STATUS_FAILED);
|
||||||
|
$this->em->persist($payment);
|
||||||
|
$this->log($payment, PaymentLog::ACTION_VERIFY, 'failed', $result->referenceId, $clientIp, ['reason' => 'replay']);
|
||||||
|
return $payment;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$payment->setStatus(Payment::STATUS_SUCCESS);
|
||||||
|
$payment->setReferenceId($result->referenceId);
|
||||||
|
$this->em->persist($payment);
|
||||||
|
|
||||||
|
$this->runPostAction($payment);
|
||||||
|
|
||||||
|
$this->log($payment, PaymentLog::ACTION_VERIFY, 'success', $result->referenceId, $clientIp, $this->sanitize($callbackData));
|
||||||
|
return $payment;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function callbackUrl(Payment $payment): string
|
||||||
|
{
|
||||||
|
$prefix = $payment->getType() === Payment::TYPE_SUBSCRIPTION
|
||||||
|
? '/api/v1/subscription-payment/callback/'
|
||||||
|
: '/api/v1/payment/callback/';
|
||||||
|
return $this->appBaseUrl . $prefix . $payment->getGateway() . '?order_id=' . $payment->getOrderId();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Post-actions ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private function runPostAction(Payment $payment): void
|
||||||
|
{
|
||||||
|
match ($payment->getType()) {
|
||||||
|
Payment::TYPE_SUBSCRIPTION => $this->handleSubscriptionActivation($payment),
|
||||||
|
Payment::TYPE_SMS_WALLET => $this->handleSmsWalletCharge($payment),
|
||||||
|
Payment::TYPE_APPOINTMENT => $this->handleAppointmentConfirmation($payment),
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function handleAppointmentConfirmation(Payment $payment): void
|
||||||
|
{
|
||||||
|
$appointment = $payment->getAppointment();
|
||||||
|
if ($appointment === null || !$appointment->canTransitionTo(Appointment::STATUS_CONFIRMED)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$appointment->transitionTo(Appointment::STATUS_CONFIRMED);
|
||||||
|
$this->em->persist($appointment);
|
||||||
|
|
||||||
|
$doctor = $appointment->getDoctor();
|
||||||
|
$this->commissionService->processAppointment(
|
||||||
|
$payment,
|
||||||
|
$doctor->getRepresentationId(),
|
||||||
|
$appointment->getBookingRepresentationId(),
|
||||||
|
$doctor->getId(),
|
||||||
|
);
|
||||||
|
|
||||||
|
$mobile = $appointment->getPatientMobile();
|
||||||
|
if ($mobile) {
|
||||||
|
$when = $this->jalali->formatDateTime($appointment->getSlotStart());
|
||||||
|
$message = $this->smsText->resolve(SmsLog::TAG_PAYMENT, [
|
||||||
|
'doctor' => $doctor->getName(),
|
||||||
|
'date' => $when,
|
||||||
|
]);
|
||||||
|
$this->smsService->dispatchAsync($mobile, $message, tag: SmsLog::TAG_PAYMENT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function handleSubscriptionActivation(Payment $payment): void
|
||||||
|
{
|
||||||
|
$periodUuid = ($payment->getMetadata() ?? [])['period_uuid'] ?? null;
|
||||||
|
if ($periodUuid === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = $payment->getUser();
|
||||||
|
$doctor = $this->doctorRepo->findByUser($user);
|
||||||
|
if ($doctor !== null) {
|
||||||
|
$this->subscriptionService->createFromPayment($payment, 'doctor', $doctor->getId(), $periodUuid);
|
||||||
|
$this->commissionService->processSubscription($payment, $doctor->getRepresentationId(), $doctor->getId(), null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$clinic = $this->clinicRepo->findByUser($user);
|
||||||
|
if ($clinic !== null) {
|
||||||
|
$this->subscriptionService->createFromPayment($payment, 'clinic', $clinic->getId(), $periodUuid);
|
||||||
|
$this->commissionService->processSubscription($payment, $clinic->getRepresentationId(), null, $clinic->getId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function handleSmsWalletCharge(Payment $payment): void
|
||||||
|
{
|
||||||
|
$meta = $payment->getMetadata() ?? [];
|
||||||
|
$entityType = $meta['entity_type'] ?? null;
|
||||||
|
$entityId = isset($meta['entity_id']) ? (int) $meta['entity_id'] : null;
|
||||||
|
if ($entityType === null || $entityId === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$wallet = $this->smsWalletService->getOrCreate($entityType, $entityId);
|
||||||
|
$this->smsWalletService->charge($wallet, $payment->getAmountRials(), $payment);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private function failPayment(Payment $payment, string $action, array $payload): void
|
||||||
|
{
|
||||||
|
$payment->setStatus(Payment::STATUS_FAILED);
|
||||||
|
$this->paymentRepo->save($payment);
|
||||||
|
$this->log($payment, $action, 'failed', null, null, $payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function log(Payment $payment, string $action, string $result, ?string $authority, ?string $clientIp, ?array $payload): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$this->paymentLogRepo->save(new PaymentLog(
|
||||||
|
(int) $payment->getId(),
|
||||||
|
$action,
|
||||||
|
$payment->getGateway(),
|
||||||
|
$result,
|
||||||
|
$authority,
|
||||||
|
$clientIp,
|
||||||
|
$payload,
|
||||||
|
));
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// لاگ نباید جریان پرداخت را بشکند.
|
||||||
|
$this->logger->error('PaymentLog write failed: ' . $e->getMessage(), ['orderId' => $payment->getOrderId()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** حذف کلیدهای حساس احتمالی از payload کالبک قبل از ذخیره. */
|
||||||
|
private function sanitize(array $data): array
|
||||||
|
{
|
||||||
|
unset($data['password'], $data['userPassword'], $data['userName']);
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user