---
feature: Provider direct profile and previous works
slug: profile
surface: provider
consumer: flutter
version: 1.2.3
status: ready (scoped delivery)
brd_version: "1.3"
brd_ids: [PROV-008]
business_rules: [BR-03, BR-04, BR-08]
decisions: [DEC-25, SCR-26, TD-91, TD-01, TD-14, TD-19, TD-44, TD-45, TD-56, TD-57, TD-62, TD-63, TD-64, TD-69, TD-72, TD-73, TD-74, TD-75, TD-76, TD-77]
schema_tables: [providers, provider_districts, payout_accounts, governorates, regions, users, media, platform_settings]
screens: ["provider--account--04-edit-profile-p1", "provider--account--04-edit-profile-p2", "provider--account--06-bank-account"]
related_docs: [onboarding.md, services-pricing.md, auth.md, ../admin/settings.md]
last_updated: 2026-11-10
---

# Direct profile and previous works — Flutter Integration Guide (provider)

> **Base URL:** `{HOST}/api/v1/provider` · **Scoped delivery:** photo, bio, professional title, default transport, portfolio, and (1.1.0) city, coverage and payout account completed from the account. Remaining PROV-008 requirements are pending in §13; DEC-25 remains open.

## Version history

| Version | Date | Type | Summary | App action required |
|---------|------|------|---------|---------------------|
| 1.2.3 | 2026-11-10 | PATCH | Docs only: changing a saved restricted value after approval now has its own flow, the reviewed change request ([profile-change-requests.md](profile-change-requests.md), TD-184, DEC-25 resolved). The direct edits in this guide are unchanged: they still answer 409 `PROFILE_CHANGE_REVIEW_REQUIRED`. | When a restricted field is locked, open the change-request form instead of showing "contact support" (once the change-request endpoints are announced as deployed). |
| 1.2.2 | 2026-11-02 | PATCH | A payout account an admin `rejected` while refusing a transfer request (DEC-11, TD-158) can be replaced by `POST /profile/payout-account` in any account status, including approved. | None: the old 409 does not apply to a rejected account; its `status` reads `rejected`. |
| 1.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. |
| 1.2.0 | 2026-10-25 | MINOR | Mobile number change with an OTP to the new number (`POST /profile/phone`, `…/resend-code`, `…/verify`), allowed in every account status (SCR-26, TD-91). | Make the phone field on the edit screen a "Change" action that opens the OTP sheet. |
| 1.1.0 | 2026-10-22 | MINOR | City/coverage, dedicated photo route and payout account: first set is direct in any status; changing a saved value after approval is 409 (TD-76/77). | Add the coverage and bank screens; prefill from the new GETs; branch on `PROFILE_CHANGE_REVIEW_REQUIRED` and `locked_fields` / `can_edit`. |
| 1.0.0 | 2026-10-22 | Initial scoped release | Direct profile read/edit and previous works append/list/reorder/delete before approval. | Bind only the scoped fields; upload fresh images first and use the returned operating limit. |

## Changelog

### 1.2.2 — 2026-11-02

- **Changed:** `POST /profile/payout-account` treats a `rejected` primary account as not effective: replacing it is direct in every account status. Any other saved account follows the rules below. Postman: `provider.payout-account.store` › "201 Rejected payout account replaced after approval".
- **Breaking:** No. **App action required:** None.

### 1.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.

### 1.2.0 — 2026-10-25

- **Added:** `POST /profile/phone`, `POST /profile/phone/resend-code`, `POST /profile/phone/verify` (§3.13); the shared implementation also serves the customer app ([client/profile.md](../client/profile.md)).
- **Changed / Fixed / Removed / Deprecated:** — (name, documents and bank stay as before; DEC-25 is still open for them).
- **Breaking:** No.
- **Endpoints affected:** the three above (new).
- **App action required:** the "رقم الجوال" field of `provider--account--04-edit-profile-p1` is read-only text with a Change button; the flow is §3.13.
- **BRD / decisions:** PROV-008, SCR-26 (phone only), TD-91, DEC-15, TD-24.

### 1.1.0 — 2026-10-22

- **Added:** `GET`/`PUT /profile/work-area`, `PUT /profile/photo`, `GET`/`POST /profile/payout-account` (§3.8–3.12); error code `PROFILE_CHANGE_REVIEW_REQUIRED` (409); `locked_fields`, `can_edit`, `has_photo` response keys.
- **Changed:** `PUT /profile` photo handling now shares the same Domain action as `PUT /profile/photo`; its contract is unchanged.
- **Fixed:** N/A.
- **Removed / Deprecated:** N/A.
- **Breaking:** No.
- **Endpoints affected:** the five new routes above.
- **App action required:** build the coverage form (city, districts, base point, radius) and the bank screen; prefill from the GETs; disable fields listed in `locked_fields` and hide the bank form when `can_edit` is false; on 409 `PROFILE_CHANGE_REVIEW_REQUIRED` direct the provider to support.
- **BRD / decisions:** PROV-008, AC-PROV-008, SCR-18/26, BR-04/08, TD-45, TD-62, TD-76, TD-77; DEC-25 still open.

### 1.0.0 — 2026-10-22

- **Added:** scoped GET/PUT profile and four portfolio operations; effective limit/can_add_photo.
- **Changed:** N/A.
- **Fixed:** shared uploader/freshness checks reused for account photos.
- **Removed / Deprecated:** N/A.
- **Breaking:** No.
- **Endpoints affected:** the six in §3.
- **App action required:** load scoped profile and portfolio separately; prevent edits to pending reviewed fields.
- **BRD / decisions:** PROV-008, SCR-18, DEC-25 open, TD-72…75.

## 1. Business summary

