---
feature: Platform settings (base endpoints — settings and feature flags)
slug: settings
surface: admin
consumer: react
version: 0.18.1
status: base (TD-32)
brd_version: "1.3"
brd_ids: [ADMIN-025]
business_rules: [BR-29]
decisions: [D3, DEC-05, DEC-17, DEC-26, TD-02, TD-03, TD-27, TD-32, TD-73, TD-84, TD-92, TD-102, TD-110, TD-114, TD-133, TD-158, TD-165, TD-167, TD-170, TD-172, TD-174]
schema_tables: [app_configs, feature_flags, platform_settings]
related_docs: [admins-permissions.md, notifications.md, ../API_CONVENTIONS.md]
last_updated: 2026-11-10
---

# Platform settings — React Dashboard Integration Guide

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

> **Base endpoints (TD-32).** This doc describes the settings and feature-flag
> endpoints inherited from the base project, as they behave today. The BRD
> feature (ADMIN-025: name, logo, payment methods, commissions, fees,
> other settings…) is not fully built yet. When it ships, this doc moves to
> `1.0.0` and the contract may change.

## Version history

Newest first. Never edit an old row.

| Version | Date | Type | Summary | Dashboard action required |
|---------|------|------|---------|---------------------------|
| 0.18.1 | 2026-11-10 | PATCH | Clarified `tracking_route_detour_factor`: no server-side range check (any decimal saves); on read a missing, non-numeric or ≤ 0 value uses 1.3 and a value below 1 is raised to 1.0. | Validate as a number ≥ 1 (suggested cap about 3). |
| 0.18.0 | 2026-11-09 | MINOR | New `tracking` group in app_configs: `tracking_route_detour_factor` (1.3), `tracking_stale_after_seconds` (60) (the speed itself lives on the delivery fee rule) — values confirmed by the owner on 2026-11-10 (R06, R07, TD-183). | Show the two values with the other tracking-related settings if the settings screen lists groups. |

| 0.17.1 | 2026-11-09 | PATCH | DEC-17 note corrected: the seeded contact numbers are already Saudi. | None. |
| 0.17.0 | 2026-11-09 | MINOR | `group_booking_max_people` (group `booking`, integer, default `6`): most people one group booking may hold, customer included (D2, TD-180); a missing row is inserted by a migration. | Show it with the other booking values; it must be a positive integer. |
| 0.16.2 | 2026-11-08 | PATCH | Complete entry for `featured_expiry_reminder_days` (group `featured`); no contract change (TD-179). | None. |
| 0.16.1 | 2026-11-08 | PATCH | Postman examples for feature flags. | None. |
| 0.16.0 | 2026-11-08 | MINOR | Group `notifications` gains `driver_nearby_alert_min_trips` (1–50, default `1`) and `driver_nearby_alert_cooldown_minutes` (1–1440, default `60`) for the driver "requests near you" alert (TD-174). Fix: invalid `booking_reminder_hours`, `featured_expiry_reminder_days`, `notification_push_enabled` or `notification_sms_enabled` returned 500; they now return 422 `INVALID_NOTIFICATION_SETTINGS`. | Show the two settings in the notifications group; handle the 422. |
| 0.15.2 | 2026-11-08 | PATCH | Fix: keys with a domain validator (booking, Joshen, trip, dispatch, payout, wallet, payment methods, VAT, notifications) return that validator's error code again (`INVALID_BOOKING_SETTINGS`, `JOSHEN_CONFIG_UNAVAILABLE`, …), as before 0.15.0; the 422 `message` is the standard first-error message again (TD-172). | Keep handling the domain codes listed in 0.3.0–0.14.0; type errors on other keys stay 422 `VALIDATION_FAILED` with `data.errors`. |
| 0.15.1 | 2026-11-05 | PATCH | Reject malformed setting items and nonnumeric values for existing `number` settings atomically. | Send numeric values as strings and handle `VALIDATION_FAILED`. |
| 0.15.0 | 2026-11-05 | MINOR | Add app logo and enforce each stored setting type (TD-170). | Upload an image, send its hash and handle type validation errors. |
| 0.14.1 | 2026-11-05 | PATCH | Fix: `PUT /settings` with an unknown key is 422 `VALIDATION_FAILED` instead of 500, and nothing is saved. §13 lists what ADMIN-025 still lacks (logo, gateway fees). | Show the 422 message; send only keys from `GET /settings`. |
| 0.14.0 | 2026-11-04 | MINOR | Added group `payments` switches `payment_method_card_enabled`, `payment_method_mada_enabled`, `payment_method_apple_pay_enabled` (default `1`). Values must be `0`/`1`; card and mada cannot both be `0` (422 `VALIDATION_FAILED`). A switched-off method is refused at checkout with `PAYMENT_METHOD_DISABLED` (ADMIN-025, TD-168). | Show the three switches under Payments next to `wallet_payments_enabled`. |
| 0.13.0 | 2026-11-04 | MINOR | Added notification settings group (`status_update_reminder_lead_minutes`, `document_expiry_reminder_days`, `notification_push_enabled`, `notification_sms_enabled`, `notification_whatsapp_enabled`) (TD-167). | Display notifications settings group; validate lead minutes < delay minutes; treat whatsapp toggle as unavailable. |
| 0.12.0 | 2026-11-03 | MINOR | Added vat_seller_name and vat_registration_number (TD-165). | Show the vat group and validate Saudi VAT number. |
| 0.11.0 | 2026-11-03 | MINOR | Added wallet_payments_enabled to payments settings. | Show the switch and send string 0 or 1. |
| 0.10.0 | 2026-11-02 | MINOR | New `payouts` group with `payout_min_amount` (SAR string, default `0.00`) — the smallest transfer a provider or driver may request (DEC-11, TD-158). | Show it as an editable amount; handle `INVALID_PAYOUT_SETTINGS`. |
| 0.9.0 | 2026-10-28 | MINOR | Document the driver search duration and dispatch rate limits. | Show the search duration and existing dispatch limits. |
| 0.8.0 | 2026-10-27 | MINOR | Admin-editable dispatch request limits (TD-133). | Show the new `dispatch_limits` group and accept integer values from 1 to 600 per minute. |
| 0.7.0 | 2026-10-27 | MINOR | `GET /settings` supports free-text `search` (matching key, value, group, and JSON metadata) and `group` filter; `GET /feature-flags` supports free-text `search` (matching key, description, is_enabled). | Filter or search settings via `search` and `group` / `filters[group]`; search feature flags via `search`. |
| 0.6.0 | 2026-10-26 | MINOR | Generic settings updates now validate Joshen `app_configs` rows through `JoshenSettings`, including budget JSON and address visibility. | Preserve the documented Joshen key shapes; malformed updates fail with `JOSHEN_CONFIG_UNAVAILABLE`. |
| 0.5.0 | 2026-10-26 | MINOR | Two delay-alert settings, `order_status_delay_minutes` and `scheduled_delay_clock_lead_minutes` (TD-110); a missing booking row is inserted by a migration. | Show both as provisional booking values and handle the new `INVALID_BOOKING_SETTINGS` message. |
| 0.4.0 | 2026-10-26 | MINOR | New `execution_early_start_minutes` booking value (TD-102). | Show it as a provisional booking value and handle `INVALID_BOOKING_SETTINGS`. |
| 0.3.0 | 2026-10-25 | MINOR | Phase 2 booking values in `app_configs`; deadline sum guard on save (TD-92). | Show the six booking values as provisional and handle `INVALID_BOOKING_SETTINGS`. |
| 0.2.1 | 2026-10-25 | PATCH | The seeded `app.default_currency` is now `SAR` (was `EGP`, DEC-17, TD-84); `config/geo.php` defaults to `SA`/`SAR`. No contract change. | None; reseed or edit the value on existing databases. |
| 0.2.0 | 2026-10-22 | MINOR (setting documentation) | Documents provider_portfolio_max_photos in platform_settings, default 12 and lowered-limit behavior (TD-73). | Treat as a separate JSON integer operating setting; legacy /settings does not edit this table. |
| 0.1.0 | 2026-10-21 | Initial (base) | Documents the existing `/settings` and `/feature-flags` endpoints with real responses and known gaps. | Integrate only the two response timers (§5.1) for now; treat the rest as provisional. |

