---
feature: Package management
slug: packages
surface: provider
consumer: flutter
version: 1.0.1
status: ready
brd_version: "1.3"
brd_ids: [PROV-011]
business_rules: [BR-03, BR-07, BR-24]
decisions: [D1, TD-19, TD-87, TD-88, SCR-01, SCR-02, SCR-32]
schema_tables: [packages, package_items, package_occasions, occasions, provider_services, commission_rules, media]
screens: ["provider--packages--*"]
related_docs: [services-pricing.md, ../admin/packages.md, ../client/catalog.md]
last_updated: 2026-11-04
---

# Package management — Flutter Integration Guide (provider)

> **Base URL:** `{HOST}/api/v1/provider` · 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.1 | 2026-11-04 | PATCH | §11: package buying is built (CUST-008); the stale "not built" note is replaced. | None. |
| 1.0.0 | 2026-10-25 | Initial | Create, edit, submit for Joshen review, show/hide and archive prepaid multi-session packages; form options with the commission preview. | Integrate the eight endpoints. |

## Changelog

### 1.0.1 — 2026-11-04

- **Docs only:** §11 now says package buying is built (customers pay by card, mada or wallet; sessions are booked one by one). No contract change.

### 1.0.0 — 2026-10-25
- **Added:** `GET /packages`, `GET /packages/options`, `POST /packages`, `GET|PUT /packages/{id}`, `POST /packages/{id}/submit`, `PATCH /packages/{id}/visibility`, `POST /packages/{id}/archive`; in-app notice `package_reviewed`.
- **Changed / Fixed / Removed / Deprecated:** —
- **Breaking:** No.
- **Endpoints affected:** the eight above (new).
- **App action required:** build the four-step create flow, the list with status badges and the visibility switch; drive every button from the `can_*` flags.
- **BRD / decisions:** PROV-011, D1, BR-07, TD-87, TD-88.

---

## 1. Business summary

A package is a **prepaid multi-session bundle** (D1): the customer pays the fixed
package price once, then redeems the sessions as separate bookings within the
validity period (30, 45, 60 or 90 days). The provider builds it from services she
has **enabled and fully priced** (BR-07); `compare_at_price` is the total of her own
service prices × sessions, and the package price may not exceed it, so the customer
always sees a discount or an equal price. Every new package and every edit to an
approved one is **reviewed by Joshen** before customers see it (D1, SCR-32): she
can only *submit*; Joshen approves or rejects with a reason. After approval she can
switch the package on or off for customers; Joshen can also hide it, which she
cannot undo. Only an **approved** provider account uses these endpoints (BR-03).

## 2. User flow and screens

| Step | Screen / widget | Endpoint | Notes |
|------|-----------------|----------|-------|
| 1 | My packages (`provider--packages--01-*`) | `GET /packages` | Status badge, price, net, visibility switch, "preview as customer". Archived hidden unless `filters[status]=archived`. |
| 2 | Create — basics (`…02-create-step1-basics`) | `GET /packages/options` | Name, description, category chips (`categories`), occasion chips (`occasions`). |
| 3 | Create — services (`…03-create-step2-services`) | options `services` | At least **two sessions in total** (two services, or one service for several sessions, TD-88). |
| 4 | Create — price (`…04-create-step3-price`) | options `validity_days`, `commission` | Shows total of prices, discount %, commission and net. Validity includes **45** days (SCR-02). |
| 5 | Create — publish (`…05-create-step4-publish`) | `POST /packages`, then `POST /packages/{id}/submit` | Cover image optional. Save as draft first, submit when ready. |
| 6 | Package details / edit | `GET|PUT /packages/{id}` | A rejected package shows `review_note`. |
| 7 | Visibility switch | `PATCH /packages/{id}/visibility` | Approved packages only. |

```
draft ──submit──▶ pending_review ──Joshen approves──▶ approved ──edit──▶ pending_review
  ▲                    │                                 │
  └──edit── rejected ◀─┘ (Joshen rejects, with a reason)  └─ visibility on/off (hidden_by_admin is Joshen's)
any status except archived ──archive──▶ archived (final)
```

## 3. Endpoints

All: `Authorization: Bearer <provider token>`, **approved account required** (403 otherwise),
`Accept-Language: ar|en`. Responses are `Cache-Control: no-store, private`.

### 3.1 List my packages — `GET /packages`