The provider directly edits her public photo, bio, professional title and default transport preference, and manages صور الأعمال السابقة (photos of previous works). These changes do not require review and are allowed before account approval so the account can be completed after registration. Pricing remains approval-gated. Since 1.1.0 she also completes her city, coverage and payout account from the account (SCR-18): the first time she sets each one it saves directly in any status. Changing a saved city, coverage or bank account after approval needs a reviewed change request ([profile-change-requests.md](profile-change-requests.md), TD-184); the direct edit here still answers 409 and keeps the approved data until an admin approves (BR-04, TD-76). Identity and document changes stay outside this release.

## 2. User flow and screens

| Step | Screen | Endpoint | UI behavior |
|------|--------|----------|-------------|
| 1 | `provider--account--04-edit-profile-p1` | `GET /profile`, `GET /profile/portfolio` | Load existing values; bio/title nullable. Name is not writable here; the phone has its own OTP flow (step 9). |
| 2 | Same screen, photo and bio | Shared `POST /api/v1/media`, then `PUT /profile` | Send collection=image for photo, then fresh returned hash in photo; preserve omitted fields. |
| 3 | `provider--account--04-edit-profile-p1/p2`, صور الأعمال السابقة | Shared upload, then `POST /profile/portfolio` | Upload each image with collection=portfolio, then append hashes; use returned can_add_photo and portfolio_max_photos. |
| 4 | Same screen delete/reorder | DELETE hash; PATCH reorder | Display ascending sort_order; submit every current photo once when reordering. |
| 5 | `provider--account--04-edit-profile-p2` | `PUT /profile` | Save enum transport preference; retain selected radio after reload. Fee calculation/dispatch are not implemented by this preference. |
| 6 | Coverage form (no captured screen; SCR-18 account completion) | `GET` / `PUT /profile/work-area` | Prefill city, districts, base point and radius. Disable inputs listed in `locked_fields`. Use the shared geo lists for city/district choices. |
| 7 | Photo-only picker (optional) | Shared upload, then `PUT /profile/photo` | Same as step 2 when the app edits only the photo. |
| 8 | `provider--account--06-bank-account` | `GET` / `POST /profile/payout-account` | No account → show the form. Saved account → show masked IBAN and status; when `can_edit` is false show "change through support" (DEC-25). |
| 9 | `provider--account--04-edit-profile-p1` (phone field) | `POST /profile/phone` → `POST /profile/phone/verify` (+ `resend-code`) | Tap the phone field → sheet for the new number → code sheet. Nothing changes until the code is verified (§3.13). Works before approval too. |

Use a loading state for initial reads, a separate upload progress state and explicit save status. After an uncertain append outcome, reload the list; attached hashes cannot be reused as fresh uploads. Lowered limits never remove existing photos.

## 3. Endpoints

All IDs are UUID strings; prices are exact two-decimal SAR strings. Send `Accept: application/json`, `Authorization: Bearer …`, and `Accept-Language: ar|en`. See [API_CONVENTIONS.md](../API_CONVENTIONS.md). No `X-City` or `Idempotency-Key` is required.

### 3.1 Read direct profile fields

`GET /api/v1/provider/profile`

Auth: Bearer JWT, `app=provider`, active user; approval is not required, including pending_review. No extra headers or endpoint-specific throttle. Responses are private/no-store.

No query or body fields. Returns only provider UUID, absolute photo URL (existing placeholder if empty), nullable bio/title and default_transport_mode enum. This is the edit-screen prefill endpoint (TD-75).

Success — **200**. Real MySQL HTTP response: [captured example](../postman-collection.json) (Postman: `provider.profile.show` › "200 Reload persisted direct profile fields before approval").

```json
{
  "status": "success",
  "message": "",
  "data": {
    "id": "01a16305-e27f-73df-8c54-916ae64f3c0a",
    "photo": "http://localhost/storage/portfolio/48a913bf-30e2-4290-b74d-65671a3c44cd.png",
    "bio": "My previous work",
    "professional_title": "Skin care specialist",
    "default_transport_mode": {
      "value": "self",
      "label": "Travel myself"
    }
  }
}
```

### 3.2 Save direct profile fields

`PUT /api/v1/provider/profile`

Auth: Bearer JWT, `app=provider`, active user; approval is not required, including pending_review. No extra headers or endpoint-specific throttle. Responses are private/no-store.

All fields optional; omitted keys preserve persisted values. Only these four keys are writable; unrelated submitted keys are ignored.

| Field | Validation | Behavior |
|-------|------------|----------|
| photo | null or string hash max 255 | Fresh owned uploaded image → users.image; null removes photo, omission preserves. Existing user image collection permits jpg/jpeg/png/webp, max 2048 KiB. |
| bio | null or string, at most 65535 UTF-8 bytes | Public biography; direct edit. |
| professional_title | null or string max 150 | Public professional title. |
| default_transport_mode | ask / joshen_driver / self | Schema enum, non-null; omission preserves. |

Upload files first to shared POST /api/v1/media using multipart file+collection. JSON forms accept hashes, never file URLs or local paths. A bad photo rejects the whole update without changing bio/title/transport. No profile_change_requests row is created.

Success — **200**. Real MySQL HTTP response: [captured example](../postman-collection.json) (Postman: `provider.profile.update` › "200 Pending provider saves direct profile photo bio title and transport").

```json
{
  "status": "success",
  "message": "",
  "data": {
    "id": "01a16305-e27f-73df-8c54-916ae64f3c0a",
    "photo": "http://localhost/storage/portfolio/48a913bf-30e2-4290-b74d-65671a3c44cd.png",
    "bio": "My previous work",
    "professional_title": "Skin care specialist",
    "default_transport_mode": {
      "value": "self",
      "label": "Travel myself"
    }
  }
}
```

### 3.3 List previous works

`GET /api/v1/provider/profile/portfolio`

Auth: Bearer JWT, `app=provider`, active user; approval is not required, including pending_review. No extra headers or endpoint-specific throttle. Responses are private/no-store.

No query/body fields. `portfolio[]` has attached hash, absolute URL and integer sort_order. `portfolio_max_photos` is the effective JSON operating setting (absent default 12); `can_add_photo` reflects whether current count is below it.

