---
feature: Service provider management
slug: providers
surface: admin
consumer: react
version: 2.4.0
status: ready
brd_version: "1.3"
brd_ids: [ADMIN-004, ADMIN-007, ADMIN-021, PROV-002, PROV-004, PROV-008]
business_rules: [BR-01, BR-03, BR-04, BR-08, BR-09, BR-27, BR-29]
decisions: [D4, DEC-25, TD-83, TD-06, TD-19, TD-25, TD-27, TD-28, TD-35, TD-36, TD-38, TD-39, TD-43, TD-44, TD-45, TD-46, TD-47, TD-50, TD-51, TD-54, TD-55, DEC-12, TD-03, TD-59, TD-60, TD-173, TD-178]
schema_tables: [users, providers, provider_categories, provider_services, provider_districts, provider_working_hours, provider_time_off, user_documents, payout_accounts, profile_change_requests, account_review_events, packages, provider_subscriptions, featured_plans, governorates, governorate_translations, regions, region_translations, services, service_translations, categories, category_translations, media, audit_logs, notifications]
related_docs: [customers.md, services.md, admins-permissions.md, ../provider/auth.md, ../provider/onboarding.md]
last_updated: 2026-11-10
---

# Service provider management — React Dashboard Integration Guide

Base URL: `{HOST}/api/v1/admin`. Authentication, envelope and shared TypeScript helpers: [API_CONVENTIONS](../API_CONVENTIONS.md). Every route requires `auth:api`, `app:admin`, `active`, `rbac.auto`. Super admins pass; supervisors need the exact route grant.

JSON examples below come from passing `ProviderManagementTest` HTTP requests on MySQL `joshen_testing`. Names, document URLs and identifiers are synthetic factory fixtures. Date-times are **ISO 8601 with offset** (`2026-10-22T06:57:15+00:00`, TD-03); date-only expiry values remain `YYYY-MM-DD`. Parse them directly with `new Date(...)` / `dayjs(...)` — do **not** use `parseLegacyUtc` here (that helper is only for the legacy TD-38 contracts such as customers.md).

## Version history

| Version | Date | Type | Summary | Dashboard action required |
|---------|------|------|---------|---------------------------|
| 2.4.0 | 2026-11-10 | MINOR | Change requests are now reviewed here: list, approve exactly as submitted, reject with a reason (R01–R03, TD-184). The read-only metadata of TD-44 is superseded; see [profile-change-requests.md](profile-change-requests.md). | Add the approve and reject actions to the provider details. |
| 2.3.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.2.2 | 2026-11-08 | PATCH | Orders, ratings, earnings and subscriptions point to the sections that show them. | None. |
| 2.2.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.2.0 | 2026-10-25 | MINOR | `PUT /providers/{id}/work-area`: the admin links a provider to her city and coverage in any status (ADMIN-021, TD-83). | Add a coverage editor to the provider page, gated by `admin.providers.work-area`. |
| 2.1.1 | 2026-10-25 | PATCH | Documentation only (PATCH): ADMIN-007 is built as a combined queue over this review — see [registration-review.md](registration-review.md). No contract change. | None. |
| 2.1.0 | 2026-10-22 | MINOR | Initial provider approval also approves requested registration categories atomically (TD-60). | Refetch provider main_services after approval; request/response shape is unchanged. |
| 2.0.0 | 2026-10-22 | MAJOR | ISO 8601 date-times; search no longer matches identity numbers; new `featured_status` list field. Reclassifies the 1.0.1 city-filter 404 → 422 change as breaking. | Parse date-times as ISO; stop offering ID/CR search; show `featured_status`; treat bad city filter as 422. |
| 1.1.0 | 2026-10-21 | MINOR | Localized approval prerequisites, transactional document verification and full document numbers in audited identity review. | Render identity.documents; refetch documents/history after approval. |
| 1.0.1 | 2026-10-21 | PATCH | Live-review fixes: named city/sort validation, localized transition messages, safe default seeds and local manual-review fixtures. | Handle bad city filters as 422; show localized messages. Fixture seeder is opt-in dev only. |
| 1.0.0 | 2026-10-21 | Initial | Provider list/details, audited identity read and transactional account review. Important-change review is partially covered (§12/§13). | Integrate these eight routes; gate identity by approve permission; keep provider content read-only. |

## Changelog

### 2.3.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.2.2 — 2026-11-08

- **Changed:** Orders, ratings, earnings and subscriptions point to the sections that show them.
- **Breaking:** No. **App action required:** None.

### 2.2.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.2.0 — 2026-10-25

- **Added:** `PUT /providers/{id}/work-area` (permission `providers` · update): city, districts, base point and radius, any account status, audited (§6.7).
- **Changed / Fixed / Removed / Deprecated:** —
- **Breaking:** No.
- **Endpoints affected:** the one above (new).
- **Dashboard action required:** re-sync the permission catalog; show the editor to admins with the cell.
- **BRD / decisions:** ADMIN-021, TD-83.

### 2.1.0 — 2026-10-22

- **Changed:** initial `approve` now promotes this provider's `requested` registration categories to `approved` with the approving admin and timestamp. Category audits run inside the existing document/profile/event/audit/inbox transaction. Any later failure rolls everything back. Already approved/rejected categories and other providers' selections remain unchanged; reactivate does not rewrite category review metadata.
- **Fixed:** required-document types and qualification now come from one Domain policy shared with provider onboarding (TD-59); existing TD-46/50 approval requirements are preserved.
- **Added / Removed / Deprecated:** no endpoint or response field changes; no historical backfill.
- **Breaking:** no.
- **Endpoints affected:** `POST /providers/{id}/approve`; existing `GET /providers/{id}` reflects saved category statuses.
- **Dashboard action required:** refetch details/main_services after approval. Existing action payload and permission remain unchanged.
- **BRD / decisions:** ADMIN-004, PROV-002/003/004, BR-06, TD-59/60. Important-profile change review is built (TD-184): [profile-change-requests.md](profile-change-requests.md).


### 2.0.0 — 2026-10-22

- **Changed (breaking):** every date-time field is ISO 8601 with offset instead of `Y-m-d H:i:s` — list `submitted_at`/`created_at`; details `consents.*`, `approved_at`, `instant_terms_ack_at`, `time_off.starts_at/ends_at`, `change_requests.created_at`, `review_history.created_at`, `updated_at`; and `approved_at` in every action response. Date-only `documents.expires_at` stays `YYYY-MM-DD`. These endpoints are new, so they follow TD-03 rather than the TD-38 legacy format.
- **Changed (breaking):** `search` matches only `display_name` and phone. It no longer matches `national_id` or `commercial_register_no`: a view-only admin could confirm or rebuild a masked number through search, bypassing the audited identity grant (TD-54).
- **Added:** list and details `featured_status: {value, label}` — `active` / `expired` / `not_subscribed` — read from the provider's subscriptions, independent of account status. A suspended provider with a paid, unexpired subscription shows `active` while `is_featured` is `false` (AC-ADMIN-004, TD-55).
- **Reclassified:** the 1.0.1 change of an invalid/unknown `filters[city_id]` from 404 to 422 changed an error contract and is breaking; it is listed here so the dashboard handles it as part of this major version.
- **Endpoints affected:** all eight provider routes (dates); `GET /providers` (search, featured_status); `GET /providers/{id}` (featured_status).
- **Dashboard action required:** parse date-times as ISO 8601 (drop `parseLegacyUtc` for these screens); remove any "search by ID/CR number" hint — use the audited identity panel instead; render `featured_status` (badge: active / expired / not subscribed) next to `is_featured` (ranking now); handle a bad city filter as a 422 field error.
- **Migration note (old → new):** `"2026-10-21 14:52:36"` → `"2026-10-21T14:52:36+00:00"`. Both are UTC, so stored values are unchanged.
- **BRD / decisions:** ADMIN-004, AC-ADMIN-004, BR-27, TD-03, TD-45, TD-54, TD-55, DEC-12.