| Name | In | Type | Required | Rules | Description |
|------|----|------|----------|-------|-------------|
| `page`, `per_page` | query | int | no | `per_page` 1–50, default 15 | |
| `filters[status]` | query | string | no | `draft`, `pending_review`, `approved`, `rejected`, `archived` | Without it, archived packages are left out. |

**Success — `200`** (list key `packages`, standard pagination `links` / `meta`). Postman: `provider.packages.index` › "200 My packages".

```json
{
  "status": "success",
  "message": "Data retrieved successfully",
  "data": {
    "packages": [
      {
        "id": "01a171fc-8d46-7184-95a9-b55ada86512a",
        "name": "Sample-dkyw85214385",
        "image": "https://ui-avatars.com/api/?name=N&background=f3f4f6&color=9ca3af&size=200",
        "category": { "id": "01a171fc-8d30-72f3-9089-9df83b1afe6d", "name": "Sample-rehf75103221" },
        "status": { "value": "approved", "label": "Published" },
        "is_visible": true,
        "hidden_by_admin": false,
        "price": "300.00",
        "compare_at_price": "400.00",
        "discount_percent": 25,
        "net_amount": null,
        "validity_days": 30,
        "services_count": 2,
        "sessions_count": 2,
        "review_note": null,
        "can_edit": true,
        "can_submit": false,
        "can_toggle_visibility": true,
        "can_archive": true,
        "submitted_at": null,
        "created_at": "2026-10-25T07:35:39+00:00"
      }
    ],
    "links": { "first": "…", "last": "…", "prev": null, "next": null },
    "meta": { "current_page": 1, "per_page": 15, "total": 2 }
  }
}
```

| Field | Type | Nullable | Description |
|-------|------|----------|-------------|
| `status` | EnumValue | no | §4. Branch on `value`; `label` is localized. |
| `price`, `compare_at_price`, `net_amount` | money string | `compare_at_price` and `net_amount` yes | SAR, 2 decimals. `net_amount` is a preview after Joshen's commission (BR-24); `null` until a package commission rule exists. |
| `discount_percent` | int | yes | Whole-number discount against `compare_at_price`; `null` when equal. |
| `review_note` | string | yes | Joshen's reason, only while `rejected`. |
| `hidden_by_admin` | bool | no | Joshen hid it; customers do not see it whatever `is_visible` says. |
| `can_edit`, `can_submit`, `can_toggle_visibility`, `can_archive` | bool | no | Show each button only when `true`. |

Errors: `422` unknown status → `data.errors["filters.status"]`.

### 3.2 Form options — `GET /packages/options`

Everything the create form needs in one call. Postman: `provider.packages.options` › "200 Package form options".

```json
{
  "status": "success",
  "message": "Data retrieved successfully",
  "data": {
    "validity_days": [30, 45, 60, 90],
    "categories": [{ "id": "01a171fc-8a6a-71ef-9d6e-c49f97359667", "name": "Sample-yxgm05982508" }],
    "occasions": [{ "id": "01a171fc-8a76-7372-b9e2-62534f449a0a", "name": "Sample-puou24726484" }],
    "services": [
      { "id": "01a171fc-8a6e-718f-9643-bb9c0b44f58e", "name": "Makeup", "category": { "id": "01a171fc-8a6a-71ef-9d6e-c49f97359667", "name": "Sample-yxgm05982508" }, "price": "150.00", "duration_min": 90 },
      { "id": "01a171fc-8a72-703a-a099-af3242cf911d", "name": "Hair", "category": { "id": "01a171fc-8a6a-71ef-9d6e-c49f97359667", "name": "Sample-yxgm05982508" }, "price": "250.00", "duration_min": 60 }
    ],
    "commission": { "calc_type": "percent", "value": "20.00", "commission_amount": "20.00", "net_amount": "80.00" }
  }
}
```

`categories`: her approved, enabled categories. `services`: bookable services she can put in a package (active, in those categories, enabled and priced on the base and every active choice); `price` is *her* price. `commission`: what Joshen keeps from a 100.00 package (`null` when no rule is set), so the app can show commission and net while she types.

### 3.3 Create — `POST /packages`

Creates a **draft**. Returns **201** with the package details.

