---
feature: Payment and tax invoices
slug: payment
surface: client
consumer: flutter
version: 1.1.1
status: ready
brd_version: "1.3"
brd_ids: [CUST-015]
business_rules: [BR-14]
decisions: [D1, D3, DEC-06, DEC-09, DEC-10, TD-19, TD-94, TD-151, TD-164, TD-165]
schema_tables: [payments, invoices, orders, app_configs]
screens: ["customer--payment--*"]
related_docs: [booking.md, wallet.md, my-packages.md, ../admin/orders.md]
last_updated: 2026-11-10
---

# Payment and tax invoices — Flutter Integration Guide (client)

> **Base URL:** `{HOST}/api/v1/client` · Shared rules (headers, envelope,
> errors, pagination, Dio setup): [API_CONVENTIONS.md](../API_CONVENTIONS.md)

## Version history

Newest first. Never edit an old row.

| Version | Date | Type | Summary | App action required |
|---------|------|------|---------|---------------------|
| 1.1.1 | 2026-11-10 | PATCH | `POST /orders/{order}/pay` still answers 200 with the order when the gateway declines (`payment_status = failed`, `can_pay = true`), but `message` now says the payment failed (`booking.payment_failed_retry`) instead of the generic "Data updated successfully" (QA-07). | None. Keep branching on `data.payment_status.value`, not on the message. |
| 1.1.0 | 2026-11-04 | MINOR | New `GET /payment-methods` lists the methods the admin has enabled (card, mada, wallet with balance). `POST /orders/{order}/pay` answers 422 `PAYMENT_METHOD_DISABLED` for a switched-off method (TD-168). | Build the payment-method picker from this list; handle the new error. |
| 1.0.1 | 2026-11-04 | PATCH | Staging QA path for the payment simulator and what the real gateway will still add. | None. |
| 1.0.0 | 2026-11-03 | Initial | Order tax invoices, invoice list and payment reference. | Integrate invoice viewing from order details and the invoices list. |

## Changelog

### 1.1.1 — 2026-11-10

- **Fixed:** `POST /orders/{order}/pay` still answers 200 with the order when the gateway declines (`payment_status = failed`, `can_pay = true`), but `message` now says the payment failed (`booking.payment_failed_retry`) instead of the generic "Data updated successfully" (QA-07).
- **Breaking:** No. **App action required:** None. Keep branching on `data.payment_status.value`, not on the message.

### 1.1.0 — 2026-11-04
- **Added:** `GET /payment-methods` (`client.payment-methods.index`): enabled methods in display order. Each item has `method {value,label}`, `type` (`gateway` or `wallet`) and `wallet_balance` (a 2-decimal string on the wallet item, else `null`). Wallet is listed only while wallet payments are on (TD-164).
- **Added:** `POST /orders/{order}/pay` returns 422 `error_code: PAYMENT_METHOD_DISABLED` when an admin switched the chosen method off (ADMIN-025).
- **Breaking:** No. **App action required:** Fill the picker from `GET /payment-methods` instead of hard-coding methods; show a message on `PAYMENT_METHOD_DISABLED` and refresh the list. Apple Pay, Tabby and saved cards arrive with the real gateway (DEC-06).
- **BRD / decisions:** CUST-015, ADMIN-025, TD-168.

Postman: `client.payment-methods.index` › "200 Methods with wallet"

```json
{
  "status": "success",
  "message": "Data retrieved successfully",
  "data": [
    {
      "method": {
        "value": "card",
        "label": "Bank card"
      },
      "type": "gateway",
      "wallet_balance": null
    },
    {
      "method": {
        "value": "mada",
        "label": "mada"
      },
      "type": "gateway",
      "wallet_balance": null
    },
    {
      "method": {
        "value": "wallet",
        "label": "Wallet"
      },
      "type": "wallet",
      "wallet_balance": "75.00"
    }
  ]
}
```

Postman: `client.orders.pay` › "422 Payment method disabled" (from `PaymentMethodSettingsTest`).

### 1.0.1 — 2026-11-04
- **Documented:** section 4.1, the staging QA path (env, methods, `simulate_failure`, expiry, replay, cleanup) and the not-built list (DEC-06). New captured examples on `client.orders.pay`. No contract change.
- **Breaking:** No. **App action required:** None.

### 1.0.0 — 2026-11-03

