# Payment API

> **Prefix:** `/api/v1/payment`, `/api/v1/subscription-payment`  
> **Supported Gateways:** `mellat` (Mellat Bank SOAP) | `sep` (SEP REST)

---

## GET `/api/v1/payment/config`

دریافت تنظیمات عمومی پرداخت — برای نمایش وضعیت درگاه آزمایشی در frontend.

**Permission:** `IS_AUTHENTICATED_FULLY`

### Response `200`
```json
{
  "success": true,
  "data": {
    "test_mode": true,
    "appointment_fee_rials": 150000
  }
}
```

| Field | Type | Description |
|-------|------|-------------|
| `test_mode` | boolean | `true` = درگاه آزمایشی فعال است — backend از MockGateway استفاده می‌کند و پول واقعی کسر نمی‌شود |
| `appointment_fee_rials` | integer | مبلغ هر نوبت به ریال (از تنظیمات سایت، کلید `appointment_fee_rials`). frontend برای نمایش «مبلغ قابل پرداخت» از این می‌خواند؛ مبلغِ واقعیِ تراکنش هم در backend از همین کلید خوانده می‌شود (نه از client) |

**نکته:** این endpoint هیچ اطلاعات حساسی (terminal_id، password، ...) را expose نمی‌کند. تنها یک boolean برای مصرف frontend است.

---

## 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-...",
    "redirect_url": "https://bpm.shaparak.ir/pgwchannel/...",
    "order_id": "CLINICPRO-1717000000-ABC123"
  }
}
```

> **مبلغ نوبت:** مبلغِ پرداخت از کلید `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_PAYMENT_001` | 503 | Payment gateway unavailable |

---

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

**Permission:** `PUBLIC` — called by the gateway, not the user

### 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-...",
    "redirect_url": "https://bpm.shaparak.ir/pgwchannel/...",
    "order_id": "CLINICPRO-SUB-1717000000-XYZ"
  }
}
```

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