## Changelog

### 0.17.1 — 2026-11-09

- **Fixed (docs):** the DEC-17 row said the contact numbers were Egyptian placeholders; the `contact` group seeds Saudi values. VAT seller name and number stay empty until an admin sets them (TD-165).
- **Breaking:** No. **App action required:** None.


### 0.17.0 — 2026-11-09

- **Added:** `group_booking_max_people` (default `6`). `PUT /settings` validates it as a positive integer with the other booking keys.
- **Breaking:** No. **App action required:** None beyond listing the new key.

### 0.16.2 — 2026-11-08

- **Documented:** `featured_expiry_reminder_days` — group `featured`, type `int`, default `"3"` (settings values are always strings), unit days: how many days before a provider's active featured subscription ends she gets the `featured_subscription_expiring` reminder. The scheduler task `featured:alert-expiring` (every minute) reads it on each run, so a change applies within a minute with no restart; one reminder per subscription. Read it with `GET /settings?filters[group]=featured` (section `settings` · view, `admin.settings.index`); change it with `PUT /settings` `{"settings":[{"key":"featured_expiry_reminder_days","value":"10"}]}` (`admin.settings.update`; a supervisor without `settings.update` gets 403). Valid: a whole number ≥ 1. `0`, negatives, `2.5`, `abc` and an empty string are rejected with 422 and nothing is saved; `0`/negatives answer `INVALID_NOTIFICATION_SETTINGS`. There is no upper bound; a large value only widens the window. If the row is missing the consumer falls back to `3`.
- **Real responses** (Postman: `Update` › "200 Featured expiry reminder days saved", "422 Featured expiry reminder days not positive"): `{"status":"success","message":"Settings updated successfully","data":null}` and `{"status":"fail","message":"Featured expiry reminder days must be a positive integer.","data":{"error_code":"INVALID_NOTIFICATION_SETTINGS","correlation_id":"…"}}`.
- **Breaking:** No. **Dashboard action required:** None (documentation only). Verified by `NotificationEventTriggersTest::test_td_179_featured_expiry_reminder_days_…`.

### 0.16.1 — 2026-11-08

- **Changed:** Postman examples for feature flags.
- **Breaking:** No. **App action required:** None.

### 0.16.0 — 2026-11-08

- **Added:** two settings in group `notifications` for the driver nearby-requests alert (TD-174; [delivery/notifications.md](../delivery/notifications.md) 1.5.0):
  - `driver_nearby_alert_min_trips` (`int`, 1–50, default `1`): trips waiting for a driver in her districts before an offline driver is invited to go online.
  - `driver_nearby_alert_cooldown_minutes` (`int`, 1–1440, default `60`): minutes before the same driver can get the alert again.
  - Out of range or not a whole number: 422 `INVALID_NOTIFICATION_SETTINGS`, nothing saved. Turn the alert off by switching off the `driver_nearby_demand` template ([notifications.md](notifications.md) §4).
- **Fixed:** an invalid `booking_reminder_hours` or `featured_expiry_reminder_days` (≤ 0), or a push / SMS switch other than `0`/`1`, answered 500. It now answers 422 `INVALID_NOTIFICATION_SETTINGS` as documented in 0.13.0.
- **Breaking:** No. **Dashboard action required:** Show the two new settings; no change for the fix.

### 0.15.2 — 2026-11-08