- **Added:** `GET /orders/{order}/invoice` (`client.orders.invoice`) returns the electronic tax invoice of a paid order; `GET /invoices` (`client.invoices.index`) returns the paginated invoice history; additive `invoice_id` in client order details (`GET /orders/{order}`).
- **Referenced:** `POST /orders/{order}/pay` (`client.orders.pay`) detailed in [booking.md](booking.md) and [wallet.md](wallet.md).
- **Breaking:** No.
- **Endpoints affected:** `GET /orders/{order}/invoice`, `GET /invoices`, `GET /orders/{order}`.
- **App action required:** Display invoice details button when `invoice_id` is present or fetch via `GET /orders/{order}/invoice`. Render ZATCA Phase-1 QR code when `qr_payload` is present.
- **BRD / decisions:** CUST-015, DEC-06, DEC-09, DEC-10, TD-151, TD-164, TD-165.

---

## 1. Business summary

When an order payment succeeds (via simulated bank card or wallet credit), the backend automatically issues a simplified electronic tax invoice with a sequential invoice number (`INV-100000+`) and snapshots the amounts. If the order is unpaid, no invoice exists and the endpoint returns `404 INVOICE_NOT_FOUND`.

The invoice includes a ZATCA Phase-1 compliant Base64 TLV payload (`qr_payload`) containing Seller Name, VAT Registration Number, Timestamp (ISO 8601 with offset), Total Amount, and VAT Amount. When VAT settings are unconfigured, `qr_payload` is `null` without blocking payment. Refunds do not issue credit notes (not built; see TD-165). Invoices are strictly JSON-based (no PDF download).

## 2. User flow and screens

| Step | Screen / widget | Endpoint | Notes |
|------|-----------------|----------|-------|
| 1 | `<OrderDetailsScreen>` | `GET /orders/{order}` | Contains additive `invoice_id` (`uuid` or `null`). Show "View Invoice" when non-null. |
| 2 | `<InvoiceDetailsScreen>` | `GET /orders/{order}/invoice` | Fetch invoice breakdown and Base64 `qr_payload` to render QR code widget. |
| 3 | `<InvoicesListScreen>` | `GET /invoices` | Paginated listing of customer's electronic invoices. |

```
Order details ──► (if invoice_id != null) ──► View Invoice ──► Render ZATCA QR
Customer Profile ──► Invoices ──► GET /invoices
```

## 3. Endpoints

### 3.1 Order tax invoice

`GET /api/v1/client/orders/{order}/invoice` · route `client.orders.invoice`

| | |
|---|---|
| Auth | Bearer token (`customer` app) |
| Extra headers | None |
| Rate limit | Standard API rate limit |

**Path parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `order` | path | uuid (string) | yes | Order UUID |

**Success — `200` (Postman: `client.orders.invoice` › "200 Paid order tax invoice")**

```json
{
    "status": "success",
    "message": "Data retrieved successfully",
    "data": {
        "id": "01a1a5ba-28dd-7082-8ad0-0b47369801bc",
        "invoice_no": "INV-100003",
        "order_id": "01a1a5ba-265c-7289-ac67-170b5500a598",
        "subscription_id": null,
        "subtotal": "150.00",
        "delivery_fee": "0.00",
        "discount": "0.00",
        "vat_amount": "0.00",
        "total": "150.00",
        "seller": {
            "name": "Joshen / جوشن",
            "vat_number": "300000000000003"
        },
        "payment_method": {
            "value": "card",
            "label": "Bank card"
        },
        "order_code": "JSH-gryf45767821",
        "items": [
            {
                "name": "Hair Styling",
                "quantity": 1,
                "unit_price": "120.00",
                "options_price": "30.00",
                "line_total": "150.00"
            }
        ],
        "plan_name": null,
        "zatca_uuid": null,
        "qr_payload": "ARFKb3NoZW4gLyDYrNmI2LTZhgIPMzAwMDAwMDAwMDAwMDAzAxkyMDI2LTEwLTI1VDA3OjAwOjAwKzAwOjAwBAYxNTAuMDAFBDAuMDA=",
        "issued_at": "2026-10-25T07:00:00+00:00"
    }
}
```

**Response fields**

