Files
clinicpro/docs/api/payment.md
T
hamed 7716b40f6a feat: implement tax calculations for subscription and SMS wallet payments
- Updated SubscriptionPeriod interface to include tax-related fields: tax_percent, tax_rials, and payable_rials.
- Modified payment API documentation to reflect changes in tax handling for subscriptions and SMS wallet charges.
- Adjusted PaymentController to calculate payment amounts based on subscription period details instead of client input.
- Enhanced PaymentManager to handle net amounts for SMS wallet charges, ensuring tax is not credited to the wallet.
- Created PaymentTaxCalculator and SubscriptionTaxCalculator services to manage tax calculations consistently across payment types.
- Added tests for tax calculations in both subscription and SMS wallet contexts, ensuring correct behavior with and without tax enabled.
- Updated frontend components to display tax information appropriately during payment processes.
2026-08-09 16:51:22 +03:30

456 lines
35 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.
# Payment API
> **Prefix:** `/api/v1/payment`, `/api/v1/subscription-payment`
> **Supported Gateways:** `mellat` (Mellat Bank SOAP) | `sep` (SEP REST)
> **دسترسی منشی:** `GET /api/v1/my/payments` برای `ROLE_SECRETARY` به مجوز `payments.view` نیاز دارد (`SecretaryAccessChecker`)؛ نبودِ مجوز → `403`. جزئیات: [secretary.md](secretary.md).
> **محیط پرداخت:** هر پرداخت جفت `(entity_type, entity_id)` دارد و به محیط **گیرنده** تعلق می‌گیرد، نه به پرداخت‌کننده — نوبت → محیط همان نوبت، اشتراک → محیطی که خریدار صاحبش است، شارژ پیامک → محیط همان کیف پول. بیمار محیطی انتخاب نکرده، پس `TenantFilter` برایش خاموش است و پرداخت خودش را می‌بیند. جزئیات: [architecture/tenancy.md](../architecture/tenancy.md).
---
## معماری (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-check، امنیت با tamper+verify)
│ gateway->verify()؛ بررسی مبلغ؛ جلوگیری از replay (reference_id یکتا)؛ ست وضعیت
│ post-action: برداشتن انقضای نوبت / فعال‌سازی اشتراک / شارژ کیف‌پول + پیامک
│ (۴) 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 بی‌اثر).
**کمیسیون دامنه‌محور (post-action):** نماینده‌ی مبدأ از `payment.frontend_address` با `DomainContextResolver` تعیین می‌شود؛ کمیسیون (نوبت و اشتراک) فقط وقتی ثبت می‌شود که این نماینده فعال باشد **و** پزشک/کلینیک موضوع خرید `representation_id` همان نماینده را داشته باشد — جزئیات در `docs/api/representation.md` §قانون کمیسیون دامنه‌محور.
**یکدستیِ typeها:** هر سه نوع (`appointment`/`subscription`/`sms_wallet`) از همان `GET /payment/pay/{orderId}` عبور می‌کنند و روی همان یک `POST|GET /api/v1/payment/callback` برمی‌گردند؛ `PaymentManager::callbackUrl()` دیگر بر اساس `type` شاخه نمی‌زند. POST این endpointها فقط `Payment` pending می‌سازد و `pay_url` برمی‌گرداند (نه `redirect_url`).
**افزودن درگاه جدید (Open/Closed):** یک کلاس جدید implements `PaymentGatewayInterface` بساز، در `GatewayFactory::$gateways` + `LABELS` ثبت کن. `PaymentController`/`PaymentManager` تغییر نمی‌کنند.
**درگاه ملت (BPM) — نکات پیاده‌سازی طبق مستند رسمی ۱.۳۸:**
- **prod = SOAP با `SoapClient` بومی** روی WSDL عملیاتی `https://bpm.shaparak.ir/pgwchannel/services/pgw?wsdl` (متدها: `bpPayRequest`، `bpVerifyRequest`+`bpSettleRequest` جدا، `bpRefundRequest`، `bpReversalRequest`). WSDL با کلید `mellat_wsdl_url` قابل override است (مثلاً WSDL تستِ pgw.dev). نیازمند اکستنشن `soap` روی سرور. **sandbox = REST** (banktest.ir) بدون تغییر.
- **چک‌لیست عملیاتی prod:** (۱) IP سرور نزد «به‌پرداخت ملت» whitelist شود (وگرنه کد `421`). (۲) `APP_BASE_URL` = دامنهٔ ثبت‌شدهٔ پذیرنده (نه IP؛ وگرنه کد `62`). (۳) اعتبارنامهٔ واقعی در env (`MELLAT_*`) یا `/admin/settings`. (۴) `mellat_sandbox=0` و `payment_test_mode=0`. (۵) پورت‌های ۴۴۳/۸۰ خروجی باز.
- کدهای پاسخ با `mellatMessage()` به پیام فارسی نگاشت می‌شوند.
- `orderId` ملت از نوع **long (عددی)** است؛ به همین دلیل `PaymentManager` هنگام init، **`payment.id` عددی** را به‌عنوان orderId درگاه می‌فرستد (نه رشتهٔ `ORD-…`). جستجوی پرداخت در callback از طریق query `order_id` (رشتهٔ `ORD-…`) انجام می‌شود.
- **verify+settle** با یک فراخوانی `bpVerifySettleRequest` انجام می‌شود و به `saleOrderId` (= همان `payment.id` مرحلهٔ Pay) و `saleReferenceId` (که بانک در callback POST می‌فرستد) نیاز دارد — **نه** `RefId`. کدهای `0`/`43`/`45` (موفق/قبلاً verify/قبلاً settle) موفق تلقی می‌شوند. `reference_id` ذخیره‌شده = `SaleReferenceId`.
- **چک ضد-دستکاری (اجباری مستند):** در callback، `RefId` بازگشتی باید با `gateway_token` ذخیره‌شده و `SaleOrderId` با `payment.id` برابر باشد؛ در غیر این‌صورت تراکنش `failed` می‌شود (این چک برای درگاه‌هایی که این فیلدها را برنمی‌گردانند، مثل سپ، رد می‌شود).
- **دامنهٔ callback/Referer:** ملت `Referer` و `callBackUrl` را با دامنهٔ ثبت‌شدهٔ پذیرنده مقایسه می‌کند؛ در صورت عدم تطابق خطای `62`. مطمئن شوید دامنهٔ بک‌اند = دامنهٔ ثبت‌شده نزد ملت.
**استرداد / برگشت وجه ملت:** درگاه دو متد عودت دارد که از پنل ادمین (`POST /api/v1/admin/payments/{uuid}/refund` و `/reverse`) در دسترس‌اند:
- **Refund (`bpRefundRequest`):** برای تراکنش **settle‌شده**؛ کل یا جزئی (چندباره تا سقف مبلغ خرید). خروجی `0,RefId`؛ کد `0` فقط پذیرش اولیه است. استردادها در `payment.metadata.refunds[]` نگه داشته می‌شوند؛ استرداد کامل → وضعیت `refunded`.
- **Reversal (`bpReversalRequest`):** فقط برای تراکنش **settle‌نشده** (قبل از واریز)؛ کد `0`/`48` موفق. چون جریان ما بلافاصله verify+settle می‌کند، مسیر اصلی Refund است.
- در `MellatGateway`: sandbox=REST (`/ipg2/rest/bpRefundRequest`,`/bpReversalRequest` با Basic Auth)، prod=SOAP. `orderId` هر درخواست یکتای عددی است. `SepGateway`/`MockGateway` هم متدها را دارند (سپ = عدم پشتیبانی، mock = موفق).
- **معکوس‌سازی post-action:** هنگام **استرداد کامل** (یا برگشت وجه)، اثر پرداخت هم برگردانده می‌شود (`runReversePostAction`): نوبت → `cancelled_by_user` (اسلات آزاد می‌شود)؛ اشتراک → حذف `ClinicSubscription` ساخته‌شده از آن پرداخت؛ کیف‌پول پیامک → `deduct` مبلغ شارژ. استرداد **جزئی** post-action را برنمی‌گرداند.
**حالت Sandbox ملت (موقت — banktest.ir):** برای تست بدون درگاه واقعی، فلگ تنظیماتی `mellat_sandbox` وجود دارد (روی branch `feature/mellat-sandbox`؛ موقت).
- روشن‌کردن: `mellat_sandbox=1` **و** `payment_test_mode=0` (sandbox اتصال واقعی است، نه `MockGateway`؛ اگر `payment_test_mode=1` هم باشد sandbox اولویت دارد). خاموش (نبود/`0`) = رفتار prod.
- **پروتکل sandbox = REST** (نه SOAP): SOAP آزمایشیِ banktest روی `pgwchannel` خطای `502` می‌دهد؛ فقط REST (`.../ipg2/rest/…`) سالم است. پس در حالت sandbox، `MellatGateway`:
- `initiate``POST .../ipg2/rest/bpPayRequest` با JSON + هدر `Authorization: Basic base64(userName:userPassword)`؛ پاسخ رشتهٔ `0,RefId`.
- فرم پرداخت → `https://sandbox.banktest.ir/mellat/bpm.shaparak.ir/pgwchannel/startpay.mellat` (POST `RefId`). توجه: API روی `ipg2/rest` است ولی صفحهٔ فرم فقط روی `pgwchannel/startpay.mellat` سالم است (`ipg2/startpay.mellat` روی banktest خطای `404` می‌دهد).
- `verify` → sandbox متد ترکیبی `bpVerifySettleRequest` را پشتیبانی نمی‌کند (کد `44`)؛ پس دو فراخوانی جدا: `POST .../ipg2/rest/bpVerifyRequest` (قبول `0`/`43`) سپس `POST .../ipg2/rest/bpSettleRequest` (قبول `0`/`45`). در prod همان `bpVerifySettleRequest` ترکیبی (SOAP) استفاده می‌شود.
- callback در sandbox از IP خارج از رنج شاپرک می‌آید؛ کنترلر برای `gateway=mellat` + `mellat_sandbox` چک IP را رد می‌کند (مثل `payment_test_mode`).
- credentials sandbox در خودِ `MellatGateway` به‌صورت const است (terminalId `134759344` / user `user134759344`)؛ در حالت sandbox عمداً از `site_config` خوانده نمی‌شود تا credentials واقعیِ prod نشتی نکند. کل رفتار prod (SOAP روی `bpm.shaparak.ir`) دست‌نخورده باقی می‌ماند.
- CSP صفحات پرداخت `form-action` را علاوه بر `*.shaparak.ir` به `sandbox.banktest.ir` هم می‌دهد.
- **موقتی:** بعد از اتمام تست، این branch/فلگ باید حذف شود.
- **محدودیت sandbox:** banktest عملیات **استرداد/برگشت وجه** را شبیه‌سازی نمی‌کند؛ `bpRefundRequest` همیشه کد `34` (خطای سیستمی) می‌دهد. کدهای پاسخ ملت با `mellatMessage()` به پیام فارسی نگاشت می‌شوند. استرداد واقعی فقط در prod (SOAP) کار می‌کند.
---
## GET `/api/v1/payment/config`
دریافت تنظیمات عمومی پرداخت — برای نمایش وضعیت درگاه آزمایشی در frontend.
**Permission:** `IS_AUTHENTICATED_FULLY`
### Response `200`
```json
{
"success": true,
"data": {
"test_mode": false,
"appointment_fee_rials": 150000,
"gateways": [
{ "name": "mellat", "label": "بانک ملت" }
],
"tax_percent": 10
}
}
```
| Field | Type | Description |
|-------|------|-------------|
| `test_mode` | boolean | `true` = درگاه آزمایشی فعال است — backend از MockGateway استفاده می‌کند و پول واقعی کسر نمی‌شود |
| `appointment_fee_rials` | integer | مبلغ هر نوبت به ریال (از تنظیمات سایت، کلید `appointment_fee_rials`). frontend برای نمایش «مبلغ قابل پرداخت» از این می‌خواند؛ مبلغِ واقعیِ تراکنش هم در backend از همین کلید خوانده می‌شود (نه از client) |
| `gateways` | array | فقط درگاه‌های **فعال** (اعتبارنامه‌شان در تنظیمات سایت یا env ست شده). هر عضو: `{ name, label }`. frontend فقط همین‌ها را برای انتخاب نمایش می‌دهد. در `test_mode` تنها `[{ "name": "mellat", "label": "بانک ملت (آزمایشی)" }]` برمی‌گردد. اگر هیچ درگاهی فعال نباشد آرایه خالی است و frontend باید پرداخت را غیرفعال کند. |
| `tax_percent` | number | نرخ مالیات بر ارزش افزوده برای **اشتراک** و **شارژ کیف پول پیامک**. صفر یعنی مالیات خاموش است (`tax_enabled=0`). frontend با این عدد جمع کل را پیش از ارسال درخواست نشان می‌دهد؛ مبلغ نهایی همیشه در backend دوباره حساب می‌شود. نوبت این نرخ را به این شکل به‌کار نمی‌برد — آنجا مبلغ شامل مالیات است. |
فعال‌بودن هر درگاه با `PaymentGatewayInterface::isConfigured()` **و** کلید فعال‌سازی در تنظیمات سایت تعیین می‌شود: `mellat` نیازمند `mellat_terminal_id` + `mellat_username` + `mellat_password`؛ `sep` نیازمند `sep_terminal_id`. علاوه بر این، اگر ادمین درگاه را در تنظیمات غیرفعال کند (`mellat_enabled` / `sep_enabled` = `"0"`)، آن درگاه از این لیست حذف می‌شود و در `initiate` نیز رد می‌شود (خطای ۴۲۲: «درگاه پرداخت نامعتبر یا غیرفعال است»). کلید تنظیم‌نشده = فعال (پیش‌فرض).
**نکته:** این endpoint هیچ اطلاعات حساسی (terminal_id، password، ...) را expose نمی‌کند — فقط نام/برچسب درگاه‌های فعال.
---
## GET `/api/v1/my/payments`
List the **authenticated user's own** payments (derived from the token — there is no userId in the URL). Used by the public dashboard's transactions tab.
**Permission:** `IS_AUTHENTICATED_FULLY`
### Query Parameters
| Param | Type | Default | Description |
|-------|------|---------|-------------|
| `page` | integer | 1 | Page number |
| `limit` | integer | 20 | Items per page (max 100) |
| `status` | string | — | Optional filter: `pending` / `success` / `failed` / `canceled` / `refunded` |
### Response `200` (paginated)
```json
{
"success": true,
"data": [
{
"uuid": "pay-uuid-...",
"order_id": "ORD-84FB82E12A4A45CE",
"amount_rials": 590000,
"status": "success",
"gateway": "mellat",
"type": "appointment",
"reference_id": "...",
"appointment_uuid": "appt-uuid-...",
"created_at": 1781521834
}
],
"meta": { "totalRecords": 1, "totalPages": 1, "currentPage": 1 }
}
```
> Items at `data` (flat array), total at `meta.totalRecords`. Ordered by `created_at` DESC.
### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_AUTH_001` | 401 | Missing token |
---
## POST `/api/v1/payment/appointment`
Initiate payment for an appointment. Returns a redirect URL to the payment gateway.
**Permission:** `AUTH` — must be the appointment owner (patient)
### Request Body (`application/json`)
```json
{
"appointment_uuid": "appt-uuid-...",
"gateway": "mellat",
"frontend_address": "https://myapp.com/payment/result"
}
```
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `appointment_uuid` | string (UUID) | ✅ | Appointment to pay for |
| `gateway` | string | ✅ | `"mellat"` or `"sep"` |
| `frontend_address` | string | ❌ | Redirect URL after payment (overrides default) |
### Response `200`
```json
{
"success": true,
"data": {
"payment_uuid": "pay-uuid-...",
"pay_url": "{APP_BASE_URL}/api/v1/payment/pay/ORD-XXXX",
"order_id": "ORD-XXXX"
}
}
```
> **جریان انتقال به درگاه:** این endpoint فقط **صلاحیت اوردر** را بررسی و یک پرداختِ `pending` می‌سازد؛ **هیچ ارتباطی با بانک برقرار نمی‌کند**. کلاینت باید مرورگر را به `pay_url` هدایت کند. سپس `GET /api/v1/payment/pay/{orderId}` (سمت بک‌اند) درگاه را init می‌کند (ارتباط با بانک) و مرورگر را به درگاه می‌فرستد (302 برای درگاه‌های GET مثل سپ، یا فرم auto-submit با متد POST برای درگاه ملت). به این ترتیب کلاینت هرگز مستقیم به بانک ریکوست نمی‌زند.
>
> **مبلغ نوبت:** مبلغِ پرداخت از کلید `appointment_fee_rials` تنظیمات سایت خوانده می‌شود (نه از client و نه hardcode). برای تغییر، در `/admin/settings` ویرایش کنید.
>
> **On successful callback** for an appointment payment, the booking **stays `pending`** («ثبت شده») and only its 15-minute `expires_at` is cleared, so a paid booking is never auto-expired. قطعی‌شدن تصمیم پزشک/منشی است: `POST /api/v1/appointment/{uuid}/confirm` ([appointment.md](appointment.md#post-apiv1appointmentuuidconfirm)). پیامک تأیید پرداخت همان لحظه برای بیمار ارسال می‌شود. تاریخِ نوبت در متن پیامک به‌صورت **شمسی** (`JalaliDateService::formatDateTime`، مثل `۱۴۰۵/۰۴/۰۲ ۰۹:۰۰`) درج می‌شود. If the booking already lapsed to `expired` before payment confirmed, it is **not** re-confirmed (the transition is rejected) — handle refund out of band.
>
> **پورسانت نماینده:** تقسیم مالی نوبت در لحظهٔ **تأیید نوبت** اجرا می‌شود (نه لحظهٔ پرداخت). اگر پزشک نوبت `representation_id` داشته باشد و `appointment_commission_enabled=1` باشد، `CommissionService` هزینه پنل پیامک و مالیات را کسر و سهم نماینده را به کیف‌پولش واریز می‌کند (ردیف `FinancialBreakdown` ثبت می‌شود). برای پرداخت اشتراک هم اگر `upgrade_commission_enabled=1` و پزشک/کلینیک `representation_id` داشته باشد همین منطق با درصد `upgrade_commission_percent` اعمال می‌شود. کلیدهای تنظیمات و ترتیب محاسبه در `docs/api/admin.md`.
### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_AUTH_001` | 401 | Missing token |
| `ERR_FORBIDDEN_001` | 403 | Not the patient |
| `ERR_PAYMENT_003` | 422 | Appointment not in payable state |
| `ERR_PAYMENT_002` | 422 | Invalid amount |
| `ERR_VALIDATION_001` | 422 | درگاه پرداخت نامعتبر یا غیرفعال / آدرس بازگشت مجاز نیست |
---
## GET `/api/v1/payment/order/{appointmentUuid}`
**entry ریدایرکتِ خالص (بدون XHR)** برای شروع پرداخت نوبت. مرورگر مستقیماً به این آدرس هدایت می‌شود؛ Backend همهٔ کار را انجام می‌دهد: اعتبارسنجی سفارش → ساخت `Payment` → ارتباط با بانک → ریدایرکت به شاپرک.
**Permission:** `PUBLIC` (بدون JWT).
### Query Parameters
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `gateway` | string | ✅ | نام درگاه فعال (`mellat`/`sep`) — از `GET /payment/config` |
| `return` | string | ❌ | آدرس بازگشت (دامنهٔ مبدأ)؛ باید در `payment_allowed_frontend_hosts` مجاز باشد |
### رفتار
- نوبت یافت نشد → `302` به `return?status=notfound` (یا `422` اگر return نبود/نامجاز).
- **نوبت منقضی** (`status=expired` یا گذشتنِ پنجرهٔ ۱۵ دقیقه‌ای `expires_at`/زمان اسلات) → نوبت `expired` و پرداخت pending آن `canceled` می‌شود و `302` `return?status=expired` (پیام «مهلت پرداخت به پایان رسید»).
- نوبت قابل‌پرداخت نیست (نه `pending`/`confirmed`) → `302` `return?status=invalid`.
- درگاه نامعتبر/غیرفعال → `302` `return?status=gateway`.
- موفق → ساخت/ادامهٔ `Payment` pending (بدون pending تکراری via `findPendingByAppointment`) و `302` به بانک (یا فرم auto-submit POST برای ملت).
### امنیت مالکیت
چون entry عمومی و full-page cross-domain است، JWT در دسترس نیست؛ `appointmentUuid` (UUID غیرقابل‌حدس) نقش capability را دارد و پرداخت فقط به نفع صاحب نوبت است. برای مالکیت سخت‌گیرانه می‌توان پارامتر امضاشدهٔ `sig` (HMAC) افزود.
> **صفحهٔ نتیجه:** این endpointهای مرورگرمحور (`order`/`pay`/`callback`) هرگز JSON به کاربر نمی‌دهند. اگر `return` معتبر باشد → `302` به همان دامنه با `?status=`؛ در غیر این‌صورت (نبود/نامعتبر بودن `return`، یافت‌نشدن سفارش، نبود آدرس بازگشت) یک صفحهٔ **Twig** (`templates/payment/result.html.twig`, RTL، `noindex`) با پیام وضعیت رندر می‌شود.
---
## GET `/api/v1/payment/pay/{orderId}`
انتقال مرورگر به درگاه پرداخت برای یک پرداختِ `pending`. **عمومی (بدون JWT)** — مرورگر مستقیماً به این آدرس هدایت می‌شود. صلاحیت اوردر قبلاً در `POST /api/v1/payment/appointment` (نیازمند JWT) بررسی و پرداخت ساخته شده است. **ارتباط با بانک (init درگاه) در همین endpoint انجام می‌شود.**
### Path Parameters
| Param | Type | Description |
|-------|------|-------------|
| `orderId` | string | `order_id` پرداخت (از پاسخ initiate) |
### رفتار
- اگر پرداخت یافت نشد → `404 { success:false, message:"payment not found" }`.
- **پرداختِ نوبت با نوبتِ منقضی** (نوع `appointment` و نوبت `expired` یا گذشتنِ پنجرهٔ ۱۵ دقیقه‌ای): نوبت `expired` و پرداخت pending آن `canceled` می‌شود و صفحهٔ نتیجهٔ `expired` («مهلت پرداخت به پایان رسید») رندر می‌شود — به بانک نمی‌رود.
- اگر وضعیت پرداخت `pending` نباشد → `302` به `frontend_address` با نتیجه (جلوگیری از پرداخت تکراری).
- درگاه resolve می‌شود (در حالت تست → mock)؛ اگر نامعتبر بود یا circuit breaker باز بود → پرداخت `failed` و `302` به `frontend_address`.
- `gateway->initiate(...)` صدا زده می‌شود (ارتباط با بانک). در صورت شکست → `failed` و `302` به `frontend_address`.
- در صورت موفقیت: توکن ذخیره و انتقال به درگاه:
- متد `GET` (سپ/mock) → `302` به URL درگاه.
- متد `POST` (ملت) → صفحهٔ HTML با فرم **auto-submit** (POST) به درگاه، شامل فیلدهای لازم (مثل `RefId`).
### Permission
`PUBLIC` (در `security.yaml` تحت firewall `payment_callback` و access_control عمومی).
---
## POST `/api/v1/payment/callback`
## GET `/api/v1/payment/callback`
Payment gateway callback. Called by the bank after user completes (or cancels) payment.
**یک آدرس برای همه.** همهٔ درگاه‌ها (`mellat`، `sep`، `mock`) و همهٔ نوع‌های پرداخت
(`appointment`، `subscription`، `sms_wallet`) روی همین یک مسیر برمی‌گردند؛ درگاه و سفارش
به‌صورت query param می‌روند. URL هنگام `initiate` در `PaymentManager::callbackUrl()` از
`APP_BASE_URL` و ثابت `PaymentManager::CALLBACK_PATH` ساخته می‌شود:
```
{APP_BASE_URL}/api/v1/payment/callback?gateway={gateway}&order_id={orderId}
```
دلیل: مسیر ثابت می‌ماند، پس آدرسِ ثبت‌شده در پنل پذیرندگی بانک با اضافه‌شدن درگاه یا نوع
پرداخت جدید عوض نمی‌شود.
> **Breaking change.** دو مسیر قدیمی حذف شده‌اند و `404` می‌دهند:
> `POST|GET /api/v1/payment/callback/{gateway}` و
> `POST|GET /api/v1/subscription-payment/callback/{gateway}`.
> آدرس ثبت‌شده در پنل ملت و سپ باید به مسیر جدید به‌روز شود.
**نکته IPG ملت:** طبق راهنمای درگاه ملت، `callBackUrl` باید روی **دامنهٔ ثبت‌شدهٔ پذیرنده** باشد و **IP مجاز نیست** (در غیر این صورت کد پاسخ `62` — «مسیر back call در دامنهٔ ثبت‌شده نیست»). بنابراین `APP_BASE_URL` در پروداکشن باید دقیقاً `https://clinic-pro.ir` (دامنهٔ ثبت‌شده نزد ملت/شاپرک) باشد.
**Permission:** `PUBLIC`. **نکتهٔ مهم:** درگاه‌های **ملت و سپ** نتیجه را با **ریدایرکتِ مرورگرِ کاربر** (POST/GET) برمی‌گردانند، نه server-to-server؛ پس IP دریافتی، IPِ کاربر است و **allowlist شاپرک اعمال نمی‌شود** (برای `gateway ∈ {mellat, sep}` و نیز `test_mode`). در غیر این صورت هر callback واقعی — از جمله «لغو» توسط کاربر — با «دسترسی غیرمجاز» رد می‌شد. امنیت از طریق **چک ضد-دستکاری** (`RefId==gateway_token`، `SaleOrderId==payment.id`) و **verify سمت بانک** در `PaymentManager` تأمین می‌شود. `isAllowedCallbackIp` فقط برای درگاه‌های آیندهٔ server-to-server معنی دارد.
### Query Parameters
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `order_id` | string | yes | شناسهٔ سفارش (`ORD-…`)؛ در نبودش `ResNum` خوانده می‌شود |
| `gateway` | string | no | `mellat` \| `sep` \| `mock`. در نبودش از فیلد `gateway` همان رکورد پرداخت خوانده می‌شود |
### Request (varies by gateway)
**Mellat POST fields:**
```
ResCode=0&SaleOrderId=...&SaleReferenceId=...&RefId=...
```
**SEP POST fields:**
```
Status=2&RRN=...&RefNum=...&TerminalId=...&TraceNo=...
```
### Response
After verifying the gateway result, the backend redirects the user **back to the origin site** (`frontend_address`) with the outcome appended as query params:
```
{frontend_address}?payment_uuid={uuid}&status={status}
```
- **Success** (`verify` ok **and** amount matches): payment → `success`, then the type-specific action runs (appointment → پنجرهٔ انقضا پاک می‌شود ولی وضعیت `pending` می‌ماند، subscription → activated, sms_wallet → credited).
- **Amount mismatch**: when the gateway reports the settled amount (SEP `AffectiveAmount`) and it does **not** equal the order's `amount_rials`, the callback is treated as failed — payment → `failed`, the type-specific action does **not** run. Guards against underpayment and replaying another (cheaper) order's reference. Gateways that don't report a settled amount (Mellat binds it server-side to the original request) skip this check.
- **Replayed reference**: a gateway `reference_id` identifies exactly one settled transaction. If the callback's reference already belongs to another payment, it is rejected (payment → `failed`). Enforced by a unique index on `payments.reference_id` with an application-level pre-check.
- **User canceled** (e.g. Mellat `ResCode=17`, SEP `State=CanceledByUser`, mock `cancel=1`): payment → `canceled`. The gateway circuit-breaker is **not** marked as failed (it's a user choice, not a gateway fault).
- **Failed** (any other unsuccessful verify): payment → `failed`, circuit-breaker records a failure.
If `frontend_address` is empty, a JSON body `{ success, payment }` is returned instead of a redirect.
### Notes
- On appointment success: status می‌ماند `pending`، فقط `expires_at` پاک می‌شود و پیامک پرداخت ارسال می‌گردد؛ قطعی‌کردن با `POST /api/v1/appointment/{uuid}/confirm` است.
- Payment record stores: `order_id`, `amount_rials`, `status`, `gateway`, `reference_id`, `frontend_address`, `callback_ip`.
- Same flow for **all clients** (the main site and every consumer site) — the only per-client difference is `frontend_address`, which is validated against an allowlist (see below) to prevent open redirects.
---
## POST `/api/v1/subscription-payment`
Initiate a subscription / wallet top-up payment (not tied to a specific appointment).
**Permission:** `AUTH`
### Request Body
```json
{
"gateway": "mellat",
"period_uuid": "uuid-of-subscription-period",
"frontend_address": "https://myapp.com/wallet/result"
}
```
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `gateway` | string | ✅ | `"mellat"` or `"sep"` |
| `period_uuid` | string | ✅ | دورهٔ اشتراک؛ مبلغ از آن محاسبه می‌شود |
| `frontend_address` | string | ❌ | Redirect URL after payment |
> `amount_rials` دیگر خوانده نمی‌شود. مبلغ = `price_rials + tax_rials` همان دوره، محاسبه‌شده در
> `SubscriptionTaxCalculator`. قیمت دوره خالص است و مالیات رویش اضافه می‌شود — جزئیات در
> [subscription.md](subscription.md#مالیات-دورهها). پاسخ هم هر چهار عدد را برمی‌گرداند:
> `price_rials`، `tax_percent`، `tax_rials`، `payable_rials`.
### Response `200`
```json
{
"success": true,
"data": {
"payment_uuid": "pay-uuid-...",
"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/payment/callback` می‌رود.
### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_AUTH_001` | 401 | Missing token |
| `ERR_PAYMENT_002` | 422 | Invalid amount |
| `ERR_PAYMENT_004` | 422 | خریدار صاحب هیچ محیطی نیست (نه پزشک، نه کلینیک) |
| `ERR_PAYMENT_001` | 503 | Gateway unavailable |
```json
{
"success": false,
"errors": [{ "code": "ERR_PAYMENT_004", "message": "محیط این پرداخت مشخص نیست" }]
}
```
> اشتراک روی محیطی می‌نشیند که خریدار **صاحبش** است (اول مطب شخصی، بعد کلینیک) — نه روی محیط فعالش. همان جفت روی خودِ اشتراک هم ثبت می‌شود، پس پرداخت و اشتراک هرگز روی دو محیط متفاوت نمی‌افتند.
---
## ~~POST/GET `/api/v1/subscription-payment/callback/{gateway}`~~ — حذف شد
پرداخت اشتراک callback اختصاصی ندارد. از [`/api/v1/payment/callback`](#post-apiv1paymentcallback)
استفاده کنید؛ نوع پرداخت از خودِ رکورد `Payment` خوانده می‌شود.
---
## GET `/api/v1/payment/{uuid}`
Get payment status and details.
**Permission:** `AUTH` — must be the payment owner or `ROLE_ADMIN`
### Path Parameters
| Param | Type | Description |
|-------|------|-------------|
| `uuid` | string (UUID) | Payment UUID |
### Response `200`
```json
{
"success": true,
"data": {
"uuid": "pay-uuid-...",
"order_id": "CLINICPRO-1717000000-ABC123",
"amount_rials": 500000,
"status": "paid",
"gateway": "mellat",
"ref_id": "123456789",
"appointment_uuid": "appt-uuid-...",
"created_at": 1717000000,
"paid_at": 1717000120
}
}
```
**Payment Status Values:**
| Value | Description |
|-------|-------------|
| `pending` | Transaction created, awaiting payment |
| `success` | Successfully paid and verified |
| `failed` | Gateway returned a failure |
| `canceled` | User canceled at the gateway, or the payment window lapsed (booking expired) |
| `refunded` | Refunded |
> `canceled` is set in two cases: (1) the gateway callback reports a user cancellation, and (2) the appointment's 15-minute payment window lapses — the scheduled expiry job marks the booking `expired` and its pending payment `canceled`.
### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_AUTH_001` | 401 | Missing token |
| `ERR_FORBIDDEN_001` | 403 | Not the owner |
| `ERR_NOT_FOUND_001` | 404 | Payment not found |
---
## Sandbox (test) mode & origin allowlist
**Test mode:** when `payment_test_mode` (in admin settings) is `1`, every initiation resolves to the internal `MockGateway` — no bank call is made, the transaction is simulated and verified inside the system, and the user is redirected back to `frontend_address` exactly like a real payment. `GET /api/v1/payment/config` exposes this as `test_mode`.
**Origin allowlist:** `frontend_address` (the origin site the user returns to) must match an allowed host, to prevent open redirects. The allowlist is read from the `payment_allowed_frontend_hosts` site setting (comma-separated hosts), falling back to the `ALLOWED_FRONTEND_HOSTS` env var when the setting is empty. Manage it via `PATCH /api/v1/admin/settings` — so a new consumer site can be allowed without a code or `.env` change. A non-allowed host yields `422 ERR_VALIDATION_001` (`field: frontend_address`).