# Combined orders in the app — design + API (v2)

**For:** the mobile app developer. One file with everything needed to build the new order screens in the app the
same way the website shows them: what the screens look like, the endpoints, and the rules the server applies.

**Server side:** done (branch `feat/combined-orders-2026-10-20`); the website already uses it.
**Nothing existing changed.** Every endpoint below is new. `purchase-history`, `purchase-history-details/{id}`,
`purchase-history-items/{id}`, `order/cancel2/{id}` and the rest keep their exact responses, so current app builds
keep working. Switch to the new screens whenever you are ready.

---

## 1. What changes and why

Until now the app (and the website) listed every **seller order** as its own "order". A customer who bought from 3
sellers in one checkout saw 3 unrelated orders, each with its own page, while shipping, the coupon, the invoice
number, the online payment and the rating all belong to the **checkout**.

| Term | What it is | In the API |
|---|---|---|
| **Combined Order** (checkout, "purchase") | everything bought in one checkout: address, shipping (charged once), coupon (once), invoice number, Paymera payment, rating | one item of `purchase-history/grouped`; `number` = invoice number |
| **Child Order** (seller order) | one seller's share of the checkout: its own code, delivery status, payment status, seller | `orders[]` inside the item |
| **Single Order** | a Combined Order with exactly one Child Order (about 80% of orders) | `is_single: true` — show it like today |

---

## 2. The screens to build

### 2.1 Orders list

- **One entry per checkout**, 10 per page, newest checkout first.
- **Single Order** (`is_single: true`): one row, like today.
- **Combined Order**: one card.
  - **Header:** invoice number (`#number`), date, total (shipping counted once), checkout delivery status, checkout
    payment status, number of sellers and products.
  - **Whole-purchase actions on the header:** Invoice, Rate, Pay with Paymera, Refund (all of them act on the whole
    checkout).
  - **Under the header, one row per seller order:** code, seller name, amount, its own delivery and payment status,
    and its own actions **Edit**, **Cancel**, Details.
- Seller orders are **collapsed** by default. When a filter is on, the ones that match are **expanded and highlighted**.
  On a phone the card stacks and the seller orders become an accordion.
- Keep the "new update" marker (the old green `*`) when `unseen` is true.
- **Filters** above the list: delivery status as **tabs** (pending, confirmed, picked up, on the way, delivered,
  cancelled, returned) and payment status as a **dropdown** (paid, unpaid).

**Checkout payment status** (from the live seller orders): *Paid* = all paid, *Unpaid* = none, *Partly paid* = mixed;
an unpaid Paymera purchase reads *Payment not completed*. The API already sends the key and the label.

**Look of the website** (match it if you like): one rounded card per checkout with up to 4 product thumbnails, a
4-step progress bar (placed → confirmed → on the way → delivered; cancelled / returned shown as a red label instead),
pill-shaped status badges, and a yellow "New update" badge when `unseen`.

### 2.2 Order page (one page for the whole checkout)

Any seller order id opens the page of its whole checkout and scrolls to that seller order.

- **Shown once, at the top:** invoice number, date, shipping address, payment method, totals (subtotal, tax, **one**
  coupon line, **one** shipping line, total), Pay with Paymera / Refund, invoice download, rating card.
- **One section per seller order:** seller name, code, delivery and payment status, tracking code, cancellation or
  return info, its products (photo, name, variant, quantity, price), the customer's own edit history ("Changes you
  made"), and its **Edit** / **Cancel** buttons.
- Opening the page clears the "new update" marker for every seller order of the checkout.

### 2.3 Edit screen (one seller order)

Opened from **Edit** on a seller order that is still editable (rules in §5).

- Per product: a **quantity stepper**, a **variant picker** (colour / size … of the same product; sold-out variants
  shown but **not selectable**), and a **remove** switch.
- After every change the app asks the server for a **preview** and shows the new line prices, the new checkout total
  and, if any, the extra shipping. **Save** is enabled only after a successful preview.
- Removing every product is not an edit: the app sends the customer to **Cancel** instead.
- No adding products (that is a new checkout).

---

## 3. Conventions

- Auth: `Authorization: Bearer <sanctum token>` (customer), plus the usual `System-Key` and `App-Language` headers.
  All endpoints are `auth:sanctum`.
- Ids in URLs are **seller order ids** (`orders.id`, the `id` you already have). Any seller order of a checkout
  identifies the whole checkout.
