---
feature: Customer management
slug: customers
surface: admin
consumer: react
version: 2.2.0
status: ready
brd_version: "1.3"
brd_ids: [ADMIN-003]
business_rules: [BR-01, BR-29]
decisions: [DEC-10, TD-03, TD-06, TD-17, TD-19, TD-25, TD-27, TD-28, TD-30, TD-32, TD-38, TD-39, TD-40, TD-49, TD-173, TD-178]
schema_tables: [users, customers, customer_addresses, governorates, governorate_translations, regions, region_translations, wallets, wallet_transactions, orders, refunds, payments, media, audit_logs]
related_docs: [admins-permissions.md, ../client/auth.md]
last_updated: 2026-11-08
---

# Customer management — React Dashboard Integration Guide

> **Base URL:** `{HOST}/api/v1/admin` · Shared headers, envelope, errors, pagination and TS helpers: [API_CONVENTIONS.md](../API_CONVENTIONS.md).
> All JSON below is captured from passing MySQL HTTP tests in `tests/Feature/Api/V1/Admin/CustomerManagementTest.php`; generated UUIDs and fixture addresses are examples.

## Version history

| Version | Date | Type | Summary | Dashboard action required |
|---------|------|------|---------|---------------------------|
| 2.2.0 | 2026-11-08 | MINOR | Recipient pickers can filter existing customer, provider and driver lists with `filters[notification_reachable]` (TD-178). | Use `filters[notification_reachable]=1` for notification picks; keep handling send-time 422. |
| 2.1.0 | 2026-11-03 | MINOR | Optional include_debits adds order debits to wallet history. | Send include_debits=1 to show order debits; the default and counts remain refund-only. |
| 2.0.1 | 2026-10-26 | PATCH | Documentation only: validation 422 examples and rows now show `data.errors` (`{field: [messages]}`, TD-02, API_CONVENTIONS 2.7.0) instead of `data: null`; examples are the captured Postman responses. | None. |
| 2.0.0 | 2026-10-22 | MAJOR | Reclassifies the 1.0.1 city-filter change (404 → 422) as breaking: it changed an error contract. No new code change. | Handle an invalid `filters[city_id]` as a 422 field error, not a 404 page. |
| 1.0.1 | 2026-10-21 | PATCH | Consistent named 422 city-filter errors and concrete sort keys on customers/refund history. | Handle invalid city as 422 and show the actual input key; wallet history remains refund-only. |
| 1.0.0 | 2026-10-21 | MAJOR | Standard customer list, real saved addresses, localized enums, wallet balance/counts and refund-credit history; audited account blocking. Breaking vs 0.1.x. | Migrate data.data to data.users, use nested filters/sorts and enum objects, replace legacy address types; integrate wallet history. |
| 0.1.1 | 2026-10-21 | PATCH | Corrects offsetless timestamp timezone: UTC, not local (TD-38). | Interpret Y-m-d H:i:s as UTC before converting for display. |
| 0.1.0 | 2026-10-21 | Initial (base) | Documents `/users` list, details and block/unblock with real responses. | Integrate list + block/unblock; expect the list key and params to change in 1.0.0. |

## Changelog

### 2.2.0 — 2026-11-08

- **Added:** Optional boolean `filters[notification_reachable]` on `GET /admin/users`, `GET /admin/providers` and `GET /admin/drivers` (TD-173/178). `1` returns reachable rows; `0` returns unreachable rows within each list's existing scope; omission preserves the usual list.
- **Eligibility:** Active, not admin-disabled, not banned (`is_ban = false` or `null`), not deleted; providers/drivers must be `approved`. The filter reuses the general-send audience query. It does not require a registered device, enabled notification preferences, city coverage, an online driver or an active vehicle: in-app recipients remain eligible without push delivery.
- **Dashboard action required:** Use `filters[notification_reachable]=1` in the selected-users picker. Existing search, city, account-status filters, sorting, pagination and list permissions still apply. `paginate=false` returns the existing direct array capped at 500; use paginated search for larger audiences. Pass the user UUID from `/users` to `customer_ids`; pass profile UUIDs from `/providers` and `/drivers` to their respective keys.
- **Errors:** Invalid values return 422 with `data.errors["filters.notification_reachable"]`. Account state can change after picking; still handle `NOTIFICATION_RECIPIENTS_UNREACHABLE` at send time and refresh the picker. Reachability is account eligibility, not a delivery guarantee.
- **Postman:** `admin.users.index`, `admin.providers.index`, `admin.drivers.index` › "200 Reachable general notification recipients", "200 Reachable recipients dropdown", "422 Invalid notification reachability filter" (real responses from `NotificationRecipientFilterTest`).
- **Breaking:** No. Existing endpoints, response keys and default results are preserved.

### 2.1.0 — 2026-11-03