### 1.1.0 — 2026-10-21

- **Added:** `documents: [{id, type: {value,label}, doc_number}]` to the existing audited identity endpoint. Saved document numbers are full here; audit records field names and document UUIDs, never numbers. Missing numbers remain null; IBAN is never revealed.
- **Changed:** approval verifies exactly the qualifying required documents in the same transaction, setting reviewed_by/reviewed_at on pending rows. Existing verified rows keep their attribution. The review event summarizes qualifying/newly verified counts; the profile audit links the event and document UUIDs, alongside document change audits. Non-qualifying rows remain untouched (TD-50).
- **Fixed:** missing-document 422 messages use English/Arabic document-type labels and a localized submission label.
- **Removed / Deprecated:** none. **Breaking:** no response type, route, permission or error-code changes; existing review prerequisites are enforced with localized messages.
- **Endpoints affected:** `GET /providers`, `GET /providers/{id}`, `GET /providers/{id}/identity`, `POST /providers/{id}/approve`.
- **Dashboard action required:** render full document numbers only in the explicit identity panel; keep default numbers and all IBANs masked. Refetch document statuses/history after approval.
- **BRD / decisions:** ADMIN-004/006/007, TD-50/51.

### 1.0.1 — 2026-10-21

- Invalid/unknown city UUID filters now return 422 naming `filters.city_id`; route IDs retain 404. Bad sort directions identify the concrete key; transition failures localize both action and status.
- Traced the 2026-10-20 14:15 approved demo provider to UserSeeder, not app registration. New default provider/driver seeds start incomplete. Existing records/live review events are preserved, with no invented historical approval.
- Adds opt-in AdminReviewDevSeeder for manual review, never invoked by the tests/default seeder; submitted provider scenarios with labelled synthetic PDF documents. **App action required:** treat bad city as a filter-validation error. No response field changes.

### 1.0.0 — 2026-10-21

- Adds the provider management screens and five profile-status actions; no admin create/edit/delete.
- Adds the shared `AccountReviewService` for Providers, later Drivers and ADMIN-007. ADMIN-007 remains planned.
- TD-43 adds nullable review-event transitions through a **new additive migration**; original create migration unchanged. Old/non-transition events retain null states.
- TD-44 defers important-change decisions/apply semantics. TD-45 separates full identity from masked details and never reveals payout IBAN.
- **App action required:** integrate §2/§8; run the additive migration before serving new review-history/action routes; sync permissions and explicitly grant the new routes.

## 1. Business summary

Review self-registered individuals and establishments, inspect their saved content and documents, and approve, reject, request corrections, suspend or reactivate their provider profile. An establishment is one account, with `specialists_count` as information only (D4).

Suspension affects the provider profile only. It removes booking/featured eligibility even with an active subscription; it leaves `users.is_active` and customer/driver profiles untouched. Account-wide blocking belongs to [customers.md](customers.md). Existing confirmed orders/trips require downstream handling (§13).

## 2. Pages and permissions

| Page / component | Purpose | Endpoint | Exact RBAC permission | Section · action |
|------------------|---------|----------|-----------------------|------------------|
| ProvidersPage | Search/filter/paginate | GET /providers | admin.providers.index | providers · view |
| ProviderDetailsPage | Read saved provider content | GET /providers/{id} | admin.providers.show | providers · view |
| IdentityReviewPanel | Explicit reveal while comparing documents | GET /providers/{id}/identity | admin.providers.identity | providers · approve |
| CoverageEditor | Set city, districts, base point and radius | PUT /providers/{id}/work-area | admin.providers.work-area | providers · update |
| ApproveDialog | Approve submitted account | POST /providers/{id}/approve | admin.providers.approve | providers · approve |
| RejectDialog | Reject with reason | POST /providers/{id}/reject | admin.providers.reject | providers · approve |
| RequestChangesDialog | Request corrections | POST /providers/{id}/request-changes | admin.providers.request-changes | providers · approve |
| SuspendDialog | Suspend provider profile | POST /providers/{id}/suspend | admin.providers.suspend | providers · approve |
| ReactivateDialog | Reactivate with optional note | POST /providers/{id}/reactivate | admin.providers.reactivate | providers · approve |

`{id}` is **providers.uuid**, not users.uuid. All nested `id`, `user_id`, `approved_by`, city/district IDs and notification profile IDs are their owning record's UUID. Unknown/integer/users UUIDs or accounts without this provider profile return 404. Deleted users and admin identities are excluded even if a provider row exists. Multi-profile app users remain reachable.

Sidebar: Accounts → Service providers; hide when `admin.providers.index` is absent. A view grant does not grant identity; approving does not implicitly grant other exact routes unless the whole matrix cell was assigned. Registration-review has no routes yet.

## 3. List / table

`GET /api/v1/admin/providers`: paginated rows at **data.providers**, with standard links/meta. Default page size 15, max 100. `paginate=false|0` returns a direct `data` array capped at 500; default `paginate=true|1`. `page` integer ≥1. `search` nullable string max255.

| Columns | Type / meaning | Sort / filter |
|---------|----------------|---------------|
| id, display_name | Provider UUID, saved display/trading name | sort display_name; search name |
| provider_type | `{value,label}` individual/establishment | filters[provider_type] |
| phone_code, phone | Separate stored user phone fields; display together | Search phone |
| city | `{id,name}` or null; translated with Arabic fallback | filters[city_id] UUID; invalid/unknown →422 naming filters.city_id |
| service_radius_km, districts_count | Saved radius and actual linked district count | — |
| account_status | Localized enum; see §7 | filters[account_status] |
| is_accepting_bookings, instant_enabled | Saved boolean preferences; status may prevent operation | Boolean filters for both |
| is_featured | Ranking priority applies **now**: actual `v_provider_featured` membership | Approved + qualifying subscription required |
| featured_status | `{value,label}`: `active` / `expired` / `not_subscribed`. Subscription state regardless of account status (TD-55) | — |
| rating_avg | Decimal string or null | sort rating_avg |
| rating_count, completed_orders_count | Existing profile counters only | sort completed_orders_count |
| submitted_at, created_at | ISO 8601 date-time with offset; submission nullable | sort both; date ranges below |

Search performs partial matches on **display_name and phone** only. Identity numbers (`national_id`, `commercial_register_no`) are never searchable: matching them would let a view-only admin confirm a masked value without the audited identity grant (TD-54). Use `GET /providers/{id}/identity` to compare numbers.

`featured_status` values: `active` = an activated subscription that has not ended; `expired` = no current one, but an earlier activated subscription (status active past its end, or expired); `not_subscribed` = never activated (none, or only pending_payment / payment_failed / cancelled / suspended rows). Cancelled and suspended subscriptions do not count as history until DEC-12 is decided (§13).

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

Allowed filters: `filters[notification_reachable]`, `filters[account_status]`, `filters[provider_type]`, `filters[city_id]`, `filters[instant_enabled]`, `filters[is_accepting_bookings]`, `filters[created_from]`, `filters[created_to]`, `filters[submitted_from]`, `filters[submitted_to]`. Booleans use 0/1. Dates are inclusive UTC `YYYY-MM-DD`; each supplied end must be ≥ its start. Null submission dates do not match a submission date filter.

