391 lines
22 KiB
Markdown
391 lines
22 KiB
Markdown
# Payment API
|
||
|
||
> **Prefix:** `/api/v1/payment`, `/api/v1/subscription-payment`
|
||
> **Supported Gateways:** `mellat` (Mellat Bank SOAP) | `sep` (SEP REST)
|
||
|
||
---
|
||
|
||
## معماری (Flow & مسئولیتها)
|
||
|
||
**اصل:** Frontend هرگز مستقیم با بانک صحبت نمیکند و منطق پرداخت را نگه نمیدارد. Backend تنها مرجع معتبر (Source of Truth) است.
|
||
|
||
```
|
||
[Frontend] کلیک پرداخت
|
||
│ (۱) XHR authenticated: POST /api/v1/payment/appointment {appointment_uuid, gateway, frontend_address}
|
||
▼
|
||
[PaymentController::initiateAppointment] ← فقط Orchestration + Validation
|
||
│ اعتبارسنجی: وجود نوبت، مالکیت کاربر، وضعیت قابلپرداخت، آدرس بازگشت مجاز، فعال بودن درگاه (GatewayFactory)
|
||
│ ساخت Payment(pending) + ذخیره؛ برمیگرداند pay_url (هیچ ارتباطی با بانک اینجا نیست)
|
||
▼
|
||
[Frontend] مرورگر full-page redirect → pay_url
|
||
│ (۲) GET /api/v1/payment/pay/{orderId} (عمومی)
|
||
▼
|
||
[PaymentController::pay]
|
||
│ GatewayFactory::resolve(نام درگاه) → درگاه (تست→Mock)
|
||
│ CircuitBreaker چک؛ gateway->initiate(amount, orderId, callbackUrl) ← ارتباط با بانک
|
||
│ ذخیرهٔ token؛ انتقال به بانک: 302 (GET) یا فرم auto-submit POST (ملت)
|
||
▼
|
||
[درگاه بانک / شاپرک] پرداخت کاربر
|
||
│ (۳) بازگشت به callbackUrl بکاند
|
||
▼
|
||
[PaymentController::callback] (عمومی، محدود به IP شاپرک مگر تست)
|
||
│ gateway->verify()؛ بررسی مبلغ؛ جلوگیری از replay (reference_id یکتا)؛ ست وضعیت
|
||
│ post-action: confirm نوبت / فعالسازی اشتراک / شارژ کیفپول + کمیسیون + پیامک
|
||
│ (۴) RedirectResponse → frontend_address?payment_uuid=..&status=.. (همان دامنهٔ مبدأ)
|
||
▼
|
||
[Frontend] /payment/result → نمایش وضعیت
|
||
```
|
||
|
||
**کلاسها و مسئولیتها:**
|
||
|
||
| کلاس | مسئولیت (SRP) |
|
||
|------|----------------|
|
||
| `PaymentController` | فقط Orchestration: دریافت request، اعتبارسنجی مالکیت/سفارش، فراخوانی `PaymentManager`، ساخت پاسخ/redirect HTTP (`autoSubmitForm`, `redirectToFrontend`, IP-check, Open-Redirect guard). بدون منطق درگاه/verify. |
|
||
| `PaymentManager` (`src/Payment/Service/`) | **منطق پرداخت**: `startGatewayHandoff()` (init درگاه + CircuitBreaker + ذخیرهٔ token) و `processCallback()` (verify داخل **transaction + قفل بدبینانه**، بررسی مبلغ، ضد-replay، idempotent، post-action، لاگ). |
|
||
| `GatewayFactory` (`src/Payment/Gateway/`) | **Factory + Strategy**: انتخاب درگاه بر اساس نام + حالت تست + فعال بودن؛ فهرست درگاههای فعال. |
|
||
| `PaymentGatewayInterface` | قرارداد درگاه: `initiate()`, `verify()`, `isConfigured()`, `getName()`. |
|
||
| `MellatGateway` / `SepGateway` / `MockGateway` | پیادهسازی هر درگاه (SOAP/REST/mock). ملت با POST به بانک، سپ با GET. |
|
||
| `PaymentInitResult` / `PaymentVerifyResult` | DTO نتیجهٔ init/verify (شامل `redirectMethod`/`redirectParams`). |
|
||
| `CircuitBreakerService` | جلوگیری از فشار روی درگاهِ خراب. |
|
||
| `Payment` (Entity) | وضعیت پرداخت، `orderId` یکتا، `referenceId` یکتا (backstop برای replay)، `frontendAddress` (دامنهٔ مبدأ). |
|
||
| `PaymentLog` (Entity) + `PaymentLogRepository` | **audit trail**: هر گام (`initiate`/`verify`) با نتیجه، authority، IP، payload کالبک (بدون اعتبارنامه). |
|
||
|
||
**امنیت verify:** `processCallback` داخل `EntityManager::wrapInTransaction` با `findByOrderIdForUpdate` (SELECT … FOR UPDATE) اجرا میشود؛ گاردِ «فقط `pending`» آن را **idempotent** میکند (verify تکراری/race بیاثر).
|
||
|
||
**یکدستیِ typeها:** هر سه نوع (`appointment`/`subscription`/`sms_wallet`) از همان `GET /payment/pay/{orderId}` عبور میکنند؛ `PaymentManager::callbackUrl()` پیشوند callback را بر اساس `type` انتخاب میکند. POST این endpointها فقط `Payment` pending میسازد و `pay_url` برمیگرداند (نه `redirect_url`).
|
||
|
||
**افزودن درگاه جدید (Open/Closed):** یک کلاس جدید implements `PaymentGatewayInterface` بساز، در `GatewayFactory::$gateways` + `LABELS` ثبت کن. `PaymentController`/`PaymentManager` تغییر نمیکنند.
|
||
|
||
---
|
||
|
||
## GET `/api/v1/payment/config`
|
||
|
||
دریافت تنظیمات عمومی پرداخت — برای نمایش وضعیت درگاه آزمایشی در frontend.
|
||
|
||
**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 is transitioned `pending → confirmed` (its 15-minute `expires_at` is cleared) and a confirmation SMS is dispatched to the patient's mobile. تاریخِ نوبت در متن پیامک بهصورت **شمسی** (`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` باشد، پس از confirm شدن `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 نبود/نامجاز).
|
||
- نوبت قابلپرداخت نیست (نه `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" }`.
|
||
- اگر وضعیت پرداخت `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` — called by the gateway, not the user (محدود به IPهای شبکهٔ شاپرک `isAllowedCallbackIp`؛ در `test_mode` بدون محدودیت IP)
|
||
|
||
### 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 → `confirmed`, 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 → `confirmed`, its 15-minute `expires_at` cleared, confirmation SMS dispatched.
|
||
- 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_001` | 503 | Gateway unavailable |
|
||
|
||
---
|
||
|
||
## 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`).
|