Success — **200**. Real MySQL HTTP response: [captured example](../postman-collection.json) (Postman: `provider.portfolio.index` › "200 Ordered portfolio and effective photo limit").

```json
{
  "status": "success",
  "message": "",
  "data": {
    "portfolio": [
      {
        "hash": "93dfb7f6a9440663cc75a08465362b75",
        "url": "http://localhost/storage/uploads/portfolio/85c23045-5e61-4487-818f-f574f72add9d.webp",
        "sort_order": 1
      },
      {
        "hash": "2475f77c-f2f4-4d1d-b66a-47b21489745c",
        "url": "http://localhost/storage/portfolio/2475f77c-f2f4-4d1d-b66a-47b21489745c.png",
        "sort_order": 2
      }
    ],
    "portfolio_max_photos": 12,
    "can_add_photo": true
  }
}
```

### 3.4 Append previous work photos

`POST /api/v1/provider/profile/portfolio`

Auth: Bearer JWT, `app=provider`, active user; approval is not required, including pending_review. No extra headers or endpoint-specific throttle. Responses are private/no-store.

Body: required nonempty list `photos`; each item is a distinct string hash max 255 from a fresh upload owned by the token user. Existing providers.portfolio collection accepts jpg/jpeg/png/webp, max 5120 KiB per image. Domain rejects attached/foreign/missing/unsupported/oversized media and current+incoming count over the setting. All additions commit together under provider-row lock and append ascending positions.

Success — **201**. Real MySQL HTTP response: [captured example](../postman-collection.json) (Postman: `provider.portfolio.store` › "201 Append two previous work photos before approval").

```json
{
  "status": "success",
  "message": "",
  "data": {
    "portfolio": [
      {
        "hash": "93dfb7f6a9440663cc75a08465362b75",
        "url": "http://localhost/storage/uploads/portfolio/85c23045-5e61-4487-818f-f574f72add9d.webp",
        "sort_order": 1
      },
      {
        "hash": "2475f77c-f2f4-4d1d-b66a-47b21489745c",
        "url": "http://localhost/storage/portfolio/2475f77c-f2f4-4d1d-b66a-47b21489745c.png",
        "sort_order": 2
      }
    ],
    "portfolio_max_photos": 12,
    "can_add_photo": true
  }
}
```

### 3.5 Reorder previous works

`PATCH /api/v1/provider/profile/portfolio/reorder`

Auth: Bearer JWT, `app=provider`, active user; approval is not required, including pending_review. No extra headers or endpoint-specific throttle. Responses are private/no-store.

Body: present list `photos`, each item exactly `{hash, sort_order}`. Hash is required/distinct string max 255; sort_order is required/distinct integer 0…2147483647. Submit every current owned portfolio hash exactly once. Empty list is valid only for an empty portfolio. Reorder remains allowed above a lowered count limit.

Success — **200**. Real MySQL HTTP response: [captured example](../postman-collection.json) (Postman: `provider.portfolio.reorder` › "200 Reorder every previous work photo").

```json
{
  "status": "success",
  "message": "",
  "data": {
    "portfolio": [
      {
        "hash": "2475f77c-f2f4-4d1d-b66a-47b21489745c",
        "url": "http://localhost/storage/portfolio/2475f77c-f2f4-4d1d-b66a-47b21489745c.png",
        "sort_order": 0
      },
      {
        "hash": "93dfb7f6a9440663cc75a08465362b75",
        "url": "http://localhost/storage/uploads/portfolio/85c23045-5e61-4487-818f-f574f72add9d.webp",
        "sort_order": 5
      }
    ],
    "portfolio_max_photos": 12,
    "can_add_photo": true
  }
}
```

### 3.6 Delete a previous work photo

`DELETE /api/v1/provider/profile/portfolio/{portfolio_hash}`

Auth: Bearer JWT, `app=provider`, active user; approval is not required, including pending_review. No extra headers or endpoint-specific throttle. Responses are private/no-store.

Path: attached hash of an owned live providers.portfolio item. No body. Removes that live media row and returns the remaining list; existing positions are retained. Wrong owner, wrong collection, missing or already deleted hash returns404. Delete remains allowed above a lowered count limit.

Success — **200**. Real MySQL HTTP response: [captured example](../postman-collection.json) (Postman: `provider.portfolio.destroy` › "200 Delete an owned previous work photo").

```json
{
  "status": "success",
  "message": "",
  "data": {
    "portfolio": [
      {
        "hash": "2475f77c-f2f4-4d1d-b66a-47b21489745c",
        "url": "http://localhost/storage/portfolio/2475f77c-f2f4-4d1d-b66a-47b21489745c.png",
        "sort_order": 0
      }
    ],
    "portfolio_max_photos": 12,
    "can_add_photo": true
  }
}
```

### 3.7 Errors and app behavior

| HTTP | error_code | Cause / action |
|------|------------|----------------|
| 401 | UNAUTHENTICATED | Refresh/sign in under shared auth behavior. |
| 403 | PERMISSION_DENIED / ACCOUNT_INACTIVE | Wrong app/profile or inactive user; stop protected action. |
| 404 | absent (`data:null`) | DELETE hash not in this owned live portfolio; reload list. |
| 422 | absent (`data.errors`) | Invalid enum/title/bio/hash-list/order shape or duplicate keys; mark fields from `data.errors`, falling back to the localized message (TD-02). |
| 422 | BUSINESS_RULE_VIOLATED | Photo uploader/freshness/storage/MIME/size failure, invalid reorder set, or operating count limit. Show message, refetch list/settings, upload a new file if hash is already attached. Work area: district of another city, or districts without a city. |
| 422 | SERVICE_AREA_UNAVAILABLE | Work area: the city or a district is disabled. Ask for another area. |
| 409 | INVALID_STATE_TRANSITION | Work area / payout: changing a saved city or payout account while the account is pending_review (TD-62). Wait for the review result. |
| 409 | PROFILE_CHANGE_REVIEW_REQUIRED | Work area / payout: changing saved approved data (approved or suspended account, BR-04/TD-76). Nothing was saved; direct the provider to support. |