Allowed sorts: `created_at`, `submitted_at`, `rating_avg`, `completed_orders_count`, `display_name`, each `asc|desc`. Default created_at DESC; ties use an internal descending key which is never exposed. Unknown nested keys/invalid values →422 naming the path. Preserve nested query keys using the same serializer as customers.md.

```json
{
  "status": "success",
  "message": "Data retrieved successfully",
  "data": {
    "providers": [
      {
        "id": "01a16266-506f-72a2-93bf-6918df93b452",
        "display_name": "Claudia Pfeffer",
        "provider_type": {
          "value": "individual",
          "label": "Individual"
        },
        "phone_code": "966",
        "phone": "573921190",
        "city": null,
        "service_radius_km": 10,
        "districts_count": 0,
        "account_status": {
          "value": "pending_review",
          "label": "Pending review"
        },
        "is_accepting_bookings": false,
        "instant_enabled": false,
        "is_featured": false,
        "featured_status": {
          "value": "not_subscribed",
          "label": "Not subscribed"
        },
        "rating_avg": null,
        "rating_count": 0,
        "completed_orders_count": 0,
        "submitted_at": "2026-10-22T06:57:14+00:00",
        "created_at": "2026-10-22T06:57:14+00:00"
      }
    ],
    "links": {
      "first": "http://localhost/api/v1/admin/providers?page=1",
      "last": "http://localhost/api/v1/admin/providers?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/providers?page=1",
          "label": "1",
          "page": 1,
          "active": true
        },
        {
          "url": null,
          "label": "Next &raquo;",
          "page": null,
          "active": false
        }
      ],
      "path": "http://localhost/api/v1/admin/providers",
      "per_page": 15,
      "to": 1,
      "total": 1
    }
  }
}
```

### Registration categories after account approval (2.1.0)

The following `data` excerpt is copied from the captured [approved-category details response](../postman-collection.json) (Postman: `admin.providers.show` › "200 Approved registration categories in provider details"); the remaining keys are unchanged.

```json
{
  "id": "01a162b1-50e4-70be-afd3-7ac164d23ec9",
  "account_status": {
    "value": "approved",
    "label": "Approved"
  },
  "main_services": [
    {
      "id": "01a162b1-50f6-70c5-a8fb-843ecceb105c",
      "category": {
        "id": "01a162b1-50f6-70c5-a8fb-843ecd43f5e5",
        "name": "Sample-zmnx33996315"
      },
      "status": {
        "value": "approved",
        "label": "Approved"
      },
      "is_enabled": true
    },
    {
      "id": "01a162b1-50fa-713f-bcf4-fb6f8d3cb6ec",
      "category": {
        "id": "01a162b1-50fa-713f-bcf4-fb6f8daa284f",
        "name": "Sample-ssyl98276402"
      },
      "status": {
        "value": "rejected",
        "label": "Rejected"
      },
      "is_enabled": true
    },
    {
      "id": "01a162b1-50fe-73dc-b999-3cb9780765a0",
      "category": {
        "id": "01a162b1-50fe-73dc-b999-3cb9788ad4f1",
        "name": "Sample-zzbp63398287"
      },
      "status": {
        "value": "approved",
        "label": "Approved"
      },
      "is_enabled": true
    }
  ]
}
```

## 4. Details view

`GET /api/v1/admin/providers/{id}` returns list fields plus saved review information. No fields are inferred from unbuilt commerce modules.

```json
{
  "status": "success",
  "message": "Data retrieved successfully",
  "data": {
    "id": "01a16266-70c7-7357-a164-b9649003c311",
    "display_name": "Vivianne Maggio",
    "provider_type": {
      "value": "individual",
      "label": "Individual"
    },
    "phone_code": "966",
    "phone": "578641552",
    "city": {
      "id": "01a16266-70c0-72c3-ac13-9218701ae2fb",
      "name": "Riyadh"
    },
    "service_radius_km": 10,
    "districts_count": 1,
    "account_status": {
      "value": "pending_review",
      "label": "Pending review"
    },
    "is_accepting_bookings": false,
    "instant_enabled": false,
    "is_featured": false,
    "featured_status": {
      "value": "not_subscribed",
      "label": "Not subscribed"
    },
    "rating_avg": "4.50",
    "rating_count": 7,
    "completed_orders_count": 9,
    "submitted_at": "2026-10-22T06:57:23+00:00",
    "created_at": "2026-10-22T06:57:23+00:00",
    "user_id": "01a16266-70c7-7357-a164-b964906ad8a2",
    "bio": null,
    "professional_title": null,
    "years_experience": null,
    "experience_note": null,
    "specialists_count": null,
    "authorized_person": {
      "name": null,
      "phone": null
    },
    "national_id": "****7890",
    "commercial_register_no": null,
    "vat_number": "****9003",
    "status_reason": null,
    "consents": {
      "female_declared_at": null,
      "terms_accepted_at": null,
      "privacy_accepted_at": null
    },
    "approved_at": null,
    "approved_by": null,
    "instant_terms_ack_at": null,
    "coverage": {
      "base_lat": null,
      "base_lng": null,
      "service_radius_km": 10,
      "districts": [
        {
          "id": "01a16266-70d0-7317-ab37-14de29c9f993",
          "name": "Sample-mzjd63544170"
        }
      ]
    },
    "image": null,
    "portfolio": [],
    "working_hours": [
      {
        "id": "01a16266-70d5-700f-85f6-35180c901c67",
        "weekday": 1,
        "start_time": "09:00:00",
        "end_time": "17:00:00"
      }
    ],
    "time_off": [
      {
        "id": "01a16266-70d6-7342-8709-022af17dfdcf",
        "starts_at": "2026-10-23T06:57:23+00:00",
        "ends_at": "2026-10-23T07:57:23+00:00",
        "reason": null
      }
    ],
    "documents": [
      {
        "id": "01a16266-70ce-739f-88a2-e9e0f212bd11",
        "type": {
          "value": "health_certificate",
          "label": "Health certificate"
        },
        "status": {
          "value": "pending",
          "label": "Pending"
        },
        "doc_number": null,
        "expires_at": null,
        "file_url": "http://localhost/storage/demo/placeholder.jpg"
      },
      {
        "id": "01a16266-70cc-70f8-be0d-26472684078b",
        "type": {
          "value": "professional_certificate",
          "label": "Professional certificate"
        },
        "status": {
          "value": "pending",
          "label": "Pending"
        },
        "doc_number": null,
        "expires_at": null,
        "file_url": "http://localhost/storage/demo/placeholder.jpg"
      },
      {
        "id": "01a16266-70ca-7203-93d9-aefffef54515",
        "type": {
          "value": "national_id",
          "label": "National ID / iqama"
        },
        "status": {
          "value": "pending",
          "label": "Pending"
        },
        "doc_number": null,
        "expires_at": null,
        "file_url": "http://localhost/storage/demo/placeholder.jpg"
      }
    ],
    "main_services": [
      {
        "id": "01a16266-70df-71f3-8d45-22374c95e349",
        "category": {
          "id": "01a16266-70d8-7385-ac99-20b681d5b593",
          "name": "Sample-shen26984310"
        },
        "status": {
          "value": "approved",
          "label": "Approved"
        },
        "is_enabled": true
      }
    ],
    "priced_services": [
      {
        "id": "01a16266-70e1-716e-b6be-2a806c6b869e",
        "service": {
          "id": "01a16266-70d7-72f0-9d5b-63ef0aba2b2f",
          "name": "Sample-bddl79666507"
        },
        "price": "100.00",
        "min_price": "10.00",
        "max_price": "200.00",
        "out_of_range": false,
        "duration_min": null,
        "is_active": true
      }
    ],
    "packages": [
      {
        "id": "01a16266-70e2-701b-9a21-61dfb712b4ac",
        "name": "Sample-spfd57473814",
        "status": {
          "value": "draft",
          "label": "Draft"
        },
        "price": "200.00",
        "validity_days": 30,
        "is_visible": true,
        "hidden_by_admin": false
      }
    ],
    "payout_account": {
      "id": "01a16266-70e5-702c-bb99-491ac568f99e",
      "holder_name": "ztalbaygckjv",
      "bank_name": "xdqtwphysjez",
      "iban": "****9012",
      "status": {
        "value": "pending",
        "label": "Pending"
      }
    },
    "change_requests": [
      {
        "id": "01a16266-70e7-7340-9109-ed5459a14c4b",
        "change_type": {
          "value": "profile",
          "label": "Profile"
        },
        "status": {
          "value": "pending",
          "label": "Pending"
        },
        "created_at": "2026-10-22T06:57:23+00:00"
      }
    ],
    "review_history": [],
    "updated_at": "2026-10-22T06:57:23+00:00"
  }
}
```