- **Added:** Optional include_debits adds order debits to wallet history.
- **Breaking:** No. **App action required:** Send include_debits=1 to show order debits; the default and counts remain refund-only.
- **BRD / decisions:** CUST-015/016, TD-164.

### 2.0.1 — 2026-10-26

- **Added:** N/A.
- **Changed:** Validation 422 examples and error-table rows show `data.errors`, not `data: null` (TD-02).
- **Fixed:** Documentation only; the server already returned `data.errors`.
- **Removed / Deprecated:** N/A.
- **Breaking:** No.
- **Endpoints affected:** None (documentation).
- **App action required:** None; map `data.errors` onto form fields, fall back to `message`.
- **BRD / decisions:** TD-02.

### 2.0.0 — 2026-10-22

- **Reclassified (breaking):** in 1.0.1 an invalid, integer or unknown `filters[city_id]` on `GET /users` changed from **404** to **422** (`status: "fail"`, a `message` naming `filters.city_id`, `data: null`). A changed HTTP status / error contract is MAJOR under the versioning rules, so it was mislabelled PATCH. This entry records it as breaking; behaviour is the same as 1.0.1.
- **Unchanged:** response fields, date format (UTC `Y-m-d H:i:s`, TD-38), permissions, refund-only wallet history (TD-39), route ID 404s.
- **Endpoints affected:** `GET /users`.
- **Dashboard action required:** if the dashboard treated a 404 from the list as "city not found", switch to showing the 422 validation message on the city filter.

### 1.0.1 — 2026-10-21

- Invalid/integer/unknown city filters now return 422 naming filters.city_id; missing/integer route IDs stay 404.
- Bad customer/refund-history sort directions identify sort.created_at, sort.full_name or sort.amount instead of sort.*. Postman customer examples use supported nested filters.
- **App action required:** treat bad city as input validation. No response fields or wallet semantics change; TD-39 refund_credit-only history remains read-only and paginated.

### 1.0.0 — 2026-10-21
- **Added:** city and registration filters, safe sorting, capped dropdown list; real `customer_addresses`, customer profile consent timestamps, actual wallet balance, saved-address/refund-credit counts; paginated `GET /users/{id}/wallet-transactions`.
- **Changed:** list adopts the shared paginated shape (`data.users`); `user_type` becomes `{value,label}`; `addresses` uses the schema-backed address contract. Block/unblock is transactional with an actor-bound audit entry.
- **Fixed:** customer scoping also excludes administrators with a customer profile and accounts marked as deleted.
- **Removed / Deprecated:** provisional top-level `is_active`, `from`, `to`, `user_type` list filters; legacy `addresses` table data and label-only account type.
- **Breaking:** Yes. Routes/names remain unchanged; the provisional 0.x contract is replaced immediately (TD-32).
- **Endpoints affected:** `GET /users`, `GET /users/{id}`, `POST /users/{id}/toggle-active`; new `GET /users/{id}/wallet-transactions`.
- **Dashboard action required:** migration table in §3; use the §8 types, wallet history in §4.1, and UTC parsing below.
- **BRD / decisions:** ADMIN-003, BR-01/29, DEC-10, TD-06/19/27/28/38/39. Order/payment/invoice/rating/chat screens remain pending (§13).

### 0.1.1 — 2026-10-21
- **Fixed:** timestamp interpretation is UTC (TD-38); use ISO syntax plus Z before parsing.
- **Breaking:** No. **Dashboard action required:** interpret offsetless date-times as UTC, never local time.

### 0.1.0 — 2026-10-21
- **Added:** documentation for `GET /users`, `GET /users/{id}`, `POST /users/{id}/toggle-active`. No code change.
- **Changed:** —
- **Fixed:** —
- **Removed / Deprecated:** —
- **Breaking:** No
- **Endpoints affected:** the three above
- **Dashboard action required:** none yet.
- **BRD / decisions:** ADMIN-003 (partial), BR-29, TD-25, TD-28, TD-32

---

## 1. Business summary

Customers register themselves by phone OTP; admins browse accounts and block or unblock them. Details expose saved addresses, recorded consents and the real wallet balance, with separate refund-credit history. Only accounts with a customer profile are reachable; administrators and provider-only/driver-only accounts return 404. Blocking changes `users.is_active` across every app used by that person, including existing sessions on their next request; provider/driver profile statuses stay unchanged.

All endpoints require `Authorization: Bearer <admin-app JWT>` and `Accept: application/json`, enforced by `auth:api`, `app:admin`, `active`, `rbac.auto`. Send `Accept-Language: ar|en` (default Arabic). Every path `{id}` means **users.uuid**, never the profile/wallet UUID or an integer.

Date-times are UTC `Y-m-d H:i:s` (TD-03/38). Parse with `parseLegacyUtc` from API_CONVENTIONS (replace space with `T`, append `Z`), then convert for display. `birth_date` is a calendar date; do not timezone-shift it. Money is SAR, decimal strings with two places.

