# Orders grouped per checkout + editing an order — app API (v2)

**For:** the mobile app developer. **Server side:** done and live on the branch `feat/combined-orders-2026-10-20` (web page 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. Adopt the new screens whenever you are ready.

---

## 1. What changed and why

Until now the app (and the website) listed every **seller order** (`orders` row) 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**.

New model (same words as the server's `CONTEXT.md`):

| Term | What it is | In the API |
|---|---|---|
| **Combined Order** (checkout, "purchase") | everything bought in one checkout; holds 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; has its own code, delivery status, payment status, seller | `orders[]` inside the item |
| **Single Order** | a Combined Order with exactly one Child Order (≈80% of orders) | `is_single: true` — show it like today |

The website now shows:

- **List:** one entry per checkout. A single order = one row (as before). A combined order = a card with its seller orders under it (collapsed; expanded when a filter is on).
- **One order page** for the whole checkout: address, payment, totals and coupon **once**, then one section per seller order with its products and its own buttons.
- **Edit:** the customer can change **quantity**, change **variant** (colour / size … of the same product) or **remove** a product, per seller order, as long as the order is still editable (rules in §4).
- **Filters:** by delivery status and payment status.

Please build the same in the app.

---

## 2. 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**.

---

## 3. Endpoints

### 3.1 `GET /api/v2/purchase-history/grouped` — the order 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",
      "sale_discount": {"amount": 0, "formatted": "0 ل.س"},
      "subtotal_before_sale": {"amount": 405000, "formatted": "405,000 ل.س"},
      "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.
- `sale_discount` / `subtotal_before_sale` (added 2026-10-24): the products' own sale ("حسم") and the products before it; `subtotal_before_sale − sale_discount = subtotal`. Show a "حسم" row after the coupon when `sale_discount.amount > 0`, with `subtotal_before_sale` as the subtotal. See `docs/api/sale-discount-mobile.md`.
- `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); show each seller order's own.
- `unseen`: something changed the customer hasn't looked at (the old green `*`). Opening the order page (§3.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. 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.
- **Look of the website** (to match in the app 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, a yellow "New update" badge when `unseen`, delivery status as tabs and payment status as a dropdown above the list.

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

Any seller order id of the checkout. Returns the item of §3.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,
            "is_cancelled": false
          }
        ],
        "edit_history": [
          {
            "at": "19-10-2026 14:35",
            "changes": [
              {"product": "قميص قطني", "from": "Red-L · الكمية 1", "to": "Red-XL · الكمية 2", "removed": false, "cancelled": 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`.
- `lines[].is_cancelled` (added 2026-10-21): the customer took this product out with the edit form's **remove** switch. Since that date a removed product is **cancelled, not deleted**: it stays in `lines` with `delivery_status: "cancelled"` - show it struck through with a "Cancelled" badge, without review / refund actions. It is in no total (`amount`, `subtotal`, `total`, the coupon): never add up `lines` yourself, use those totals. Products removed before that date were deleted and do not appear.
- `edit_history`: the customer's own edits of that seller order, oldest first. Show it under the seller order ("Changes you made"). A change with `removed: true` took the product out; `cancelled: true` means it is kept as a cancelled line (`is_cancelled`), `false` means it was deleted (before 2026-10-21).
- 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).

### 3.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** (§3.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`. `unit_price` of a variant 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.
- Lines of the seller order that are cancelled / returned are not listed.

### 3.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 (`{"id": 7781, "quantity": 2, "variation": "Red-XL"}`) — 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: 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 (one coupon per checkout).

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` values:

| `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 cancelled | 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 |

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

Body: the same `changes` as the preview **plus `basket`** from §3.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 (§3.2).
- **422** `{"result": false, "message": "…", "data": {"ok": false, "code": "…", "errors": {…}, "saved": false}}` with the codes of §3.4, plus:

| `code` | Meaning | What to do |
|---|---|---|
| `basket_changed` | the order changed since the edit screen was opened (staff edited it) | show `message`, reload §3.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.

---

## 4. 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 copy 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.
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).
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. **The seller is told** (push to the seller app/panel: "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.

---

## 5. 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"`.

---

## 6. Suggested app work (checklist)

- [ ] 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; filters (delivery + payment) from `filters`; expand + highlight `orders[].matches_filter` when a filter is on.
- [ ] Order page → `GET purchase-history/combined/{id}`; shared block (number, date, address, payment, totals with one coupon line and one shipping line), one section per seller order (seller name, code, statuses, lines, tracking, cancel/return info, edit history), 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 §3.4/§3.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).