| Additional field | Type / nullability | Meaning |
|------------------|--------------------|---------|
| user_id | UUID | Related app identity, not the provider path ID |
| bio, professional_title, experience_note | string/null | Provider-owned free text |
| years_experience, specialists_count | number/null | Specialists are informational, no staff sub-accounts |
| authorized_person | `{name,phone}` nullable members | Saved responsible person |
| national_id, commercial_register_no, vat_number | masked string/null | Fixed **** prefix and last four; short values fully redacted |
| status_reason | string/null | Most recent adverse-action reason; cleared on approve/reactivate |
| consents | object with three ISO 8601 date-times/null | Female declaration, terms, privacy; no fabricated acceptance |
| approved_at, approved_by | timestamp/null, admin UUID/null | Original approval attribution; preserved by reactivate |
| instant_terms_ack_at | timestamp/null | Provider's recorded instant-booking consent |
| coverage | base_lat/base_lng numbers or null, radius, districts[] | Actual coverage; pure links expose district UUIDs |
| image, portfolio | URL/null, URL[] | Attached provider/user media only; absent media is null/[] |
| working_hours | `{id,weekday,start_time,end_time}[]` | weekday 0 Sunday…6 Saturday, HH:mm:ss wall-clock schedule |
| time_off | `{id,starts_at,ends_at,reason}[]` | ISO 8601 closures, optional reason |
| documents | `{id,type,status,doc_number,expires_at,file_url}[]` | Provider-role only; document number masked, expiry date-only, URL null without attached file |
| main_services | `{id,category,status,is_enabled}[]` | provider_categories rows; requested/approved/rejected localized |
| priced_services | `{id,service,price,min_price,max_price,out_of_range,duration_min,is_active}[]` | Read-only provider price + admin range; exact decimal comparisons |
| packages | `{id,name,status,price,validity_days,is_visible,hidden_by_admin}[]` | Actual saved summaries; no purchase/redemption or review workflow |
| payout_account | `{id,holder_name,bank_name,iban,status}`/null | Current primary non-inactive provider payout account; IBAN masked everywhere |
| change_requests | `{id,change_type,status,created_at}[]` | Pending provider-role metadata only; payload/files not exposed until a safe contract exists |
| review_history | `{id,action,from_status,to_status,reason,actor,created_at}[]` | Provider-role history newest first, deterministic for equal timestamps; actor UUID/name or null |
| updated_at | ISO 8601 date-time | Profile update time |

Other-role documents/payout accounts/requests/events are excluded. Collection fields stay arrays. Related names localize with Arabic fallback; user-written text is not returned as editable ar/en forms.

`review_history` actions are submitted, approved, rejected, needs_changes, suspended, reactivated. Historical/non-transition states can be null. Example copied from the tested approval/suspension/reactivation cycle:

```json
[
  {
    "id": "01a16266-61a3-71b3-a740-c0b6d7af1f68",
    "action": {
      "value": "reactivated",
      "label": "Reactivated"
    },
    "from_status": {
      "value": "suspended",
      "label": "Suspended"
    },
    "to_status": {
      "value": "approved",
      "label": "Approved"
    },
    "reason": "Reviewed again",
    "actor": {
      "id": "01a16266-6173-726a-baa3-db7661fcc421",
      "full_name": "Fausto Medhurst"
    },
    "created_at": "2026-10-22T06:57:19+00:00"
  },
  {
    "id": "01a16266-6195-73ca-9f61-8e415543e8d7",
    "action": {
      "value": "suspended",
      "label": "Suspended"
    },
    "from_status": {
      "value": "approved",
      "label": "Approved"
    },
    "to_status": {
      "value": "suspended",
      "label": "Suspended"
    },
    "reason": "Compliance review",
    "actor": {
      "id": "01a16266-6173-726a-baa3-db7661fcc421",
      "full_name": "Fausto Medhurst"
    },
    "created_at": "2026-10-22T06:57:19+00:00"
  },
  {
    "id": "01a16266-6186-7349-a413-b9586f5119b2",
    "action": {
      "value": "approved",
      "label": "Approved"
    },
    "from_status": {
      "value": "pending_review",
      "label": "Pending review"
    },
    "to_status": {
      "value": "approved",
      "label": "Approved"
    },
    "reason": "Approval documents: 3 qualifying; 3 newly verified.",
    "actor": {
      "id": "01a16266-6173-726a-baa3-db7661fcc421",
      "full_name": "Fausto Medhurst"
    },
    "created_at": "2026-10-22T06:57:19+00:00"
  },
  {
    "id": "01a16266-61ac-736d-8452-896d11322304",
    "action": {
      "value": "submitted",
      "label": "Submitted"
    },
    "from_status": null,
    "to_status": null,
    "reason": null,
    "actor": null,
    "created_at": "2026-10-21T06:57:19+00:00"
  }
]
```

## 5. Forms (create / edit)

N/A. No create, profile-edit, delete, price, package, availability, document-content or payout routes. Approval changes only document verification metadata (§6.1). Providers self-register/update their content. Important-change requests stay pending and read-only; admin cannot approve/reject them in this release (TD-44).

## 6. Actions (non-CRUD)

All POST actions return `{id,account_status,status_reason,approved_at}` inside the standard success envelope. Each locks the profile and, in one database transaction, updates it, inserts the from/to review event with actor/reason, writes the audit log, and inserts the app-scoped inbox notice. Any failure rolls back all four. No account-wide block occurs.

### 6.1 Approve

`POST /providers/{id}/approve` · `admin.providers.approve`. Empty body; `reason`/`note` prohibited. Only pending_review → approved.

Requires `submitted_at` and every required document with an attached `file` media row, status pending or verified, and expiry null or today/later (UTC). Rejected/expired/replaced documents, driver-role documents and metadata without a file do not qualify. Missing prerequisites are named with localized document-type/submission labels in the 422 message even outside debug mode; clients must not parse labels as schema values.

| Provider type | Required document types (adopted SCR-18) |
|---------------|-----------------------------------------|
| individual | national_id (also iqama), professional_certificate, health_certificate |
| establishment | commercial_register, business_license, staff_health_certificates |

On successful approval, every required-type, correct-role, current attached document used by the check becomes verified if pending, with the acting admin and review timestamp. Already verified rows retain their original reviewer/time. Non-required types, rejected/expired/replaced rows, expired dates, other roles and rows without attached files remain untouched. All qualifying duplicates are included; no newest-only rule is assumed. The event reason contains a qualifying/newly-verified count summary, and the profile audit stores review_event_id, qualifying_document_ids and verified_document_ids (UUIDs). Each newly verified document also has a status-change audit. Document updates, profile approval, event, audits and inbox notice roll back together (TD-50).