The count-limit message names the effective limit. No existing photo is removed when an admin lowers it; hide Add photo from can_add_photo but retain delete/reorder. The limit is checked again in the transaction, not trusted from the app.

```json
{
  "status": "fail",
  "message": "Your portfolio may contain at most 2 photos. Remove photos before adding more.",
  "data": {
    "error_code": "BUSINESS_RULE_VIOLATED",
    "context": {
      "violations": [
        "Your portfolio may contain at most 2 photos. Remove photos before adding more."
      ]
    },
    "correlation_id": "bb4d5dcd-4d47-4637-9f70-8123411d78d8"
  }
}
```

### 3.8 Read city and coverage

`GET /api/v1/provider/profile/work-area`

Auth: Bearer JWT, `app=provider`, active user; any account status (approval not required). No extra headers. Responses are private/no-store.

No query or body fields. Returns the saved city, covered districts (sorted by name), base point, travel radius and `locked_fields` (§4). Use it to prefill the coverage screen after a restart (TD-77).

Success — **200**. Real MySQL HTTP response: [captured example](../postman-collection.json) (Postman: `provider.work-area.show` › "200 Saved city and coverage").

```json
{
  "status": "success",
  "message": "",
  "data": {
    "city": {
      "id": "01a16320-b1e8-7326-8c80-796fbd7d0fea",
      "name": "Sample-uskm53975439"
    },
    "districts": [
      {
        "id": "01a16320-b1f3-704e-9286-990b1650caf1",
        "name": "Sample-ioxe06346486"
      },
      {
        "id": "01a16320-b1f2-726a-8050-b7367415b698",
        "name": "Sample-xefa34031805"
      }
    ],
    "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"
    ]
  }
}
```

### 3.9 Save city and coverage

`PUT /api/v1/provider/profile/work-area`

Auth: Bearer JWT, `app=provider`, active user; any account status. Responses are private/no-store.

All keys optional; omitted keys keep their saved value. The response is the same resource as §3.8.

| Field | Validation | Behavior |
|-------|------------|----------|
| city_id | uuid of `governorates` (BRD city); not null when sent | Must be enabled (`SERVICE_AREA_UNAVAILABLE`). Changing it leaves saved districts in place, so send the new `district_ids` too or you get 422. |
| district_ids | array (list), may be empty | Replaces the whole saved list; `[]` clears it. |
| district_ids.* | uuid of `regions` (BRD district), distinct | Each must be enabled and belong to the resulting city (BR-08). Districts without a city → 422. |
| base_lat | null or numeric −90…90; send with base_lng | Both numbers or both null. One without the other → 422. |
| base_lng | null or numeric −180…180; send with base_lat | Paired with base_lat. |
| service_radius_km | integer 1…32767 | Schema default 10. |

**First set vs change (TD-76).** Setting a value for the first time is always direct. Coverage (districts + base point + radius) is one group: it counts as set once districts or a base point are saved. Changing an already-set value:

| Account status | City change | Coverage change |
|----------------|-------------|-----------------|
| incomplete / needs_changes / rejected | direct | direct |
| pending_review | 409 `INVALID_STATE_TRANSITION` (TD-62) | direct |
| approved / suspended | 409 `PROFILE_CHANGE_REVIEW_REQUIRED` | 409 `PROFILE_CHANGE_REVIEW_REQUIRED` |

Re-sending the saved value is not a change. A refused change writes nothing and creates no `profile_change_requests` row: the reviewed change request is a separate call ([profile-change-requests.md](profile-change-requests.md), TD-184). After the first save, `city` and `coverage` leave `missing_for_bookability` in `GET /onboarding`.

Success — **200**, approved provider, first-time set. Real MySQL HTTP response: [captured example](../postman-collection.json) (Postman: `provider.work-area.update` › "200 Approved provider sets city and coverage for the first time").

```json
{
  "status": "success",
  "message": "",
  "data": {
    "city": {
      "id": "01a16320-b1e8-7326-8c80-796fbd7d0fea",
      "name": "Sample-uskm53975439"
    },
    "districts": [
      {
        "id": "01a16320-b1f3-704e-9286-990b1650caf1",
        "name": "Sample-ioxe06346486"
      },
      {
        "id": "01a16320-b1f2-726a-8050-b7367415b698",
        "name": "Sample-xefa34031805"
      }
    ],
    "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"
    ]
  }
}
```

Error — **409**, approved provider changes her saved city ([captured](../postman-collection.json) (Postman: `provider.work-area.update` › "409 Changing the approved city needs review")):

```json
{
  "status": "fail",
  "message": "Changing City after approval needs an admin-reviewed change request. Please send a change request from your account.",
  "data": {
    "error_code": "PROFILE_CHANGE_REVIEW_REQUIRED",
    "correlation_id": "c24b172b-1c0c-4a5b-b34e-d0ef51d229dd"
  }
}
```

Error — **409**, city change during review ([captured](../postman-collection.json) (Postman: `provider.work-area.update` › "409 City cannot change during review")):

```json
{
  "status": "fail",
  "message": "City cannot be changed while your account is under review.",
  "data": {
    "error_code": "INVALID_STATE_TRANSITION",
    "correlation_id": "065775cc-799e-48f8-98c9-d234f6f56f79"
  }
}
```

Error — **422**, district of another city ([captured](../postman-collection.json) (Postman: `provider.work-area.update` › "422 District belongs to another city")). `data.context` appears only in debug environments:

```json
{
  "status": "fail",
  "message": "The selected district does not belong to this city.",
  "data": {
    "error_code": "BUSINESS_RULE_VIOLATED",
    "context": {
      "violations": [
        "The selected district does not belong to this city."
      ]
    },
    "correlation_id": "3b08509b-1bcf-4dbb-8e2c-ea17caac463c"
  }
}
```

Error — **422**, inactive district ([captured](../postman-collection.json) (Postman: `provider.work-area.update` › "422 Inactive district")); an inactive city gives the same code:

```json
{
  "status": "fail",
  "message": "Service is unavailable in this city or district. Please choose another service area.",
  "data": {
    "error_code": "SERVICE_AREA_UNAVAILABLE",
    "correlation_id": "cd07e0e5-2c83-4927-b119-a7050a998e01"
  }
}
```

Other captured errors: [districts before a city](../postman-collection.json) (Postman: `provider.work-area.update` › "422 Districts sent before choosing a city") (422 `BUSINESS_RULE_VIOLATED`), [base latitude without longitude](../postman-collection.json) (Postman: `provider.work-area.update` › "422 Base latitude sent without longitude") and [zero radius](../postman-collection.json) (Postman: `provider.work-area.update` › "422 Zero service radius") (422 validation, `data.errors`), [coverage change after approval](../postman-collection.json) (Postman: `provider.work-area.update` › "409 Changing approved coverage needs review") (409).

### 3.10 Save profile photo

`PUT /api/v1/provider/profile/photo`

Auth: Bearer JWT, `app=provider`, active user; any account status. Always a direct edit (TD-72/76). Responses are private/no-store.

| Field | Validation | Behavior |
|-------|------------|----------|
| photo | required key; null or string max 255 | Hash of a fresh image you uploaded with `POST /api/v1/media` (jpg/jpeg/png/webp, max 2048 KiB, users.image). `null` removes the photo. |

Same rule as the `photo` key of `PUT /profile` (§3.2), which keeps working. `has_photo` tells whether a real photo is saved, because `photo` returns a placeholder URL when empty. The `photo` item leaves `missing_for_bookability` after a save.

Success — **200** ([captured](../postman-collection.json) (Postman: `provider.photo.update` › "200 Provider sets her profile photo")):

```json
{
  "status": "success",
  "message": "",
  "data": {
    "photo": "http://localhost/storage/images/6b9940b6-b305-477d-9db4-e12529fc8b8a.png",
    "has_photo": true
  }
}
```

Errors: [hash uploaded by someone else](../postman-collection.json) (Postman: `provider.photo.update` › "422 Photo hash uploaded by someone else") (422 `BUSINESS_RULE_VIOLATED`; also attached, missing, non-image or oversized files), [photo key missing](../postman-collection.json) (Postman: `provider.photo.update` › "422 Photo key missing") (422 validation). Removing: [photo null](../postman-collection.json) (Postman: `provider.photo.update` › "200 Remove profile photo") returns `has_photo: false`.

### 3.11 Read payout account

`GET /api/v1/provider/profile/payout-account`

Auth: Bearer JWT, `app=provider`, active user; any account status. Responses are private/no-store.

Returns the primary account that is not inactive, or `payout_account: null`. The IBAN is always masked to the last four (TD-45); the full IBAN is never returned. `can_edit` is false when a saved account cannot be replaced now (approved, suspended or pending_review) — show "change through support" (provider--account--06-bank-account) instead of a form. Payout cycle and last payout on that screen belong to the payouts feature.

Success — **200** ([captured](../postman-collection.json) (Postman: `provider.payout-account.show` › "200 Saved payout account with masked IBAN"); [none yet](../postman-collection.json) (Postman: `provider.payout-account.show` › "200 No payout account yet") returns `{"payout_account": null, "can_edit": true}`):

```json
{
  "status": "success",
  "message": "",
  "data": {
    "payout_account": {
      "id": "01a16320-b260-70db-9df8-de2275619383",
      "holder_name": "Noura",
      "bank_name": "Al Rajhi Bank",
      "iban": "****7519",
      "status": {
        "value": "pending",
        "label": "Pending"
      },
      "created_at": "2026-10-22T10:20:49+00:00"
    },
    "can_edit": false
  }
}
```

### 3.12 Add payout account

`POST /api/v1/provider/profile/payout-account`

Auth: Bearer JWT, `app=provider`, active user; any account status. Responses are private/no-store.

| Field | Validation | Behavior |
|-------|------------|----------|
| holder_name | required string max 150 | Account holder name. |
| bank_name | required string max 100 | Bank name. |
| iban | required, regex `^SA[0-9A-Z]{22}$` | Capital letters, no spaces (schema ck_pa_iban). No checksum check; the admin verifies the account. |

Creates a primary account with status `pending`. The first account is always direct (TD-76). A `rejected` account (an admin refused it with a transfer request, DEC-11) is always replaceable. Replacing any other existing one is direct only in incomplete / needs_changes / rejected — the old row becomes `inactive` and stays as history. During pending_review → 409 `INVALID_STATE_TRANSITION`; approved / suspended → 409 `PROFILE_CHANGE_REVIEW_REQUIRED`. The `payout_account` item leaves `missing_for_bookability` after the first save.

Success — **201**, approved provider, first account ([captured](../postman-collection.json) (Postman: `provider.payout-account.store` › "201 Approved provider adds her first payout account")):

```json
{
  "status": "success",
  "message": "",
  "data": {
    "payout_account": {
      "id": "01a16320-b260-70db-9df8-de2275619383",
      "holder_name": "Noura",
      "bank_name": "Al Rajhi Bank",
      "iban": "****7519",
      "status": {
        "value": "pending",
        "label": "Pending"
      },
      "created_at": "2026-10-22T10:20:49+00:00"
    },
    "can_edit": false
  }
}
```

Error — **409**, replacing after approval ([captured](../postman-collection.json) (Postman: `provider.payout-account.store` › "409 Replacing the approved payout account needs review")):

```json
{
  "status": "fail",
  "message": "Changing Payout account after approval needs an admin-reviewed change request. Please send a change request from your account.",
  "data": {
    "error_code": "PROFILE_CHANGE_REVIEW_REQUIRED",
    "correlation_id": "be804f17-4469-4c4f-828b-7c26f1d2135a"
  }
}
```

Error — **422**, bad IBAN ([captured](../postman-collection.json) (Postman: `provider.payout-account.store` › "422 IBAN is not SA plus 22 characters")):