| Field | Type | Nullable | Description |
|-------|------|----------|-------------|
| `id` | uuid (string) | no | Public invoice UUID (TD-19) |
| `invoice_no` | string | no | Formatted invoice code (`INV-100000+`) |
| `order_id` | uuid (string) | yes | Linked order UUID |
| `subscription_id` | uuid (string) | yes | Always null for customer invoices |
| `subtotal` | string | no | Services amount + package amount snapshot |
| `delivery_fee` | string | no | Delivery fee snapshot |
| `discount` | string | no | Discount amount snapshot |
| `vat_amount` | string | no | 15% VAT amount snapshot |
| `total` | string | no | Total amount paid |
| `seller` | object | yes | `{name, vat_number}` decoded from snapshotted `qr_payload`; null if unconfigured |
| `payment_method` | object | yes | `{value, label}` payment method used for the charge |
| `order_code` | string | yes | Order reference code |
| `items` | list | yes | Snapshot items: `[{name, quantity, unit_price, options_price, line_total}]` |
| `plan_name` | string | yes | Always null for customer order invoices |
| `zatca_uuid` | string | yes | Phase-2 UUID (always null in Phase 1) |
| `qr_payload` | string | yes | Base64-encoded ZATCA Phase-1 TLV payload for QR display; null if platform VAT settings empty |
| `issued_at` | string (ISO 8601) | no | Timestamp of invoice issuance |

**Errors**

| HTTP | `error_code` | When | What the app should do |
|------|--------------|------|------------------------|
| 401 | `UNAUTHENTICATED` | Missing or expired token | Prompt sign-in |
| 403 | `PERMISSION_DENIED` | Token issued for another app | Access denied |
| 404 | `INVOICE_NOT_FOUND` | Order is unpaid or invoice does not exist | Hide invoice action or notify user |

---

### 3.2 List customer invoices

`GET /api/v1/client/invoices` · route `client.invoices.index`

| | |
|---|---|
| Auth | Bearer token (`customer` app) |
| Extra headers | None |
| Rate limit | Standard API rate limit |

**Query parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `page` | query | integer | no | Target page (1-based, default 1) |
| `per_page` | query | integer | no | Page size (1 to 100, default 15) |

**Success — `200` (Postman: `client.invoices.index` › "200 Customer tax invoices list")**

```json
{
    "status": "success",
    "message": "Data retrieved successfully",
    "data": {
        "invoices": [
            {
                "id": "01a1a5a6-d0bc-714a-80cf-54d33ec6385a",
                "invoice_no": "INV-100007",
                "order_id": "01a1a5a6-d0a4-7224-a272-1209570eb5eb",
                "subscription_id": null,
                "subtotal": "50.00",
                "delivery_fee": "0.00",
                "discount": "0.00",
                "vat_amount": "0.00",
                "total": "50.00",
                "seller": null,
                "payment_method": {
                    "value": "card",
                    "label": "Bank card"
                },
                "order_code": "JSH-dnel95137416",
                "items": [],
                "plan_name": null,
                "zatca_uuid": null,
                "qr_payload": null,
                "issued_at": "2026-10-25T07:00:00+00:00"
            },
            {
                "id": "01a1a5a6-d06b-7036-88fc-e37b6bb7c4b8",
                "invoice_no": "INV-100006",
                "order_id": "01a1a5a6-ce1f-7007-9fb1-c03931af5435",
                "subscription_id": null,
                "subtotal": "50.00",
                "delivery_fee": "0.00",
                "discount": "0.00",
                "vat_amount": "0.00",
                "total": "50.00",
                "seller": null,
                "payment_method": {
                    "value": "card",
                    "label": "Bank card"
                },
                "order_code": "JSH-imtu47693838",
                "items": [],
                "plan_name": null,
                "zatca_uuid": null,
                "qr_payload": null,
                "issued_at": "2026-10-25T07:00:00+00:00"
            }
        ],
        "links": {
            "first": "http://localhost/api/v1/client/invoices?page=1",
            "last": "http://localhost/api/v1/client/invoices?page=1",
            "prev": null,
            "next": null
        },
        "meta": {
            "current_page": 1,
            "from": 1,
            "last_page": 1,
            "path": "http://localhost/api/v1/client/invoices",
            "per_page": 10,
            "to": 2,
            "total": 2
        }
    }
}
```

---

## 4. Payment endpoint (by reference)

Order payment is executed via `POST /api/v1/client/orders/{order}/pay` (`client.orders.pay`). Complete request and response specifications are documented in:
- [booking.md](booking.md) — Card and simulated gateway payment flow.
- [wallet.md](wallet.md) — Paying with wallet credit (`method: "wallet"`).

When payment succeeds, an `invoice` record is created synchronously within the payment transaction and is immediately available via `GET /orders/{order}/invoice` and through `data.invoice_id` on `GET /orders/{order}`.