Approval does not manufacture consent data. Standalone document editing/replacement/review controls remain outside this feature. Additional profile/bookability prerequisites remain with onboarding and search/booking (BR-08); account approval alone is not proof of all eligibility conditions.

```json
{
  "status": "success",
  "message": "Data updated successfully",
  "data": {
    "id": "01a16266-53e1-7051-8d19-2c4840132f7c",
    "account_status": {
      "value": "approved",
      "label": "Approved"
    },
    "status_reason": null,
    "approved_at": "2026-10-22T06:57:15+00:00"
  }
}
```

### 6.2 Reject

`POST /providers/{id}/reject` · `admin.providers.reject`. Only pending_review → rejected. Body: `reason` required string, nonblank, max500. It becomes status_reason and the event note. `note` prohibited.

```json
{
  "status": "success",
  "message": "Data updated successfully",
  "data": {
    "id": "01a16266-54ec-7191-9f3f-6ad38d433c89",
    "account_status": {
      "value": "rejected",
      "label": "Rejected"
    },
    "status_reason": "Please correct the documents",
    "approved_at": null
  }
}
```

### 6.3 Request changes

`POST /providers/{id}/request-changes` · `admin.providers.request-changes`. Only pending_review → needs_changes. Same required `reason` max500; `note` prohibited. This reviews registration, **not** profile_change_requests.

```json
{
  "status": "success",
  "message": "Data updated successfully",
  "data": {
    "id": "01a16266-55e2-7334-8dc5-e437b9949e92",
    "account_status": {
      "value": "needs_changes",
      "label": "Changes required"
    },
    "status_reason": "Please correct the documents",
    "approved_at": null
  }
}
```

### 6.4 Suspend

`POST /providers/{id}/suspend` · `admin.providers.suspend`. Only approved → suspended. Same required `reason` max500; `note` prohibited. Removes featured/bookable view membership without altering a subscription, provider prices, availability preferences or users.is_active. Existing order effects are deferred.

```json
{
  "status": "success",
  "message": "Data updated successfully",
  "data": {
    "id": "01a16266-56e0-73a5-b026-345310dfc4cb",
    "account_status": {
      "value": "suspended",
      "label": "Suspended"
    },
    "status_reason": "Please correct the documents",
    "approved_at": null
  }
}
```

### 6.5 Reactivate

`POST /providers/{id}/reactivate` · `admin.providers.reactivate`. Only suspended → approved. Body: optional nullable `note`, string max500; `reason` prohibited. Clears status_reason, records a reactivated event with the note, preserves original approved_at/approved_by. Approval document checks apply to initial approve; this explicit transition restores prior approval.

```json
{
  "status": "success",
  "message": "Data updated successfully",
  "data": {
    "id": "01a16266-5811-7067-a708-5b59496210a4",
    "account_status": {
      "value": "approved",
      "label": "Approved"
    },
    "status_reason": null,
    "approved_at": null
  }
}
```

### 6.6 Review identity

`GET /providers/{id}/identity` · **admin.providers.identity**, providers · **approve**. No request body. Explicitly load only when the reviewer opens the identity panel. The full national_id/iqama, CR and VAT are returned as saved; missing values are null. `documents` contains all provider-role document UUIDs, localized types and full saved `doc_number` values (or null), newest first with ID as the tie-breaker, including CR/licence/certificate/other numbers where saved. Driver-role documents are excluded. An empty set is `[]`. IBAN is never returned here (TD-51).

Every successful call writes `providers.identity.viewed` to the existing audit service: actor, target provider UUID, requested field names (`national_id`, `commercial_register_no`, `vat_number`, `documents.doc_number`), document UUIDs, IP/user-agent/correlation. **No identity values enter the audit payload.** Audit failure prevents a successful reveal. Response has `Cache-Control: no-store, private`; keep revealed data out of persistent browser storage and clear the panel when navigating away.

```json
{
  "status": "success",
  "message": "Data retrieved successfully",
  "data": {
    "id": "01a16266-6fa8-700f-816a-ba4abe18cd5e",
    "national_id": "1234567890",
    "commercial_register_no": "9876543210",
    "vat_number": "300123456789003",
    "documents": [
      {
        "id": "01a16266-6fab-7390-a7ee-7347525a3a41",
        "type": {
          "value": "commercial_register",
          "label": "Commercial register"
        },
        "doc_number": "FULL-DOCUMENT-12345"
      }
    ]
  }
}
```

### Shared action/error handling

| HTTP | data.error_code | Trigger | Dashboard response |
|------|-----------------|---------|--------------------|
| 401 | UNAUTHENTICATED | Missing/expired/invalid session | Shared refresh/login handling |
| 403 | PERMISSION_DENIED | Missing exact route or wrong app token | Deny page/action; view is insufficient for identity |
| 403 | ACCOUNT_INACTIVE | Disabled/banned operator | Stop protected requests |
| 404 | absent (data:null) | Unknown/integer/wrong-profile/removed/admin route identity | Show unavailable; do not infer another profile |
| 409 | INVALID_STATE_TRANSITION | Any source/action outside §7 | Refetch details/history and update available buttons |
| 422 | BUSINESS_RULE_VIOLATED | Approve missing submission or valid document | Show message naming missing items; request correction |
| 422 | absent (`data.errors`) | Bad query key/value (including invalid/unknown city UUID), missing/long reason or long note | Mark fields from `data.errors`; fall back to the localized message (TD-02) |
| 405 | absent | Unsupported create/edit/delete method | No corresponding controls |
| 500 | shared server-error contract | Persistence/audit/inbox failure | No success; refresh before retrying |

Disable duplicate submissions while pending. After an uncertain timeout, refetch status and review history; replaying a completed action returns 409 and sends no duplicate notice. No idempotency key is required; row locks serialize competing reviews. User resubmission of incomplete/needs_changes/rejected accounts belongs to onboarding and PROV-004 settings; admin has no re-enter-review route.

Real missing-document and invalid-transition responses:

```json
{
  "status": "fail",
  "message": "Approval requires: Account submission, National ID / iqama, Professional certificate, Health certificate.",
  "data": {
    "error_code": "BUSINESS_RULE_VIOLATED",
    "context": {
      "violations": [
        "Approval requires: Account submission, National ID / iqama, Professional certificate, Health certificate."
      ]
    },
    "correlation_id": "5b3bcb7a-3965-4da5-ab5b-3e062c7b556f"
  }
}
```

The same prerequisite failure with `Accept-Language: ar`:

```json
{
  "status": "fail",
  "message": "الموافقة تتطلب: تقديم الحساب للمراجعة, هوية / إقامة, شهادة مهنية, شهادة صحية.",
  "data": {
    "error_code": "BUSINESS_RULE_VIOLATED",
    "context": {
      "violations": [
        "الموافقة تتطلب: تقديم الحساب للمراجعة, هوية / إقامة, شهادة مهنية, شهادة صحية."
      ]
    },
    "correlation_id": "12c3c010-25c6-44d6-b720-4b3ef1eef985"
  }
}
```

```json
{
  "status": "fail",
  "message": "Cannot Approve from Suspended.",
  "data": {
    "error_code": "INVALID_STATE_TRANSITION",
    "correlation_id": "3235078c-be41-4965-8eac-ac6394a82209"
  }
}
```


```json
{
  "status": "fail",
  "message": "The filters.city_id field must be a valid UUID.",
  "data": {
    "errors": {
      "filters.city_id": [
        "The filters.city_id field must be a valid UUID."
      ]
    }
  }
}
```


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


