---
feature: Customer wallet
slug: wallet
surface: client
consumer: flutter
version: 1.0.1
status: ready
brd_version: "1.3"
brd_ids: [CUST-016, CUST-015, ADMIN-020]
business_rules: [BR-12, BR-13, BR-14]
decisions: [D1, D3, DEC-06, DEC-10, TD-11, TD-19, TD-152, TD-156, TD-157, TD-164]
schema_tables: [wallets, wallet_transactions, payments, refunds, orders]
screens: [customer--wallet--*]
related_docs: [booking.md, my-packages.md, ../admin/customers.md, ../admin/settings.md]
last_updated: 2026-11-04
---

# Customer wallet — Flutter Integration Guide

Base URL: `{HOST}/api/v1/client`. See [API conventions](../API_CONVENTIONS.md).

## Version history

| Version | Date | Type | Summary | App action required |
|---------|------|------|---------|---------------------|
| 1.0.1 | 2026-11-04 | PATCH | Ledger semantics written down (sign, order, movements, timing, consistency, duplicates, no detail route) and more captured examples. | None. |
| 1.0.0 | 2026-11-03 | Initial | Wallet balance, ledger and full order payment. | Integrate the wallet screen and handle the two wallet payment errors. |

## Changelog

### 1.0.1 — 2026-11-04
- **Clarified:** section 3.3 answers the Flutter team's ledger questions from the code; no contract change.
- **Added examples:** refund credit after a cancellation, page 2, `per_page` above 100.
- **Breaking:** No. **App action required:** None.

### 1.0.0 — 2026-11-03

- **Added:** wallet balance and paginated ledger; whole-order wallet payment.
- **Changed / fixed / removed:** None.
- **Breaking:** No. **App action required:** Offer wallet when enabled, show balance and handle insufficient credit.
- **Endpoints affected:** `GET /wallet`, `POST /orders/{order}/pay`.
- **BRD / decisions:** CUST-015/016, ADMIN-020, DEC-10, TD-164.

## 1. Business summary

Refunds create wallet credit; there is no top-up or withdrawal. Wallet payment covers an entire service order or package purchase only. The admin can disable wallet payments; reads remain available. A missing wallet reads as `0.00` and an empty list without creating a row.

## 2. User flow and screens

Open the wallet screen for balance and history. At checkout, offer wallet if `wallet_payments_enabled` is true. If the full total exceeds balance, offer card or mada instead. An eligible cancellation returns the policy refund to this wallet.

## 3. Endpoints

### 3.1 Wallet and transactions

`GET /api/v1/client/wallet` · route `client.wallet.show` · customer bearer token required. A provider token receives 403 and a guest receives 401.

| Query | Rules |
|-------|-------|
| `page` | Optional integer ≥ 1. |
| `per_page` | Optional integer 1–100; default 15. |
| `type` | Optional `refund_credit`, `order_debit`, `order_debit_reversal` or `admin_adjustment`. |

Newest transactions come first, with an internal tie-breaker. Money is a signed two-decimal SAR string. IDs are UUIDs; `order` and `refund_id` can be null. List key: `transactions`. The `links` and `meta` pagination objects are siblings of the list under `data`.

Postman: `client.wallet.show` › "200 Wallet with order debit".

```json
{
    "status": "success",
    "message": "",
    "data": {
        "balance": "50.00",
        "wallet_payments_enabled": true,
        "transactions": [
            {
                "id": "01a1a193-656f-70ca-8c92-a7f0a57953a3",
                "type": {
                    "value": "order_debit",
                    "label": "Order payment"
                },
                "amount": "-100.00",
                "balance_after": "50.00",
                "order": {
                    "id": "01a1a193-6289-70b0-922c-184f481a41fe",
                    "code": "JSH-wceb43129195"
                },
                "refund_id": null,
                "created_at": "2026-10-25T07:00:00+00:00"
            }
        ],
        "links": {
            "first": "http://localhost/api/v1/client/wallet?page=1",
            "last": "http://localhost/api/v1/client/wallet?page=1",
            "prev": null,
            "next": null
        },
        "meta": {
            "current_page": 1,
            "from": 1,
            "last_page": 1,
            "links": [
                {
                    "url": null,
                    "label": "&laquo; Previous",
                    "page": null,
                    "active": false
                },
                {
                    "url": "http://localhost/api/v1/client/wallet?page=1",
                    "label": "1",
                    "page": 1,
                    "active": true
                },
                {
                    "url": null,
                    "label": "Next &raquo;",
                    "page": null,
                    "active": false
                }
            ],
            "path": "http://localhost/api/v1/client/wallet",
            "per_page": 15,
            "to": 1,
            "total": 1
        }
    }
}
```

Invalid query values return 422 `VALIDATION_FAILED` (Postman: `client.wallet.show` › "422 Invalid transaction type").

### 3.2 Pay an order or package

`POST /api/v1/client/orders/{order}/pay` · route `client.orders.pay`. Send `{ "method": "wallet" }`; the order ID is its UUID. The existing payment response and status transitions remain unchanged; see [booking](booking.md) and [my packages](my-packages.md). The existing service-order paid replay remains unchanged; the pay action does not itself implement body-checked `Idempotency-Key` handling.