```json
{
  "status": "fail",
  "message": "The IBAN field format is invalid.",
  "data": {
    "errors": {
      "iban": [
        "The IBAN field format is invalid."
      ]
    }
  }
}
```

Other captured responses: [replacement before approval](../postman-collection.json) (Postman: `provider.payout-account.store` › "201 Replace payout account before approval") (201), [change during review](../postman-collection.json) (Postman: `provider.payout-account.store` › "409 Payout account cannot change during review") (409), [missing fields](../postman-collection.json) (Postman: `provider.payout-account.store` › "422 Missing bank details") (422).

### 3.13 Change the mobile number (OTP to the new number)

SCR-26 shows the provider editing her phone directly; the owner chose direct editing **with OTP verification of the new number** (TD-91) for the phone only. Any account status may use it (the number is her sign-in identity); the endpoints are outside the approved-only group. Same flow, limits and errors as the customer app ([client/profile.md §3.3–3.5](../client/profile.md)): the challenge works only in the provider app and only for the account that asked.

| Step | Endpoint | Body | Result |
|------|----------|------|--------|
| 1 | `POST /profile/phone` | `phone_code` (`+966`), `phone` (9 digits starting with 5; `05…` is normalized) | `200` `{verification_token, expires_at, resend_available_at}`; nothing saved; a new request replaces the previous challenge |
| 2 | `POST /profile/phone/verify` | `verification_token` (uuid), `code` | `200` with the account (same body as `GET /auth/me`) and the new number |
| — | `POST /profile/phone/resend-code` | `verification_token` | `200` same token; once per 60 s |

Postman: `provider.profile.phone.send` › "200 OTP sent to the new number" · "409 Number belongs to another account" · "422 Same number" · "422 Invalid number" · "429 Asked again too soon"; `provider.profile.phone.verify` › "200 Mobile number changed" · "422 Code is not a number" · "429 Too many verify requests"; `provider.profile.phone.resend` › "200 Code sent again" · "422 Challenge token is not a uuid" · "429 Too many codes for this number".

```json
{
  "status": "success",
  "message": "Verification code sent to the new number.",
  "data": {
    "verification_token": "01a1722d-1a26-71b4-86ba-baa51729f29a",
    "expires_at": "2026-10-25T08:38:40+00:00",
    "resend_available_at": "2026-10-25T08:29:40+00:00"
  }
}
```

```json
{
  "status": "success",
  "message": "Your mobile number was changed.",
  "data": {
    "user": { "id": "01a1722d-1a1a-718d-bc71-076ce2fd5c12", "full_name": "Telly Johnston", "phone_code": "+966", "phone": "522222222", "email": "jaquan.keeling@example.net", "image": "https://ui-avatars.com/api/?name=U&background=dbeafe&color=1d4ed8&size=200", "locale": "ar" },
    "profile": {
      "id": "01a1722d-1a1a-718d-bc71-076ce2b6958d",
      "provider_type": { "value": "individual", "label": "Individual" },
      "display_name": "Miss Beulah Beatty IV",
      "account_status": { "value": "approved", "label": "Approved" },
      "status_reason": null,
      "is_accepting_bookings": false,
      "instant_enabled": false,
      "is_subscribed": false,
      "subscription_plan": null
    }
  }
}
```

| HTTP | `error_code` | When | What the app should do |
|------|--------------|------|------------------------|
| 409 | `PHONE_IN_USE` | The number belongs to another account (at step 1, or taken while the code was pending) | Ask for another number; nothing saved |
| 422 | `VALIDATION_FAILED` | It is already her number | Ask for a different one |
| 422 | — | Bad calling code/number, bad token or code format → `data.errors` | Mark the field |
| 422 | `INVALID_OTP` | Wrong code (5 attempts, then 429) | Let her retry |
| 422 | `OTP_EXPIRED` | Unknown, expired, used, or another account's / another app's token | Start again from step 1 |
| 429 | `RATE_LIMITED` / `OTP_TOO_MANY_ATTEMPTS` | Asked again inside 60 s, or a limiter tripped | Wait for `resend_available_at` |

While TD-24 applies the code is the fixed `1234`; the SMS gateway (DEC-15) is a launch blocker. The phone is one identity for every profile of the person (TD-01): if she is also a customer, her customer sign-in number changes too.

## 4. Statuses and enums

| Enum / flag | Value | Meaning / allowed action |
|-------------|-------|--------------------------|
| default_transport_mode | ask | Ask at each booking. |
| default_transport_mode | joshen_driver | Prefer a Joshen driver. |
| default_transport_mode | self | Prefer travelling herself. |
| can_add_photo | true / false | Show/hide Add photo; server still enforces live count and upload rules. |
| locked_fields | list of `city_id`, `district_ids`, `base_lat`, `base_lng`, `service_radius_km` | Saved work-area keys whose change returns 409 now. Empty before approval except `city_id` during pending_review. |
| payout_account.status | pending | Saved, waiting for admin verification. New accounts always start here. |
| payout_account.status | verified | Verified by an admin (payouts feature). |
| payout_account.status | rejected | Refused by an admin (payouts feature). |
| payout_account.status | inactive | Replaced; never returned by GET. |
| can_edit | true / false | Whether `POST /profile/payout-account` is accepted now. |
| has_photo | true / false | Whether a real photo is saved (`photo` is a placeholder otherwise). |

All normal provider account statuses can reach these scoped account endpoints while the user remains active. They do not approve the provider or grant operational access.

## Data model

| Table | Read / write | Notes |
|-------|--------------|-------|
| providers | read/write + lock | bio, professional_title, default_transport_mode; id exposed as UUID. |
| users | read/write + lock for image changes | Existing image media collection; no identity/name/phone updates here. |
| media | read/write | providers.portfolio and users.image; existing polymorphic collection mechanism, hashes + persisted sort order. Deletes are soft deletes; storage cleanup is separate. |
| platform_settings | read | provider_portfolio_max_photos JSON integer; no media count constant. |
| providers | read/write + lock | city_id, base_lat, base_lng, service_radius_km (1.1.0). |
| provider_districts | read/replace | Covered districts; replaced as a set when it changes. |
| governorates / regions | read (shared lock) | BRD city / district; is_active and membership checked. |
| payout_accounts | read/write | Primary non-inactive row per role; replacement marks the old row inactive. IBAN stored in full, returned masked. |