## 2. Pages and permissions

| Page / component | Purpose | Endpoint(s) | Route permission | Section · action |
|------------------|---------|-------------|------------------|------------------|
| CustomersListPage | Customer table | `GET /users` | `admin.users.index` | customers · view |
| CustomerDetailsPage | Profile, addresses, balance and counts | `GET /users/{id}` | `admin.users.view` | customers · view |
| CustomerWalletHistory | Refund-credit table | `GET /users/{id}/wallet-transactions` | `admin.users.wallet-transactions` | customers · view |
| BlockCustomerButton | Block / unblock account | `POST /users/{id}/toggle-active` | `admin.users.toggle` | customers · update |

Sidebar: **Users → Customers**. Hide it without list permission. Gate each page/action by its exact route permission from the auth profile; a custom exact-route grant may differ from the section-wide grant. No create/edit/delete buttons.

For notification recipient picks, use `GET /api/v1/admin/users?filters[notification_reachable]=1`. Optional `0` selects unreachable accounts; see the 2.2.0 changelog above for eligibility and errors. Existing permissions and other filters apply.

## 3. List / table

`GET /api/v1/admin/users` — items at **`data.users`**, pagination at `data.links` and `data.meta`.

| Column | Field | Sort | Filter | Notes |
|--------|-------|------|--------|-------|
| Name | `full_name` | `sort[full_name]` | search | |
| Mobile | `phone_code`, `phone` | — | search phone | Nullable on legacy accounts; display country code separately |
| Email | `email` | — | search | Nullable for phone-OTP accounts |
| City | `city.name` | — | `filters[city_id]` | Profile city, nullable; UUID from service areas |
| Registered | `created_at` | `sort[created_at]` | `filters[created_from]`, `filters[created_to]` | Account creation in UTC |
| Account status | `is_active` | — | `filters[is_active]` | Block/unblock badge |

`search` matches **users.full_name, users.phone, users.email** by partial SQL LIKE match (max 255). Empty/null search is ignored. `%` and `_` have LIKE wildcard behavior; values are bound, never SQL identifiers.

| Param | Validation / behavior |
|-------|-----------------------|
| `page` | Integer ≥1, default 1 |
| `per_page` | Integer 1…100, default 15 |
| `paginate` | String `true|false|1|0`; default true. `false`/`0` returns a plain array in `data`, capped at 500; `per_page` does not govern this mode |
| `filters[notification_reachable]` | Boolean `0|1` (native booleans accepted); account eligibility for general notifications (TD-178) |
| `filters[is_active]` | Boolean `0|1` (native booleans also accepted in JSON) |
| `filters[city_id]` | Existing governorate UUID; invalid/integer/unknown UUID →422 naming filters.city_id |
| `filters[created_from]` | `YYYY-MM-DD`, inclusive UTC account registration date |
| `filters[created_to]` | `YYYY-MM-DD`, inclusive; must be ≥ from when both provided |
| `sort[created_at]`, `sort[full_name]` | `asc|desc`; multiple sorts applied in supplied order |

Default order: `created_at DESC`, then internal row key DESC as a stable tie-breaker (never exposed). Unknown nested filter/sort keys →422 naming the unsupported path. Malformed pagination/dates/directions →422; directions name their actual key (sort.created_at/sort.full_name, or sort.amount on refund history). This is account registration, even for a person who later added a customer profile; `customer_profile.created_at` exposes that profile's registration separately (TD-39).

Migration from 0.1.x (no deprecation window for the provisional base contract):

| Old | 1.0.0 |
|-----|-------|
| `response.data.data.data` (Axios) | `response.data.data.users` |
| top-level `is_active` | `filters[is_active]` |
| `from` / `to` | `filters[created_from]` / `filters[created_to]` |
| top-level `user_type` | Remove; customer membership controls scope |
| `user_type: "Client"` | `user_type: {value:"client",label:"App user"}` |
| Legacy `addresses[].title/address` | Schema address fields in §4 / §8 |

```json
{
  "status": "success",
  "message": "Users retrieved",
  "data": {
    "users": [
      {
        "id": "01a15e2a-f89d-717d-bbc8-29deb958f445",
        "full_name": "Noura Al-Qahtani",
        "email": null,
        "phone_code": "966",
        "phone": "512345678",
        "image": "https://ui-avatars.com/api/?name=U&background=dbeafe&color=1d4ed8&size=200",
        "user_type": {
          "value": "client",
          "label": "App user"
        },
        "city": {
          "id": "01a15e2a-f891-7094-a9a2-c8022546425a",
          "name": "Riyadh"
        },
        "is_active": true,
        "is_ban": false,
        "created_at": "2026-10-21 12:00:00"
      }
    ],
    "links": {
      "first": "http://localhost/api/v1/admin/users?page=1",
      "last": "http://localhost/api/v1/admin/users?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/admin/users?page=1",
          "label": "1",
          "page": 1,
          "active": true
        },
        {
          "url": null,
          "label": "Next &raquo;",
          "page": null,
          "active": false
        }
      ],
      "path": "http://localhost/api/v1/admin/users",
      "per_page": 15,
      "to": 1,
      "total": 1
    }
  }
}
```

