# Purchase history redesign — agreed design (input for `/to_spec`)

- Agreed: 2026-10-20 (grilling session), branch `feat/combined-orders-2026-10-20`
- **Status: implemented 2026-10-20** — code in `app/Services/CustomerOrders/`, flow in
  [`01-orders-checkout.md` §4.8](../01-orders-checkout.md), app contract in
  [`docs/api/combined-orders.md`](../../api/combined-orders.md). Changes from this design while
  building it: the app got **new, additive** v2 endpoints (so the mobile app can build the same screens; the old
  `purchase-history*` endpoints are unchanged); the app's order page also marks the checkout as seen;
  "Reorder all" and the ownership check on `re_order` were added. The open points below were settled
  as their recommendations: both filters apply each on its own (any seller order for each), and
  hand-set shipping is kept (only the edit's own shipping difference is added).
- Terms: [`/CONTEXT.md`](../../../CONTEXT.md). Decisions: ADR [0001](../../adr/0001-customer-order-edits-only-before-payment-and-seller-answer.md),
  [0002](../../adr/0002-combined-order-is-the-unit-of-the-customer-order-pages.md),
  [0003](../../adr/0003-money-rules-of-a-customer-order-edit.md),
  [0004](../../adr/0004-order-edit-concurrency-without-schema-change.md).
- Background: [`01-orders-checkout.md`](../01-orders-checkout.md) (§3 data model, §4.6 cancel paths, §6 "change together").

## Goal

The customer's `/purchase_history` shows each checkout (Combined Order) as one entry with
its seller orders (Child Orders) inside, one details page covers the whole checkout, and
the customer can change quantity / variant / remove lines while an order is still
editable. Web only; no schema change.

## Constraints

- **No new table or column.** Edit history = the existing audit log of `orders` /
  `order_details`; fingerprints are computed, not stored.
- **Web only.** `Api/V2/PurchaseHistoryController` and its resources stay as they are.
  The grouping/filter query, the "Editable" rule and the edit itself live in **one shared
  class** the web controller calls and a later API can reuse (no logic in Blade).
- Price contract (CLAUDE.md): order amounts render with `display_order_total` /
  `single_order_price` at the order's rate; line prices stored in BASE.
- Order-placed stock rule: pending orders hold no stock (`OrderStockUtility`).

## 1. List (`purchase_history.index`)

- One entry per Combined Order of the signed-in customer; paginate 10 entries, newest
  checkout first. Combined Orders whose Child Orders all have no lines are skipped (as
  today's `count($order->orderDetails) > 0`).
- **Single Order** (one Child Order): one row like today.
- **Combined Order**: a card. Header: invoice number (`#` combined id), date, total
  (`combined_order_payable_total`, shipping once), combined delivery status
  (`combined_delivery_status()`), combined payment status (below), number of sellers and
  products, whole-purchase actions: **Invoice** (already whole-checkout), **Rate**
  (already per checkout), **Pay with Paymera**, **Refund** (Paymera, per checkout),
  **Reorder all**. Under it the Child Orders as rows: code, seller (shop name), amount,
  own delivery + payment status, Details. **Edit is offered on the order page only**
  (decided 2026-10-22); `?edit=<order id>` still opens its dialog there. **So is Cancel**
  (decided 2026-10-24): the list has no cancel button or dialog, for a Single Order either.
- Children collapsed by default; when a filter is on, the matching ones are expanded and
  marked. Mobile: the card stacks, children become an accordion.
- The green `*` "not viewed" markers per Child Order stay.

**Combined payment status** (live Child Orders): `Paid` all paid / `Unpaid` none /
`Partly paid` mixed; an unpaid Paymera purchase reads `Payment not completed`.

## 2. Filters

- Delivery status (pending, confirmed, picked_up, on_the_way, delivered, cancelled,
  returned) and payment status (paid, unpaid), on the list as query parameters
  (kept across pagination).
- An entry matches when **any** of its Child Orders matches; it is shown whole (all
  children, totals add up), matching children marked and expanded. Both filters together
  = the same Child Order or different ones? → **spec to decide** (recommendation: any
  child matching delivery AND any child matching payment, each separately).

## 3. Order page (`purchase_history.details`, URL unchanged)

- `/purchase_history/details/{encrypt(child order id)}` keeps working as is; any Child
  Order's id renders its whole Combined Order and scrolls to `#order-<code>`. Ownership
  check as today (`user_id`). The list links to the first Child Order (lowest id).
- Shared once: invoice number, date, customer, shipping address, payment method (per
  child only if the children differ: 1 checkout in the data), totals (subtotal, tax,
  coupon, shipping once, total), Pay with Paymera / Refund, invoice download, rating
  card.
- Per Child Order section: seller, code, delivery + payment status, tracking code,
  cancel / return info (`OrderStockUtility::returnInfo`), lines (product, variation via
  `partials/_variation`-style rendering, quantity, price, review link), Edit / Cancel.
  A cancelled / returned Child Order says once, in a banner under its head, that it is not in
  the total (with the cancel reason), and its products are dimmed and struck.
- The totals card is a receipt (`ko-receipt`: dotted leaders, then a notched navy stub with the
  total in the brand yellow, the payment method + combined payment status, and Pay with Paymera).
  Cancelled / returned Child Orders sit under the total in a "Not charged" list (seller, code,
  status, struck amount = `Purchase::orderAmount()`), never as a row of the totals.
  Under it the shipping address card (`ko-ship`: name, address, a `tel:` phone button, address
  notes and the order's additional info). The order number in the head has a copy button
  (`ko-copy`).
- Opening the page marks **every** Child Order shown as viewed.

## 4. Editing

**Editable** Child Order (ADR 0001): `delivery_status = pending` AND `payment_status =
unpaid` AND seller not answered (`seller_confirmed_at`, `seller_unavailable_at` empty;
in-house: no seller step) AND `payment_type != paymera` AND placed after
`stock_on_confirm_since` AND not in Logistic transit (`EditGuard`, implied by pending but
kept as the backstop). Not editable → no Edit button; Cancel follows its existing rule.

**What can change:** per line: quantity, variant (same product), remove. A removed line is
**cancelled, not deleted** (since 2026-10-21): it stays on the order pages struck through as
cancelled and counts nowhere (`delivery_status = cancelled` in a live order). Removing the
last line = cancel of that Child Order (`purchase_history.destroy2`). No adding products.

**Cancelling a Child Order** (decided 2026-10-22): every cancel of a **whole** Child Order -
its Cancel button, or every product of it removed in the edit dialog (the dialog hands over
as soon as the last remaining product is removed) - opens the cancel dialog (`cancel_modal`):
reason (required) + note + picture (optional; the picture was taken out and put back on 2026-10-22), whether or not the checkout's other Child
Orders go on. Removing some products while others stay is an edit (each removed line
cancelled, no dialog). `order_cancel2`, the website's and the app's (`Api/V2/OrderController`),
always requires `cancel_reason`. App handoff: `docs/api/order-cancel-mobile.md`.

**Validation:** quantity ≥ max(1, `min_qty`) and ≤ current stock of the chosen variant;
variant picker lists the product's variants, unavailable ones struck and **not**
selectable; increasing quantity or changing variant needs the product on sale now
(published, approved); decreasing or removing is always allowed; digital, auction and
wholesale lines: remove only. Checked in the preview and again on save. Staff
confirmation can still refuse for short stock later, as today.

**Money (ADR 0003): an edit changes only the edited Child Order, and only by what the
edit causes.**

- Quantity keeps the original unit price/tax.
- Variant change is priced exactly like checkout: `cart_product_price($row, $product,
  false, false)` + `cart_product_tax($row, $product, false)` (not `CartUtility::get_price`);
  refused if the exchange rate changed since the order's date
  (`exchange_rate_at(orders.date)` ≠ live rate).
- Seller cost `purchase_price` recomputed (scaled / new variant's
  `SellerPricing::orderLineCost`) and `OrderLinePricesUtility::stamp` refreshed on a
  variant change.
- Coupon scope: a Single Order's coupon applies to that order (unchanged). A Combined
  Order's coupon belongs to the whole checkout: its minimum and value are judged on the
  **combined total** (all live Child Orders' products + tax, no shipping), and the
  customer sees **one** coupon line on the combined totals, never one per Child Order;
  Child Order rows show their products before the coupon.
- Coupon value D re-derived over the whole Combined Order. The per-Child-Order split
  (`orders.coupon_discount`) is internal bookkeeping (captain collection, accountant
  settlement); **sibling Child Orders keep their `coupon_discount` and `grand_total`
  frozen**; the edited Child Order gets
  D − siblings' shares, clamped to [0, its products + tax]. Needs a "siblings frozen"
  variant of `CombinedOrderCouponUtility` (`recalculateForOrder` re-splits over all
  children and must not be used here).
- An edit that would drop the coupon (below its minimum) is **refused** (default, not
  confirmed by the owner; alternative: allow with a warning). Reason: usage is released
  only on cancel, so the coupon would stay used up.
- Shipping: new = current + max(0, S1 − S0), where S0 / S1 are the original / edited
  basket priced at the same moment with the same checkout rule and rounding
  (`order_from`: web 1000, app 5) at the order's address, from transient cart-shaped
  rows, never real `carts` rows. Hand-set shipping is kept. Revised 2026-10-24: new =
  current + (S1 − S0), never below 0, and 0 once the edited basket ships free (it used
  to only grow). The coupon now lives on the combined order (2026-10-20), so D is
  written there and no Child Order carries a share.
- A **preview** (no write) shows new line prices, the new coupon share and the new
  total, or why the edit is refused, before the customer confirms.

**Concurrency (ADR 0004):** save locks the `orders` row and re-checks Editable; the form
carries a basket fingerprint (hash of line id/variation/quantity), mismatch → refused,
page reloads; the seller's WhatsApp action page and seller web panel send the fingerprint
they showed, and a confirm on a changed basket is refused ("The customer changed this
order, check it again"); the seller app is not protected until it sends one.

**Side effects:** Kapitano Logistic is told automatically (the `QueueOrderEdit`
listener rides `OrderDetail` / `Order` Eloquent events, so edits must go through
Eloquent saves/deletes). Audit log records the change (owen-it, `acting_user` covers
"log in as customer"). Default, not confirmed by the owner: no approval; the seller
gets a push ("the customer changed order `<code>`"); staff see a "Changed by the customer"
step on the order timeline (`OrderTimelineUtility`, from the audit); the customer sees an
edit history on the order page read from the audit log; no e-mail (sending is off
site-wide).

## 5. Known traps for the spec

- Fixed by the [order follow-up/profit feature](order-staff-notes-profit.md) on
  2026-10-25: `OrderController::updateQty` now scales the saved whole-line purchase
  costs. Admin and customer quantity/variant editors share `OrderPurchaseCostUtility`.
- `OrderDetail::creating` stamps `purchase_price` and `OrderLinePricesUtility::stamp`
  only on create: a variant change must set both itself.
- Variant text must match `product_stocks.variant` exactly (the cart's
  `create_cart_variant` format).
- `CombinedOrderCouponUtility::allocate()` rewrites `coupon_discount` and `grand_total`
  of **every** Child Order; calling it from the customer edit would move totals of
  siblings already confirmed, settled or with the captain (and send Logistic an edit for
  them). Hence the frozen-siblings rule above.
- `re_order` (web) has no ownership check (§8.10 of 01); "Reorder all" must check it.
- Cancel paths C3/C4 (`order_cancel`, `order_cancel2`) are per Child Order and do not
  call `CombinedOrderCouponUtility`: removing the last line via cancel keeps today's
  behaviour.
- Update `docs/codebase/01-orders-checkout.md` (§2, §4, §6 "change together") in the same
  MR, and add translations for every new string (`database/translations/translations.json`).

## Open for the spec (small, not business decisions)

- Both filters at once: same Child Order or any (recommendation above).
- Coupon that would drop off: refuse (recorded default) or allow with a warning — the
  owner did not choose explicitly.
- Exact Arabic wording of the new messages and buttons.