| Field | Type | Required | Validation (mirror on the client) | Description |
|-------|------|----------|-----------------------------------|-------------|
| `name` | string | yes | max 150 | Title (her own text, not translated). |
| `description` | string | no | nullable, max 2000 | |
| `category_id` | uuid | yes | one of her approved, enabled categories | Main category shown on the card. |
| `occasion_ids` | uuid[] | no | ≤ 20, distinct, active occasions | Replaces the list. |
| `items` | object[] | yes | 1–20 | Included services; total sessions ≥ 2. |
| `items[].service_id` | uuid | yes | distinct; enabled and fully priced by her | |
| `items[].sessions` | int | yes | 1–20 | Prepaid sessions of that service. |
| `price` | decimal | yes | > 0, 2 decimals, ≤ total of her prices × sessions | Fixed package price, SAR. |
| `validity_days` | int | yes | 30, 45, 60 or 90 | |
| `image` | string / null | no | media hash from `POST /api/v1/media`, jpg/jpeg/png/webp ≤ 5 MiB, uploaded by her, not used elsewhere | Cover. |

```json
{
  "name": "Bridal package",
  "description": "Bridal day",
  "category_id": "01a171fc-8902-736c-a1f5-9b22e2499531",
  "occasion_ids": ["01a171fc-8912-7015-955f-8cf50edf0bcb"],
  "items": [
    { "service_id": "01a171fc-8909-7223-855c-1e1197bb338f", "sessions": 1 },
    { "service_id": "01a171fc-890c-736c-b2ab-2c29da16bbb0", "sessions": 2 }
  ],
  "price": "500.00",
  "validity_days": 45
}
```

**Success — `201`.** Postman: `provider.packages.store` › "201 Draft created from two enabled services".

```json
{
  "status": "success",
  "message": "Data saved successfully",
  "data": {
    "id": "01a171fc-8943-708b-9220-90163d7b7d4e",
    "name": "Bridal package",
    "image": "https://ui-avatars.com/api/?name=N&background=f3f4f6&color=9ca3af&size=200",
    "category": { "id": "01a171fc-8902-736c-a1f5-9b22e2499531", "name": "Sample-msqq97998852" },
    "status": { "value": "draft", "label": "Draft" },
    "is_visible": true,
    "hidden_by_admin": false,
    "price": "500.00",
    "compare_at_price": "650.00",
    "discount_percent": 23,
    "net_amount": null,
    "validity_days": 45,
    "services_count": 2,
    "sessions_count": 3,
    "review_note": null,
    "can_edit": true,
    "can_submit": true,
    "can_toggle_visibility": false,
    "can_archive": true,
    "submitted_at": null,
    "created_at": "2026-10-25T07:35:38+00:00",
    "description": "Bridal day",
    "services": [
      { "id": "01a171fc-8946-7020-aedc-813d181fb5ba", "service": { "id": "01a171fc-8909-7223-855c-1e1197bb338f", "name": "Makeup" }, "sessions": 1, "duration_min": 90 },
      { "id": "01a171fc-8946-7020-aedc-813d18ea2efc", "service": { "id": "01a171fc-890c-736c-b2ab-2c29da16bbb0", "name": "Hair" }, "sessions": 2, "duration_min": 60 }
    ],
    "occasions": [{ "id": "01a171fc-8912-7015-955f-8cf50edf0bcb", "name": "Sample-thye59087659" }],
    "commission": null
  }
}
```

Details add to the list fields: `description`, `services[]` (`id` of the package line, `service`, `sessions`, `duration_min` snapshot of her duration), `occasions[]`, `commission` (`{calc_type, value, commission_amount, net_amount}` or `null`).

**Errors**

| HTTP | `error_code` | When | What the app should do |
|------|--------------|------|------------------------|
| 422 | — | Shape: missing name/items, `sessions` 0, `validity_days` 40… → `data.errors` per field | Mark the fields |
| 422 | `PACKAGE_SERVICES_UNAVAILABLE` | A service is not enabled and fully priced for her; `data.context.services` lists their uuids | Name them, link to services pricing |
| 422 | `BUSINESS_RULE_VIOLATED` | Category not hers; fewer than two sessions; price above the total of her prices; cover image not hers, already used or wrong type | Show `message` |
| 403 | `PERMISSION_DENIED` | Account not approved, or a token of another app | Show review status only |

Postman: `provider.packages.store` › "422 Invalid package form" · "422 Service not enabled for her" · "422 Category she has not been approved for" · "422 Content refused: price above her services" · "422 Content refused: single session".