## 4. Details view

`GET /api/v1/admin/users/{id}` · Permission `admin.users.view`.

```json
{
  "status": "success",
  "message": "Data retrieved successfully",
  "data": {
    "id": "01a15e2a-f89d-717d-bbc8-29deb958f445",
    "full_name": "Noura Al-Qahtani",
    "email": null,
    "phone_code": "966",
    "phone": "512345678",
    "image": "https://ui-avatars.com/api/?name=U&background=dbeafe&color=1d4ed8&size=200",
    "user_type": {
      "value": "client",
      "label": "App user"
    },
    "city": {
      "id": "01a15e2a-f891-7094-a9a2-c8022546425a",
      "name": "Riyadh"
    },
    "is_active": true,
    "is_ban": false,
    "created_at": "2026-10-21 12:00:00",
    "guarantees_female_gender": true,
    "accept_policies": true,
    "accept_terms": true,
    "birth_date": null,
    "locale": "ar",
    "allow_notifications": true,
    "email_verified_at": null,
    "last_login_at": null,
    "customer_profile": {
      "id": "01a15e2a-f89c-7339-b59a-2db94e657d93",
      "birth_year": 1995,
      "female_declared_at": "2026-10-21 12:00:00",
      "terms_accepted_at": "2026-10-21 12:00:00",
      "privacy_accepted_at": "2026-10-21 12:00:00",
      "created_at": "2026-10-21 12:00:00"
    },
    "addresses": [
      {
        "id": "01a15e2a-f8a2-712e-ad0c-086e40e5630d",
        "label": "Home",
        "label_type": {
          "value": "home",
          "label": "Home"
        },
        "city": {
          "id": "01a15e2a-f891-7094-a9a2-c8022546425a",
          "name": "Riyadh"
        },
        "district": {
          "id": "01a15e2a-f899-70a2-bf62-8649d08efc4c",
          "name": "Al Narjis"
        },
        "address_line": "King Salman Road",
        "details": "Building 5",
        "lat": 24.8,
        "lng": 46.6,
        "is_default": true,
        "created_at": "2026-10-21 12:00:00",
        "updated_at": "2026-10-21 12:00:00"
      }
    ],
    "wallet": {
      "id": "01a15e2a-f8a9-73b7-8361-b5246384457c",
      "balance": "100.50",
      "currency": "SAR",
      "updated_at": "2026-10-21 11:13:56"
    },
    "counts": {
      "addresses": 1,
      "wallet_transactions": 1
    },
    "updated_at": "2026-10-21 12:00:00"
  }
}
```

| Field | Type | Nullable | Description |
|-------|------|----------|-------------|
| `id` | UUID | no | Account UUID; use this in all customer URLs |
| `full_name`, `image` | string | no | Display name and HasMedia image URL/placeholder; read-only |
| `email`, `phone_code`, `phone` | string | yes | Identity contact fields |
| `user_type` | EnumValue | yes | Legacy account discriminator, not profile membership or admin permissions |
| `city` | `{id,name}` | yes | Customer profile city; localized name, Arabic fallback |
| `is_active`, `is_ban` | boolean | no | Account flags. This action only changes is_active; is_ban independently prevents login |
| `guarantees_female_gender`, `accept_policies`, `accept_terms` | boolean | no | Existing account-level consent flags |
| `birth_date` | string `YYYY-MM-DD` | yes | Existing account calendar date |
| `locale` | string | no | Preferred language |
| `allow_notifications` | boolean | no | User preference |
| `created_at`, `updated_at` | UTC date-time | no | Account timestamps |
| `email_verified_at`, `last_login_at` | UTC date-time | yes | Account activity |
| `customer_profile` | object | no | Customer UUID, optional birth_year, nullable female/terms/privacy consent timestamps, created_at |
| `addresses` | CustomerAddress[] | no | Current non-deleted saved addresses only; default first, then newest; not historical order-address snapshots |
| `wallet` | `{id,balance,currency,updated_at}` | yes | Actual current balance, `SAR`; null before wallet creation. Reading creates nothing |
| `counts.addresses` | integer | no | Number of current saved addresses, excludes soft-deleted rows |
| `counts.wallet_transactions` | integer | no | Total refund-credit entries, independent of current page/search/date filters |