---

## 4.1 Staging QA path (payment simulator)

Real card, mada and Apple Pay collection **does not exist yet**: the real gateway is not chosen (DEC-06, launch blocker), so there are no gateway callbacks, no saved customer cards, no redirect or 3-D Secure step, no split wallet + card payment, and no refund to the original method (refunds go to the wallet only, TD-152). QA runs on the simulator. Nothing here changes how production behaves: production answers `PAYMENT_GATEWAY_UNAVAILABLE`.

| Topic | Behaviour |
|---|---|
| Enabling (DevOps) | `PAYMENT_SIMULATOR_ENABLED=true` and a non-production `APP_ENV` (any value except `prod` / `production`, so `staging` and `dev` work). Then `php artisan config:clear` (or `config:cache`) and restart the queue workers. The setting defaults to on, and production refuses the simulator even when it is on (TD-130). |
| Methods | `POST /orders/{order}/pay` accepts `card`, `mada` (simulated) and `wallet` (**real**: debits the wallet, needs no simulator, works while the simulator is off). `apple_pay` is not a customer method; it exists only in the provider featured flow. |
| Success | A simulated `card` / `mada` charge succeeds by default and records a `payments` row with `gateway = simulated`; the order and tax invoice follow the normal paid path. |
| Declined payment | Send `"simulate_failure": true` (accepted in non-production only; never show it in a release build). The call answers **200** with `data.payment_status.value = failed` and a `message` that says the payment failed (not the generic write message, 1.1.1), the order stays `awaiting_payment`, and a failed `payments` row is kept. Retry (without the flag) while `payment_due_at` is still in the future. |
| Expiry | A scheduled order has `payment_due_at` = acceptance time + `customer_payment_window_minutes` (15 by default); an instant order has the same window from creation, then the provider has `provider_instant_response_seconds` (20 s) to answer after payment. `orders:expire` runs every minute and expires unpaid orders. Paying after `payment_due_at` is 422 `INVALID_STATE_TRANSITION` and writes nothing. To test it, wait out the window or lower the setting in the dashboard. |
| Repeated pay call | A paid **service** order returns its current state (200) with no second charge or invoice. A paid **package purchase** answers 422 `INVALID_STATE_TRANSITION` on a repeat. The pay route is limited to 10 requests per minute per user (429). There is no `Idempotency-Key` on this route; the order lock makes a double tap safe. |
| Simulator off or production | Card / mada answers 422 `PAYMENT_GATEWAY_UNAVAILABLE` before the order is touched; show a retry-later message. Wallet still works. |
| Cleanup | Simulated payments move no real money. Use dedicated test customers and providers; cancel or let test orders expire. A cancelled paid order refunds to the wallet, so reset a test customer's wallet by spending it or by using a fresh account. Do not delete `payments` / `invoices` rows (invoice numbers are sequential). |

Captured failure and edge examples on `client.orders.pay` (Postman): "200 Failed simulated payment", "200 Package purchase payment failed", "200 Repeated pay call on a paid order", "422 Card payment while the simulator is off (production behaviour)", "422 Payment after payment_due_at", "422 Insufficient wallet balance", "422 Wallet payments disabled", "422 Split wallet payment rejected", "429 Too many payment attempts". `PACKAGE_NOT_ACTIVE` applies to booking a session of an owned package, not to paying: see [my-packages](my-packages.md) (Postman: `client.my-packages.sessions.store` › "422 Package not active on session create", `…sessions.quote` › "422 Package has expired").

**Known gaps in older examples.** No payment-in-progress or redirect example exists because no such state exists. The 401 / 403 examples are the shared customer-surface ones. Anything the real gateway will add (callbacks, 3-D Secure, saved methods, original-method refunds) is documented only when DEC-06 is decided.

## 5. Dart models