| HTTP | Error | Meaning / action |
|------|-------|------------------|
| 422 | `WALLET_PAYMENTS_DISABLED` | Wallet payments are switched off; select card or mada. |
| 422 | `WALLET_INSUFFICIENT_BALANCE` | No wallet or insufficient credit; select card or mada. |
| 422 | `VALIDATION_FAILED` | Invalid method, including `wallet_plus_gateway`. |
| 422 | `INVALID_STATE_TRANSITION` | The order cannot be paid in its current state. |
| 401 / 403 | `UNAUTHENTICATED` / `PERMISSION_DENIED` | Sign in with a customer token. |

Postman: `client.orders.pay` › "200 Wallet payment", "422 Wallet payments disabled", "422 Insufficient wallet balance", and "422 Split wallet payment rejected".

### 3.3 Ledger semantics (confirmed against the code, 2026-11-04)

| Question | Answer |
|---|---|
| Sign of `amount` | One signed two-decimal SAR string. Credits are positive (`"100.00"`), debits negative (`"-100.00"`); never zero. The sign is the direction; do not infer it from `type`. |
| `balance_after` | The wallet balance right after that row, written by the database ledger trigger; never negative. |
| Order of rows | `created_at` descending, then row id descending, so rows with the same timestamp keep a stable, newest-first order across pages. Page 2 never repeats or skips a row unless new rows arrive between requests (then refetch from page 1). |
| Pagination | `page` ≥ 1, `per_page` 1–100 (default 15). `links` and `meta` are siblings of `transactions` under `data`. A page past the end returns an empty list. |
| `type` filter | Exactly `refund_credit`, `order_debit`, `order_debit_reversal`, `admin_adjustment`; anything else is 422 `VALIDATION_FAILED`. `type.label` follows `Accept-Language`. |
| `refund_credit` | Positive. Written when a refund to the wallet completes, for example after a cancellation. `order` and `refund_id` are set. |
| `order_debit` | Negative. Written by a wallet payment. `order` is set, `refund_id` is null. |
| `order_debit_reversal` | Reserved: a debit given back. **No flow writes it today**; a payment cannot be reversed with it yet. Handle it as a positive row linked to an order if it ever appears. |
| `admin_adjustment` | Reserved: no admin operation writes it today (the admin side cannot adjust a wallet). Both signs are possible by definition. |
| Pending or reversed rows | None. A row exists only for money that moved. Wallet refunds are always `completed` (TD-152): there is no pending state, no failed row and no status field. |
| When a cancellation credit shows | In the same database transaction as the refund, so the next `GET /wallet` after the cancel response already contains it. |
| Balance vs history | The ledger trigger owns the balance, so `balance` and the rows always agree on every read; there is no reconciliation interval and no cache. |
| Duplicate protection | A wallet payment takes the order lock then the wallet lock and re-checks payment status, so a double tap debits once (a repeat returns the paid order). A refund is completed once per refund row under the order and payment locks. |
| Single transaction route | There is none. `GET /wallet` is the only wallet route; there is no `GET /wallet/transactions/{id}`, and no foreign-owner case because the query reads only the caller's wallet (a provider token is 403, a guest 401). |
| Empty wallet | No wallet row: `balance: "0.00"`, empty `transactions`, `meta.total: 0`. Nothing is created by the read. |

Captured examples on `client.wallet.show`: "200 Empty wallet", "200 Wallet with order debit", "200 Refund credit after cancellation", "200 Page 2 of the ledger", "422 Invalid transaction type", "422 per_page above 100"; 401 and 403 come from the shared customer-surface examples.

## 4. Statuses and enums

Ledger types are `refund_credit`, `order_debit`, `order_debit_reversal`, and `admin_adjustment`, returned as `{value,label}`. This feature writes refund credits through existing refund logic and order debits for wallet payment; it adds no admin adjustment operation. Order and payment statuses are documented in [booking](booking.md).

## 5. Dart models

Model `balance` and ledger amounts as strings, `wallet_payments_enabled` as boolean, `type` as a value/label object, `order` as nullable `{id,code}`, `refund_id` as nullable UUID, and `transactions` as a list with sibling `links` and `meta` objects as shown above. Parse `created_at` as ISO 8601.

## 6. Business rules enforced by the backend

D1 package purchases share payment; D3 / BR-12 instant orders open the provider window after payment; BR-13 scheduled orders become confirmed; BR-14 failures do not confirm or activate. No split payment, wallet withdrawal, top-up, or featured subscription payment. The wallet is checked under lock after the order lock; the ledger trigger owns the balance (TD-11, TD-164).

## 7. Notifications and real-time events

The existing successful order and package notification paths run unchanged. No separate wallet-payment notification is added here; notification management is a separate work package.

## 8. Localization and UI notes

Send `Accept-Language`; transaction type labels are localized. Show the refund-only funding note and never offer a top-up or withdrawal control. Zero balance does not imply a wallet row exists.

## 9. Integration checklist

- Read the wallet and paginate/filter its transactions.
- Show wallet payment only when enabled; it must cover the whole order.
- Handle both wallet 422 codes and preserve card/mada as alternatives.
- Refresh balance after payment and refund.

## 10. Acceptance scenarios

AC-CUST-015: a valid wallet payment follows the existing order/package success path; failure leaves it unconfirmed. AC-CUST-016: an eligible cancellation shows a refund with date, linked order and resulting balance.

## 11. Open decisions and known limitations

DEC-06 real gateway vendor remains open; card/mada use the existing simulator. Refund timing and other DEC-10 matters outside the approved wallet-only implementation remain as documented in [decisions](../DECISIONS.md). Notification templates are outside this package.
