# 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`).