- **Fixed:** 0.15.0 checked stored types before the domain validators, so a bad booking or Joshen value answered `VALIDATION_FAILED` instead of the code the dashboard already handles. A key with a domain validator (booking, Joshen, trip, dispatch, payout, wallet, payment methods, VAT, notifications) is now checked by that validator first and keeps its code — for example `booking_slot_step_minutes = "abc"` is `INVALID_BOOKING_SETTINGS` and malformed `joshan_budget_brackets` is `JOSHEN_CONFIG_UNAVAILABLE`, as in 0.14.1. Its stored type is still enforced afterwards (422 `VALIDATION_FAILED`). Keys without a domain validator keep the 0.15.0 type check, reported per field in `data.errors.settings.{i}.value`.
- **Fixed:** the validation 422 `message` is again the first error plus a count (API conventions §3.1), not the fixed "The given data was invalid."; `data.error_code = VALIDATION_FAILED` stays next to `data.errors`. The type-error message is translated to Arabic.
- **Postman:** `admin.settings.update` › "422 Invalid setting type", "422 Malformed setting item", "422 Generic Settings Reject Malformed Joshan Brackets", "422 Invalid dispatch rate", "422 Setting Unknown Key", "422 Wallet Payment Setting Invalid".
- **Breaking:** No — restores the codes documented before 0.15.0.
- **App action required:** none if the dashboard handles the domain codes listed since 0.3.0; show `message` as before.
- **BRD / decisions:** ADMIN-025, TD-170, TD-172.


### 0.15.1 — 2026-11-05

- **Fixed:** Malformed settings and keys return 422 `VALIDATION_FAILED` without saving any value or media. Existing `number` settings accept numeric strings; invalid text is rejected atomically. Dispatch rate settings retain their documented numeric-input normalization. Existing domain validators continue to run.
- **Postman:** `admin.settings.index` › "200 Settings with platform logo"; `admin.settings.update` › "422 Malformed setting item".
- **App action required:** Send numeric settings as strings and display validation errors.


### 0.15.0 — 2026-11-05

- **Added:** `app_logo` in group `app`, type `media`; upload an image through the shared media endpoint and send its hash in `PUT /settings`. `GET /settings` includes its stored `value` and `url`. `GET /config/bootstrap` adds `app.logo_url` with an absolute placeholder URL when empty.
- **Changed:** Stored `int` values must be integer strings, `bool` values `0` or `1`, and `json` values valid JSON. `media` requires a valid shared image hash or an empty value; `string` accepts any string. Type mismatch returns 422 `VALIDATION_FAILED` and the transaction saves nothing. Existing domain validators still apply after type validation.
- **Fixed / removed:** None.
- **Breaking:** No.
- **Endpoints affected:** `GET /settings`, `PUT /settings`, `GET /api/v1/config/bootstrap`.
- **App action required:** show the logo and submit the upload hash; validate values against their stored type.
- **BRD / decisions:** ADMIN-025, TD-170.


### 0.14.1 — 2026-11-05

- **Fixed:** an unknown `settings.*.key` returned 500 because the row has no group. It is now a 422 `VALIDATION_FAILED`, and the whole request is refused. Postman: `admin.settings.update` › "422 Setting Unknown Key".
- **Docs:** §13 now lists only what ADMIN-025 still lacks: the platform logo and gateway fees.
- **Breaking:** No. **Dashboard action required:** show the 422 message.

### 0.14.0 — 2026-11-04

Payment-method switches (ADMIN-025 "enable or disable payment methods"). Wallet keeps its own switch (`wallet_payments_enabled`). Apple Pay applies to provider plan payments only; customers pay with card, mada or wallet today. Disabling both card and mada is refused so customers always have one gateway method.

### 0.13.0 — 2026-11-04

- **Added:** Settings group `notifications` containing `status_update_reminder_lead_minutes` (positive integer, default `5`), `document_expiry_reminder_days` (positive integer, default `30`), `notification_push_enabled` (boolean string, default `"1"`), `notification_sms_enabled` (boolean string, default `"0"`), and `notification_whatsapp_enabled` (boolean string, default `"0"`).
- **Rules & Validation:**
  - `status_update_reminder_lead_minutes` must be strictly less than `order_status_delay_minutes`; violating updates return 422 `INVALID_NOTIFICATION_SETTINGS`.
  - `notification_whatsapp_enabled` cannot be enabled (`"1"`); attempting to enable WhatsApp returns 422 `CHANNEL_UNAVAILABLE`.
- **Breaking:** No. **Dashboard action required:** Display the `notifications` settings group in the settings view; handle 422 `INVALID_NOTIFICATION_SETTINGS` and `CHANNEL_UNAVAILABLE`.
- **BRD / decisions:** ADMIN-020, ADMIN-025, TD-167.

### 0.12.0 — 2026-11-03

- **Added:** settings `vat_seller_name` and `vat_registration_number` (group `vat`, string, default `""` empty), inserted by a migration. When non-empty, `vat_registration_number` is validated against the 15-digit Saudi format (starting and ending with 3); invalid input returns 422 `INVALID_VAT_SETTINGS`. While either setting is empty, invoices are issued without ZATCA QR codes; filling them before commercial launch is a launch blocker (TD-165).
- **Breaking:** No. **App action required:** Display the `vat` settings group in the settings view.
- **BRD / decisions:** ADMIN-025, TD-165.

### 0.11.0 — 2026-11-03

- **Added:** Added wallet_payments_enabled to payments settings.
- **Breaking:** No. **App action required:** Show the switch and send string 0 or 1.
- **BRD / decisions:** CUST-015/016, TD-164.

### 0.10.0 — 2026-11-02

- **Added:** setting `payout_min_amount` (group `payouts`, string, SAR, default `0.00` = no minimum), inserted by a migration. `PUT /settings` rejects a negative value or more than two decimals with 422 `INVALID_PAYOUT_SETTINGS`. Used by [admin/payouts](payouts.md). Postman: `admin.settings.update` › "200 Payout Min Amount Updated", "422 Payout Min Amount Invalid".
- **Breaking:** No. **Dashboard action required:** show the new group.

### 0.9.0 — 2026-10-28

- **Changed:** Document the driver search duration and dispatch rate limits.
- **App action required:** Show the search duration and existing dispatch limits.
- **BRD / decisions:** Cycle F, TD-132–140; DEC-08 remains open.

### 0.8.0 — 2026-10-27