Address IDs and city/district IDs are UUIDs. Address `label` and free text are stored customer text, not translations. `label_type` is localized; city/district names use Accept-Language with Arabic fallback. District/details/coordinates can be null; coordinates are numbers. Customer fields are not translatable form objects, so no `ar`/`en` write forms exist here. Order and rating counts are deliberately absent (§13).

### 4.1 Refund-credit history

`GET /api/v1/admin/users/{id}/wallet-transactions` · Permission `admin.users.wallet-transactions`.

Items at **`data.wallet_transactions`**, standard `links`/`meta`. By default shows **refund_credit only** (TD-39); `include_debits=1` also includes `order_debit` (TD-164). Reversals, adjustments and another customer's entries remain excluded. A customer without a wallet gets an empty paginated list. Reading creates no wallet/transactions.

| Param | Validation / behavior |
|-------|-----------------------|
| `include_debits` | Optional boolean `0|1`; default `0`. Set `1` to include order debits. |
| `page`, `per_page` | Same bounds/defaults as the customer list |
| `paginate` | Omit or `true|1`; `false`/`0` →422. Financial history is always paginated |
| `search` | Nullable string max255, partial LIKE match on `note` only |
| `filters[created_from]`, `filters[created_to]` | Inclusive UTC transaction dates `YYYY-MM-DD`, ordered range |
| `sort[created_at]`, `sort[amount]` | `asc|desc`, numeric decimal amount sorting |

Default: transaction `created_at DESC`, internal key DESC tie-breaker. No `txn_type` filter: unknown nested keys are 422 with the path. `amount` is the signed transaction amount; `balance_after` is the historical full-ledger balance after that entry, **not** a running sum of displayed refunds. The current wallet balance includes spending and other ledger movements, so it need not equal the sum of the displayed transactions.

`order_id`, `refund_id`, `payment_id` are related UUIDs or null. These are read-only references; detailed order/refund/payment pages remain pending. Do not infer a completed refund workflow from a ledger entry alone.

```json
{
  "status": "success",
  "message": "Data retrieved successfully",
  "data": {
    "wallet_transactions": [
      {
        "id": "01a15e2a-f8aa-73b9-af07-75d4362cea32",
        "txn_type": {
          "value": "refund_credit",
          "label": "Refund credit"
        },
        "amount": "125.50",
        "balance_after": "125.50",
        "order_id": null,
        "refund_id": null,
        "payment_id": null,
        "note": "Order refund",
        "created_at": "2026-10-21 12:00:00"
      }
    ],
    "links": {
      "first": "http://localhost/api/v1/admin/users/01a15e2a-f89d-717d-bbc8-29deb958f445/wallet-transactions?page=1",
      "last": "http://localhost/api/v1/admin/users/01a15e2a-f89d-717d-bbc8-29deb958f445/wallet-transactions?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/admin/users/01a15e2a-f89d-717d-bbc8-29deb958f445/wallet-transactions?page=1",
          "label": "1",
          "page": 1,
          "active": true
        },
        {
          "url": null,
          "label": "Next &raquo;",
          "page": null,
          "active": false
        }
      ],
      "path": "http://localhost/api/v1/admin/users/01a15e2a-f89d-717d-bbc8-29deb958f445/wallet-transactions",
      "per_page": 15,
      "to": 1,
      "total": 1
    }
  }
}
```

## 5. Forms (create / edit)

N/A — customers self-register by OTP. Admin POST-create, PUT/PATCH-edit and DELETE customer routes are not exposed (unsupported methods return 405). No media attachment form exists on this screen; `image` only displays the account's existing HasMedia URL. Categories/services retain the upload → hash form described in [services.md](services.md).

## 6. Actions (non-CRUD)

### 6.1 Block / unblock account

`POST /api/v1/admin/users/{id}/toggle-active` · Permission `admin.users.toggle` (`customers · update`). Empty body, no reason field. Flips `users.is_active` under a transaction and row lock and writes an audit log with actor, old/new values and request context in the same transaction.

Confirm the effect across all apps before submitting. Disable the button while pending; use the returned flag to update/invalidate both list and details. **Do not automatically retry this toggle after a network timeout**: refetch current state first, since replaying toggles it again. Unblock only resets is_active; it does not clear is_ban or approve another profile.

Blocked response:

```json
{
  "status": "success",
  "message": "User blocked successfully",
  "data": {
    "id": "01a15e2b-0b16-722b-a796-7a0b89ca2fb6",
    "full_name": "Edna Bosco",
    "is_active": false
  }
}
```

Unblocked response:

```json
{
  "status": "success",
  "message": "User activated successfully",
  "data": {
    "id": "01a15e2b-0b16-722b-a796-7a0b89ca2fb6",
    "full_name": "Edna Bosco",
    "is_active": true
  }
}
```

### Errors (all four endpoints)