```json
{
  "status": "fail",
  "message": "These services are not enabled and fully priced for you: Henna.",
  "data": { "error_code": "PACKAGE_SERVICES_UNAVAILABLE", "context": { "services": ["01a171fc-8adb-710d-893d-1248e5f7a792"] }, "correlation_id": "990ae5ba-cf62-4f97-97e5-e661a3fdadfa" }
}
```

```json
{
  "status": "fail",
  "message": "The package price cannot be higher than the total of your service prices (650.00 SAR).",
  "data": { "error_code": "BUSINESS_RULE_VIOLATED", "correlation_id": "0a616d46-5a2b-4fa3-9fab-7947ae76979a" }
}
```

### 3.4 Details — `GET /packages/{id}`

`id` = package uuid; someone else's package or an integer id is **404**. Same payload as §3.3. Postman: `provider.packages.show` › "200 Package details with commission preview" (`net_amount` "552.00" from a 20% rule on 690.00).

### 3.5 Edit — `PUT /packages/{id}`

Any subset of the §3.3 fields; `items` and `occasion_ids` **replace** the stored lists, omitted fields keep their value, the whole result is re-validated (compare price, sessions, services). Allowed while `draft`, `rejected` or `approved` (`can_edit`).

- A `rejected` package stays `rejected` until she submits it again.
- **Editing an `approved` package sends it back to `pending_review`** (D1): it stops being shown until Joshen approves the new content.
- `pending_review` and `archived` packages answer **409 `INVALID_STATE_TRANSITION`**.

Postman: `provider.packages.update` › "200 Approved package edited goes back to review" · "409 Edit refused while pending review" · "422 Invalid package edit".

```json
{
  "status": "fail",
  "message": "You cannot edit a package that is Pending review.",
  "data": { "error_code": "INVALID_STATE_TRANSITION", "correlation_id": "ac5ba6ef-956f-46b5-a303-6df76f11be2f" }
}
```

### 3.6 Submit — `POST /packages/{id}/submit`

No body. `draft` or `rejected` → `pending_review`, sets `submitted_at`. Any other status: **409 `INVALID_STATE_TRANSITION`**. Postman: `provider.packages.submit` › "200 Submitted for Joshen review".

### 3.7 Show / hide — `PATCH /packages/{id}/visibility`

Body `{ "is_visible": false }` (boolean, required). **Approved packages only** (else 409). Joshen's hide (`hidden_by_admin`) is independent and cannot be changed here. Postman: `provider.packages.visibility` › "200 Provider hides her approved package" · "422 Visibility is not a boolean".

### 3.8 Archive — `POST /packages/{id}/archive`

No body. Retires the package for good (not editable, not visible); packages customers already bought keep working because they hold their own snapshot (D1). Already archived: 409. Postman: `provider.packages.archive` › "200 Package archived".

## 4. Statuses and enums

| Enum | `value` | Label (en) | Meaning | `can_*` that are true |
|------|---------|-----------|---------|----------------------|
| `status` | `draft` | Draft | Being prepared; not visible | edit, submit, archive |
| | `pending_review` | Pending review | Waiting for Joshen; locked | archive |
| | `approved` | Published | Approved; customers see it while `is_visible` and not `hidden_by_admin` | edit (→ review again), toggle visibility, archive |
| | `rejected` | Rejected | Joshen refused; `review_note` says why | edit, submit, archive |
| | `archived` | Archived | Retired, final | — |

Validity values: 30, 45, 60, 90 days (D1). Labels come localized from the API.

## Data model