- **Added:** Editable `dispatch_limits` group (TD-133; owner-requested working assumption awaiting client confirmation):

  | Key | Default | Endpoints |
  |-----|---------|-----------|
  | `dispatch_availability_per_minute` | 60 | `PATCH /delivery/availability` |
  | `dispatch_responses_per_minute` | 30 | `POST /delivery/trip-offers/{offer}/accept` and `/reject` |
  | `dispatch_status_transitions_per_minute` | 30 | `POST /delivery/trips/{trip}/status` |

- **Changed:** `PUT /settings` accepts integer strings or JSON integers from 1 to 600 requests per minute; invalid edits reject the whole update with 422 `INVALID_BOOKING_SETTINGS`. Saved changes apply on the next request. Missing or invalid stored values use safe defaults of 60/30/30 requests per minute and produce a warning; no environment variable controls these limits.
- **Endpoints affected:** `GET /settings`, `PUT /settings` and the driver endpoints above.
- **Dashboard action required:** Display the new group, its `requests_per_minute` unit and bounds. Defaults await client confirmation.
- **Breaking:** No. **BRD / decisions:** ADMIN-025, DRIV-007–014, TD-133.

---

### 0.7.0 — 2026-10-27
- **Added:** `GET /settings` accepts optional `search` parameter (case-insensitive partial match across `key`, `value`, `group`, and JSON `metadata`) and group filtering via `group` or `filters[group]`.
- **Added:** `GET /feature-flags` accepts optional `search` parameter querying `$searchableFields` (`key`, `description`, `is_enabled`) with standard pagination.
- **Changed:** `GET /settings` controller now accepts `ApiMasterRequest`.
- **Fixed:** N/A.
- **Removed / Deprecated:** None.
- **Breaking:** No.
- **Endpoints affected:** `GET /settings`, `GET /feature-flags`.
- **Dashboard action required:** Connect dashboard search bars and group filter dropdowns to the `search` and `group` query parameters.
- **BRD / decisions:** ADMIN-025.

---

### 0.6.0 — 2026-10-26
- **Added:** Generic `PUT /settings` validates updates for Joshen `app_configs` keys with the same `JoshenSettings::assertUpdate` used by the dedicated finder config route.
- **Changed:** Invalid bracket JSON, non-contiguous boundaries, or disabling the address input fail atomically with 422 `JOSHEN_CONFIG_UNAVAILABLE`.
- **Fixed:** N/A.
- **Removed / Deprecated:** None.
- **Breaking:** No.
- **Endpoints affected:** `PUT /settings`.
- **Dashboard action required:** Keep settings values in their documented JSON forms and use the dedicated finder config page for finder-specific edits.
- **BRD / decisions:** ADMIN-012, TD-114.

---

### 0.5.0 — 2026-10-26

- **Added:** `order_status_delay_minutes` (group `booking`, integer minutes, default `15`): how long a provider may go without a status update before the customer gets a delay alert; `scheduled_delay_clock_lead_minutes` (default `60`): how long before the service time a scheduled order's delay clock starts (BR-21, CUST-019, TD-110). Both are working assumptions **awaiting client confirmation**, listed in `GET /settings` under `booking`.
- **Changed:** `PUT /settings` validates both as positive integers and refuses (422 `INVALID_BOOKING_SETTINGS`) a `scheduled_delay_clock_lead_minutes` greater than `execution_early_start_minutes`, because the clock must not start before the provider may begin. A migration inserts `execution_early_start_minutes`, `order_status_delay_minutes` and `scheduled_delay_clock_lead_minutes` when the row is missing and never overwrites an edited one, so a database that never ran `AppConfigSeeder` works.
- **Fixed / Removed / Deprecated:** None. **Breaking:** No.
- **Endpoints affected:** `GET /settings`, `PUT /settings`.
- **Dashboard action required:** Show both values as provisional. Handle `INVALID_BOOKING_SETTINGS` (Postman: `admin.settings.update` › "422 Booking Settings Delay Lead Exceeds Early Start"). No seeding is needed any more; `db:seed --class=AppConfigSeeder` stays safe.
- **BRD / decisions:** CUST-019, PROV-019, BR-21, TD-102, TD-110.

### 0.4.0 — 2026-10-26

- **Added:** `execution_early_start_minutes` (group `booking`, integer minutes, default `120`) in `app_configs`: how long before a scheduled start the provider may begin preparing or leave (TD-102). It is a working assumption awaiting client confirmation.
- **Changed:** `PUT /settings` validates it like the other booking values (positive integer). It takes no part in the response-plus-payment sum guard.
- **Fixed / Removed / Deprecated:** None. **Breaking:** No.
- **Endpoints affected:** `GET /settings`, `PUT /settings`.
- **Dashboard action required:** Show it as a provisional booking value. On an existing database run `php artisan db:seed --class=AppConfigSeeder` once so the key exists.
- **BRD / decisions:** PROV-019, BR-19, TD-102.

### 0.3.0 — 2026-10-25

- **Added:** `booking_overlap_statuses`, `booking_slot_step_minutes`, `booking_minimum_lead_minutes`, `instant_eta_minutes`, `provider_scheduled_response_minutes`, `customer_payment_window_minutes` in `app_configs`.
- **Changed:** `PUT /settings` rejects a booking value if it is missing, nonpositive, or makes scheduled provider response plus payment exceed minimum lead.
- **Fixed / Removed / Deprecated:** None. **Breaking:** No.
- **Endpoints affected:** `GET /settings`, `PUT /settings`.
- **Dashboard action required:** Label values as working assumptions and show `INVALID_BOOKING_SETTINGS` on a rejected save.
- **BRD / decisions:** DEC-05, SCR-13, TD-92.


### 0.2.1 — 2026-10-25

- **Added / Removed / Deprecated:** —
- **Changed:** `AppConfigSeeder` seeds `default_currency` as `SAR`; example in §5 shows the seeded value of a fresh database.
- **Fixed:** the Egyptian default currency (DEC-17).
- **Breaking:** No.
- **Endpoints affected:** `GET /settings` returns `SAR` on a fresh seed.
- **Dashboard action required:** none.
- **BRD / decisions:** DEC-17, TD-84.

### 0.2.0 — 2026-10-22

