# Soft POS Demo API — Reference

Version 1.0.0 · Laravel 13 · DEMO / SANDBOX build

This is the backend consumed by the Soft POS Android app (tap-to-phone contactless card
payments and iBRAC-style QR payments). In this build every payment is handled by the
in-process **Demo Payment Engine**: no bank, card scheme or iBRAC endpoint is ever
contacted and no real funds move. Every transaction carries `demo_mode: true`, every
response carries `X-Environment: demo`, and receipts are footed with
`DEMO / SANDBOX — No real funds were moved.`

A machine-readable OpenAPI 3.1 description lives next to this file (`openapi.yaml`) and is
also served by the app at `/docs/openapi.yaml`.

---

## Contents

1. [Base URLs](#base-urls)
2. [Authentication](#authentication)
3. [Conventions](#conventions)
4. [Demo / sandbox behaviour](#demo--sandbox-behaviour)
5. [Idempotency](#idempotency)
6. [Endpoints](#endpoints)
   - System: [GET /health](#get-health)
   - Auth: [POST /auth/login](#post-authlogin) · [POST /auth/logout](#post-authlogout) · [GET /auth/me](#get-authme)
   - Merchant: [GET /merchant/profile](#get-merchantprofile) · [GET /merchant/dashboard](#get-merchantdashboard)
   - Payments: [POST /payments](#post-payments) · [GET /payments/{id}](#get-paymentsid) · [GET /payments/{id}/status](#get-paymentsidstatus) · [POST /payments/{id}/authorize](#post-paymentsidauthorize) · [POST /payments/{id}/cancel](#post-paymentsidcancel) · [POST /payments/{id}/events](#post-paymentsidevents) · [GET /payments/{id}/receipt](#get-paymentsidreceipt)
   - Transactions: [GET /transactions](#get-transactions) · [GET /transactions/{id}](#get-transactionsid)
   - QR: [POST /qr/create](#post-qrcreate) · [GET /qr/{reference}/status](#get-qrreferencestatus) · [POST /qr/{reference}/cancel](#post-qrreferencecancel) · [POST /qr/{reference}/demo-confirm](#post-qrreferencedemo-confirm)
   - Demo: [GET /demo/config](#get-democonfig)
7. [Payment state machine](#payment-state-machine)
8. [Complete demo lifecycle](#complete-demo-lifecycle)

---

## Base URLs

| Environment | Base URL |
|---|---|
| Local development (`php artisan serve`) | `http://localhost:8000/api` |
| Android emulator → host machine | `http://10.0.2.2:8000/api` |

All paths below are relative to the base URL.

## Authentication

The API uses **Laravel Sanctum personal access tokens** sent as a bearer token.

1. `POST /auth/login` with the merchant credentials. The response contains `data.token`
   (format `<id>|<random>`), `data.token_type: "Bearer"` and `data.expires_at`.
2. Send `Authorization: Bearer <token>` on every other request (all routes except
   `/health` and `/auth/login` require it).
3. Tokens expire after `AUTH_TOKEN_TTL_MINUTES` (default **720 min = 12 h**). After that,
   or after `POST /auth/logout`, requests fail with `401 UNAUTHENTICATED` and the app must
   log in again. There is no refresh endpoint.

Every protected route also runs two middlewares:

- **`active`** — the user and the merchant must both be `active`, otherwise
  `403 ACCOUNT_INACTIVE` (also checked at login).
- **`terminal`** — resolves the terminal from the optional `X-Terminal-Code` header
  (must be one of the merchant's *active* terminals); otherwise the merchant's first
  active terminal is used. The resolved terminal is stamped on transactions, events and
  audit entries.

Demo credentials (seeded by `DemoMerchantSeeder`, never for production):

| Field | Value |
|---|---|
| `merchant_id` | `DEMO-MERCHANT-001` |
| `password` | `Demo@12345` |
| Active terminal | `POS-DEMO-001` (`POS-DEMO-002` is seeded *inactive*) |

## Conventions

### Content type

Send `Accept: application/json` and `Content-Type: application/json`. Responses are always
JSON for `/api/*` routes.

### Envelopes

- Success: `{"data": ...}`. Paginated lists (`GET /transactions`) add Laravel's `links`
  and `meta`, plus `meta.filters` echoing the validated filters.
- Error: `{"error": {"code": "...", "message": "...", "details": ...}}`.
  - `details` is **omitted** for auth / not-found / rate-limit errors.
  - For `VALIDATION_ERROR`, `details` is `{ "<field>": ["message", ...] }`.
  - For payment-domain errors, `details` is a context object, often `{}`.
  - `SERVER_ERROR` adds `request_id` (and `debug` when `APP_DEBUG=true`).

Amounts are returned as **strings** with two decimals (`"25.50"`); timestamps are ISO-8601
with offset. Transactions can be addressed by UUID **or** reference
(`TXN-YYYYMMDD-NNNNNN`), QR payments by UUID or `QR-YYYYMMDD-NNNNNN`.

### Error codes

| Code | HTTP | When |
|---|---|---|
| `VALIDATION_ERROR` | 422 | Request failed FormRequest validation |
| `UNAUTHENTICATED` | 401 | Missing, malformed, expired or revoked token |
| `INVALID_CREDENTIALS` | 401 | Wrong merchant id or password |
| `TOO_MANY_ATTEMPTS` | 429 | 5 failed logins for the same merchant id + IP within 60 s |
| `ACCOUNT_INACTIVE` | 403 | User or merchant not active |
| `NOT_FOUND` | 404 | Unknown route / transaction / QR (scoped to the caller's merchant) |
| `RATE_LIMITED` | 429 | Throttle exceeded (see below); `Retry-After` header set |
| `INVALID_STATE_TRANSITION` | 409 | Authorize / cancel not allowed from the current status. `details: {from, to, reference}` |
| `IDEMPOTENCY_CONFLICT` | 422 | `Idempotency-Key` reused with a different payload. `details: {idempotency_key}` |
| `INVALID_AMOUNT` | 422 | Amount outside min/max (service-level guard after validation) |
| `INVALID_CURRENCY` | 422 | Currency not in the merchant's `supported_currencies` |
| `PROVIDER_NOT_CONFIGURED` | 503 | Production provider requested but not enabled — cannot happen while `DEMO_MODE=true` |
| `PROVIDER_UNAVAILABLE` | 503 | Provider threw an unexpected error |
| `DEMO_ONLY` | 403 | Endpoint / action only available when `DEMO_MODE=true` |
| `QR_NOT_PENDING` | 409 | `demo-confirm` on a QR that is not `pending` |
| `QR_NOT_CANCELLABLE` | 409 | Cancel on a QR that is `paid`, `expired` or `declined` |
| `EVENT_NOT_ALLOWED` | 422 | Event type not client-reportable (validation normally reports `VALIDATION_ERROR` first) |
| `SERVER_ERROR` | 500 | Unhandled exception |
| `HTTP_<status>` | any | Any other HTTP exception, e.g. `HTTP_405` for a wrong verb |

### Request id and environment headers

Every response carries:

- `X-Request-Id` — echoed from the request header when it matches
  `^[A-Za-z0-9\-_]{8,64}$`, otherwise a server-generated UUID. Log it on the client; it is
  included in `SERVER_ERROR` bodies.
- `X-Environment` — `demo` or `production`.

### Rate limits

| Limiter | Limit | Keyed by | Applies to |
|---|---|---|---|
| `login` | 10 / min | client IP | `POST /auth/login` |
| `payments` | 60 / min | user id (IP if unauthenticated) | everything under `/payments/*` and `/qr/*` |
| `api` | 120 / min | user id / IP | Attached to every `/api/*` route via `throttleApi('api')` in `bootstrap/app.php`; `X-RateLimit-Limit` / `X-RateLimit-Remaining` headers are returned on all endpoints, including `/api/health`. |

Throttled routes return `X-RateLimit-Limit` and `X-RateLimit-Remaining`; a `429 RATE_LIMITED`
adds `Retry-After` and `X-RateLimit-Reset`.

**Login lockout** (independent of the throttle): after **5 failed attempts** for the same
`merchant_id` + IP the pair is locked for 60 s and login returns `429 TOO_MANY_ATTEMPTS`
with the remaining seconds in the message. A successful login clears the counter. Failed
logins are audit-logged.

## Demo / sandbox behaviour

`GET /demo/config` (and the `environment` block in login / me) exposes the live settings.
Defaults from `config/softpos.php` / `.env`:

| Setting | Env var | Default | Meaning |
|---|---|---|---|
| Demo mode | `DEMO_MODE` | `true` | Everything routed to the demo provider |
| Success rate | `DEMO_PAYMENT_SUCCESS_RATE` | `0.90` | Probability of approval when nothing forces an outcome |
| Processing delay | `DEMO_PROCESSING_DELAY` | `2` s | Time a transaction stays `processing` after authorize |
| Pending settle delay | `DEMO_PENDING_RESOLVE_DELAY` | `20` s | Time a `pending` transaction waits before becoming `completed` |
| QR expiry | `DEMO_QR_EXPIRATION` | `120` s (min 10) | QR lifetime |
| QR auto-confirm | `DEMO_QR_AUTO_CONFIRM_AFTER` | `0` (off) | Auto-"pay" a pending QR after N seconds on read |
| Magic amounts | `DEMO_MAGIC_AMOUNTS` | `true` | Enable the cent rules below |
| Forced scenario | `DEMO_ALLOW_FORCED_SCENARIO` | `true` | Honour `demo_scenario` in requests |
| Payment expiry | `PAYMENT_EXPIRATION` | `900` s | Unauthorized transaction → `timeout` |
| NFC timeout | `NFC_TIMEOUT` | `60` s | Hint for the app's tap screen |
| Max amount | `PAYMENT_MAX_AMOUNT` | `100000` | Upper validation bound (min is 0.01) |

### How an outcome is chosen

The outcome is decided **when the payment/QR is created** (echoed as `demo_scenario`), in
this order of precedence:

1. **`demo_scenario`** in the request body: `success`, `declined`, `timeout`, `cancelled`, `pending`.
2. **Magic amounts** — the cents of the amount:

   | Amount ends in | Outcome |
   |---|---|
   | `.01` | `declined` (random issuer code 05/51/54/61/65) |
   | `.02` | `pending`, then auto-settles to `completed` after `DEMO_PENDING_RESOLVE_DELAY` |
   | `.03` | `timeout` (`decline_code: TIMEOUT`) |
   | `.04` | `cancelled` (by the "provider") |
   | anything else | falls through to the random draw |

3. **Random draw** against `success_rate`: `success` or `declined`.

### Timing and polling

- `POST /payments/{id}/authorize` moves the transaction to `processing` and returns
  `retry_after` (seconds). The engine resolves it after `DEMO_PROCESSING_DELAY`.
- **Resolution is lazy.** There is no queue worker or scheduler: the state advances the
  next time the transaction is *read* — `GET /payments/{id}`, `/status`, `/receipt`,
  `GET /transactions`, `GET /merchant/dashboard`, or any `/qr/.../status` call. Poll
  `GET /payments/{id}/status` after `retry_after` seconds until `is_final` is true.
- A `pending` transaction reports `retry_after` ≈ 20 s and becomes `completed`
  (`approval_code` set, `card_brand` for cards) on the first read after that.
- A transaction that is never authorized becomes `timeout` on the first read after
  `expires_at` (created + 900 s). An expired QR becomes `expired` and its transaction
  `timeout` with `decline_code: QR_EXPIRED`.

### Demo data

Approved card payments get `card_brand` `Visa` or `Mastercard`, a random `card_token`
(`demo_tok_…`) and a 6-digit `approval_code`. The QR `payload` uses the sandbox format
`softpos-demo-v1` (`softpos://pay?...&sig=<hmac>`), **not** the iBRAC specification.

## Idempotency

`POST /payments` and `POST /qr/create` accept an **`Idempotency-Key`** header so the app can
safely retry after a network failure.

- Any string; trimmed and truncated to **120 characters**. A UUID per checkout attempt is recommended.
- Scoped to the merchant; remembered for `IDEMPOTENCY_TTL_HOURS` (**24 h**).
- The fingerprint is `amount + currency + payment_method + description`. `demo_scenario`
  and `metadata` are **not** part of it.
- Same key, same fingerprint → the **original transaction** is returned. On `POST /payments`
  this is HTTP `200` with `Idempotent-Replayed: true` and `idempotent_replay: true` in the
  body (a first-time create is `201` with `Idempotent-Replayed: false`). `POST /qr/create`
  behaves the same way: the existing QR is returned with `200` and `Idempotent-Replayed: true`.
- Same key, different fingerprint → `422 IDEMPOTENCY_CONFLICT`.
- Concurrent duplicates are resolved by a database unique constraint; the loser receives
  the winner's transaction as a replay.

`POST /payments/{id}/authorize` and the cancel endpoints are naturally idempotent: calling
authorize on a `processing`/`pending` transaction, or cancel on an already cancelled
transaction/QR, returns the current state without side effects.

---

## Endpoints

Common headers on protected endpoints (not repeated below):

| Header | Direction | Notes |
|---|---|---|
| `Authorization: Bearer <token>` | request | Required on all routes except `/health`, `/auth/login` |
| `Accept: application/json` | request | Recommended |
| `X-Terminal-Code` | request, optional | Active terminal code, e.g. `POS-DEMO-001` |
| `X-Request-Id` | request, optional | 8–64 chars `[A-Za-z0-9_-]`; echoed back |
| `X-Request-Id`, `X-Environment` | response | Always present |
| `X-RateLimit-Limit`, `X-RateLimit-Remaining` | response | On throttled routes (login, payments, qr) |

Common errors on protected endpoints: `401 UNAUTHENTICATED`, `403 ACCOUNT_INACTIVE`; on
`/payments/*` and `/qr/*` additionally `429 RATE_LIMITED`.

In the curl examples `$BASE` is the base URL and `$TOKEN` the bearer token:

```bash
BASE=http://localhost:8000/api
TOKEN=$(curl -s $BASE/auth/login -H 'Accept: application/json' -H 'Content-Type: application/json' \
  -d '{"merchant_id":"DEMO-MERCHANT-001","password":"Demo@12345"}' | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["token"])')
```

---

### GET /health

| | |
|---|---|
| **Auth** | none |
| **Rate limit** | none |

Runs `select 1` against the database. Returns `200` when it succeeds, `503` with the same
body and `database: "unavailable"` when it fails.

**Response**

```json
{
  "data": {
    "service": "soft-pos-api",
    "version": "1.0.0",
    "environment": "DEMO / SANDBOX",
    "demo_mode": true,
    "database": "ok",
    "time": "2026-09-07T19:06:01+00:00"
  }
}
```

**curl**

```bash
curl -s $BASE/health -H 'Accept: application/json'
```

---

### POST /auth/login

| | |
|---|---|
| **Auth** | none |
| **Rate limit** | `login` 10/min per IP + 5-failure lockout per merchant id + IP |

Exchanges credentials for a bearer token. `merchant_id` is matched case-insensitively.
If `terminal_code` names an *active* terminal of the merchant it is bound to the session,
otherwise the first active terminal is used; `device_id`, `platform`, `app_version` are
stored on that terminal.

**Request body**

| Field | Type | Rules |
|---|---|---|
| `merchant_id` | string | required, max 60 |
| `password` | string | required, max 200 |
| `terminal_code` | string | optional, max 40 |
| `device_id` | string | optional, max 120 |
| `device_name` | string | optional, max 120 (validated, not stored) |
| `platform` | string | optional, one of `android`, `ios`, `web`, `other` |
| `app_version` | string | optional, max 20 |

**Response 200**

```json
{
  "data": {
    "token": "4|kgOiLQfJNgDRpnVkyYyRTdyZQMUYmlzB6HFMa7s046928085",
    "token_type": "Bearer",
    "expires_at": "2026-09-08T07:04:48+00:00",
    "user": {
      "id": 1, "username": "DEMO-MERCHANT-001", "name": "Demo Merchant",
      "email": "merchant@softpos.example", "role": "merchant",
      "last_login_at": "2026-09-07T19:04:48+00:00"
    },
    "merchant": {
      "id": "01a07d3e-eb77-72a7-8fad-8a87dc88296a",
      "code": "DEMO-MERCHANT-001",
      "name": "Al-Quds Demo Store",
      "name_ar": "متجر القدس التجريبي",
      "country": "PS", "city": "Jerusalem", "address": "Salah Al-Din St. 12",
      "phone": "+970 2 000 0000", "email": "demo@softpos.example",
      "default_currency": "ILS",
      "supported_currencies": ["ILS", "USD", "JOD"],
      "status": "active", "demo": true,
      "terminals": [
        { "id": "01a07d3e-eca4-7296-aed7-d7838fbdd828", "code": "POS-DEMO-001", "label": "Main counter", "platform": "android", "status": "active", "nfc_capable": true, "last_seen_at": "2026-09-07T19:04:48+00:00" },
        { "id": "01a07d3e-eca8-71cf-a225-6dd0e23900e5", "code": "POS-DEMO-002", "label": "Mobile cashier", "platform": "android", "status": "inactive", "nfc_capable": true, "last_seen_at": null }
      ]
    },
    "terminal": { "id": "01a07d3e-eca4-7296-aed7-d7838fbdd828", "code": "POS-DEMO-001", "label": "Main counter", "platform": "android", "status": "active", "nfc_capable": true, "last_seen_at": "2026-09-07T19:04:48+00:00" },
    "environment": {
      "demo_mode": true, "environment": "DEMO / SANDBOX",
      "success_rate": 0.9, "processing_delay": 2, "pending_resolve_delay": 20,
      "qr_expiration": 120, "qr_auto_confirm_after": 0,
      "magic_amounts": true,
      "magic_amount_rules": { "1": "declined", "2": "pending", "3": "timeout", "4": "cancelled" },
      "allow_forced_scenario": true,
      "scenarios": ["success", "declined", "timeout", "cancelled", "pending"]
    }
  }
}
```

**Errors**

| HTTP | code |
|---|---|
| 401 | `INVALID_CREDENTIALS` — `Merchant ID or password is incorrect.` |
| 403 | `ACCOUNT_INACTIVE` |
| 422 | `VALIDATION_ERROR` |
| 429 | `TOO_MANY_ATTEMPTS` — `Too many login attempts. Please try again in 59 seconds.` |
| 429 | `RATE_LIMITED` (more than 10/min from this IP) |

**curl**

```bash
curl -s $BASE/auth/login \
  -H 'Accept: application/json' -H 'Content-Type: application/json' \
  -d '{"merchant_id":"DEMO-MERCHANT-001","password":"Demo@12345","terminal_code":"POS-DEMO-001","device_id":"emulator-5554","platform":"android","app_version":"1.0.0"}'
```

---

### POST /auth/logout

| | |
|---|---|
| **Auth** | bearer |

Deletes the token used for the request. No body.

**Response 200**

```json
{ "data": { "logged_out": true } }
```

**curl**

```bash
curl -s -X POST $BASE/auth/logout -H "Authorization: Bearer $TOKEN" -H 'Accept: application/json'
```

---

### GET /auth/me

| | |
|---|---|
| **Auth** | bearer |

Returns `user`, `merchant` (with `terminals`), the resolved `terminal` and the
`environment` snapshot — the same objects as the login response minus the token.

**Response 200**

```json
{
  "data": {
    "user": { "id": 1, "username": "DEMO-MERCHANT-001", "name": "Demo Merchant", "email": "merchant@softpos.example", "role": "merchant", "last_login_at": "2026-09-07T19:04:48+00:00" },
    "merchant": { "...": "as in login" },
    "terminal": { "id": "01a07d3e-eca4-7296-aed7-d7838fbdd828", "code": "POS-DEMO-001", "label": "Main counter", "platform": "android", "status": "active", "nfc_capable": true, "last_seen_at": "2026-09-07T19:04:48+00:00" },
    "environment": { "...": "as in login" }
  }
}
```

**curl**

```bash
curl -s $BASE/auth/me -H "Authorization: Bearer $TOKEN" -H 'Accept: application/json'
```

---

### GET /merchant/profile

| | |
|---|---|
| **Auth** | bearer |

Merchant details, resolved terminal, enabled payment methods (ordered by `sort_order`),
supported currencies with symbols and the amount / NFC limits.

**Response 200**

```json
{
  "data": {
    "merchant": { "...": "as in login, with terminals" },
    "terminal": { "code": "POS-DEMO-001", "...": "" },
    "payment_methods": [
      { "code": "contactless_card", "name": "Contactless Card", "name_ar": "بطاقة لا تلامسية", "provider": "demo", "icon": "contactless", "is_enabled": true },
      { "code": "ibrac_qr", "name": "iBRAC QR", "name_ar": "iBRAC QR", "provider": "demo", "icon": "qr", "is_enabled": true }
    ],
    "currencies": [
      { "code": "ILS", "symbol": "₪" },
      { "code": "USD", "symbol": "$" },
      { "code": "JOD", "symbol": "JD" }
    ],
    "limits": { "min_amount": 0.01, "max_amount": 100000, "nfc_timeout": 60 }
  }
}
```

**curl**

```bash
curl -s $BASE/merchant/profile -H "Authorization: Bearer $TOKEN" -H 'Accept: application/json'
```

---

### GET /merchant/dashboard

| | |
|---|---|
| **Auth** | bearer |

Today's figures (server date): counts by UI filter group, completed-sales totals per
currency (`sales` = the merchant's default currency, or the first currency with sales, or
zeros), the terminal card and the 8 most recent transactions (lazily settled).

Group definitions: `approved` = completed + authorized · `declined` = declined + timeout ·
`pending` = pending + processing + initiated + created · `cancelled` = cancelled.

**Response 200**

```json
{
  "data": {
    "environment": "DEMO / SANDBOX",
    "demo_mode": true,
    "date": "2026-09-07",
    "today": {
      "sales": { "currency": "ILS", "symbol": "₪", "total": "378.00", "count": 6 },
      "sales_by_currency": [ { "currency": "ILS", "symbol": "₪", "total": "378.00", "count": 6 } ],
      "transactions": 19, "approved": 6, "declined": 3, "pending": 6, "cancelled": 4
    },
    "terminal": { "code": "POS-DEMO-001", "label": "Main counter", "status": "active", "online": true, "nfc_capable": true, "last_seen_at": "2026-09-07T19:04:48+00:00" },
    "recent_transactions": [ { "id": "01a07d42-426e-71cb-b9a8-0e4c735cfd15", "reference": "TXN-20260907-000013", "status": "completed", "...": "Transaction objects, max 8" } ]
  }
}
```

**curl**

```bash
curl -s $BASE/merchant/dashboard -H "Authorization: Bearer $TOKEN" -H 'Accept: application/json'
```

---

### POST /payments

| | |
|---|---|
| **Auth** | bearer |
| **Rate limit** | `payments` 60/min |
| **Headers** | `Idempotency-Key` (optional, ≤120 chars) · response `Idempotent-Replayed: true\|false` |

Creates a transaction and registers it with the (demo) provider. The response is already
in status **`initiated`** (`created → initiated` is synchronous). The demo outcome is
decided here (`demo_scenario` > magic cents > success rate) and echoed as `demo_scenario`.
The transaction must be authorized within 900 s or it times out.

`payment_method: ibrac_qr` is accepted but creates a bare transaction without a QR record —
use `POST /qr/create` for the QR flow.

**Request body**

| Field | Type | Rules |
|---|---|---|
| `amount` | number or numeric string | required, numeric, **0.01 – 100000**, regex `^\d{1,9}(\.\d{1,2})?$` (max 2 decimals) |
| `currency` | string | required, size 3, one of `ILS`, `USD`, `JOD` (lower-case is upper-cased) |
| `payment_method` | string | required, one of `contactless_card`, `ibrac_qr` |
| `description` | string | optional, max 140 — part of the idempotency fingerprint |
| `demo_scenario` | string | optional, one of `success`, `declined`, `timeout`, `cancelled`, `pending` |
| `metadata` | object | optional, max 20 keys, every value scalar (sensitive keys are masked) |

**Response 201** (`Idempotent-Replayed: false`)

```json
{
  "data": {
    "id": "01a07d42-426e-71cb-b9a8-0e4c735cfd15",
    "reference": "TXN-20260907-000013",
    "amount": "25.50",
    "currency": "ILS",
    "currency_symbol": "₪",
    "formatted_amount": "₪ 25.50",
    "payment_method": "contactless_card",
    "payment_method_label": "Contactless Card",
    "entry_mode": "contactless",
    "status": "initiated",
    "status_label": "INITIATED",
    "is_final": false,
    "provider": "demo",
    "provider_reference": "DEMO-TFFEAZDUKS93",
    "demo_mode": true,
    "demo_scenario": "success",
    "card_brand": null,
    "card_token": null,
    "approval_code": null,
    "decline_code": null,
    "decline_reason": null,
    "description": "Coffee and cake",
    "terminal": "POS-DEMO-001",
    "timestamps": {
      "created_at": "2026-09-07T19:04:49+00:00",
      "initiated_at": "2026-09-07T19:04:49+00:00",
      "processing_at": null, "authorized_at": null, "completed_at": null,
      "failed_at": null, "cancelled_at": null,
      "expires_at": "2026-09-07T19:19:49+00:00",
      "updated_at": "2026-09-07T19:04:49+00:00"
    },
    "qr": null
  }
}
```

A replay returns **200** with `Idempotent-Replayed: true` and the extra field
`"idempotent_replay": true`.

**Errors**

| HTTP | code |
|---|---|
| 422 | `VALIDATION_ERROR` — e.g. `details: {"amount": ["The amount field must be at least 0.01.", "The amount must have at most two decimal places."], "currency": ["The selected currency is not supported."], "payment_method": ["The selected payment method is not supported."]}` |
| 422 | `IDEMPOTENCY_CONFLICT` — `details: {"idempotency_key": "..."}` |
| 422 | `INVALID_AMOUNT`, `INVALID_CURRENCY` (service-level guards; `INVALID_CURRENCY` triggers when the merchant's own currency list is narrower than the global one) |
| 503 | `PROVIDER_NOT_CONFIGURED`, `PROVIDER_UNAVAILABLE` |

**curl**

```bash
curl -s $BASE/payments \
  -H "Authorization: Bearer $TOKEN" -H 'Accept: application/json' -H 'Content-Type: application/json' \
  -H 'X-Terminal-Code: POS-DEMO-001' -H "Idempotency-Key: $(uuidgen)" \
  -d '{"amount":"25.50","currency":"ILS","payment_method":"contactless_card","description":"Coffee and cake","demo_scenario":"success","metadata":{"order_id":"ORD-1001"}}'
```

---

### GET /payments/{id}

| | |
|---|---|
| **Auth** | bearer |
| **Rate limit** | `payments` 60/min |
| **Path** | `id` — transaction UUID or reference |

Full transaction with `events` (timeline), `attempts` (provider calls) and `qr` (null for
card payments). Reading lazily advances the demo engine (see *Timing and polling*).

**Response 200**

```json
{
  "data": {
    "id": "01a07d42-426e-71cb-b9a8-0e4c735cfd15",
    "reference": "TXN-20260907-000013",
    "amount": "25.50", "currency": "ILS", "currency_symbol": "₪", "formatted_amount": "₪ 25.50",
    "payment_method": "contactless_card", "payment_method_label": "Contactless Card", "entry_mode": "contactless",
    "status": "completed", "status_label": "APPROVED", "is_final": true,
    "provider": "demo", "provider_reference": "DEMO-TFFEAZDUKS93",
    "demo_mode": true, "demo_scenario": "success",
    "card_brand": "Visa", "card_token": "demo_tok_amoun8qlsga2cbdjoy4bmmtp",
    "approval_code": "367121", "decline_code": null, "decline_reason": null,
    "description": "Coffee and cake", "terminal": "POS-DEMO-001",
    "timestamps": {
      "created_at": "2026-09-07T19:04:49+00:00", "initiated_at": "2026-09-07T19:04:49+00:00",
      "processing_at": "2026-09-07T19:04:51+00:00", "authorized_at": "2026-09-07T19:04:55+00:00",
      "completed_at": "2026-09-07T19:04:55+00:00", "failed_at": null, "cancelled_at": null,
      "expires_at": "2026-09-07T19:19:49+00:00", "updated_at": "2026-09-07T19:04:55+00:00"
    },
    "events": [
      { "id": 177, "event_type": "PAYMENT_CREATED",    "from_status": null,        "to_status": "created",    "source": "server",   "metadata": { "amount": "25.50", "currency": "ILS", "method": "contactless_card", "provider": "demo" }, "occurred_at": "2026-09-07T19:04:49+00:00" },
      { "id": 178, "event_type": "PAYMENT_INITIATED",  "from_status": "created",   "to_status": "initiated",  "source": "server",   "metadata": { "provider_reference": "DEMO-TFFEAZDUKS93" }, "occurred_at": "2026-09-07T19:04:49+00:00" },
      { "id": 179, "event_type": "NFC_SESSION_STARTED","from_status": null,        "to_status": null,         "source": "terminal", "metadata": { "nfc_mode": "reader", "android_sdk": 34 }, "occurred_at": "2026-09-07T19:04:50+00:00" },
      { "id": 180, "event_type": "PAYMENT_PROCESSING", "from_status": "initiated", "to_status": "processing", "source": "server",   "metadata": { "channel": "nfc", "nfc_mode": "reader" }, "occurred_at": "2026-09-07T19:04:51+00:00" },
      { "id": 181, "event_type": "PAYMENT_AUTHORIZED", "from_status": "processing","to_status": "authorized", "source": "server",   "metadata": { "approval_code": "367121" }, "occurred_at": "2026-09-07T19:04:55+00:00" },
      { "id": 182, "event_type": "PAYMENT_APPROVED",   "from_status": "authorized","to_status": "completed",  "source": "server",   "metadata": { "approval_code": "367121" }, "occurred_at": "2026-09-07T19:04:55+00:00" }
    ],
    "attempts": [
      { "attempt_no": 1, "provider": "demo", "action": "create",    "status": "ok", "result_status": "initiated",  "error_code": null, "error_message": null, "duration_ms": 0, "finished_at": "2026-09-07T19:04:49+00:00" },
      { "attempt_no": 2, "provider": "demo", "action": "authorize", "status": "ok", "result_status": "processing", "error_code": null, "error_message": null, "duration_ms": 0, "finished_at": "2026-09-07T19:04:51+00:00" },
      { "attempt_no": 3, "provider": "demo", "action": "status",    "status": "ok", "result_status": "authorized", "error_code": null, "error_message": null, "duration_ms": 0, "finished_at": "2026-09-07T19:04:55+00:00" }
    ],
    "qr": null
  }
}
```

**Errors:** 404 `NOT_FOUND` (`Transaction not found.`).

**curl**

```bash
curl -s $BASE/payments/TXN-20260907-000013 -H "Authorization: Bearer $TOKEN" -H 'Accept: application/json'
```

---

### GET /payments/{id}/status

| | |
|---|---|
| **Auth** | bearer |
| **Rate limit** | `payments` 60/min |

Lightweight polling projection. `retry_after` (seconds, ≥1) is present while the
transaction is `processing` / `pending` and `null` once final. Same lazy-resolution side
effect as the full endpoint.

**Response 200**

```json
{ "data": { "id": "01a07d42-426e-71cb-b9a8-0e4c735cfd15", "reference": "TXN-20260907-000013", "status": "processing", "status_label": "PROCESSING", "is_final": false, "card_brand": null, "approval_code": null, "decline_code": null, "decline_reason": null, "demo_mode": true, "retry_after": 1, "updated_at": "2026-09-07T19:04:51+00:00" } }
```

Two seconds later:

```json
{ "data": { "id": "01a07d42-426e-71cb-b9a8-0e4c735cfd15", "reference": "TXN-20260907-000013", "status": "completed", "status_label": "APPROVED", "is_final": true, "card_brand": "Visa", "approval_code": "367121", "decline_code": null, "decline_reason": null, "demo_mode": true, "retry_after": null, "updated_at": "2026-09-07T19:04:55+00:00" } }
```

Other real outcomes:

```json
{ "data": { "reference": "TXN-20260907-000014", "status": "declined", "status_label": "DECLINED", "is_final": true, "decline_code": "51", "decline_reason": "Insufficient funds", "...": "" } }
{ "data": { "reference": "TXN-20260907-000020", "status": "pending",  "status_label": "PENDING",  "is_final": false, "retry_after": 19, "...": "" } }
{ "data": { "reference": "TXN-20260907-000021", "status": "timeout",  "status_label": "TIMEOUT",  "is_final": true, "decline_code": "TIMEOUT", "decline_reason": "Provider did not respond in time", "...": "" } }
```

**Errors:** 404 `NOT_FOUND`.

**curl**

```bash
curl -s $BASE/payments/TXN-20260907-000013/status -H "Authorization: Bearer $TOKEN" -H 'Accept: application/json'
```

---

### POST /payments/{id}/authorize

| | |
|---|---|
| **Auth** | bearer |
| **Rate limit** | `payments` 60/min |

Called after the card tap. Moves `initiated → processing` (a `created` transaction is
first moved to `initiated`) and asks the provider to authorize. The demo provider answers
"processing"; the engine resolves after `DEMO_PROCESSING_DELAY` (with a delay of `0` the
final state is returned directly). Idempotent while `processing` / `pending`; `409` on any
final status.

**Request body** (no validation; stored in the `PAYMENT_PROCESSING` event only)

| Field | Type | Rules |
|---|---|---|
| `channel` | string | optional, default `nfc` |
| `nfc_mode` | string | optional |

**Response 200**

```json
{
  "data": {
    "id": "01a07d42-426e-71cb-b9a8-0e4c735cfd15",
    "reference": "TXN-20260907-000013",
    "status": "processing", "status_label": "PROCESSING", "is_final": false,
    "demo_mode": true, "demo_scenario": "success",
    "retry_after": 1,
    "timestamps": { "processing_at": "2026-09-07T19:04:51+00:00", "...": "" },
    "...": "full Transaction object"
  }
}
```

**Errors**

| HTTP | code |
|---|---|
| 404 | `NOT_FOUND` |
| 409 | `INVALID_STATE_TRANSITION` — `Transaction TXN-20260907-000013 cannot move from completed to processing.` `details: {"from":"completed","to":"processing","reference":"TXN-20260907-000013"}` |
| 503 | `PROVIDER_UNAVAILABLE` |

**curl**

```bash
curl -s -X POST $BASE/payments/TXN-20260907-000013/authorize \
  -H "Authorization: Bearer $TOKEN" -H 'Accept: application/json' -H 'Content-Type: application/json' \
  -d '{"channel":"nfc","nfc_mode":"reader"}'
```

---

### POST /payments/{id}/cancel

| | |
|---|---|
| **Auth** | bearer |
| **Rate limit** | `payments` 60/min |

Cancels a transaction in `created`, `initiated`, `processing` or `pending`. Already
`cancelled` → `200` unchanged. `completed`, `declined`, `timeout` → `409`. A linked QR is
cancelled too.

**Request body**

| Field | Type | Rules |
|---|---|---|
| `reason` | string | optional, max 120 (defaults to `merchant_cancelled` in the event) |

**Response 200**

```json
{
  "data": {
    "id": "01a07d42-6782-71fb-beff-c8dfe132c50e",
    "reference": "TXN-20260907-000015",
    "amount": "5.00", "currency": "JOD", "currency_symbol": "JD", "formatted_amount": "JD 5.00",
    "status": "cancelled", "status_label": "CANCELLED", "is_final": true,
    "timestamps": { "cancelled_at": "2026-09-07T19:04:59+00:00", "...": "" },
    "...": "full Transaction object"
  }
}
```

**Errors**

| HTTP | code |
|---|---|
| 404 | `NOT_FOUND` |
| 409 | `INVALID_STATE_TRANSITION` — `details: {"from":"completed","to":"cancelled","reference":"..."}` |
| 422 | `VALIDATION_ERROR` — `The reason field must be a string.` |

**curl**

```bash
curl -s -X POST $BASE/payments/TXN-20260907-000015/cancel \
  -H "Authorization: Bearer $TOKEN" -H 'Accept: application/json' -H 'Content-Type: application/json' \
  -d '{"reason":"customer_changed_mind"}'
```

---

### POST /payments/{id}/events

| | |
|---|---|
| **Auth** | bearer |
| **Rate limit** | `payments` 60/min |

Appends a terminal-reported event (`source: terminal`) to the timeline. Never changes the
transaction status; allowed on any status.

**Request body**

| Field | Type | Rules |
|---|---|---|
| `event_type` | string | required, one of `NFC_SESSION_STARTED`, `NFC_SESSION_STOPPED`, `NFC_TIMEOUT`, `CARD_DETECTED_DEMO` |
| `metadata` | object | optional, max 20 keys, scalar values only |

**Response 201**

```json
{ "data": { "id": 179, "event_type": "NFC_SESSION_STARTED", "from_status": null, "to_status": null, "source": "terminal", "metadata": { "nfc_mode": "reader", "android_sdk": 34 }, "occurred_at": "2026-09-07T19:04:50+00:00" } }
```

**Errors**

| HTTP | code |
|---|---|
| 404 | `NOT_FOUND` |
| 422 | `VALIDATION_ERROR` — `details: {"event_type": ["The selected event type is invalid."]}` |
| 422 | `EVENT_NOT_ALLOWED` — service-level guard, only reachable if validation is bypassed |

**curl**

```bash
curl -s $BASE/payments/TXN-20260907-000013/events \
  -H "Authorization: Bearer $TOKEN" -H 'Accept: application/json' -H 'Content-Type: application/json' \
  -d '{"event_type":"NFC_SESSION_STARTED","metadata":{"nfc_mode":"reader","android_sdk":34}}'
```

---

### GET /payments/{id}/receipt

| | |
|---|---|
| **Auth** | bearer |
| **Rate limit** | `payments` 60/min |

Printable receipt projection for any transaction (declined ones carry `decline_reason`).
Each call records a `RECEIPT_GENERATED` event. `date` is `completed_at`, or `updated_at`
when the transaction never completed.

**Response 200**

```json
{
  "data": {
    "brand": "SOFT POS",
    "environment": "DEMO / SANDBOX",
    "merchant": { "name": "Al-Quds Demo Store", "name_ar": "متجر القدس التجريبي", "code": "DEMO-MERCHANT-001", "address": "Salah Al-Din St. 12", "city": "Jerusalem", "country": "PS" },
    "terminal": "POS-DEMO-001",
    "reference": "TXN-20260907-000013",
    "provider_reference": "DEMO-TFFEAZDUKS93",
    "amount": "25.50", "currency": "ILS", "currency_symbol": "₪", "formatted_amount": "₪ 25.50",
    "payment_method": "contactless_card", "payment_method_label": "Contactless Card", "entry_mode": "contactless",
    "card_brand": "Visa",
    "status": "completed", "status_label": "APPROVED",
    "approval_code": "367121", "decline_reason": null,
    "date": "2026-09-07T19:04:55+00:00",
    "demo_mode": true,
    "footer": "DEMO / SANDBOX — No real funds were moved."
  }
}
```

**Errors:** 404 `NOT_FOUND`.

**curl**

```bash
curl -s $BASE/payments/TXN-20260907-000013/receipt -H "Authorization: Bearer $TOKEN" -H 'Accept: application/json'
```

---

### GET /transactions

| | |
|---|---|
| **Auth** | bearer |
| **Rate limit** | none (outside the `payments` limiter) |

Paginated history (card + QR) for the merchant, newest first. Items are `Transaction`
objects **without** `events` / `attempts` / `qr`. In-flight demo transactions on the page
are lazily settled before serialisation.

**Query parameters**

| Param | Type | Rules |
|---|---|---|
| `status` | string | `all` (default), a group `approved` / `declined` / `pending` / `cancelled`, or a raw status `created` / `initiated` / `processing` / `authorized` / `completed` / `pending` / `declined` / `cancelled` / `timeout` |
| `payment_method` | string | `contactless_card` / `ibrac_qr` |
| `currency` | string | `ILS` / `USD` / `JOD` |
| `date_from` | date | inclusive, start of day |
| `date_to` | date | inclusive, end of day; must be `>= date_from` |
| `amount_min` | number | `>= 0` |
| `amount_max` | number | `>= 0`, `>= amount_min` |
| `search` | string | max 60; substring match on `reference`, `provider_reference`, `description` |
| `per_page` | integer | 1–100, default 20 |
| `page` | integer | `>= 1` |

**Response 200**

```json
{
  "data": [
    { "id": "01a07d42-75dc-7252-8ca6-f3105a58d808", "reference": "TXN-20260907-000018", "amount": "40.00", "currency": "ILS", "payment_method": "ibrac_qr", "status": "completed", "status_label": "APPROVED", "approval_code": "355817", "...": "" },
    { "id": "01a07d42-426e-71cb-b9a8-0e4c735cfd15", "reference": "TXN-20260907-000013", "amount": "25.50", "currency": "ILS", "payment_method": "contactless_card", "status": "completed", "status_label": "APPROVED", "card_brand": "Visa", "approval_code": "367121", "...": "" }
  ],
  "links": {
    "first": "http://localhost:8000/api/transactions?status=approved&per_page=2&currency=ILS&page=1",
    "last":  "http://localhost:8000/api/transactions?status=approved&per_page=2&currency=ILS&page=8",
    "prev": null,
    "next":  "http://localhost:8000/api/transactions?status=approved&per_page=2&currency=ILS&page=2"
  },
  "meta": {
    "current_page": 1, "from": 1, "last_page": 8, "per_page": 2, "to": 2, "total": 15,
    "path": "http://localhost:8000/api/transactions",
    "links": [ { "url": null, "label": "&laquo; Previous", "page": null, "active": false }, { "url": "...page=1", "label": "1", "page": 1, "active": true }, { "url": "...page=2", "label": "Next &raquo;", "page": 2, "active": false } ],
    "filters": { "status": "approved", "currency": "ILS", "per_page": "2" }
  }
}
```

**Errors:** 422 `VALIDATION_ERROR` — e.g. `details: {"status": ["The selected status is invalid."], "per_page": ["The per page field must not be greater than 100."]}`.

**curl**

```bash
curl -s "$BASE/transactions?status=approved&currency=ILS&date_from=2026-09-01&per_page=2" \
  -H "Authorization: Bearer $TOKEN" -H 'Accept: application/json'
```

---

### GET /transactions/{id}

| | |
|---|---|
| **Auth** | bearer |
| **Rate limit** | none |

Identical to [`GET /payments/{id}`](#get-paymentsid) (full detail with `events`,
`attempts`, `qr`) but not subject to the `payments` throttle.

```bash
curl -s $BASE/transactions/TXN-20260907-000013 -H "Authorization: Bearer $TOKEN" -H 'Accept: application/json'
```

---

### POST /qr/create

| | |
|---|---|
| **Auth** | bearer |
| **Rate limit** | `payments` 60/min |
| **Headers** | `Idempotency-Key` optional · response `Idempotent-Replayed: true\|false` (a replay returns the existing QR with `200`) |

Creates an `ibrac_qr` transaction (`initiated`) plus a QR record (`pending`) with a signed
payload to render as a QR code. The QR expires after `DEMO_QR_EXPIRATION` (120 s);
`seconds_remaining` counts down. The demo outcome is decided here exactly as for card
payments and applies when the QR is confirmed.

**Request body**

| Field | Type | Rules |
|---|---|---|
| `amount` | number or numeric string | required, 0.01 – 100000, max 2 decimals |
| `currency` | string | required, `ILS` / `USD` / `JOD` |
| `description` | string | optional, max 140 |
| `demo_scenario` | string | optional, `success` / `declined` / `timeout` / `cancelled` / `pending` |

**Response 201**

```json
{
  "data": {
    "id": "01a07d42-75f1-71ee-89cf-5e7724df1891",
    "reference": "QR-20260907-000006",
    "transaction_id": "01a07d42-75dc-7252-8ca6-f3105a58d808",
    "transaction_reference": "TXN-20260907-000018",
    "transaction_status": "initiated",
    "merchant_identifier": "DEMO-MERCHANT-001",
    "amount": "40.00", "currency": "ILS", "currency_symbol": "₪",
    "payload": "softpos://pay?v=softpos-demo-v1&ref=QR-20260907-000006&mid=DEMO-MERCHANT-001&amt=40.00&cur=ILS&exp=1788808022&env=demo&sig=6ae6369cee7bd10d",
    "payload_format": "softpos-demo-v1",
    "status": "pending",
    "demo_mode": true,
    "expires_at": "2026-09-07T19:07:02+00:00",
    "seconds_remaining": 119,
    "scanned_at": null, "paid_at": null, "cancelled_at": null,
    "created_at": "2026-09-07T19:05:02+00:00",
    "transaction": {
      "id": "01a07d42-75dc-7252-8ca6-f3105a58d808", "reference": "TXN-20260907-000018",
      "payment_method": "ibrac_qr", "payment_method_label": "iBRAC QR", "entry_mode": "qr",
      "status": "initiated", "status_label": "INITIATED", "is_final": false,
      "demo_mode": true, "demo_scenario": "success", "description": "Table 4",
      "...": "full Transaction object"
    }
  }
}
```

**Errors:** 422 `VALIDATION_ERROR` (e.g. `{"amount": ["The amount field must not be greater than 100000."], "demo_scenario": ["The selected demo scenario is invalid."]}`), `IDEMPOTENCY_CONFLICT`, `INVALID_AMOUNT`, `INVALID_CURRENCY`; 503 provider errors.

**curl**

```bash
curl -s $BASE/qr/create \
  -H "Authorization: Bearer $TOKEN" -H 'Accept: application/json' -H 'Content-Type: application/json' \
  -d '{"amount":"40.00","currency":"ILS","description":"Table 4"}'
```

---

### GET /qr/{reference}/status

| | |
|---|---|
| **Auth** | bearer |
| **Rate limit** | `payments` 60/min |
| **Path** | `reference` — `QR-…` reference or QR UUID |

Returns the QR with its nested `transaction`. Polling this endpoint drives the demo
lifecycle: resolves the transaction after demo-confirm, marks expired QRs `expired`
(transaction → `timeout`, `decline_code: QR_EXPIRED`) and auto-confirms when
`DEMO_QR_AUTO_CONFIRM_AFTER > 0`.

**Response 200** (after demo-confirm and the processing delay)

```json
{
  "data": {
    "reference": "QR-20260907-000006",
    "transaction_reference": "TXN-20260907-000018",
    "transaction_status": "completed",
    "status": "paid",
    "seconds_remaining": 0,
    "scanned_at": "2026-09-07T19:05:02+00:00",
    "paid_at": "2026-09-07T19:05:05+00:00",
    "transaction": { "status": "completed", "status_label": "APPROVED", "is_final": true, "approval_code": "355817", "...": "" },
    "...": "as in create"
  }
}
```

**Errors:** 404 `NOT_FOUND` (`QR payment not found.`).

**curl**

```bash
curl -s $BASE/qr/QR-20260907-000006/status -H "Authorization: Bearer $TOKEN" -H 'Accept: application/json'
```

---

### POST /qr/{reference}/cancel

| | |
|---|---|
| **Auth** | bearer |
| **Rate limit** | `payments` 60/min |

Cancels an open QR (`pending` / `scanned`) and its transaction (event reason
`qr_cancelled`). Idempotent on an already cancelled QR. No body.

**Response 200**

```json
{
  "data": {
    "reference": "QR-20260907-000008",
    "transaction_reference": "TXN-20260907-000023",
    "transaction_status": "cancelled",
    "status": "cancelled",
    "seconds_remaining": 0,
    "cancelled_at": "2026-09-07T19:05:58+00:00",
    "transaction": { "status": "cancelled", "status_label": "CANCELLED", "is_final": true, "...": "" },
    "...": ""
  }
}
```

**Errors**

| HTTP | code |
|---|---|
| 404 | `NOT_FOUND` |
| 409 | `QR_NOT_CANCELLABLE` — `This QR can no longer be cancelled (declined).` |

**curl**

```bash
curl -s -X POST $BASE/qr/QR-20260907-000008/cancel -H "Authorization: Bearer $TOKEN" -H 'Accept: application/json'
```

---

### POST /qr/{reference}/demo-confirm

| | |
|---|---|
| **Auth** | bearer |
| **Rate limit** | `payments` 60/min |
| **Middleware** | `demo` — `403 DEMO_ONLY` unless `DEMO_MODE=true` |

Simulates the customer's banking app scanning and paying the QR. Only a `pending` QR can
be confirmed. Marks the QR `scanned`, records `QR_SCANNED_DEMO` and authorizes the
transaction (`initiated → processing`). The response shows `status: scanned` /
`transaction_status: processing`; poll `GET /qr/{reference}/status` after
`transaction.retry_after` seconds to see `paid` (or `declined` etc.).

**Request body**

| Field | Type | Rules |
|---|---|---|
| `demo_scenario` | string | optional; overrides the scenario chosen at creation |

**Response 200**

```json
{
  "data": {
    "reference": "QR-20260907-000006",
    "transaction_status": "processing",
    "status": "scanned",
    "scanned_at": "2026-09-07T19:05:02+00:00",
    "paid_at": null,
    "transaction": { "status": "processing", "status_label": "PROCESSING", "is_final": false, "retry_after": 1, "...": "" },
    "...": ""
  }
}
```

**Errors**

| HTTP | code |
|---|---|
| 403 | `DEMO_ONLY` — `This endpoint is only available in DEMO mode.` |
| 404 | `NOT_FOUND` |
| 409 | `QR_NOT_PENDING` — `This QR is no longer awaiting payment (paid).` |
| 422 | `VALIDATION_ERROR` — invalid `demo_scenario` |

**curl**

```bash
curl -s -X POST $BASE/qr/QR-20260907-000006/demo-confirm \
  -H "Authorization: Bearer $TOKEN" -H 'Accept: application/json' -H 'Content-Type: application/json' \
  -d '{"demo_scenario":"success"}'
```

---

### GET /demo/config

| | |
|---|---|
| **Auth** | bearer |

The engine snapshot plus provider summary and NFC timeout.

**Response 200**

```json
{
  "data": {
    "demo_mode": true,
    "environment": "DEMO / SANDBOX",
    "success_rate": 0.9,
    "processing_delay": 2,
    "pending_resolve_delay": 20,
    "qr_expiration": 120,
    "qr_auto_confirm_after": 0,
    "magic_amounts": true,
    "magic_amount_rules": { "1": "declined", "2": "pending", "3": "timeout", "4": "cancelled" },
    "allow_forced_scenario": true,
    "scenarios": ["success", "declined", "timeout", "cancelled", "pending"],
    "providers": {
      "demo":  { "enabled": true,  "demo": true },
      "ibrac": { "enabled": false, "demo": false },
      "card":  { "enabled": false, "demo": false }
    },
    "nfc_timeout": 60
  }
}
```

**curl**

```bash
curl -s $BASE/demo/config -H "Authorization: Bearer $TOKEN" -H 'Accept: application/json'
```

---

## Payment state machine

Only the backend (`PaymentService`) moves a transaction between states; clients observe.

```
                        ┌───────────┐
                        │  created  │
                        └─────┬─────┘
                              │  POST /payments (synchronous)
                              ▼
                        ┌───────────┐
            ┌───────────┤ initiated ├───────────┐
            │           └─────┬─────┘           │
            │ cancel          │ authorize       │ expiry (PAYMENT_EXPIRATION, 900 s)
            ▼                 ▼                 ▼
       ┌───────────┐   ┌────────────┐     ┌───────────┐
       │ cancelled │◄──┤ processing ├────►│  timeout  │
       └───────────┘   └─────┬──────┘     └───────────┘
            ▲                │  engine resolves after DEMO_PROCESSING_DELAY (lazy, on read)
            │      ┌─────────┼──────────┬───────────────┐
            │      ▼         ▼          ▼               ▼
            │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌─────────┐
            └─┤ pending  │ │authorized│ │ declined │ │ timeout │
              └────┬─────┘ └────┬─────┘ └──────────┘ └─────────┘
                   │            │  immediately, same request
                   │            ▼
                   │      ┌───────────┐
                   └─────►│ completed │   pending auto-settles after DEMO_PENDING_RESOLVE_DELAY (20 s)
                          └───────────┘
```

| From | Allowed to |
|---|---|
| `created` | `initiated`, `cancelled`, `timeout` |
| `initiated` | `processing`, `cancelled`, `timeout` |
| `processing` | `authorized`, `declined`, `cancelled`, `timeout`, `pending` |
| `authorized` | `completed`, `declined` |
| `pending` | `completed`, `declined`, `cancelled`, `timeout` |
| `completed`, `declined`, `cancelled`, `timeout` | — final |

Anything else raises `409 INVALID_STATE_TRANSITION`. `status_label` is the upper-cased
status except `completed` → `APPROVED`. Cancellable statuses: `created`, `initiated`,
`processing`, `pending`.

**QR statuses:** `pending → scanned → paid | declined`; `pending → expired | cancelled`.
The QR is kept in sync with its transaction: `completed → paid`, `declined → declined`,
`timeout → expired`, `cancelled → cancelled`.

**Timeline events** (`events[].event_type`): `PAYMENT_CREATED`, `PAYMENT_INITIATED`,
`PAYMENT_PROCESSING`, `PAYMENT_AUTHORIZED`, `PAYMENT_APPROVED`, `PAYMENT_DECLINED`,
`PAYMENT_PENDING`, `PAYMENT_TIMEOUT`, `PAYMENT_CANCELLED`, `QR_CREATED`, `QR_SCANNED_DEMO`,
`QR_EXPIRED`, `QR_CANCELLED`, `RECEIPT_GENERATED` (server) and `NFC_SESSION_STARTED`,
`NFC_SESSION_STOPPED`, `NFC_TIMEOUT`, `CARD_DETECTED_DEMO` (terminal).

---

## Complete demo lifecycle

All commands assume:

```bash
BASE=http://localhost:8000/api        # or http://10.0.2.2:8000/api from the emulator
H=(-H 'Accept: application/json' -H 'Content-Type: application/json')
```

### 0. Health + login

```bash
curl -s $BASE/health "${H[@]}"

TOKEN=$(curl -s $BASE/auth/login "${H[@]}" \
  -d '{"merchant_id":"DEMO-MERCHANT-001","password":"Demo@12345","terminal_code":"POS-DEMO-001","platform":"android","app_version":"1.0.0"}' \
  | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["token"])')
A=(-H "Authorization: Bearer $TOKEN" -H 'X-Terminal-Code: POS-DEMO-001')

curl -s $BASE/merchant/profile "${H[@]}" "${A[@]}"     # limits, currencies, methods
curl -s $BASE/demo/config      "${H[@]}" "${A[@]}"     # processing_delay, magic rules
```

### A. Contactless card payment (tap-to-phone)

```bash
# A1. Create (status: initiated). Idempotency-Key makes the call retry-safe.
KEY=$(uuidgen)
TX=$(curl -s $BASE/payments "${H[@]}" "${A[@]}" -H "Idempotency-Key: $KEY" \
  -d '{"amount":"25.50","currency":"ILS","payment_method":"contactless_card","description":"Coffee and cake"}')
ID=$(echo "$TX" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["id"])')
echo "$TX" | python3 -m json.tool | grep -E '"(reference|status|demo_scenario)"'

# A1b. Retry with the same key → 200, Idempotent-Replayed: true, same transaction
curl -s -D - $BASE/payments "${H[@]}" "${A[@]}" -H "Idempotency-Key: $KEY" \
  -d '{"amount":"25.50","currency":"ILS","payment_method":"contactless_card","description":"Coffee and cake"}' | grep -i -E 'HTTP/|Idempotent'

# A2. Terminal starts the NFC reader and reports it (audit only)
curl -s $BASE/payments/$ID/events "${H[@]}" "${A[@]}" \
  -d '{"event_type":"NFC_SESSION_STARTED","metadata":{"nfc_mode":"reader"}}'

# A3. Card tapped → report demo detection, then authorize (status: processing, retry_after: 1..2)
curl -s $BASE/payments/$ID/events "${H[@]}" "${A[@]}" -d '{"event_type":"CARD_DETECTED_DEMO"}'
curl -s $BASE/payments/$ID/authorize "${H[@]}" "${A[@]}" -d '{"channel":"nfc","nfc_mode":"reader"}'

# A4. Poll until is_final (engine resolves lazily after DEMO_PROCESSING_DELAY = 2 s)
until curl -s $BASE/payments/$ID/status "${H[@]}" "${A[@]}" | tee /dev/stderr | grep -q '"is_final":true'; do sleep 1; done

# A5. Receipt (records RECEIPT_GENERATED)
curl -s $BASE/payments/$ID/receipt "${H[@]}" "${A[@]}"

# A6. Full detail with timeline + provider attempts
curl -s $BASE/payments/$ID "${H[@]}" "${A[@]}"

# A7. A cancel on the completed transaction is rejected (409 INVALID_STATE_TRANSITION)
curl -s $BASE/payments/$ID/cancel "${H[@]}" "${A[@]}" -d '{"reason":"too_late"}'
```

Variations (same steps A1–A4):

```bash
# Declined via magic cents (.01) → status: declined, decline_code 05/51/54/61/65
curl -s $BASE/payments "${H[@]}" "${A[@]}" -d '{"amount":"10.01","currency":"USD","payment_method":"contactless_card"}'

# Pending via magic cents (.02) → pending with retry_after≈20, then completed on the next read
curl -s $BASE/payments "${H[@]}" "${A[@]}" -d '{"amount":"12.02","currency":"ILS","payment_method":"contactless_card"}'

# Timeout (.03) / cancelled-by-provider (.04)
curl -s $BASE/payments "${H[@]}" "${A[@]}" -d '{"amount":"7.03","currency":"ILS","payment_method":"contactless_card"}'
curl -s $BASE/payments "${H[@]}" "${A[@]}" -d '{"amount":"7.04","currency":"ILS","payment_method":"contactless_card"}'

# Forced scenario (overrides magic cents and the random draw)
curl -s $BASE/payments "${H[@]}" "${A[@]}" -d '{"amount":"9.00","currency":"ILS","payment_method":"contactless_card","demo_scenario":"declined"}'

# Merchant cancels before the tap (created/initiated → cancelled)
curl -s $BASE/payments/<id>/cancel "${H[@]}" "${A[@]}" -d '{"reason":"customer_changed_mind"}'
```

### B. QR payment (iBRAC-style, sandbox)

```bash
# B1. Create QR (QR: pending, transaction: initiated). Render data.payload as a QR image.
QR=$(curl -s $BASE/qr/create "${H[@]}" "${A[@]}" -d '{"amount":"40.00","currency":"ILS","description":"Table 4"}')
REF=$(echo "$QR" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["reference"])')
echo "$QR" | python3 -c 'import sys,json;d=json.load(sys.stdin)["data"];print(d["reference"],d["status"],d["seconds_remaining"],d["payload"])'

# B2. App polls while the customer scans (seconds_remaining counts down from 120)
curl -s $BASE/qr/$REF/status "${H[@]}" "${A[@]}"

# B3. Simulate the customer paying (DEMO only) → QR: scanned, transaction: processing
curl -s -X POST $BASE/qr/$REF/demo-confirm "${H[@]}" "${A[@]}" -d '{"demo_scenario":"success"}'

# B4. Poll until paid (transaction resolves after DEMO_PROCESSING_DELAY)
until curl -s $BASE/qr/$REF/status "${H[@]}" "${A[@]}" | tee /dev/stderr | grep -q '"status":"paid"'; do sleep 1; done

# B5. Receipt / detail via the linked transaction reference
TXREF=$(curl -s $BASE/qr/$REF/status "${H[@]}" "${A[@]}" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["transaction_reference"])')
curl -s $BASE/payments/$TXREF/receipt "${H[@]}" "${A[@]}"

# B6. A second demo-confirm is rejected (409 QR_NOT_PENDING), and a cancel too (409 QR_NOT_CANCELLABLE)
curl -s -X POST $BASE/qr/$REF/demo-confirm "${H[@]}" "${A[@]}"
curl -s -X POST $BASE/qr/$REF/cancel       "${H[@]}" "${A[@]}"
```

QR variations:

```bash
# Merchant cancels an unpaid QR → QR: cancelled, transaction: cancelled
Q=$(curl -s $BASE/qr/create "${H[@]}" "${A[@]}" -d '{"amount":"15.00","currency":"ILS"}' | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["reference"])')
curl -s -X POST $BASE/qr/$Q/cancel "${H[@]}" "${A[@]}"

# Customer "pays" but the demo declines → QR: declined, transaction: declined
Q=$(curl -s $BASE/qr/create "${H[@]}" "${A[@]}" -d '{"amount":"22.00","currency":"USD","demo_scenario":"declined"}' | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["reference"])')
curl -s -X POST $BASE/qr/$Q/demo-confirm "${H[@]}" "${A[@]}"; sleep 3
curl -s $BASE/qr/$Q/status "${H[@]}" "${A[@]}"

# Nobody pays for 120 s → on the next status read: QR expired, transaction timeout (QR_EXPIRED)
```

### C. History, dashboard, logout

```bash
curl -s "$BASE/transactions?status=approved&per_page=5" "${H[@]}" "${A[@]}"
curl -s "$BASE/transactions?payment_method=ibrac_qr&date_from=$(date +%F)" "${H[@]}" "${A[@]}"
curl -s $BASE/merchant/dashboard "${H[@]}" "${A[@]}"
curl -s -X POST $BASE/auth/logout "${H[@]}" "${A[@]}"
curl -s $BASE/auth/me "${H[@]}" "${A[@]}"        # → 401 UNAUTHENTICATED
```
