Files
clinicpro/docs/api/payment.md
T
hamedandClaude Opus 5 c9d4348c46 feat(tenant): mark the financial tables with their owning environment
Phase 6 of the tenant series. GlobalTables::DEFERRED is now empty and the
coverage test asserts it stays that way.

payments carries the (entity_type, entity_id) pair and belongs to the
receiving side, never the payer: an appointment payment takes the
appointment's environment, a subscription takes the environment its buyer
owns, and an SMS wallet top-up takes the wallet's. The patient never chose
an environment, so TenantFilter stays off for them and they still see their
own payment.

Three corrections to the analysis the phase was planned on, each backed by
the code or the data rather than the plan:

- A third payment type exists. Payment::TYPE_SMS_WALLET is created in
  SmsWalletController and already carries its environment in the metadata;
  without assigning it the write would fail at flush.
- clinic_subscriptions has no user_id, and its trial rows carry no payment,
  so it cannot drive the subscription backfill. The environment is derived
  the way handleSubscriptionActivation derives it — and that method now
  reads the pair off the payment instead of re-deriving it, so a payment and
  the subscription it buys can no longer land on different environments.
- WalletTransaction is not a child of Payment. payment_id is nullable and
  none of the four creation sites set it; the wallet is a person's, with a
  running balance per user. It and Settlement, which withdraws from that same
  wallet, are global with a recorded reason instead.

bank_accounts and pos_devices move from the registering user to the
environment. Their pair is deliberately nullable: nothing in the existing
data says which of a multi-environment owner's cards belongs where, and
guessing would point real money at the wrong account. Ambiguous rows stay
unassigned and the migration reports how many. The cost is that such a row
is invisible in every environment, so the owner reaches it through a
user-scoped lookup that runs outside the filter, and assigns it with
PATCH .../{uuid}/environment. The admin panel marks those rows and offers
the assignment.

Tests: 896 backend (+11), 570 frontend (+4). PHPStan unchanged at its 17
pre-existing errors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 15:06:28 +03:30

436 lines
33 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}` عبور می‌کنند؛ `PaymentManager::callbackUrl()` پیشوند callback را بر اساس `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": "بانک ملت" }
]
}
}
```
| 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 باید پرداخت را غیرفعال کند. |
فعال‌بودن هر درگاه با `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/{gateway}`
## GET `/api/v1/payment/callback/{gateway}`
Payment gateway callback. Called by the bank after user completes (or cancels) payment. هر درگاه callback مخصوص خودش را دارد؛ URL آن هنگام `initiate` از `APP_BASE_URL` ساخته می‌شود:
```
{APP_BASE_URL}/api/v1/payment/callback/{gateway}?order_id={orderId}
```
**نکته 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 معنی دارد.
### Path Parameters
| Param | Type | Description |
|-------|------|-------------|
| `gateway` | string | `mellat` or `sep` |
### 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",
"amount_rials": 1000000,
"frontend_address": "https://myapp.com/wallet/result"
}
```
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `gateway` | string | ✅ | `"mellat"` or `"sep"` |
| `amount_rials` | integer | ✅ | Amount in Rials (min: 10,000) |
| `frontend_address` | string | ❌ | Redirect URL after payment |
### 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/subscription-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 for subscription payments. Same behavior as appointment callback but credits wallet instead.
**Permission:** `PUBLIC`
---
## 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`).