```json
{
  "status": "fail",
  "message": "لا يمكن تنفيذ إعادة تفعيل من الحالة غير مكتمل.",
  "data": {
    "error_code": "INVALID_STATE_TRANSITION",
    "correlation_id": "0076d67d-1af0-4f6f-8403-acee1aa85563"
  }
}
```

### 6.7 Set city and coverage

`PUT /providers/{id}/work-area` · **admin.providers.work-area**, providers · **update**. ADMIN-021: the admin links the provider to her city and coverage areas (TD-83). The account owner can also set them from her own app (`profile/work-area`); the admin may do it **in any account status**, because she is the reviewer — the owner's "changing a saved value after approval needs review" block (BR-04, TD-76) does not apply to the admin. The change is audited (action `providers.work-area.updated`, field names only) and takes effect at once for eligibility (BR-08) and in what customers see. The owner is not notified in-app (limitation, TD-83).

| Field | Type | Validation | Description |
|-------|------|------------|-------------|
| `city_id` | uuid | optional, enabled city | Omit to keep the saved city |
| `district_ids` | uuid[] | optional | Full list; replaces the saved one, `[]` clears it; each enabled and in the resulting city |
| `base_lat`, `base_lng` | number / null | both or neither, −90…90 / −180…180 | Point she travels from (TD-77) |
| `service_radius_km` | int | 1–32767 | Radius around the base point |

Success returns the saved work area (same body as her own `GET /profile/work-area`; `locked_fields` lists what **she** can no longer change herself). Postman: `admin.providers.work-area` › "200 Provider Work Area Set By Admin".

```json
{
  "status": "success",
  "message": "Data updated successfully",
  "data": {
    "city": {
      "id": "01a17247-3140-71cb-ba5c-60c14e050965",
      "name": "Sample-rldh56017390"
    },
    "districts": [
      {
        "id": "01a17247-3145-70bd-98eb-f877f6c14354",
        "name": "Sample-ondx18997149"
      },
      {
        "id": "01a17247-3145-70bd-98eb-f877f789e018",
        "name": "Sample-qahn32285329"
      }
    ],
    "base_lat": 24.713552,
    "base_lng": 46.675296,
    "service_radius_km": 15,
    "locked_fields": [
      "city_id",
      "district_ids",
      "base_lat",
      "base_lng",
      "service_radius_km"
    ]
  }
}
```

| HTTP | `error_code` | When | Dashboard behavior |
|------|--------------|------|--------------------|
| 403 | `PERMISSION_DENIED` | Missing `admin.providers.work-area` | Hide the editor |
| 404 | — | Unknown, integer or wrong-type id; administrator or deleted account | Toast, reload |
| 422 | `BUSINESS_RULE_VIOLATED` | A district does not belong to the resulting city | Toast `message` |
| 422 | `SERVICE_AREA_UNAVAILABLE` | The city or a district is disabled | Toast `message` |
| 422 | — | Shape: bad uuid, `base_lat` without `base_lng`, radius out of range → `data.errors` | Mark the fields |

## 7. Statuses and enums

Use enum `value` for logic and `label` for presentation. Labels follow Accept-Language; booleans remain booleans. Badges below are dashboard presentation choices.

| account_status value | Label en / ar | Badge color | Admin actions |
|----------------------|---------------|-------------|---------------|
| incomplete | Incomplete / غير مكتمل | neutral | None |
| pending_review | Pending review / قيد المراجعة | amber | Approve, reject, request-changes |
| needs_changes | Changes required / تعديلات مطلوبة | orange | None; user must resubmit |
| approved | Approved / معتمدة | green | Suspend |
| rejected | Rejected / مرفوضة | red | None; user resubmission policy PROV-004 |
| suspended | Suspended / موقوفة | red | Reactivate |

provider_type: individual / Individual / فردية; establishment / Establishment / منشأة. Document statuses: pending/verified/rejected/expired/replaced. Category statuses: requested/approved/rejected. Payout statuses: pending/verified/rejected/inactive. Package statuses: draft/pending_review/approved/rejected/archived. Pending change-request types: profile/work_area/identity/bank_account/vehicle/document/new_category. All displayed enum objects use localized labels. INVALID_STATE_TRANSITION messages also use localized action/status labels in English and Arabic, never raw slugs.

## Data model

| Table / view | Read / write | Scope |
|--------------|--------------|-------|
| users, providers | Read; provider status/approval fields write | Real provider profile, non-admin/non-deleted user |
| user_documents | Read + qualifying approval verification | Provider role; only status/reviewed_by/reviewed_at change on pending qualifying rows (TD-50) |
| media | Read only | Existing attached files |
| provider_categories, categories/translations | Read; requested provider_categories reviewed on initial approve | TD-60 promotes requested registration rows inside the account review transaction; catalog definitions stay read-only |
| provider_services, services/translations | Read only | Provider prices beside admin ranges |
| provider_districts, governorates/regions/translations | Read only | Geography/coverage; pure links expose no internal IDs |
| provider_working_hours, provider_time_off, packages | Read only | Saved schedules, closures and summaries |
| payout_accounts, profile_change_requests | Read only | Primary masked bank record; pending request metadata |
| account_review_events | Read + insert | Nullable from/to matching account_status; actor/from/to/reason on new transitions |
| audit_logs, notifications | Insert | Audit and provider inbox inside the review transaction |
| provider_subscriptions, featured_plans, v_provider_featured | Read eligibility only | Existing qualifying featured membership, no subscription/payment management |
| v_bookable_provider_services, v_selectable_provider_addons | Eligibility test | Suspended/unapproved accounts excluded |

Upgrade with `2026_10_21_134350_add_transition_statuses_to_account_review_events_table.php` before enabling these actions/details. It adds two nullable ENUM columns; no data rewrite or invented historical state. Its rollback preserves event rows but drops the new transition data. SQL bootstrap and migration metadata are verified together.

## 8. TypeScript types

Import `ApiResponse`, `Paginated`, `EnumValue` and `Money` from the shared conventions. Date-times here are ISO 8601 — parse with `new Date(value)`; `parseLegacyUtc` does not apply.