```dart
class Invoice {
  final String id;
  final String invoiceNo;
  final String? orderId;
  final String? subscriptionId;
  final String subtotal;
  final String deliveryFee;
  final String discount;
  final String vatAmount;
  final String total;
  final InvoiceSeller? seller;
  final InvoicePaymentMethod? paymentMethod;
  final String? orderCode;
  final List<InvoiceItem>? items;
  final String? planName;
  final String? zatcaUuid;
  final String? qrPayload;
  final DateTime issuedAt;

  Invoice({
    required this.id,
    required this.invoiceNo,
    this.orderId,
    this.subscriptionId,
    required this.subtotal,
    required this.deliveryFee,
    required this.discount,
    required this.vatAmount,
    required this.total,
    this.seller,
    this.paymentMethod,
    this.orderCode,
    this.items,
    this.planName,
    this.zatcaUuid,
    this.qrPayload,
    required this.issuedAt,
  });

  factory Invoice.fromJson(Map<String, dynamic> j) => Invoice(
    id: j['id'] as String,
    invoiceNo: j['invoice_no'] as String,
    orderId: j['order_id'] as String?,
    subscriptionId: j['subscription_id'] as String?,
    subtotal: j['subtotal'] as String,
    deliveryFee: j['delivery_fee'] as String,
    discount: j['discount'] as String,
    vatAmount: j['vat_amount'] as String,
    total: j['total'] as String,
    seller: j['seller'] != null ? InvoiceSeller.fromJson(j['seller'] as Map<String, dynamic>) : null,
    paymentMethod: j['payment_method'] != null ? InvoicePaymentMethod.fromJson(j['payment_method'] as Map<String, dynamic>) : null,
    orderCode: j['order_code'] as String?,
    items: (j['items'] as List<dynamic>?)?.map((e) => InvoiceItem.fromJson(e as Map<String, dynamic>)).toList(),
    planName: j['plan_name'] as String?,
    zatcaUuid: j['zatca_uuid'] as String?,
    qrPayload: j['qr_payload'] as String?,
    issuedAt: DateTime.parse(j['issued_at'] as String),
  );
}

class InvoiceSeller {
  final String name;
  final String vatNumber;

  InvoiceSeller({required this.name, required this.vatNumber});

  factory InvoiceSeller.fromJson(Map<String, dynamic> j) => InvoiceSeller(
    name: j['name'] as String,
    vatNumber: j['vat_number'] as String,
  );
}

class InvoicePaymentMethod {
  final String value;
  final String label;

  InvoicePaymentMethod({required this.value, required this.label});

  factory InvoicePaymentMethod.fromJson(Map<String, dynamic> j) => InvoicePaymentMethod(
    value: j['value'] as String,
    label: j['label'] as String,
  );
}

class InvoiceItem {
  final String name;
  final int quantity;
  final String unitPrice;
  final String optionsPrice;
  final String lineTotal;

  InvoiceItem({
    required this.name,
    required this.quantity,
    required this.unitPrice,
    required this.optionsPrice,
    required this.lineTotal,
  });

  factory InvoiceItem.fromJson(Map<String, dynamic> j) => InvoiceItem(
    name: j['name'] as String,
    quantity: (j['quantity'] as num).toInt(),
    unitPrice: j['unit_price'] as String,
    optionsPrice: j['options_price'] as String,
    lineTotal: j['line_total'] as String,
  );
}
```

---

## 6. Business rules enforced by the backend

| Rule | What the backend does | What the client sees |
|------|-----------------------|----------------------|
| BR-14 | Invoices reflect final charged amounts with 15% VAT snapshot | Accurate tax breakdown in SAR |
| TD-165 | Unpaid orders have no invoice | Returns `404 INVOICE_NOT_FOUND` |
| TD-165 | ZATCA Phase-1 QR encoded via Base64 TLV | Renderable QR code for scanning |
| TD-165 | Refunds do not issue credit notes | Invoices remain immutable; credit notes not built |

---

## 7. Acceptance scenarios

| AC ID | Scenario | Expected |
|-------|----------|----------|
| AC-CUST-015 | Order paid via card or wallet | Invoice issued with code `INV-100000+` and amounts matching payment |
| AC-CUST-015 | Unpaid order invoice requested | `404 INVOICE_NOT_FOUND` |
| AC-CUST-015 | Customer accesses another customer's invoice | `404 INVOICE_NOT_FOUND` |
| AC-CUST-015 | Customer views order details | `invoice_id` is present if paid, `null` if unpaid |
| AC-CUST-015 | Customer lists invoices | Returns chronological paginated invoices |

---

## 8. Open decisions and known limitations

| ID | Impact | Current behavior |
|----|--------|------------------|
| TD-165 | Platform VAT configuration | If `vat_seller_name` or `vat_registration_number` are empty, `qr_payload` is null (launch blocker) |
| TD-165 | Credit notes for refunds | Credit notes are not built; refunds create wallet credit without modifying invoice |
| TD-165 | PDF invoice export | Not built; JSON electronic invoice only |
| DEC-06 / TD-94 | Real payment vendor | Payments run against simulator (TD-151) |