- Every amount is an object: `{"amount": 12345.0, "formatted": "12,345 ل.س"}`. `amount` is the number in **Syrian
  pounds** at the rate of the order's date; `formatted` is ready to show. Show `formatted`; use `amount` only for
  comparisons (e.g. `> 0`).
- Status keys (stable, use them for logic; show the `*_label` fields):
  - delivery: `pending`, `confirmed`, `picked_up`, `on_the_way`, `delivered`, `cancelled`, `returned`
  - payment (seller order): `paid`, `unpaid`, `payment_not_completed` (unpaid online / Paymera)
  - payment (whole checkout): the same plus `partly_paid` (some seller orders paid, some not)
- Errors: `{"result": false, "message": "<translated sentence to show>"}`. Not your order / unknown id → **404**.

---

## 4. Endpoints

### 4.1 `GET /api/v2/purchase-history/grouped` — the orders list

Query: `delivery_status` (optional, a delivery key), `payment_status` (optional: `paid` | `unpaid`), `page` (1…).
10 checkouts per page, newest first. An unknown filter value is ignored.

**Filter rule:** a checkout is returned when **any** of its seller orders matches (each filter on its own). It is
returned **whole**; `orders[].matches_filter` tells which seller orders matched — expand and highlight those.

```json
{
  "result": true,
  "data": [
    {
      "number": "1532",
      "combined_order_id": 1532,
      "is_single": false,
      "first_order_id": 2051,
      "date": "19-10-2026",
      "date_epoch": 1792413000,
      "delivery_status": "pending",
      "delivery_status_label": "قيد الانتظار",
      "payment_status": "unpaid",
      "payment_status_label": "غير مدفوع",
      "payment_type": "cash_on_delivery",
      "payment_type_label": "الدفع عند الاستلام",
      "sellers_count": 2,
      "products_count": 3,
      "subtotal": {"amount": 405000, "formatted": "405,000 ل.س"},
      "tax": {"amount": 0, "formatted": "0 ل.س"},
      "shipping": {"amount": 15000, "formatted": "15,000 ل.س"},
      "coupon_discount": {"amount": 20000, "formatted": "20,000 ل.س"},
      "coupon_code": "SAVE20",
      "total": {"amount": 400000, "formatted": "400,000 ل.س"},
      "can_refund": false,
      "refund_order_id": null,
      "can_pay_online": false,
      "pay_order_id": null,
      "unseen": true,
      "orders": [
        {
          "id": 2051,
          "code": "20261019-14223051",
          "seller_name": "Shop One",
          "delivery_status": "pending",
          "delivery_status_label": "قيد الانتظار",
          "payment_status": "unpaid",
          "payment_status_label": "غير مدفوع",
          "payment_type": "cash_on_delivery",
          "payment_type_label": "الدفع عند الاستلام",
          "amount": {"amount": 270000, "formatted": "270,000 ل.س"},
          "is_void": false,
          "products_count": 2,
          "matches_filter": false,
          "can_edit": true,
          "edit_refusal": null,
          "can_cancel": true,
          "tracking_code": null
        },
        {
          "id": 2052,
          "code": "20261019-14223052",
          "seller_name": "Shop Two",
          "delivery_status": "confirmed",
          "...": "same keys"
        }
      ]
    }
  ],
  "meta": {"current_page": 1, "last_page": 4, "per_page": 10, "total": 37},
  "filters": {
    "delivery_statuses": [{"key": "pending", "label": "قيد الانتظار"}, "..."],
    "payment_statuses": [{"key": "paid", "label": "مدفوع"}, {"key": "unpaid", "label": "غير مدفوع"}]
  }
}
```

Field notes:

- `delivery_status` of the checkout = the **least advanced** live seller order (one seller delivered and one pending
  → `pending`). All cancelled → `cancelled`; nothing left but returns → `returned`.
- `total` = what the customer pays for the whole checkout (cancelled seller orders count for nothing, shipping once,
  coupon once). `subtotal + tax + shipping − coupon_discount = total` always.
- `orders[].amount` = that seller order's products (price + tax) **before** the coupon. The coupon is a checkout-level
  line only — never show a coupon per seller order. For a cancelled / returned seller order (`is_void: true`) it is
  what the order was worth — show it struck through; it is not part of `total`.