- **Added:** `platform_settings.provider_portfolio_max_photos` for [provider profile/portfolio](../provider/profile.md), JSON nonnegative integer with owner-approved absent default 12 (TD-73).
- **Changed:** documentation distinguishes provider operating keys from the legacy app_configs settings store.
- **Fixed / Removed / Deprecated:** N/A.
- **Breaking:** No.
- **Endpoints affected:** provider portfolio reads/adds; existing admin endpoints unchanged.
- **Dashboard action required:** expose the key through the future ADMIN-025 platform_settings editor; do not send it to legacy PUT /settings.
- **BRD / decisions:** PROV-008, ADMIN-025 partial, TD-73.

### 0.1.0 — 2026-10-21
- **Added:** documentation for `GET /settings`, `PUT /settings`, `GET /feature-flags`, `POST /feature-flags`, `PUT /feature-flags/{id}`, `DELETE /feature-flags/{id}`, `POST /feature-flags/{id}/toggle`. No code change.
- **Changed:** —
- **Fixed:** —
- **Removed / Deprecated:** —
- **Breaking:** No
- **Endpoints affected:** the seven above
- **Dashboard action required:** none yet. Read §13 before building the settings page.
- **BRD / decisions:** ADMIN-025 (partial), D3, DEC-26, TD-27, TD-32

---

Driver search duration: `driver_search_duration_seconds` in `response_timers`, default 180 seconds, must be at least `driver_offer_response_seconds`; client confirmation remains pending (DEC-07). Dispatch limits are documented below (TD-133).

## 1. Business summary

Settings are key/value pairs grouped by `group` (`app`, `contact`, `social`,
`limits`, `ui`, `booking`, `response_timers`, `dispatch_limits`). The dashboard reads them all and
saves any number of keys in one request. `booking` holds the Phase 2 overlap,
slot, lead and ETA working values; `response_timers` holds the scheduled provider
and customer deadlines, the instant provider accept window (D3, 20 s), and the
driver offer countdown (DEC-26, 12 s). `dispatch_limits` contains the three admin-editable driver request rates (TD-133). Other base-project seed values are not
BRD operating policies.

Feature flags are on/off switches with optional targeting rules. They are
platform tooling for developers and operators, not a BRD feature. TD-27 places
them in the `settings` permission section.

## 2. Pages and permissions

Phase 2 adds provisional booking keys to the legacy `app_configs` store (TD-92):
`booking_overlap_statuses` = `["pending_provider","awaiting_payment","confirmed"]`,
`booking_slot_step_minutes` = `15`, `booking_minimum_lead_minutes` = `60`,
`instant_eta_minutes` = `45`, `provider_scheduled_response_minutes` = `30`,
`customer_payment_window_minutes` = `15`, `execution_early_start_minutes` = `120` (TD-102), `order_status_delay_minutes` = `15` and
`scheduled_delay_clock_lead_minutes` = `60` (TD-110, delay alerts; awaiting client confirmation). The two response timers must sum to no more
than the minimum lead, and the delay lead may not exceed `execution_early_start_minutes`. The dashboard must surface a rejected save (422
`INVALID_BOOKING_SETTINGS`) and leave the prior values intact. Values proposed
by the project owner on 2026-10-25; DEC-05 stays Partly resolved until the
client confirms the numbers.

| Page / component | Purpose | Endpoint(s) | RBAC permission (route name) |
|------------------|---------|-------------|------------------------------|
| SettingsPage | Grouped settings form | `GET /settings` | `admin.settings.index` |
| SettingsPage → Save | Save changed keys | `PUT /settings` | `admin.settings.update` |
| FeatureFlagsPage | Table of flags | `GET /feature-flags` | `admin.feature-flags.index` |
| FeatureFlagForm (create) | Add a flag | `POST /feature-flags` | `admin.feature-flags.create` |
| FeatureFlagForm (edit) | Edit a flag | `PUT /feature-flags/{id}` | `admin.feature-flags.update` |
| FeatureFlagSwitch | Flip on/off | `POST /feature-flags/{id}/toggle` | `admin.feature-flags.toggle` |
| DeleteFlagDialog | Delete a flag | `DELETE /feature-flags/{id}` | `admin.feature-flags.delete` |

Section `settings` (TD-27): `index` → view, `update` / `toggle` → update,
`create` → create, `delete` → delete. Sidebar entry: **Settings → General**,
**Settings → Feature flags**. Hide each item when the admin lacks its index
permission. There is no `GET /feature-flags/{id}`: open the edit form from the
list row.

## 3. List / table

### 3.1 Settings — `GET /api/v1/admin/settings`

Not paginated (returns all matching settings grouped by `group`). Supports free-text
search and group filtering:

| Query parameter | Type | Description |
|-----------------|------|-------------|
| `search` | string | Free-text search matching `key`, `value`, `group`, or inside JSON `metadata` (case-insensitive partial match). |
| `group` / `filters[group]` | string | Filter settings strictly by group name (e.g. `booking`, `app`, `response_timers`, `dispatch_limits`, `limits`). |
| `page` | integer | Optional page number (tolerated; response remains grouped by `group`). |

`data` is an object keyed by group; each group is an array of settings sorted by `group` then `key`.

