---
feature: Profile and mobile number change
slug: profile
surface: client
consumer: flutter
version: 1.0.2
status: ready
brd_version: "1.3"
brd_ids: [CUST-023]
business_rules: [BR-02, BR-04]
decisions: [DEC-15, TD-01, TD-02, TD-19, TD-24, TD-89, SCR-08, SCR-17, SCR-33, SCR-34]
schema_tables: [users, customers, governorates, verifications, media]
screens: ["customer--account--0[1-3]-*"]
related_docs: [auth.md, addresses.md, ../API_CONVENTIONS.md]
last_updated: 2026-11-08
---

# Profile and mobile number change — Flutter Integration Guide (client)

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

## Version history

Newest first. Never edit an old row.

| Version | Date | Type | Summary | App action required |
|---------|------|------|---------|---------------------|
| 1.0.2 | 2026-11-08 | PATCH | Postman examples for `GET /profile` and `GET`/`PUT /settings`; `allow_notifications` and `locale` described. | None. |
| 1.0.1 | 2026-10-25 | PATCH | The mobile-change flow is now shared with the provider app ([provider/profile.md §3.13](../provider/profile.md)); challenges are bound to the app that asked. No contract change for the customer app. | None. |
| 1.0.0 | 2026-10-25 | Initial | `PUT /profile` (name, email, city, birth year, photo) and the two-step mobile change with an OTP to the new number (`POST /profile/phone`, `…/resend-code`, `…/verify`). `GET /profile` is unchanged. | Build the edit-profile screen and the change-number sheet. |

## Changelog

### 1.0.2 — 2026-11-08

- **Changed:** Postman examples for `GET /profile` and `GET`/`PUT /settings`; `allow_notifications` and `locale` described.
- **Breaking:** No. **App action required:** None.

### 1.0.0 — 2026-10-25
- **Added:** `PUT /profile`, `POST /profile/phone`, `POST /profile/phone/resend-code`, `POST /profile/phone/verify`. Every response has the `auth/me` shape (`user` + `profile`).
- **Changed / Fixed / Removed / Deprecated:** — (`PUT /client/profile` answered 405 before; `DELETE /client/profile` is still not routed).
- **Breaking:** No.
- **Endpoints affected:** the four above (new); `GET /profile` unchanged.
- **App action required:** build the edit-profile screen (`customer--account--03-edit-profile`) and the change-number flow; refresh the stored account from each response.
- **BRD / decisions:** CUST-023, DEC-15, TD-24, TD-89.

---

## 1. Business summary

A customer edits her own name, optional email, city, optional birth year and photo
directly — customers are not reviewed (BR-04 applies to providers and drivers). Her
**mobile number is her identity** (one phone, one person, all apps — TD-01), so it is never
edited with the profile form: she asks for the change, a code is sent to the **new**
number, and the number is saved only after she enters it correctly (AC-CUST-023). There is
no password to change (DEC-15): the BRD line "change their password" does not apply to app
users. Addresses, wallet, notifications and orders have their own docs.

## 2. User flow and screens

| Step | Screen / widget | Endpoint | Notes |
|------|-----------------|------|-------|
| 1 | Account menu (`customer--account--01/02`) | `GET /profile` (same as `GET /auth/me`) | Name, photo, wallet balance (`profile.wallet_balance`). |
| 2 | Edit profile (`customer--account--03-edit-profile`) | `PUT /profile` | Send only changed keys. Photo: upload with `POST /api/v1/media` first, then send the hash. Hint under the photo: providers see it only after a confirmed booking (SCR-17). |
| 3 | Change mobile → enter new number | `POST /profile/phone` | The field on the edit screen is read-only; "Change" opens this sheet. |
| 4 | Enter code | `POST /profile/phone/verify` | Show the countdown from `expires_at`; "Resend" enabled at `resend_available_at`. |
| 5 | Resend | `POST /profile/phone/resend-code` | Same `verification_token`. |

```
Edit profile ──► Save (PUT /profile) ──► refreshed account
     └─► Change mobile ──► POST /profile/phone ──► code on the NEW number ──► POST /profile/phone/verify ──► new number saved
                                        └─► Resend (60 s) ──► POST /profile/phone/resend-code
```

## 3. Endpoints

