feat(payment): canceled status, manageable origin allowlist, CORS subdomains
Unify and harden the payment flow (same API for the main site and all consumer sites; per-client difference is only frontend_address). - Payment gains STATUS_CANCELED. Gateways distinguish user-cancel from failure (Mellat ResCode=17, SEP CanceledByUser, mock cancel=1) via a new PaymentVerifyResult::canceled flag; callback sets canceled vs failed and skips the circuit-breaker on cancel. - Expiry job now cancels the pending payment when a booking lapses (AppointmentExpiryService + PaymentRepository::findPendingByAppointment). - frontend_address allowlist is read from the payment_allowed_frontend_hosts site setting (manageable via PATCH /api/v1/admin/settings), falling back to the ALLOWED_FRONTEND_HOSTS env var — so a new consumer site needs no code change. - .env: broaden CORS_ALLOW_ORIGIN to city subdomains (*.localhost / *.clinic-pro.ddev.site) and add yazd-nobat.localhost to ALLOWED_FRONTEND_HOSTS. - Update docs/api/payment.md and docs/api/admin.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+27
-9
@@ -40,7 +40,7 @@ List the **authenticated user's own** payments (derived from the token — there
|
||||
|-------|------|---------|-------------|
|
||||
| `page` | integer | 1 | Page number |
|
||||
| `limit` | integer | 20 | Items per page (max 100) |
|
||||
| `status` | string | — | Optional filter: `pending` / `success` / `failed` / `cancelled` / `refunded` |
|
||||
| `status` | string | — | Optional filter: `pending` / `success` / `failed` / `canceled` / `refunded` |
|
||||
|
||||
### Response `200` (paginated)
|
||||
```json
|
||||
@@ -142,12 +142,20 @@ Status=2&RRN=...&RefNum=...&TerminalId=...&TraceNo=...
|
||||
```
|
||||
|
||||
### Response
|
||||
- If `ResCode=0` (success): appointment confirmed, redirect to `frontend_address?success=1&uuid=...`
|
||||
- If failed: redirect to `frontend_address?success=0&error=...`
|
||||
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): payment → `success`, then the type-specific action runs (appointment → `confirmed`, subscription → activated, sms_wallet → credited).
|
||||
- **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 success: appointment status → `confirmed`, wallet credited with doctor's share
|
||||
- Payment record stored with: `order_id`, `amount_rials`, `status`, `gateway`, `ref_id`
|
||||
- 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.
|
||||
|
||||
---
|
||||
|
||||
@@ -233,15 +241,25 @@ Get payment status and details.
|
||||
**Payment Status Values:**
|
||||
| Value | Description |
|
||||
|-------|-------------|
|
||||
| `pending` | Created, not paid yet |
|
||||
| `paid` | Successfully paid |
|
||||
| `failed` | Gateway returned failure |
|
||||
| `cancelled` | User cancelled at gateway |
|
||||
| `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`).
|
||||
|
||||
Reference in New Issue
Block a user