| HTTP | `error_code` | When | Dashboard behavior |
|------|--------------|------|--------------------|
| 401 | UNAUTHENTICATED | Missing/expired/invalid admin token | Follow shared refresh/logout flow |
| 403 | PERMISSION_DENIED | Missing exact route permission or wrong-app token | Hide action or deny page |
| 403 | ACCOUNT_INACTIVE | Inactive operator | Stop protected requests; use correct admin session |
| 404 | absent (`data:null`) | Unknown/integer route ID, profile UUID instead of users UUID, unreachable non-customer/admin/deleted account | Show unavailable; never offer a mutation |
| 422 | absent (`data.errors`) | Invalid query (including invalid/unknown city UUID), deprecated non-empty top-level filters, unsupported nested keys | Mark the named input from `data.errors`; fall back to the localized message (TD-02) |
| 405 | absent | Unsupported customer create/edit/delete method | No such controls in dashboard |

Named validation examples captured from the PATCH regression tests:

```json
{
  "status": "fail",
  "message": "The selected filters.city_id is invalid.",
  "data": {
    "errors": {
      "filters.city_id": [
        "The selected filters.city_id is invalid."
      ]
    }
  }
}
```

```json
{
  "status": "fail",
  "message": "The selected sort.amount is invalid.",
  "data": {
    "errors": {
      "sort.amount": [
        "The selected sort.amount is invalid."
      ]
    }
  }
}
```

No new business-error code is needed for customer management. Queries have validation errors; the only write is account activation. Shared error catalog and operator gates apply as in API_CONVENTIONS.

Real validation response:

```json
{
  "status": "fail",
  "message": "Unsupported input key(s): filters.role.",
  "data": {
    "errors": {
      "filters": [
        "Unsupported input key(s): filters.role."
      ]
    }
  }
}
```

Real missing-account response:

```json
{
  "status": "fail",
  "message": "Resource not found",
  "data": null
}
```

## 7. Statuses and enums

Labels localize by Accept-Language. Drive enum presentation by `value`, not the label. Boolean flags stay booleans (shared convention), with UI labels below.

| Enum | value | Label en / ar | Badge color | Allowed action |
|------|-------|---------------|-------------|----------------|
| is_active | true | Active / نشطة | green | Block |
| is_active | false | Blocked / محظورة | red | Unblock |
| user_type | client | App user / مستخدمة التطبيق | neutral | No type change |
| user_type | provider | Provider (legacy) / مقدمة خدمة (قديم) | neutral | Only visible with a customer profile |
| user_type | delivery | Driver (legacy) / سائقة (قديم) | neutral | Only visible with a customer profile |
| address.label_type | home | Home / المنزل | blue | Read only |
| address.label_type | work | Work / العمل | purple | Read only |
| address.label_type | other | Other / أخرى | neutral | Read only |
| transaction.txn_type | refund_credit | Refund credit / رصيد مسترد | green | Read only |

`user_type` may be null on legacy accounts; it is not a provider/driver membership flag (TD-25). `super_admin` and `supervisor` never appear here even if a customer profile exists.

## Data model

| Table (`db-schema.sql`) | Read / write | Notes |
|-------------------------|--------------|-------|
| users | read + write is_active | UUID path identity; no account deletion |
| customers | read | Actual customer membership, city, consents and per-app registration |
| customer_addresses | read | customer_id joins customers.user_id, not customers.id; non-deleted saved addresses |
| governorates / governorate_translations | read | City UUID and localized name |
| regions / region_translations | read | District UUID and localized name |
| wallets | read | user_id owner, true decimal balance |
| wallet_transactions | read | wallet_user_id owner; refund_credit by default, order_debit optionally |
| orders / refunds / payments | reference read only | Related UUIDs on existing ledger rows; no management workflows or fabricated aggregates |
| media | read | Existing user HasMedia image/placeholder |
| audit_logs | write | Account status action audit, same transaction |

No schema or migration changes in this feature. Application database sessions now use UTC, including trigger-generated wallet timestamps (TD-40).

## 8. TypeScript types

Use shared `ApiResponse`, `Paginated`, `EnumValue` and `Money` from API_CONVENTIONS §6, and `parseLegacyUtc` from §4.