```json
{
  "status": "success",
  "message": "Settings retrieved",
  "data": {
    "app": [
      {
        "id": "01a15dd8-fe27-72ba-89f7-f272105797b1",
        "key": "app_description",
        "type": "string",
        "value": "E-commerce platform",
        "group": "app",
        "metadata": null,
        "created_at": "2026-10-21T09:44:24.000000Z",
        "updated_at": "2026-10-21T09:44:24.000000Z"
      },
      {
        "id": "01a15dd8-fe26-73f4-ba5e-93c700275ddf",
        "key": "app_name",
        "type": "string",
        "value": "My App",
        "group": "app",
        "metadata": null,
        "created_at": "2026-10-21T09:44:24.000000Z",
        "updated_at": "2026-10-21T09:44:24.000000Z"
      },
      {
        "id": "01a15dd8-fe29-73fb-9fa9-5f824f586cf0",
        "key": "default_currency",
        "type": "string",
        "value": "SAR",
        "group": "app",
        "metadata": null,
        "created_at": "2026-10-21T09:44:24.000000Z",
        "updated_at": "2026-10-21T09:44:24.000000Z"
      },
      {
        "id": "01a15dd8-fe28-73e9-99a0-6e9e68b30943",
        "key": "default_locale",
        "type": "string",
        "value": "ar",
        "group": "app",
        "metadata": null,
        "created_at": "2026-10-21T09:44:24.000000Z",
        "updated_at": "2026-10-21T09:44:24.000000Z"
      }
    ],
    "response_timers": [
      {
        "id": "01a15dd8-fe37-7351-a837-1c1d67bf17f0",
        "key": "driver_offer_response_seconds",
        "type": "int",
        "value": "12",
        "group": "response_timers",
        "metadata": {
          "unit": "seconds",
          "description": "Time a driver has to accept or reject a transportation offer before it moves on (DEC-26)."
        },
        "created_at": "2026-10-21T09:44:24.000000Z",
        "updated_at": "2026-10-21T09:44:24.000000Z"
      },
      {
        "id": "01a15dd8-fe36-716d-b0b7-88e7867f4c96",
        "key": "provider_instant_response_seconds",
        "type": "int",
        "value": "20",
        "group": "response_timers",
        "metadata": {
          "unit": "seconds",
          "description": "Time the provider has to accept or reject a paid instant order before it is refunded (D3)."
        },
        "created_at": "2026-10-21T09:44:24.000000Z",
        "updated_at": "2026-10-21T09:44:24.000000Z"
      }
    ]
  }
}
```

Trimmed: the real response also has the groups `contact`, `limits`, `social`,
`ui` (same item shape); `app` has four keys.

| Field | Type | Nullable | Description |
|-------|------|----------|-------------|
| `id` | uuid | no | Not used by the update request (it is keyed by `key`) |
| `key` | string | no | Unique, ≤ 100 chars |
| `type` | string | no | `string`, `number`, `int`, `bool`, `json`, `media` — determines validation; values are strings except nullable media and the existing dispatch input normalization |
| `value` | string | yes | Always a string (`"20"`, not `20`) |
| `group` | string | no | Section on the page |
| `metadata` | object | yes | Free-form; `response_timers` and `dispatch_limits` carry `unit` and `description` |
| `created_at` / `updated_at` | string | yes | ISO 8601 UTC with microseconds (raw model output — different from the other admin lists) |

### 3.2 Feature flags — `GET /api/v1/admin/feature-flags` — list key `feature_flags`

Paginated (standard params: `page`, `per_page`). Supports free-text search across
the model's searchable fields (`key`, `description`, `is_enabled`):

| Query parameter | Type | Description |
|-----------------|------|-------------|
| `search` | string | Free-text search matching `key`, `description`, or `is_enabled` (case-insensitive partial match). |
| `page` | integer | Page number (default: 1). |
| `per_page` | integer | Records per page (default: 15, max: 100). |

Default order is newest first (`created_at` desc).

```json
{
  "status": "success",
  "message": "Data retrieved successfully",
  "data": {
    "feature_flags": [
      {
        "id": "01a15dd8-fe39-72c3-b6f4-d4643a01b2a9",
        "key": "enable_notifications",
        "is_enabled": true,
        "targeting_rules": null,
        "description": "Enable push notifications system-wide",
        "created_at": "2026-10-21 09:44:24"
      }
    ],
    "links": {
      "first": "http://localhost/api/v1/admin/feature-flags?page=1",
      "last": "http://localhost/api/v1/admin/feature-flags?page=3",
      "prev": null,
      "next": "http://localhost/api/v1/admin/feature-flags?page=2"
    },
    "meta": {
      "current_page": 1,
      "from": 1,
      "last_page": 3,
      "path": "http://localhost/api/v1/admin/feature-flags",
      "per_page": 2,
      "to": 2,
      "total": 5
    }
  }
}
```

## 4. Details view

N/A — no details endpoint for either resource.

## 5. Forms (create / edit)

### 5.1 Settings — `PUT /api/v1/admin/settings`

| Field | Input type | Required | Validation (mirror in zod/yup) | Notes |
|-------|-----------|----------|-------------------------------|-------|
| `settings` | array | yes | array | Send only the changed keys |
| `settings.*.key` | string | yes | string, max 100, must exist | An existing key from `GET /settings`; an unknown key is 422 |
| `settings.*.value` | string or integer | no | nullable string; dispatch limits also accept JSON integers | Send other settings as strings; dispatch limits accept integers or integer strings from 1 to 600 |

```json
{ "settings": [ { "key": "provider_instant_response_seconds", "value": "25" } ] }
```

```json
{
  "status": "success",
  "message": "Settings updated successfully",
  "data": null
}
```

The save also invalidates the apps' bootstrap cache (`/api/v1/config/bootstrap`).

Joshen operating values in use today:

| Key | Group | Type | Default | Read by |
|-----|-------|------|---------|---------|
| `provider_instant_response_seconds` | `response_timers` | int | `20` | Instant booking accept window (D3) — when booking ships |
| `driver_offer_response_seconds` | `response_timers` | int | `12` | Driver offer countdown (DEC-26) — when trips ship |
| `dispatch_availability_per_minute` | `dispatch_limits` | int | `60` | Driver availability and location updates |
| `dispatch_responses_per_minute` | `dispatch_limits` | int | `30` | Driver offer accept and reject requests |
| `dispatch_status_transitions_per_minute` | `dispatch_limits` | int | `30` | Driver trip status updates |

Dispatch updates take effect on the next request. Handle 422 `INVALID_BOOKING_SETTINGS` without discarding the form; all keys in a rejected save remain unchanged. Real responses: Postman `admin.settings.update` › "200 Dispatch rate updated" and "422 Invalid dispatch rate".

### 5.1.1 Provider portfolio operating value (TD-73)