| Table (`db-schema.sql`) | Read / write | Notes |
|-------------------------|--------------|-------|
| `packages` | read + write | `compare_at_price` = Σ her price × sessions at save time; `status`, `is_visible` (hers), `hidden_by_admin` (Joshen's), `review_note`, `submitted_at` |
| `package_items` | write | One row per service: `sessions`, `duration_min` snapshot |
| `package_occasions` | write | Chosen occasions |
| `occasions`, `provider_services`, `commission_rules`, `media` | read | Options, her prices, commission preview, cover (`image`) |

## 5. Dart models

```dart
class ProviderPackage {
  final String id;            // uuid (TD-19)
  final String name;
  final String image;         // absolute URL (placeholder when none)
  final EnumValue status;     // draft | pending_review | approved | rejected | archived
  final bool isVisible, hiddenByAdmin;
  final String price;         // money string
  final String? compareAtPrice, netAmount;
  final int? discountPercent;
  final int validityDays, servicesCount, sessionsCount;
  final String? reviewNote;
  final bool canEdit, canSubmit, canToggleVisibility, canArchive;
  final DateTime? submittedAt;
  // details only: description, services[], occasions[], commission
}
```

## 6. Business rules enforced by the backend

| Rule | What the backend does | What the user sees |
|------|-----------------------|--------------------|
| BR-03 | Operational endpoints need an approved account | 403 for other statuses |
| BR-07 | A package uses only services she has enabled and fully priced, in her approved categories | 422 `PACKAGE_SERVICES_UNAVAILABLE` |
| D1 / SCR-32 | New and edited packages are reviewed by Joshen before customers see them | Status `pending_review`; `409` while locked |
| D1 | Validity 30/45/60/90 days; prepaid sessions | 422 on other values |
| TD-88 | ≥ 2 sessions in total; price > 0 and ≤ total of her service prices | 422 `BUSINESS_RULE_VIOLATED` |
| BR-24 | Commission applies to the package value; net is a preview | `net_amount` / `commission`, `null` without a rule |
| BR-08 | Customers see the package only where she is eligible (coverage, accepting bookings, active services) | Not in the provider app's control |

## 7. Notifications and real-time events

| Event | Sent when | Payload `data` keys | App action on tap |
|-------|-----------|---------------------|-------------------|
| `package_reviewed` (shared inbox, `GET /notifications`) | Joshen approves, rejects, hides or shows a package again | `package_id`, `status`, `action` (`approve`, `reject`, `hide`, `unhide`), `hidden_by_admin`, `reason` | Open the package; refresh the list |

Title and body come in her language (Arabic fallback). Push delivery follows ADMIN-020.

## 8. Localization and UI notes

- Package `name` and `description` are her own text and are shown as typed; category, service and occasion names are localized by `Accept-Language`.
- Money: strings with 2 decimals (SAR); show "was" (`compare_at_price`) crossed out next to `price`.
- Use `status.label` for badges; colors by `status.value`.

## 9. Integration checklist

- [ ] Models parse the real responses above
- [ ] Every error in §3.3 and §3.5 handled; `data.errors` mapped to form fields
- [ ] Buttons driven by `can_*`; visibility switch only when `can_toggle_visibility`
- [ ] Create flow: options → draft → (edit) → submit
- [ ] Rejected package shows `review_note`
- [ ] `package_reviewed` notice routed
- [ ] Tested in `ar` (RTL) and `en`

## 10. Acceptance scenarios

| AC ID | Scenario | Expected / test |
|-------|----------|-----------------|
| AC-PROV-011 | Create a package from enabled services at a fixed price | `test_ac_prov_011_provider_creates_a_draft_from_her_enabled_services` |
| AC-PROV-011 | Services not enabled for her cannot be included | `…services_must_be_enabled_and_fully_priced_by_her` |
| AC-PROV-011 | Price and sessions are checked; category must be hers; cover must be hers | `…price_and_sessions_are_checked`, `…category_must_be_one_of_hers`, `…foreign_cover_upload_is_refused` |
| AC-PROV-011 | Review lifecycle (submit, lock, rejected fix, approved edit → review again) | `…submit_locks_the_package…`, `…rejected_package_shows_the_reason…`, `…editing_an_approved_package_sends_it_back_to_review` |
| AC-PROV-011 | Visibility, archive, list, privacy, approval gate | the remaining `PackageTest` cases |

## 11. Open decisions and known limitations

| ID | Impact on this feature | Current behavior |
|----|------------------------|------------------|
| TD-87 | Review lifecycle | Accepted (reviewed against D1): editing an approved package re-enters review (D1); a rejected package can be fixed and resubmitted |
| TD-88 | Content rules | Accepted: ≥ 2 sessions in total (the screen asks for two services, D1 allows one service for several sessions); 1–20 sessions per service |
| ADMIN-016 | Commission values | `net_amount` / `commission` stay `null` until the admin sets a package commission rule |
| CUST-008 | Buying a package | Built: a signed-in customer buys an approved, visible package (`can_buy`) and pays it like any order, by card, mada or wallet; each session is then booked as its own order (D1, [client/my-packages.md](../client/my-packages.md)) |