All: `Authorization: Bearer <customer token>`, `Accept-Language: ar|en`. A token of another app is **403**; none is **401**. Responses are `Cache-Control: no-store, private`.

### 3.1 Profile — `GET /profile`

Unchanged: same body as `GET /auth/me` ([auth.md §3.9](auth.md)).

### 3.2 Edit — `PUT /profile`

Every key is optional; omitted keys keep their value, an explicit `null` clears `email`, `city_id`, `birth_year` or `photo`.

| Field | Type | Validation (mirror on the client) | Description |
|-------|------|-----------------------------------|-------------|
| `full_name` | string | not empty when sent, max 255 | Shared by every app of this person |
| `email` | string / null | valid email, max 255, unique across accounts | Optional contact email; never verified, never a login (DEC-15) |
| `city_id` | uuid / null | existing city that is an enabled service area | `governorates` = BRD city |
| `birth_year` | int / null | 1920 … this year | Optional (SCR-34) |
| `photo` | string / null | media hash from `POST /api/v1/media`, jpg/jpeg/png/webp ≤ 5 MiB, uploaded by her, not used elsewhere | Replaces the previous photo; `null` removes it |
| `phone_code`, `phone`, `password` | — | **refused (422)** | Use §3.3; app users have no password |

```json
{ "full_name": "Lamar Al-Otaibi", "email": "lamar@example.com", "city_id": "01a17204-6035-73e1-b274-7e8551b439fb", "birth_year": 1994 }
```

**Success — `200`.** Postman: `client.profile.update` › "200 Customer edits her profile" · "200 Optional fields cleared".

```json
{
  "status": "success",
  "message": "Profile updated.",
  "data": {
    "user": {
      "id": "01a17204-601a-73ad-892a-11bd14ef28cc",
      "full_name": "Lamar Al-Otaibi",
      "phone_code": "966",
      "phone": "500197139",
      "email": "lamar@example.com",
      "image": "https://ui-avatars.com/api/?name=U&background=dbeafe&color=1d4ed8&size=200",
      "locale": "ar"
    },
    "profile": {
      "id": "01a17204-6018-7345-b868-469ebc9d498c",
      "city": { "id": "01a17204-6035-73e1-b274-7e8551b439fb", "name": "Sample-wrjr71004868" },
      "birth_year": 1994,
      "onboarding_seen": false,
      "wallet_balance": "0.00"
    }
  }
}
```

**Errors**

| HTTP | `error_code` | When | What the app should do |
|------|--------------|------|------------------------|
| 422 | — | Shape: empty name, taken or malformed email, bad uuid, year out of range, refused keys → `data.errors` per field | Mark the fields |
| 422 | `SERVICE_AREA_UNAVAILABLE` | The city exists but is disabled | Ask for another city; nothing was saved |
| 422 | `BUSINESS_RULE_VIOLATED` | `photo` is not a fresh upload of hers, was already used, or is not an image | Upload again |

```json
{
  "status": "fail",
  "message": "The Name field is required. (and 5 more errors)",
  "data": {
    "errors": {
      "full_name": ["The Name field is required."],
      "email": ["The Email has already been taken."],
      "city_id": ["The City field must be a valid UUID."],
      "birth_year": ["The Birth year field must be between 1920 and 2026."],
      "phone": ["The phone field is prohibited."],
      "password": ["The password field is prohibited."]
    }
  }
}
```

Postman: `client.profile.update` › "422 Invalid profile form" · "422 City is not an enabled service area". The edit is atomic: any error saves nothing.

### 3.3 Change mobile — step 1: `POST /profile/phone`

Sends an OTP to the **new** number. Nothing is saved yet. Rate limit: same `otp-send` limiter as sign-in (per number, per app) plus one request per customer per 60 s.

| Field | Type | Required | Validation | Description |
|-------|------|----------|-----------|-------------|
| `phone_code` | string | yes | `+966` (`966`, `00966` normalized) | |
| `phone` | string | yes | 9 digits starting with 5 (`05…` normalized) | The new Saudi mobile number |

**Success — `200`.** Postman: `client.profile.phone.send` › "200 OTP sent to the new number".

```json
{
  "status": "success",
  "message": "Verification code sent to the new number.",
  "data": {
    "verification_token": "01a17204-8e47-7313-8961-ef5c6b753bb4",
    "expires_at": "2026-10-25T07:54:23+00:00",
    "resend_available_at": "2026-10-25T07:45:23+00:00"
  }
}
```