| Table / key | JSON type | Absent default | Consumer / behavior |
|-------------|-----------|----------------|---------------------|
| platform_settings / provider_portfolio_max_photos | Nonnegative integer | 12 | GET provider/profile/portfolio returns effective portfolio_max_photos and can_add_photo; POST rejects current live count + new photos over the limit with 422 BUSINESS_RULE_VIOLATED naming that limit. |

The existing providers.portfolio media key retains jpg/jpeg/png/webp and max 5120 KiB per image; count is not a media-key constant. Additions check the count under the provider-row lock. Lowering the value never removes existing photos; delete and reorder remain available above the limit. Invalid legacy non-integer/negative values fall back to12; configure an integer, not a JSON string/object.

This is a live schema `platform_settings` consumer, like the onboarding resubmission settings. Current GET/PUT admin/settings still operate on app_configs and cannot list/edit this key; the full ADMIN-025 settings editor remains pending. No new admin route or setting payload is claimed by this doc bump.

### 5.2 Feature flag — `POST /feature-flags` · `PUT /feature-flags/{id}`

Update accepts partial payloads (`key` becomes `sometimes`).

| Field | Input type | Required on create | Validation | Notes |
|-------|-----------|--------------------|-----------|-------|
| `key` | text | yes | string, max 100, unique in `feature_flags.key` | snake_case by convention |
| `is_enabled` | switch | no | boolean | Default `false` |
| `targeting_rules` | JSON editor | no | nullable array/object | Stored as JSON, returned as sent |
| `description` | textarea | no | nullable string | |

```json
{ "key": "enable_group_booking", "is_enabled": false, "description": "Group booking (D2)", "targeting_rules": { "apps": ["customer"] } }
```

Create — 201:

```json
{
  "status": "success",
  "message": "Data saved successfully",
  "data": {
    "id": "01a15dd8-fe7d-71a3-8922-dc752e4e1b81",
    "key": "enable_group_booking",
    "is_enabled": false,
    "targeting_rules": {
      "apps": [
        "customer"
      ]
    },
    "description": "Group booking (D2)",
    "created_at": "2026-10-21 09:44:24",
    "updated_at": "2026-10-21 09:44:24"
  }
}
```

Update — 200:

```json
{
  "status": "success",
  "message": "Data saved successfully",
  "data": {
    "id": "01a15dd8-fe7d-71a3-8922-dc752e4e1b81",
    "key": "enable_group_booking",
    "is_enabled": false,
    "targeting_rules": {
      "apps": [
        "customer"
      ]
    },
    "description": "Group booking — one order, several people (D2)",
    "created_at": "2026-10-21 09:44:24",
    "updated_at": "2026-10-21 09:44:24"
  }
}
```

## 6. Actions (non-CRUD)

### 6.1 Toggle a flag

`POST /api/v1/admin/feature-flags/{id}/toggle` · Permission: `admin.feature-flags.toggle`

No body. Flips `is_enabled` and clears the flag cache.

```json
{
  "status": "success",
  "message": "Feature flag toggled",
  "data": {
    "id": "01a15dd8-fe7d-71a3-8922-dc752e4e1b81",
    "key": "enable_group_booking",
    "is_enabled": true,
    "targeting_rules": {
      "apps": [
        "customer"
      ]
    },
    "description": "Group booking — one order, several people (D2)",
    "created_at": "2026-10-21T09:44:24.000000Z",
    "updated_at": "2026-10-21T09:44:24.000000Z"
  }
}
```

The toggle answer is the raw model: dates are ISO 8601 with microseconds,
unlike the list/create/update answers (`Y-m-d H:i:s`). Parse both, or reload the
list after a toggle.

### 6.2 Delete a flag

`DELETE /api/v1/admin/feature-flags/{id}` · Permission: `admin.feature-flags.delete`

```json
{
  "status": "success",
  "message": "Data removed successfully",
  "data": null
}
```

### Errors (all endpoints in this doc)

| HTTP | `error_code` | When | Dashboard behavior |
|------|--------------|------|--------------------|
| 401 | `UNAUTHENTICATED` | Missing or expired token | Refresh, then retry ([admin/auth.md](auth.md)) |
| 403 | `PERMISSION_DENIED` | Missing route permission | Hide/disable the action |
| 404 | — | Unknown or integer id | Toast "not found", reload the list |
| 422 | — | Validation failed (one combined `message`, TD-02) | Show `message` |
| 500 | `INTERNAL_ERROR` | `PUT /settings` with a key that does not exist | Send existing keys only; show a generic error and log `correlation_id` |

Real 422 (settings without body, flag with a taken key):

```json
{
  "status": "fail",
  "message": "The Settings field is required.",
  "data": null
}
```

```json
{
  "status": "fail",
  "message": "The Key has already been taken.",
  "data": null
}
```

Real 500 (unknown key; the `message` shows the SQL error only with `APP_DEBUG=true`):

```json
{
  "status": "error",
  "message": "SQLSTATE[HY000]: General error: 1364 Field 'group' doesn't have a default value (Connection: mysql, Host: 127.0.0.1, Port: 3306, Database: joshen_testing, SQL: insert into `app_configs` (`key`, `value`, `uuid`, `updated_at`, `created_at`) values (no_such_key, 1, 01a15dd8-fe63-73ce-8e10-fb67956a18b9, 2026-10-21 09:44:24, 2026-10-21 09:44:24))",
  "data": {
    "error_code": "INTERNAL_ERROR",
    "correlation_id": "01b272dc-2560-45d2-b074-e1e6a0c9de28"
  }
}
```

Real 404 (integer id):

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

## 7. Statuses and enums

| Enum | `value` | Label (en) | Badge color | Actions allowed |
|------|---------|------------|-------------|-----------------|
| flag `is_enabled` | `true` / `false` | On / Off | green / grey | Toggle, edit, delete |

Settings have no statuses.

## Data model