```ts
export type Uuid = string;
export type UtcDateTime = string; // Y-m-d H:i:s, UTC (TD-38)
export interface GeoSummary { id: Uuid; name: string | null }
export interface Customer {
  id: Uuid;
  full_name: string;
  email: string | null;
  phone_code: string | null;
  phone: string | null;
  image: string;
  user_type: EnumValue<'client' | 'provider' | 'delivery'> | null;
  city: GeoSummary | null;
  is_active: boolean;
  is_ban: boolean;
  created_at: UtcDateTime;
}
export interface CustomerAddress {
  id: Uuid;
  label: string;
  label_type: EnumValue<'home' | 'work' | 'other'>;
  city: GeoSummary;
  district: GeoSummary | null;
  address_line: string;
  details: string | null;
  lat: number | null;
  lng: number | null;
  is_default: boolean;
  created_at: UtcDateTime;
  updated_at: UtcDateTime;
}
export interface CustomerDetails extends Customer {
  guarantees_female_gender: boolean;
  accept_policies: boolean;
  accept_terms: boolean;
  birth_date: string | null; // YYYY-MM-DD, calendar date
  locale: string;
  allow_notifications: boolean;
  email_verified_at: UtcDateTime | null;
  last_login_at: UtcDateTime | null;
  customer_profile: {
    id: Uuid; birth_year: number | null;
    female_declared_at: UtcDateTime | null;
    terms_accepted_at: UtcDateTime | null;
    privacy_accepted_at: UtcDateTime | null;
    created_at: UtcDateTime;
  };
  addresses: CustomerAddress[];
  wallet: { id: Uuid; balance: Money; currency: 'SAR'; updated_at: UtcDateTime } | null;
  counts: { addresses: number; wallet_transactions: number };
  updated_at: UtcDateTime;
}
export interface RefundCredit {
  id: Uuid;
  txn_type: EnumValue<'refund_credit'>;
  amount: Money;
  balance_after: Money;
  order_id: Uuid | null;
  refund_id: Uuid | null;
  payment_id: Uuid | null;
  note: string | null;
  created_at: UtcDateTime;
}
export type CustomerList = Paginated<'users', Customer>;
export type RefundCreditList = Paginated<'wallet_transactions', RefundCredit>;
export interface CustomerStatus { id: Uuid; full_name: string; is_active: boolean }
export interface CustomerListQuery {
  page?: number; per_page?: number; paginate?: 'true' | 'false' | '1' | '0';
  search?: string | null;
  filters?: { notification_reachable?: 0 | 1; is_active?: 0 | 1; city_id?: Uuid; created_from?: string; created_to?: string };
  sort?: { created_at?: 'asc' | 'desc'; full_name?: 'asc' | 'desc' };
}
export interface RefundCreditQuery {
  page?: number; per_page?: number; paginate?: 'true' | '1'; search?: string | null;
  filters?: { created_from?: string; created_to?: string };
  sort?: { created_at?: 'asc' | 'desc'; amount?: 'asc' | 'desc' };
}
// Paginated mode:
// api.get<ApiResponse<CustomerList>>('/users', { params: query })
// Dropdown mode:
// api.get<ApiResponse<Customer[]>>('/users', { params: { paginate: 'false' } })
```

Use a serializer which preserves nested query keys (`filters[city_id]`, `sort[created_at]`). Keep query/response typing conditional on paginate; unpaginated `data` is an array, without links/meta. Never render a missing wallet as an invented stored zero balance: show “No wallet yet”; counts are safely zero.

## 9. Business rules enforced by the backend

| Rule | Backend behavior | Dashboard effect |
|------|------------------|------------------|
| BR-01 / TD-17 | Reports saved consent flags/timestamps; legacy timestamps may be null | Display recorded evidence, no inferred acceptance timestamps |
| BR-29 / TD-25/28 | Real customer profile required on list/details/history/toggle; admin accounts excluded | A multi-profile customer is reachable; provider/driver-only account is 404 |
| TD-19 | Only public UUIDs; internal foreign keys never serialized | Use users UUID for paths, geo UUID for city filter |
| TD-28/39 | users.is_active flip plus transactional audit; all app sessions gate on next request | Warn that blocking affects every app; do not present this as provider suspension |
| DEC-10 / TD-39 | Wallet is read-only, refund credits only in this history; balance reflects full ledger | No top-up, withdrawal, manual credit/debit controls |
| TD-06/38 | Localized enum objects; UTC timestamps | Use values for logic, labels for display and explicit UTC parsing |

## 10. Notifications triggered

N/A — block/unblock sends no notification in the existing contract. Authentication and next-request account gates enforce the change immediately. No notification work is claimed as implemented.

## 11. Integration checklist

- [ ] Replace `data.data` with `data.users` and replace provisional filters as in §3.
- [ ] Match §8 response types, including enum objects, nullable wallet/geo fields and schema address fields.
- [ ] Gate each page and block button by its exact route permission.
- [ ] Wire search, nested filters, sorting, pagination and the 500-item dropdown limit.
- [ ] Use users.uuid in paths; never substitute customer_profile.id or wallet.id.
- [ ] Show refund-credit history with independent pagination; label balance_after as historical balance.
- [ ] Confirm account-wide block, disable duplicate submission and refetch after uncertain network outcomes.
- [ ] Parse UTC date-times explicitly, preserve birth_date as a calendar date, format SAR strings without integer truncation.
- [ ] Verify English/Arabic labels and RTL, including city/district Arabic fallback.
- [ ] Handle 401/403/404/422; no unsupported CRUD controls or unbuilt order/rating/chat tabs.