```ts
export type Uuid = string;
export type IsoDateTime = string; // ISO 8601 with offset, e.g. 2026-10-22T06:57:15+00:00
export type FeaturedStatus = 'active' | 'expired' | 'not_subscribed';
export type AccountStatus = 'incomplete' | 'pending_review' | 'needs_changes' | 'approved' | 'rejected' | 'suspended';
export type ReviewAction = 'submitted' | 'approved' | 'rejected' | 'needs_changes' | 'suspended' | 'reactivated';
export type DocumentType = 'national_id' | 'professional_certificate' | 'health_certificate' | 'security_check' | 'commercial_register' | 'business_license' | 'staff_health_certificates' | 'driving_license' | 'vehicle_registration' | 'other';
export type DocumentStatus = 'pending' | 'verified' | 'rejected' | 'expired' | 'replaced';
export type ChangeType = 'profile' | 'work_area' | 'identity' | 'bank_account' | 'vehicle' | 'document' | 'new_category';
export interface GeoSummary { id: Uuid; name: string | null }
export interface Provider {
  id: Uuid; display_name: string;
  provider_type: EnumValue<'individual' | 'establishment'>;
  phone_code: string | null; phone: string | null; city: GeoSummary | null;
  service_radius_km: number; districts_count: number;
  account_status: EnumValue<AccountStatus>;
  is_accepting_bookings: boolean; instant_enabled: boolean; is_featured: boolean; featured_status: EnumValue<FeaturedStatus>;
  rating_avg: string | null; rating_count: number; completed_orders_count: number;
  submitted_at: IsoDateTime | null; created_at: IsoDateTime;
}
export interface IdentityDocument { id: Uuid; type: EnumValue<DocumentType>; doc_number: string | null } // full, identity endpoint only
export interface ProviderIdentity {
  id: Uuid; national_id: string | null; commercial_register_no: string | null; vat_number: string | null;
  documents: IdentityDocument[];
}
export interface ReviewEvent {
  id: Uuid; action: EnumValue<ReviewAction>;
  from_status: EnumValue<AccountStatus> | null; to_status: EnumValue<AccountStatus> | null;
  reason: string | null; actor: { id: Uuid; full_name: string } | null; created_at: IsoDateTime;
}
export interface ProviderDetails extends Provider {
  user_id: Uuid; bio: string | null; professional_title: string | null;
  years_experience: number | null; experience_note: string | null; specialists_count: number | null;
  authorized_person: { name: string | null; phone: string | null };
  national_id: string | null; commercial_register_no: string | null; vat_number: string | null; // masked
  status_reason: string | null;
  consents: { female_declared_at: IsoDateTime | null; terms_accepted_at: IsoDateTime | null; privacy_accepted_at: IsoDateTime | null };
  approved_at: IsoDateTime | null; approved_by: Uuid | null; instant_terms_ack_at: IsoDateTime | null;
  coverage: { base_lat: number | null; base_lng: number | null; service_radius_km: number; districts: GeoSummary[] };
  image: string | null; portfolio: string[];
  working_hours: { id: Uuid; weekday: number; start_time: string; end_time: string }[];
  time_off: { id: Uuid; starts_at: IsoDateTime; ends_at: IsoDateTime; reason: string | null }[];
  documents: { id: Uuid; type: EnumValue<DocumentType>; status: EnumValue<DocumentStatus>; doc_number: string | null; expires_at: string | null; file_url: string | null }[];
  main_services: { id: Uuid; category: GeoSummary; status: EnumValue<'requested' | 'approved' | 'rejected'>; is_enabled: boolean }[];
  priced_services: { id: Uuid; service: GeoSummary; price: Money; min_price: Money; max_price: Money; out_of_range: boolean; duration_min: number | null; is_active: boolean }[];
  packages: { id: Uuid; name: string; status: EnumValue<'draft' | 'pending_review' | 'approved' | 'rejected' | 'archived'>; price: Money; validity_days: number; is_visible: boolean; hidden_by_admin: boolean }[];
  payout_account: { id: Uuid; holder_name: string; bank_name: string; iban: string; status: EnumValue<'pending' | 'verified' | 'rejected' | 'inactive'> } | null;
  change_requests: { id: Uuid; change_type: EnumValue<ChangeType>; status: EnumValue<'pending'>; created_at: IsoDateTime }[];
  review_history: ReviewEvent[]; updated_at: IsoDateTime;
}
export type ProviderList = Paginated<'providers', Provider>;
export interface ProviderListQuery {
  search?: string | null; page?: number; per_page?: number; paginate?: 'true' | 'false' | '1' | '0';
  filters?: { notification_reachable?: 0 | 1; account_status?: AccountStatus; provider_type?: 'individual' | 'establishment'; city_id?: Uuid; instant_enabled?: 0 | 1; is_accepting_bookings?: 0 | 1; created_from?: string; created_to?: string; submitted_from?: string; submitted_to?: string };
  sort?: Partial<Record<'created_at' | 'submitted_at' | 'rating_avg' | 'completed_orders_count' | 'display_name', 'asc' | 'desc'>>;
}
export interface ProviderStatus { id: Uuid; account_status: EnumValue<AccountStatus>; status_reason: string | null; approved_at: IsoDateTime | null }
export type AdverseReviewBody = { reason: string }; // nonblank, max500
export type ReactivateBody = { note?: string | null }; // max500
// api.get<ApiResponse<ProviderList>>('/providers', { params: query });
// Dropdown: ApiResponse<Provider[]>; params { paginate: 'false' }
// api.post<ApiResponse<ProviderStatus>>(`/providers/${id}/reject`, { reason });
// Reveal only through an explicit panel interaction; never persist ProviderIdentity.
```

## 9. Business rules enforced by the backend

| Rule | Backend behavior | Dashboard implication |
|------|------------------|-----------------------|
| BR-01 / D4 | Shows recorded consents and establishment specialist count | No inferred acceptance/staff accounts |
| BR-03 / TD-35 | Explicit pending review decisions and profile suspension/reactivation | Show only valid actions for current status |
| BR-04 / TD-44 / TD-184 | Important changes stay pending; old saved profile remains authoritative until approval | Decided in [profile-change-requests.md](profile-change-requests.md): approve exactly as submitted, or reject with a reason |
| BR-08/09/27 | Eligibility views require approved account, including with active subscription | Suspension removes priority/new booking eligibility |
| BR-29 / TD-27/28/45/51 | Exact route grants; identity and document numbers belong to audited approve read | Gate identity separately from details; IBAN never revealed |
| TD-50 | Approval verifies only qualifying pending documents, atomically | Refetch documents/history; show localized missing prerequisites |
| TD-19/03 | UUID-only IDs and ISO 8601 date-times with offset | Never substitute users/profile IDs; parse the offset, never assume local time |
| TD-45/53 | Identity numbers are never searchable | Search matches name and phone only; compare numbers through the audited identity panel |
| TD-43/46 | Atomic profile, event, audit, inbox write | Display outcome only on success; refetch after uncertain failure |

## 10. Notifications triggered

| Event catalog entry | Recipient / app | Channel | Payload |
|---------------------|-----------------|---------|---------|
| account_review_status_changed | Reviewed user / provider | Existing shared in-app inbox | profile_id UUID, account_status string, action, reason/null |

Every successful approve/reject/request-changes/suspend/reactivate creates one notice in the review transaction. Title/body use the recipient's en/ar locale (Arabic fallback). In-app notices are stored independently of push preferences. Read through `/api/v1/notifications` with the **provider-app JWT**; customer/driver inboxes cannot see it (TD-25). Push/SMS/email delivery, templates/preferences and registration-review UI remain pending ADMIN-020/007; none are claimed here.

## 11. Integration checklist

- [ ] Use providers.uuid in every provider path and UUID geography filters.
- [ ] Gate list/details/each action by exact route; gate identity by providers approve permission.
- [ ] Wire nested filters/sorts, search, pagination and the 500-item dropdown cap.
- [ ] Render enum labels and account-status badge colors; use values for action logic.
- [ ] Preserve nullable ratings/submission/consent/location/media; show saved counters only.
- [ ] Mask default identity/document numbers and every IBAN; reveal document numbers only in the audited identity panel.
- [ ] Keep documents/prices/packages/schedules/payout read-only here; change requests are decided through [profile-change-requests.md](profile-change-requests.md).
- [ ] Validate nonblank adverse reasons/max500 and optional reactivation note/max500.
- [ ] Refetch document statuses/history after approval. Disable duplicate action submissions; refetch status/history after timeout or 409.
- [ ] Parse ISO 8601 date-times (with offset); keep expiry calendar dates and schedule times unchanged.
- [ ] Show `featured_status` (subscription) separately from `is_featured` (ranking now); a suspended provider can be `active` + `false`.
- [ ] Do not offer search by national ID / CR number.
- [ ] Handle 401/403/404/409/422/500; verify Arabic labels/fallback and RTL.
- [ ] Apply additive migration, sync RBAC and assign grants before enabling the screen.
- [ ] Do not add unbuilt order/trip/ratings-detail/earnings/chat/subscription-payment screens.

## 12. Acceptance scenarios