- `payment_type` / `payment_type_label` are `null` when the seller orders were paid differently (rare); then show each
  seller order's own.
- `unseen`: something changed that the customer hasn't looked at. Opening the order page (§4.2, app or website) clears
  it for every seller order of the checkout.
- `can_pay_online` + `pay_order_id`: show "Pay with Paymera" on the card; call your existing re-pay endpoint with
  `pay_order_id`.
- `can_refund` + `refund_order_id`: show "Refund" on the card; call your existing refund endpoint with
  `refund_order_id` (the refund covers the whole checkout, as before).
- `orders[].can_edit` / `can_cancel`: show the **Edit** / **Cancel** buttons on that seller order. `edit_refusal` is
  the sentence to show when it cannot be edited. Cancel = your existing `POST /api/v2/order/cancel2/{id}` with that
  seller order's `id` (unchanged; it cancels only that seller's order).
- `is_void`: cancelled or fully returned seller order — show its amount struck through. When every seller order is
  void, `total` is 0: show the sum of `orders[].amount` (+ `shipping`) struck through with the status, as the website
  does.

### 4.2 `GET /api/v2/purchase-history/combined/{order_id}` — the order page

Any seller order id of the checkout. Returns the list item of §4.1 plus:

```json
{
  "result": true,
  "data": {
    "...every key of a list item...": "",
    "requested_order_id": 2052,
    "shipping_address": {
      "name": "…", "phone": "…", "address": "…", "city": "…",
      "state": "…", "country": "…", "postal_code": null, "notes": null
    },
    "orders": [
      {
        "...every key of a list item's order...": "",
        "cancel_reason": null,
        "return": null,
        "basket": "3f1c0a9e2b7d4c5a6e8f",
        "lines": [
          {
            "id": 7781,
            "product_id": 412,
            "product_name": "قميص قطني",
            "product_slug": "cotton-shirt-ab12",
            "thumbnail": "https://kapitano.shop/public/uploads/all/....webp",
            "variation": "Red-XL",
            "quantity": 2,
            "price": {"amount": 180000, "formatted": "180,000 ل.س"},
            "delivery_status": "pending",
            "is_returned": false
          }
        ],
        "edit_history": [
          {
            "at": "19-10-2026 14:35",
            "changes": [
              {"product": "قميص قطني", "from": "Red-L · الكمية 1", "to": "Red-XL · الكمية 2", "removed": false}
            ]
          }
        ]
      }
    ]
  }
}
```

- Scroll to / highlight the seller order `requested_order_id` (the one the user tapped, or that a push named).
- `lines[].price` is the **line total** (unit × quantity).
- `cancel_reason`: label of the reason when that seller order is cancelled, else `null`. `return`:
  `{"items": 1, "reason": "…"}` when some products came back, else `null`.
- `edit_history`: the customer's own edits of that seller order, oldest first. Show it under the seller order
  ("Changes you made").
- Invoice: your existing invoice endpoint with any seller order id already returns the **whole checkout** on one page.
- Rating: unchanged (`order-ratings/*`, one rating per checkout, any seller order id).

### 4.3 `GET /api/v2/order-edit/{order_id}` — open the edit screen of one seller order

```json
{
  "result": true,
  "data": {
    "order_id": 2051,
    "code": "20261019-14223051",
    "editable": true,
    "reason": null,
    "basket": "3f1c0a9e2b7d4c5a6e8f",
    "lines": [
      {
        "id": 7781,
        "product_id": 412,
        "product_name": "قميص قطني",
        "thumbnail": "https://…",
        "variation": "Red-L",
        "quantity": 1,
        "unit_price": 90000,
        "unit_price_formatted": "90,000 ل.س",
        "price": 90000,
        "price_formatted": "90,000 ل.س",
        "mode": "full",
        "min_quantity": 1,
        "max_quantity": 6,
        "on_sale": true,
        "variants": [
          {"variant": "Red-L",  "label": "Red-L",  "stock": 5, "available": true,  "current": true,  "unit_price": 90000, "unit_price_formatted": "90,000 ل.س"},
          {"variant": "Red-XL", "label": "Red-XL", "stock": 6, "available": true,  "current": false, "unit_price": 90000, "unit_price_formatted": "90,000 ل.س"},
          {"variant": "Blue-L", "label": "Blue-L", "stock": 0, "available": false, "current": false, "unit_price": 95000, "unit_price_formatted": "95,000 ل.س"}
        ]
      }
    ]
  }
}
```