A new request replaces her previous pending challenge: only the latest token can be confirmed.

| HTTP | `error_code` | When | What the app should do |
|------|--------------|------|------------------------|
| 409 | `PHONE_IN_USE` | The number belongs to another account | Tell her it is taken |
| 422 | `VALIDATION_FAILED` | It is already her number | Ask for a different one |
| 422 | — | Wrong calling code or format, refused keys (`email`, `password`) → `data.errors` | Mark the field |
| 429 | `RATE_LIMITED` | Asked again inside 60 s, or the send limiter tripped | Wait for `resend_available_at` |
| 503 | `INFRASTRUCTURE_FAILURE` | The SMS channel failed | Retry later |

Postman: `client.profile.phone.send` › "409 Number belongs to another account" · "422 Same number" · "422 Invalid number" · "429 Asked again too soon".

```json
{
  "status": "fail",
  "message": "This mobile number is already registered to another account.",
  "data": { "error_code": "PHONE_IN_USE", "correlation_id": "540d71be-ad8f-40fe-bb8a-eef972977512" }
}
```

### 3.4 Change mobile — step 2: `POST /profile/phone/verify`

| Field | Type | Required | Validation | Description |
|-------|------|----------|-----------|-------------|
| `verification_token` | uuid | yes | uuid | From step 1; only the customer who asked can use it |
| `code` | string | yes | digits, length from the Verification module (4 now, TD-24; 6 with the SMS gateway, DEC-15) | Code sent to the new number |

**Success — `200`** with the account and the new number. Postman: `client.profile.phone.verify` › "200 Mobile number changed".

```json
{
  "status": "success",
  "message": "Your mobile number was changed.",
  "data": {
    "user": { "id": "01a17204-859f-71ef-97c3-952894b978c4", "full_name": "Yvonne Cartwright", "phone_code": "+966", "phone": "522222222", "email": "hobart67@example.net", "image": "https://ui-avatars.com/api/?name=U&background=dbeafe&color=1d4ed8&size=200", "locale": "ar" },
    "profile": { "id": "01a17204-859f-71ef-97c3-9528949c4c3d", "city": null, "birth_year": null, "onboarding_seen": false, "wallet_balance": "0.00" }
  }
}
```

The current session stays valid (a token identifies the account, not the number). The number is also her sign-in number from now on, in every app where she has a profile.

| HTTP | `error_code` | When | What the app should do |
|------|--------------|------|------------------------|
| 422 | `INVALID_OTP` | Wrong code (5 attempts, then 429) | Let her retry |
| 422 | `OTP_EXPIRED` | Unknown, expired, already used or someone else's token | Start again from step 1 |
| 429 | `OTP_TOO_MANY_ATTEMPTS` / `RATE_LIMITED` | Attempts or request limit exhausted | Request a new code |
| 409 | `PHONE_IN_USE` | Someone registered the number while the code was pending; nothing saved | Ask for another number |

Postman: `client.profile.phone.verify` › "422 Wrong code" · "429 Too many verify requests".

### 3.5 Change mobile — resend: `POST /profile/phone/resend-code`

Body `{ "verification_token": "<uuid>" }`. Issues a new code on the **same** token, once per 60 s. Errors: `422 OTP_EXPIRED` (unknown / expired / foreign / verified), `429 RATE_LIMITED`, `409 PHONE_IN_USE`. Postman: `client.profile.phone.resend` › "200 Code sent again" · "422 Challenge token is not a uuid" · "429 Too many codes for this number".

## 4. Statuses and enums

N/A — no status values. `profile.city` is `null` until she picks one.

## Data model

| Table (`db-schema.sql`) | Read / write | Notes |
|-------------------------|--------------|-------|
| `users` | read + write | `full_name`, `email` (unverified), `phone_code` / `phone`, `phone_verified_at` after a change |
| `customers` | read + write | `city_id`, `birth_year` |
| `media` | read + write | Account photo (`users.image`) |
| `verifications` | read + write | The OTP challenge; the target number is also kept encrypted server-side until confirmed |
| `audit_logs` | write | Edits and number changes (last 3 digits of the previous number only) |

## 5. Dart models