## 5. Dart models

```dart
typedef Json = Map<String, dynamic>;
class PortfolioPhoto {
  final String hash, url;
  final int sortOrder;
  PortfolioPhoto.fromJson(Json j)
      : hash = j['hash'] as String, url = j['url'] as String,
        sortOrder = j['sort_order'] as int;
}
class Portfolio {
  final List<PortfolioPhoto> photos;
  final int maxPhotos;
  final bool canAddPhoto;
  Portfolio.fromJson(Json j)
      : photos = (j['portfolio'] as List).map((e) => PortfolioPhoto.fromJson(e as Json)).toList(),
        maxPhotos = j['portfolio_max_photos'] as int,
        canAddPhoto = j['can_add_photo'] as bool;
}
class AreaRef {
  final String id;
  final String? name;
  AreaRef.fromJson(Json j) : id = j['id'] as String, name = j['name'] as String?;
}
class WorkArea {
  final AreaRef? city;
  final List<AreaRef> districts;
  final double? baseLat, baseLng;
  final int serviceRadiusKm;
  final List<String> lockedFields;
  WorkArea.fromJson(Json j)
      : city = j['city'] == null ? null : AreaRef.fromJson(j['city'] as Json),
        districts = (j['districts'] as List).map((e) => AreaRef.fromJson(e as Json)).toList(),
        baseLat = (j['base_lat'] as num?)?.toDouble(), baseLng = (j['base_lng'] as num?)?.toDouble(),
        serviceRadiusKm = j['service_radius_km'] as int,
        lockedFields = (j['locked_fields'] as List).cast<String>();
}
class PayoutAccount {
  final String id, holderName, bankName, iban; // iban is masked, e.g. ****7519
  final Json status;
  final String createdAt;
  PayoutAccount.fromJson(Json j)
      : id = j['id'] as String, holderName = j['holder_name'] as String, bankName = j['bank_name'] as String,
        iban = j['iban'] as String, status = j['status'] as Json, createdAt = j['created_at'] as String;
}
class PayoutAccountState {
  final PayoutAccount? account;
  final bool canEdit;
  PayoutAccountState.fromJson(Json j)
      : account = j['payout_account'] == null ? null : PayoutAccount.fromJson(j['payout_account'] as Json),
        canEdit = j['can_edit'] as bool;
}
class DirectProfile {
  final String id, photo;
  final String? bio, professionalTitle;
  final Json transport;
  DirectProfile.fromJson(Json j)
      : id = j['id'] as String, photo = j['photo'] as String,
        bio = j['bio'] as String?, professionalTitle = j['professional_title'] as String?,
        transport = j['default_transport_mode'] as Json;
}
```

## 6. Business rules enforced by the backend

| Rule | Backend behavior | User effect |
|------|------------------|-------------|
| PROV-008 / SCR-18 / TD-72 | Direct account completion before approval. | No review wait for these scoped edits. |
| TD-63/72 | Shared fresh upload/uploader/storage checks, configured MIME/size. | Cannot steal attached photos or another user's upload. |
| TD-73 | Count + incoming under provider lock, JSON setting absent default 12. | Concurrent adds cannot exceed configured count; lowered limits retain content. |
| TD-72 | Reorder exact owned set; delete owned collection only. | No omission, cross-provider move or resurrection. |
| SCR-26 / TD-91 | The mobile number changes directly, but only after an OTP to the new number is verified; another account's number is refused. | Number unchanged until `verify` succeeds. |
| BR-04 / TD-44 / DEC-25 | Reviewed fields not writable here. | Remaining edit controls stay pending (§13). |
| TD-76 | First set of city/coverage/payout is direct in any status; changing a saved value while approved/suspended → 409 `PROFILE_CHANGE_REVIEW_REQUIRED`, nothing written. | Approved data stays effective; the provider contacts support. |
| TD-62 / TD-76 | pending_review refuses city and payout changes (409 `INVALID_STATE_TRANSITION`); coverage changes stay direct. | Wait for the review result. |
| BR-08 / TD-69 / TD-77 | Districts belong to the resulting city; city and districts enabled. | 422 with a clear message; nothing saved. |
| TD-45 | IBAN masked to the last four in every response. | The app never receives the full IBAN. |

## 7. Notifications and real-time events

N/A. No review event, push or external message is sent for direct photo/bio/portfolio edits.

## 8. Localization and UI notes

Portfolio means صور الأعمال السابقة, not legal/onboarding documents. Use backend transport enum labels and localized errors. Biography/title are provider-authored text without translation rows. Preserve explicit sort_order and hashes independently of URLs. Show empty-state Add photo, upload/save failures and lowered-limit state separately.

## 9. Integration checklist

- [ ] Prefill from GET profile and GET portfolio after app restart.
- [ ] Upload first; send fresh hashes, not URLs.
- [ ] Apply existing 2 MiB profile image / 5 MiB portfolio limits.
- [ ] Send only scoped profile fields; omission preserves and null clears nullable fields.
- [ ] Hide Add photo when can_add_photo=false; retain delete/reorder above a lowered limit.
- [ ] Send a complete unique hash/position list when reordering.
- [ ] Reload after uncertain append/delete outcomes and handle §3.7 errors.
- [ ] Verify Arabic/English states and pending account completion.
- [ ] Coverage form: send `district_ids` with any city change; send base_lat/base_lng together; radius 1–32767.
- [ ] Disable inputs in `locked_fields`; hide the bank form when `can_edit` is false.
- [ ] Handle 409 `PROFILE_CHANGE_REVIEW_REQUIRED` (support) and 409 `INVALID_STATE_TRANSITION` (under review).
- [ ] Never store or show the full IBAN after saving; show the masked value from the response.
- [ ] Refresh `GET /onboarding` after each save so the checklist drops completed items (working hours stay with availability).