## 12. Acceptance scenarios

PATCH focused verification: **35 customer/user tests / 261 assertions passed**. Final combined provider/driver/customer MySQL suite: **598 tests / 5,007 assertions passed**, including auth/seeders/schema lifecycle. RBAC 89 routes; Postman 145 routes; formatting and documentation checks passed. Local backend verification only.

Historical 1.0.0 verification: full MySQL `joshen_testing` suite passed **507 tests / 3,895 assertions**, including all customer, catalog and permission tests. Postman regenerated; RBAC synced (73 routes). This is local backend verification, not deployed/browser acceptance.

| AC ID | Scenario | Expected / HTTP test evidence |
|-------|----------|-------------------------------|
| AC-ADMIN-003 | Review registered customers with filters/search/sort/page | `test_ac_admin_003_list_filters_sort_pagination_and_dropdown_shape`, `test_ac_admin_003_searches_each_documented_field` |
| AC-ADMIN-003 | Review saved addresses, consents, balance and counts | `test_ac_admin_003_details_and_list_return_real_localized_data_and_safe_counts`; actual saved data, removed/foreign addresses excluded |
| AC-ADMIN-003 | Review refund wallet history | `test_ac_admin_003_refund_history_is_scoped_filtered_sorted_and_paginated`; refund credits only, true ledger balances |
| AC-ADMIN-003 | Existing ledger order/refund/payment references | `test_ac_admin_003_refund_references_are_public_uuids`; UUIDs, no workflow invented |
| AC-ADMIN-003 / BR-29 | Only authorized customer accounts reachable | `test_ac_admin_003_scope_excludes_non_customers_and_admins_even_with_customer_profiles`, existing `UserControllerTest` |
| AC-ADMIN-003 / BR-29 | Missing auth, missing permission, granted supervisor | `test_ac_admin_003_endpoints_require_authentication_and_permission` for all four routes |
| AC-ADMIN-003 / TD-19 | Unknown/integer/profile route IDs 404; bad city filter 422 | `test_ac_admin_003_unknown_integer_and_profile_ids_return_404`, `test_ac_admin_003_unknown_or_integer_city_filter_returns_422` |
| AC-ADMIN-003 / TD-40 | Database defaults and trigger timestamps | `test_ac_admin_003_database_generated_wallet_timestamps_use_utc`; MySQL session +00:00, no UTC drift |
| AC-ADMIN-003 | Invalid queries | `test_ac_admin_003_invalid_query_returns_422`; exact unsupported paths, pagination and date boundaries |
| AC-ADMIN-003 | Block and unblock a multi-profile account | `test_ac_admin_003_block_unblock_is_account_wide_and_audited`; audit actor/old/new, all apps denied then restored |
| AC-ADMIN-003 | No wallet and no admin CRUD | `test_ac_admin_003_customer_without_wallet_has_null_balance_and_empty_paginated_history`, `test_ac_admin_003_customer_create_edit_delete_are_not_routed` |
| AC-ADMIN-003 | Orders/bookings/invoices/refund-case/ratings/confirmed-order chat | Pending downstream features (§13); this release does not claim full BRD scenario completion |

## 13. Open decisions and known limitations

| ID | Impact | Current behavior |
|----|--------|------------------|
| ADMIN-003 / TD-39 | Orders/bookings, payments/invoices, refund-case details, ratings and confirmed-order chat depend on unbuilt features | Omitted from details and counts; no placeholders or fake zero totals. Existing ledger reference UUIDs only |
| DEC-10 | Refund destination original_method policy remains partly open | Read-only existing refund credits; no refund execution or payout routes here |
| TD-28 | Account block affects customer/provider/driver apps together | users.is_active only. Provider/driver profile suspension is separate (TD-36); profile statuses stay unchanged |
| TD-40 | Historical timestamps and deployment | MySQL/MariaDB application sessions are pinned to UTC. Reload cached configuration/workers on deployment. Existing naive DATETIME rows may have mixed provenance; audit before normalization; no stored dates rewritten |
| TD-38 | Existing date-time contract has no written offset | All such fields UTC; explicit UTC parsing required; ISO migration would need a versioned change |
| TD-39 | Table shows account creation, not later customer-app onboarding date | Account created_at used for list registration filter; per-app date exposed in customer_profile.created_at |
| TD-39 | History excludes spending/reversals/adjustments | Refund-only sum need not equal current wallet balance or historical balance_after; no full-ledger endpoint in this release |
| TD-30 / ADMIN-003 | Customer address editing, historical addresses used on orders and detailed transaction screens | Read-only current customer_addresses; no address management or order-snapshot history here |

`GET /users/{id}` retains refund-only `counts.wallet_transactions` and `refund_transactions_count`. Postman: `admin.users.wallet-transactions` › "200 Refund credits only" and "200 Refund credits and order debits".