```dart
class CustomerAccount {          // identical to the auth/me shape
  final String id, fullName, phoneCode, phone, locale;
  final String? email;
  final String image;            // absolute URL (placeholder when none)
  final String? cityId, cityName;
  final int? birthYear;
  final bool onboardingSeen;
  final String walletBalance;    // money string
}

class PhoneChallenge {
  final String verificationToken;
  final DateTime expiresAt, resendAvailableAt;   // parseDate()
}
```

## 6. Business rules enforced by the backend

| Rule | What the backend does | What the user sees |
|------|-----------------------|--------------------|
| AC-CUST-023 | The new number is saved only after its OTP is verified | Number unchanged until `verify` succeeds |
| TD-01 | One phone identifies one person in every app | The number cannot belong to another account (409) |
| DEC-15 | No password for app users | `password` refused |
| BR-08 | A city must be an enabled service area | `SERVICE_AREA_UNAVAILABLE` |
| TD-19 | Only uuids (`city_id`) | 422 for malformed ids |

## 7. Notifications and real-time events

N/A. No push or inbox event is sent for a profile edit or number change.

## 8. Localization and UI notes

- Field names and error messages follow `Accept-Language`; `data.errors` keys are the request keys.
- Show the number as `phone_code` + `phone`; accept `05…` input and send it as typed — the backend normalizes it.
- While TD-24 applies the code has 4 digits (`1234`); build the input from the code length, not a constant.

## 9. Integration checklist

- [ ] Models parse the real responses above; refresh the stored account after every success
- [ ] Only changed keys sent on `PUT /profile`; `null` used to clear optional fields
- [ ] Change-number sheet handles 409, 422 (both kinds), 429 and the resend countdown
- [ ] No password UI anywhere (DEC-15)
- [ ] Tested in `ar` (RTL) and `en`

## 10. Acceptance scenarios

| AC ID | Scenario | Expected / test |
|-------|----------|-----------------|
| AC-CUST-023 | Edit name, email, city, birth year | `test_ac_cust_023_customer_edits_her_profile_directly`, `…omitted_keys_stay_and_nulls_clear`, `…invalid_edits_are_per_field_errors`, `…disabled_city_is_refused` |
| AC-CUST-023 | Photo attached, replaced, removed; foreign upload refused | `…photo_is_attached_replaced_and_removed`, `…foreign_or_used_photo_is_refused` |
| AC-CUST-023 | Change the mobile number; verify the new number before saving | `…mobile_changes_only_after_the_new_number_is_verified` |
| AC-CUST-023 | Another account's number / same number refused; number taken meanwhile | `…number_of_another_account_or_the_same_number_is_refused`, `…number_taken_while_the_code_was_pending…` |
| AC-CUST-023 | Challenge bound to its owner; new request replaces the old; throttles | `…challenge_belongs_to_the_customer_who_asked`, `…a_new_request_replaces_the_previous_challenge`, `…sending_again_inside_the_interval…`, `…code_requests_are_rate_limited` |

## 11. Open decisions and known limitations

| ID | Impact on this feature | Current behavior |
|----|------------------------|------------------|
| TD-24 / DEC-15 | OTP generation and SMS gateway | The code is the fixed `1234` and the SMS channel only logs until a gateway is chosen — **a launch blocker** for this flow too |
| TD-89 / TD-91 | Shared identity | The number is the person's identity: changing it in this app also changes the sign-in number of her provider / driver profiles (and the provider app has the same flow), and a number owned by another account is refused (409). A challenge works only in the app that asked |
| BRD "change password" | CUST-023 | Not applicable: app users have no password (DEC-15) |
| BRD "orders, wallet, notifications, log out" | CUST-023 | Their own docs: orders, wallet, notifications, `POST /auth/logout` |
| SCR-17 | Photo visibility | The provider sees the photo only after a confirmed booking; enforced by the orders feature |
| — | Account deletion | Not offered; `DELETE /profile` is not routed |

## Postman examples (2026-11-08)

Real responses captured by `tests/Feature/Api/V1/Client/CustomerAccountExamplesTest.php` for `GET /profile` and `GET`/`PUT /settings` (`allow_notifications`, `locale`).

- `client.profile.view` › "200 Customer profile"
- `client.settings.update` › "200 Notifications off and English", "422 Unsupported language and non-boolean switch"
- `client.settings.view` › "200 Current settings"