| Table | Read / write | Notes |
|-------|--------------|-------|
| `app_configs` (base) | read + write | `key` unique, `type`, `value` (text), `group`, `metadata` JSON. Holds business settings today ([database/README.md §2.2](../database/README.md#22-follows-the-schema-business-domain): `platform_settings` ↔ `app_configs`) |
| `feature_flags` (base) | read + write | `key` unique, `is_enabled`, `targeting_rules` JSON, `description` |
| `platform_settings` (schema) | — | Provider portfolio limit (TD-73) and onboarding policy consumers; not read/written by these legacy admin endpoints |

## 8. TypeScript types

```ts
export interface Setting {
  id: string; // uuid
  key: string;
  type: string; // 'string' | 'number' | 'int' | …
  value: string | null; // always a string
  group: string;
  metadata: { unit?: string; description?: string } | null;
  created_at: string | null; // ISO 8601 UTC
  updated_at: string | null;
}
export type SettingsByGroup = Record<string, Setting[]>;
export interface UpdateSettingsBody { settings: Array<{ key: string; value: string | null }> }

export interface FeatureFlag {
  id: string; // uuid
  key: string;
  is_enabled: boolean;
  targeting_rules: Record<string, unknown> | unknown[] | null;
  description: string | null;
  created_at: string; // "Y-m-d H:i:s" (TD-03); ISO 8601 on the toggle answer
  updated_at?: string; // create/update/toggle answers only
}
export type FeatureFlagList = Paginated<'feature_flags', FeatureFlag>;
```

## 9. Business rules enforced by the backend

| Rule | What the backend does | What the admin sees |
|------|-----------------------|---------------------|
| BR-29 | `rbac.auto` checks the route name on every call | 403 `PERMISSION_DENIED` |
| TD-133 | Dispatch request limits must be integer values from 1 to 600; the save is atomic | Show `INVALID_BOOKING_SETTINGS` for a rejected value |
| TD-170, TD-172 | Every value must fit its stored type (`int`, `number`, `bool` `0`/`1`, `json`, `media` image hash, `string`). Keys with a domain validator answer with that validator's code first; the type check follows | 422 `VALIDATION_FAILED` with `data.errors.settings.{i}.value` for other keys; the domain code (e.g. `INVALID_BOOKING_SETTINGS`) for validated keys. Nothing is saved |
| — | Most other BRD setting rules (such as who may edit commissions) are not implemented yet | Treat unrelated base settings as provisional |

## 10. Notifications triggered

N/A.

## 11. Integration checklist

- [ ] Settings page renders groups from `data` keys, including `dispatch_limits`; other values are edited as strings
- [ ] Dispatch inputs accept 1–600 requests per minute and show rejected saves
- [ ] Save sends only changed keys
- [ ] Flags table, create/edit form, toggle switch, delete dialog
- [ ] Toggle answer dates parsed (ISO 8601) or list reloaded
- [ ] Menu items/buttons gated by the listed permissions
- [ ] 403/404/422 handled

## 12. Acceptance scenarios

| AC ID | Scenario | Expected |
|-------|----------|----------|
| AC-ADMIN-025 | Admin searches settings by key, value, group, or metadata (`?search=...`) | Returns only matching settings grouped by `group` |
| AC-ADMIN-025 | Admin filters settings by group (`?group=booking` or `?filters[group]=booking`) | Returns only settings belonging to the specified group |
| AC-ADMIN-025 | Admin searches feature flags (`?search=...`) | Returns paginated feature flags matching key, description, or enabled state |
| AC-ADMIN-025 | Admin changes a dispatch limit | Saved via the generic settings route and applied on the next driver request; invalid values reject the entire update |
| AC-ADMIN-025 | Admin changes other BRD platform settings | Partially covered; the full BRD setting editor remains pending |

## 13. Open decisions and known limitations

| ID | Impact on this feature | Current behavior |
|----|------------------------|------------------|
| TD-32 | Base endpoints, provisional contract | Documented as found; 1.0.0 comes with ADMIN-025 |
| DEC-06 | Gateway fees | Gateway and fee policy remain undecided and are not built. |
| DEC-17 | Seeded `default_currency` is `SAR` since 0.2.1; the seeded contact numbers are Saudi (`+966…`) since the contact group (TD-181) | Put the real hotline, e-mail and WhatsApp in the `contact` group before launch |
| — | `PUT /settings` with an unknown key | 422 `VALIDATION_FAILED` (since 0.14.1) and nothing in the request is saved; the update runs in one transaction. Send only keys read from `GET /settings` |
| TD-03 | Flag dates `Y-m-d H:i:s` without offset; settings and toggle use ISO 8601 UTC | Parse both until TD-03 |
| TD-02 | 422 has one combined `message`, no per-field map | Show `message` |

### Wallet payment switch (TD-164)

The `payments` group contains `wallet_payments_enabled`, type `bool`, string value `1` (default, enabled) or `0` (disabled). Update through `PUT /settings` using the existing settings array. Other values return 422 `VALIDATION_FAILED`. Postman: `admin.settings.update` › "200 Wallet Payment Setting Disabled" and "422 Wallet Payment Setting Invalid".

### VAT and ZATCA Phase-1 settings (TD-165)

The `vat` group contains `vat_seller_name` and `vat_registration_number` (type `string`, default `""` empty). Update through `PUT /settings`. If a non-empty `vat_registration_number` is submitted, it is validated via `InvoiceSettings` against the Saudi 15-digit standard (`^3[0-9]{13}3$`); invalid formats return 422 `INVALID_VAT_SETTINGS`. Filling these with official entity values before commercial launch is required for compliant ZATCA Phase-1 QR generation (launch blocker).

## Postman examples (2026-11-08)

Feature-flag responses captured by `tests/Feature/Api/V1/Admin/AdminAccessExamplesTest.php`. The toggle route is `POST /feature-flags/{id}/toggle`.

- `admin.feature-flags.create` › "201 Feature Flag Created", "422 Feature Flag Missing Key"
- `admin.feature-flags.delete` › "200 Feature Flag Deleted"
- `admin.feature-flags.index` › "200 Feature Flags Search By Key"
- `admin.feature-flags.toggle` › "200 Feature Flag Toggled"
- `admin.feature-flags.update` › "200 Feature Flag Updated", "422 Feature Flag Update Duplicate Key"