- `editable: false` → show `reason` (translated sentence) instead of the form. This can happen at any time: the seller
  may answer while the screen is open.
- **Keep `basket` and send it back with the save** (§4.5). It is a fingerprint of the lines you showed.
- `mode`: `full` = quantity, variant and remove allowed; `remove_only` = digital / auction / wholesale product, or a
  product that no longer exists: only the remove switch.
- Quantity input: between `min_quantity` and `max_quantity` (`max_quantity` = what is in stock now, never less than
  the current quantity). Lowering is always fine.
- `variants`: empty when the product has no options (show the text `variation`). Otherwise a picker; **disable**
  entries with `available: false` (sold out or product not on sale) unless `current: true`. A variant's `unit_price`
  is today's price — a variant change takes it (a quantity change keeps the price the customer already accepted).
- `on_sale: false`: the product is no longer published — the customer can only lower its quantity or remove it.
- Products of the seller order that are cancelled / returned are not listed.

### 4.4 `POST /api/v2/order-edit/{order_id}/preview` — what the change would give (nothing saved)

Body (JSON):

```json
{
  "changes": [
    {"id": 7781, "quantity": 2, "variation": "Red-XL"},
    {"id": 7790, "remove": true}
  ]
}
```

One object per changed line (`id` = `lines[].id`). Send only lines that changed; for one line put `quantity` and
`variation` in the **same** object — if a line appears twice the last one wins. Omit `variation` for products without
options. Call it (debounced ~300 ms) after every change, and enable **Save** only after a successful preview.

Success:

```json
{
  "result": true,
  "message": null,
  "data": {
    "ok": true,
    "code": "ok",
    "message": null,
    "errors": {},
    "lines": [
      {"id": 7781, "variation": "Red-XL", "quantity": 2, "price": {"amount": 180000, "formatted": "180,000 ل.س"}, "changed": true}
    ],
    "removed": [7790],
    "order_total": {"before": {"amount": 270000, "formatted": "…"}, "after": {"amount": 290000, "formatted": "…"}},
    "coupon": {"had_coupon": true, "code": "SAVE20", "share_before": {"amount": 10000, "formatted": "…"}, "share_after": {"amount": 10000, "formatted": "…"}},
    "shipping_added": {"amount": 0, "formatted": "0 ل.س"},
    "shipping": {"before": {"amount": 10000, "formatted": "…"}, "after": {"amount": 0, "formatted": "…"}},
    "total": {"before": {"amount": 400000, "formatted": "400,000 ل.س"}, "after": {"amount": 420000, "formatted": "420,000 ل.س"}}
  }
}
```

Show at least: the new line prices, `total.before` → `total.after` (the whole checkout), and when
`shipping_added.amount > 0` an "Extra shipping" line (the edit took the order below the free-shipping limit). `shipping` (added 2026-10-24) is the checkout's shipping before / after and also shows a shipping taken off (`shipping_added` stays ≥ 0). `coupon.share_before` / `share_after` are the **checkout's** coupon before / after the edit.

Refusal (HTTP 200, `result: false`):

```json
{
  "result": false,
  "message": "Some changes cannot be made.",
  "data": {
    "ok": false,
    "code": "invalid",
    "message": "Some changes cannot be made.",
    "errors": {"7781": "Only 3 left in stock"}
  }
}
```

`errors` is keyed by line id — show each under its line.

| `code` | Meaning | What to do |
|---|---|---|
| `invalid` | some lines can't change (stock, minimum, sold-out or unknown variant, product no longer on sale, variant already in the order, prices changed since the order) | show `errors[line id]` under each line |
| `remove_all` | every product would be removed | tell the user to **cancel** the seller order instead (open your cancel flow) |
| `coupon_dropped` | the checkout would fall below the coupon's minimum and lose the coupon | show `message`; keep more products or cancel |
| `no_change` | nothing differs from the order | disable Save |
| `not_editable` | the order can no longer be edited (seller answered, confirmed, paid…) | show `message`, close the form, refresh the order |

### 4.5 `POST /api/v2/order-edit/{order_id}` — save

Body: the same `changes` as the preview **plus `basket`** from §4.3:

```json
{"changes": [{"id": 7781, "quantity": 2}], "basket": "3f1c0a9e2b7d4c5a6e8f"}
```