## 10. Acceptance scenarios

| AC ID | Scenario | Expected |
|-------|----------|----------|
| AC-PROV-008 | Pending provider photo/bio/title/transport save and reload. | Direct persistence, no review request; unrelated fields unchanged. |
| AC-PROV-008 | Shared uploader → append → list → reorder → delete. | Own hashes/URLs/order persist; onboarding portfolio item follows live photos. |
| AC-PROV-008 | Default limit12; one over; setting lowered below count. | Extra add rejected; existing photos remain, delete/reorder still work. |
| AC-PROV-008 | Attached/foreign/missing/PDF/SVG/oversized uploads. | 422 typed Domain failure, no partial profile/portfolio mutation. |
| AC-PROV-008 | Foreign/deleted hash and incomplete reorder set. | 404 deletion /422 set rejection, no foreign mutation. |
| AC-PROV-008 | Approved provider sets city, coverage, photo and payout for the first time. | 200/201; checklist items city/coverage/photo/payout_account clear; working_hours stays. |
| AC-PROV-008 | Approved/suspended provider changes saved city, coverage or payout. | 409 `PROFILE_CHANGE_REVIEW_REQUIRED`; approved data unchanged, no change-request row; re-sending saved values is 200. |
| AC-PROV-008 | pending_review: city/payout change vs coverage change vs first-time set. | 409 `INVALID_STATE_TRANSITION` / 200 / 200-201. |
| AC-PROV-008 | District of another city, inactive city/district, districts without city. | 422 `BUSINESS_RULE_VIOLATED` / `SERVICE_AREA_UNAVAILABLE`; nothing saved. |
| AC-PROV-008 | Bad IBAN; masking; foreign photo hash; customer/driver token. | 422; `****` + last four only; 422; 403. |
| AC-PROV-008 / SCR-26 | Mobile number changes only after the new number's OTP is verified, in every account status. | `PhoneChangeTest::test_ac_prov_008_mobile_changes_only_after_the_new_number_is_verified` (5 statuses) |
| AC-PROV-008 / SCR-26 | Another account's / the same number refused; number taken while pending; challenge bound to its app and account; resend, throttles, token checks. | the other `PhoneChangeTest` cases |

## 11. Open decisions and known limitations

DEC-25 is resolved (TD-184). A reviewed change to city, coverage, payout, identity or a document is requested through [profile-change-requests.md](profile-change-requests.md); the direct edits here still return 409 after approval. Direct profile/portfolio completion does not imply review approval, availability or customer booking eligibility. Configuring platform_settings is separate from the legacy admin/settings app_configs store; see [settings.md](../admin/settings.md). No schema migration was needed.

## 12. Verification evidence

**1.1.0:** local MySQL `joshen_testing` full suite **793 tests / 7,048 assertions passed**. `tests/Feature/Api/V1/Provider/AccountCompletionTest.php` (8 AC-PROV-008 tests) covers first set while approved, change after approval 409, pending-review freeze, payout history, district of another city, inactive areas, bad IBAN, masking, foreign hash and other-app tokens. `postman:generate --check` green for 184 routes; the 5 new provider routes are complete, the 454-gap baseline unchanged. Flutter acceptance not tested.

**1.0.0:** Local MySQL `joshen_testing`: full suite **778 tests / 6,837 assertions passed**; affected provider pricing/profile and provider/driver onboarding capture run **120 tests / 1,161 assertions passed**. `postman:generate --check` is green for 172 exported routes; all 13 newly introduced endpoints are complete, none added to the 454 pre-existing baseline gaps. Pint and changed-document links/diff checks passed. Production rollout and Flutter acceptance were not tested.

`tests/Feature/Api/V1/Provider/ProfileTest.php` contains AC-named MySQL tests, actual image bytes/shared upload lifecycle, persistent response/database assertions and captured success/error responses in `docs/postman-collection.json` (saved responses of `provider.profile.*`) and `provider.portfolio.*`. TD-56 generation/check and affected onboarding regressions are reported in the final execution summary. No production or Flutter journey acceptance is claimed.

## 13. Pending PROV-008 scope

| Remaining requirement | Boundary |
|-----------------------|----------|
| Provider type; trading/provider name; account identity | Trading/provider name, authorized person and CR number: reviewed change request ([profile-change-requests.md](profile-change-requests.md)). Provider type and the national id number never change (owner decision, TD-184). Direct mutation is not offered here. Name direct-versus-reviewed changes remain DEC-25. (The mobile number now changes by OTP, §3.13.) No password flow: app auth is phone OTP under TD-01/DEC-15. |
| City, neighborhoods/coverage/base/radius | First set done in 1.1.0 (TD-76). Changing saved values after approval: reviewed change request ([profile-change-requests.md](profile-change-requests.md)); the direct edit here still returns 409. |
| National ID/CR, authorized-person details, document replacement after approval | Reviewed change request for authorized-person details, the CR number and document replacement ([profile-change-requests.md](profile-change-requests.md)); the national id number never changes. Onboarding documents remain their separate draft/submission flow. |
| Bank/payout details and primary-account changes | First account done in 1.1.0. Replacement after approval: reviewed change request ([profile-change-requests.md](profile-change-requests.md)). Admin verification, payout cycle and last payout belong to the payouts feature. |
| Full reviewed profile-change submission/review/apply | Built as typed requests with a field matrix, not a generic JSON apply (TD-184). |
| Featured subscription state, plan and start/end dates; purchasing/renewal | Existing auth mini-summary remains; full subscription/profile presentation and subscription lifecycle belong to their owning feature. |
| Full profile overview and all other account screens | Only the two requested edit-profile screenshots were inspected. Full PROV-008 acceptance, especially reviewed coverage/payout changes, remains pending. |