Version 1.1.0 verification on MySQL `joshen_testing`: **603 tests / 5,154 assertions passed** (236.94 seconds), including all five schema lifecycle tests. Focused provider/driver HTTP run: **81 tests / 1,014 assertions passed**. RBAC synchronized locally: **89 routes, 0 changed, 0 removed**; Postman regenerated: **145 routes**. Pint, captured JSON, all 13 guide sections, documentation file links and diff checks passed. This is local backend verification; deployed/browser business-cycle acceptance is not claimed.

| AC ID | Scenario | Result / evidence |
|-------|----------|-------------------|
| AC-ADMIN-004 / BR-29 | Every exposed endpoint: 401, 403, exact-route grant 200 | endpoint_authentication_and_exact_route_permissions |
| AC-ADMIN-004 / TD-19 | Unknown/integer/users UUID/customer-only/admin/deleted account | uuid_and_profile_scope; route identities 404, zero review writes; bad city filters 422 |
| AC-ADMIN-004 | List search/filter/sort/page/dropdown and existing counters; identity numbers are not searchable (TD-54) | list_search_filters_sort_and_pagination |
| AC-ADMIN-004 / TD-55 | Featured status active / expired / not subscribed from activated subscriptions only | featured_status_reflects_subscription_history |
| AC-ADMIN-004 | Default identifiers masked, payout never revealed, provider-only content | details_and_masking; no raw change-request payload |
| AC-ADMIN-004 / TD-45/51 | Full identity/document numbers, no-store, field-name audit on each call; failure refuses reveal | identity_read_is_audited_and_not_granted_by_view; identity_is_not_revealed_when_auditing_fails |
| AC-ADMIN-004 / TD-35 | Every legal transition, representative illegal source/actions, reasons | review_cycle_is_profile_scoped_and_audited; reject_and_request_changes; illegal_transition_returns_409; invalid_queries_and_reasons |
| AC-ADMIN-004 / AC-ADMIN-007 | Approve missing submission, file, expired/rejected/replaced/wrong-role documents | approval_prerequisites; both account types tested; 422 naming omissions |
| AC-ADMIN-004 / TD-50 | Qualifying pending documents verified with actor/time; all other rows untouched | approval_verifies_only_qualifying_documents; missing_prerequisites_are_localized |
| AC-ADMIN-004 / TD-43/50 | Atomic documents, status, source/destination event, audit and inbox | audit/inbox failure tests prove rollback; schema lifecycle proves additive upgrade/rollback and SQL parity |
| AC-ADMIN-004 | Suspend active featured provider | suspension_removes_featured_and_booking_eligibility; subscription stays active, profile loses eligibility |
| AC-ADMIN-004 / TD-06 | en/ar enum labels and city translation | details_and_masking; English endpoint examples |
| AC-PROV-003 / TD-60 | Initial account approval, reactivation and late inbox failure | Requested registration categories gain reviewer/time atomically; reactivation preserves metadata and a failure rolls category writes back |
| AC-ADMIN-004 / BR-04 | Important-information change-request review | **Covered (TD-184):** list, approve, reject in [profile-change-requests.md](profile-change-requests.md) |
| AC-ADMIN-004 | Associated commerce/ratings/earnings/subscription history/chat | **Not on this screen (§13).** They are shown in their own sections: orders and chat monitor in [orders](orders.md), ratings in [ratings](ratings.md), earnings and payouts in [payouts](payouts.md), subscriptions in [featured-subscriptions](featured-subscriptions.md) |
| AC-ADMIN-007 | Unified registration-review module | **Ready:** [registration-review.md](registration-review.md) lists providers and drivers together and delegates to this review |

Test method names above have prefix `test_ac_admin_004_` in `tests/Feature/Api/V1/Admin/ProviderManagementTest.php`. Local backend tests are not deployed/browser acceptance.

## 13. Open decisions and known limitations

| ID | Impact | Current behavior |
|----|--------|------------------|
| TD-44 / ADMIN-004 / DEC-25 | Allowed fields, validation, stale check and apply | **Resolved (TD-184, DEC-25 resolved 2026-11-10):** see [profile-change-requests.md](profile-change-requests.md) |
| TD-36 | Confirmed orders/trips during suspension | New eligibility blocked; existing execution/cancellation/refund effects deferred to orders/transport |
| ADMIN-004 / Phase 2–3 | Order/trip lists, rating detail, earnings/amounts due, payouts/net-after-commission | Not on the provider screen; built in [orders](orders.md), [ratings](ratings.md) and [payouts](payouts.md). Existing completed_orders_count/rating_avg/rating_count read from profile; no fabricated money/order totals |
| ADMIN-004/005 | Subscription details/history/payments require their own feature | `featured_status` gives the list state (TD-55) and `is_featured` the ranking state; subscription start/end dates, history and payments wait for ADMIN-005 |
| DEC-12 / TD-55 | Meaning of cancelled / suspended subscriptions is open | They never count as active or as history: a provider whose only subscription was cancelled reads `not_subscribed`. Revisit when DEC-12 is decided |
| PROV-002/003 / BR-06 / TD-60 | Registration categories are reviewed with the account | Initial approve promotes requested rows atomically with reviewed_by/at. Rejected/already approved rows remain unchanged; later category-change workflows remain separate. Pricing/coverage/service eligibility still apply. See [provider onboarding](../provider/onboarding.md). |
| TD-16 | Confirmed-order chat deferred | No chat endpoint or invented conversation |
| TD-35 / PROV-004 | User resubmission and onboarding policy/settings | No admin resubmit route; approved → needs_changes prohibited; rejected/needs_changes return through user app onboarding only |
| TD-46 / SCR-18/20 | Security check is a supported document type, not an adopted registration requirement | Shown when saved; required approval set follows SCR-18; document management remains separate |
| ADMIN-007 | Combined registration-review screen | Built (TD-85); suspend / reactivate stay on this page |
| ADMIN-020 | Push/SMS/email/template delivery | Only actual shared inbox row implemented (§10) |
| TD-50 | Earlier approvals did not verify document rows | New approvals verify qualifying documents; no historical reviews or document verification metadata are backfilled |
| TD-43 | Existing history has no reliable source/destination | New nullable fields; historical states remain null; no fabricated backfill |
| TD-40 | Deployment of UTC database sessions | Values are UTC and now emitted with `+00:00` (2.0.0). Historical provenance caveat remains; no old dates rewritten |

### Local manual review fixtures (TD-47)

Run `php artisan db:seed --class=AdminReviewDevSeeder --no-interaction` against the local development database. It runs only for APP_ENV=local/dev, is not in DatabaseSeeder and is never used by tests. Re-running preserves existing profiles and admin decisions rather than resetting them. Existing legacy UserSeeder records are not rewritten.

| Label / phone (+966) | Status | Documents | Expected manual action |
|----------------------|--------|-----------|------------------------|
| DEV-REVIEW Provider Individual / 500009101 | pending_review | 3 valid attached synthetic PDFs | Approve succeeds |
| DEV-REVIEW Provider Establishment / 500009102 | pending_review | 3 valid attached synthetic PDFs | Approve succeeds |
| DEV-REVIEW Provider Missing document / 500009103 | pending_review | Missing health_certificate | Approve →422 names Health certificate / شهادة صحية; request changes/reject succeeds |

Statuses in the fixture table are initial seed states; existing manual approvals/rejections are preserved on rerun. These accounts have submitted_at, recorded consent timestamps and a submitted review event. PDFs are clearly marked development-only; they are fixtures, not real identity proof. Approval is never seeded. They use the existing temporary OTP policy (TD-24). Drivers have their own four scenarios in [drivers.md](drivers.md#local-manual-review-fixtures-td-4748).