- **200** `{"result": true, "message": "تم تحديث طلبك", "data": {…preview shape…, "saved": true}}` → show the message
  and reload the order page (§4.2).
- **422** `{"result": false, "message": "…", "data": {"ok": false, "code": "…", "errors": {…}, "saved": false}}` with
  the codes of §4.4, plus:

| `code` | Meaning | What to do |
|---|---|---|
| `basket_changed` | the order changed since the edit screen was opened (staff edited it) | show `message`, reload §4.3 and let the user redo the change |
| `not_editable` | the seller answered / the order was confirmed or paid in the meantime | show `message`, close the form, refresh |

Rate limits: preview 60/min, save 20/min per user.

---

## 5. Rules the server enforces (so the app can explain them)

The app never decides these; it shows `can_edit` / `editable` / `reason` and the error messages. For wording and
expectations:

1. **When a seller order is editable** — all of: delivery `pending`, payment `unpaid`, **the seller has not
   confirmed it or reported products missing yet**, not a Paymera (online) order, and placed after the
   stock-on-confirmation switch (old orders are never editable). After that the customer can only cancel (same rule
   as today: pending + unpaid) or call customer service. The window is short: sellers often answer within minutes.
2. **What can change** — quantity, variant of the same product, remove a product. **No adding products** (that is a
   new checkout). Removing every product = cancel.
3. **Price** — a quantity change keeps the unit price the customer accepted at checkout; a variant change takes that
   variant's **current** price. Untouched products never change price. A variant change is refused when the exchange
   rate changed since the order was placed (reported as `invalid`, "prices changed since the order").
4. **Coupon** — belongs to the whole checkout; its minimum and value are judged on the **total of all seller
   orders**, never one alone. An edit that would drop the checkout below the minimum is refused (`coupon_dropped`).
   The other sellers' amounts never move because of an edit.
5. **Shipping** — charged once per checkout; an edit moves it only by what the edit itself causes: falling below the
   free-shipping limit adds it, reaching the limit takes it off (since 2026-10-24; before, it never went down).
   A coupon is judged again on every edit too (a percentage follows the new basket). Handoff:
   `docs/api/order-edit-coupon-shipping-mobile.md`.
6. **Stock** — checked on preview and again on save; sold-out variants can't be picked. Nothing is reserved (stock is
   taken when the order is confirmed), so staff confirmation may still find a product short, as today.
7. **Who is told** — the seller gets a push ("The customer changed order …"), and the change is listed in the order's
   history for the customer and on the staff timeline. No approval step, no e-mail.

---

## 6. Seller app

**No change needed.** Sellers confirm orders in the seller panel (PWA) and on the WhatsApp page; both now refuse to
confirm a basket the customer changed after the seller opened it ("The customer changed this order. Please check it
again before confirming.") and reload. The seller app's `orders/update-delivery-status` already refuses all status
changes. A new push type reaches the seller panel: `data.type = "order_changed"`, `data.id` = order id, `data.code` =
order code, link = the seller order page — if the seller app ever shows seller pushes, open the order like for
`type = "order"`.

---

## 7. Checklist for the app

- [ ] Orders list → `GET purchase-history/grouped` (10 per page, `meta.last_page`); single order as one row, combined
      as a card with collapsible seller orders; badges from the `*_label` fields; delivery tabs + payment dropdown from
      `filters`; expand + highlight `orders[].matches_filter` when a filter is on; "New update" badge from `unseen`.
- [ ] Order page → `GET purchase-history/combined/{id}`; shared block once (number, date, address, payment, totals
      with one coupon line and one shipping line, Pay / Refund, invoice, rating), one section per seller order
      (seller name, code, statuses, products, tracking, cancel / return info, edit history, Edit / Cancel); scroll to
      `requested_order_id`.
- [ ] Push / deep links that carry an order id open this page (any seller order id works).
- [ ] Edit screen per seller order → `GET order-edit/{id}`; quantity stepper (min/max), variant picker (disable
      unavailable), remove switch; debounced `POST …/preview`; Save → `POST order-edit/{id}` with `basket`; handle the
      codes of §4.4 / §4.5.
- [ ] Cancel per seller order (existing endpoint) from the card and the order page; Pay / Refund on the checkout card
      with `pay_order_id` / `refund_order_id`.
- [ ] Keep the old screens working until the new ones ship (the old endpoints are unchanged).
