# ADR 0003 — Money rules of a customer order edit

- Status: accepted
- Date: 2026-10-20 (revised the same day after a price-safety review)
- Depends on: ADR 0001 (only unpaid, non-Paymera orders are editable, so no edit moves paid money)

## Rule of thumb

An edit may change **only the edited Child Order's** amounts, and only by what the edit
itself causes. Sibling Child Orders, and anything caused by time passing since checkout
(a new shipping tariff, a new exchange rate), must not move.

## Decision

| What | Rule |
| --- | --- |
| Quantity change | keeps the line's **original unit price and tax** (`price / quantity`, `tax / quantity`) × new quantity, like the admin quantity screen |
| Variant change (same product) | priced exactly as checkout prices a line: `cart_product_price($row, $product, false, false)` and `cart_product_tax($row, $product, false)` × quantity (what `OrderController::store` uses). **Not** `CartUtility::get_price` / `tax_calculation`, which round tax differently |
| Variant change after a rate change | refused when the rate of the order's date (`exchange_rate_at(orders.date)`) differs from the live rate: the new price would be computed at today's rate but shown at the order's rate |
| Lines not edited | never repriced |
| Seller cost | `order_details.purchase_price` (whole line, BASE): quantity change scales it; variant change takes the new variant's cost (`SellerPricing::orderLineCost`). The accountant's per-piece stamps (`OrderLinePricesUtility::stamp`) are refreshed on a variant change. Both are written only on line creation today, so the edit must set them itself |
| Coupon scope | **Single Order:** the coupon applies to that order, as today. **Combined Order:** the coupon belongs to the checkout as a whole. Its minimum is checked against, and its value computed from, the **total of the whole Combined Order** (every live Child Order's products + tax, never shipping), never one Child Order alone (owner's decision, 2026-10-20) |
| Coupon value | re-derived on the whole Combined Order with the edited basket (`CombinedOrderCouponUtility`'s rules: minimum, cap, scoped products) = **D** |
| Coupon display | the customer sees **one** coupon line on the Combined Order's totals (list card and order page), never a coupon per Child Order. Child Order rows show their products' amount before the coupon |
| Coupon bookkeeping | the per-Child-Order split (`orders.coupon_discount`) stays as internal bookkeeping only, because captains collect and the accountant settles per Child Order. **Sibling Child Orders keep their current `coupon_discount` and `grand_total` frozen.** The edited Child Order's share = D − (sum of the siblings' frozen shares), never below 0 and never above its own products + tax. `combined_orders.coupon_discount` = the sum of the shares actually given |
| Coupon would drop off | an edit that takes the **Combined Order's total** below the coupon's minimum (D = 0 while a coupon is applied) is **refused** with a message; the customer can cancel instead. Default chosen without the owner's explicit answer (alternative: allow after a warning) |
| Shipping | only the **difference the edit causes**: price the original basket (S0) and the edited basket (S1) at the same moment, with the same checkout rule and the same rounding (web 1000 / app 5, by `orders.order_from`), at the order's address. New shipping = current + (S1 − S0), never below 0, and 0 when the edited basket ships free (S1 = 0). **Revised 2026-10-24** (owner's request): it used to be current + max(0, S1 − S0) and never went down, so a customer who raised the quantity past the free-shipping limit kept paying shipping |
| Preview | before saving, the customer sees the new line prices, the new coupon share and the new total; a refused edit says why |
| Sales counter | `products.num_of_sale` moves by the quantity difference (placement added the quantity; a cancel takes it back) |

> **Revised 2026-10-24.** Since 2026-10-20 the coupon lives on the combined order only
> (`orders.coupon_discount` = 0, see CLAUDE.md "The checkout's coupon lives on the combined order"),
> so there are no sibling shares to freeze: D is written on `combined_orders.coupon_discount` and taken
> off `combined_orders.grand_total`, and no Child Order row moves. The "Coupon bookkeeping" row and the
> "siblings frozen" points below describe the shape before that move. `customerEditShare()` kept reading
> the children's shares until 2026-10-24, so in that time an edit never re-decided the coupon.

## Why

- **Coupon on the total.** The customer applied one coupon to one checkout; judging it,
  or showing it, per seller order would make a checkout that met the minimum look as if
  some of its orders did not. The split per Child Order exists only so each seller order
  has an amount to collect and settle.
- **Siblings frozen.** `allocate()` splits the coupon across every Child Order by its
  share of the products and rewrites each one's `coupon_discount` and `grand_total`.
  Re-running it after a customer edit would change the total of a sibling that is
  already confirmed, settled by the accountant or with the captain. Example: a 20,000
  SYP coupon split 10,000 / 10,000 over two 100,000 orders; the customer halves order B;
  re-allocation gives A 13,333, so A's total moves from 90,000 to 86,667 although nobody
  touched A, and Logistic receives an edit for an order it already collected.
- **Refuse when the coupon would drop.** A coupon's usage is released only when an order
  is cancelled (`CouponUtility`), so a coupon removed by an edit stays used up: the
  customer would lose the discount and the coupon. Dropping it would also raise the
  frozen siblings' share of the bill.
- **Shipping by difference.** "max(current, recomputed)" could raise shipping because
  the tariff changed after checkout, or because web and app round shipping differently
  (1000 vs 5 SYP), with nothing in the edit causing it. Comparing S1 with S0 computed
  the same way cancels both out; what is left is the effect of the edit (e.g. falling
  below the free-shipping limit).
- **Checkout's own price functions.** The edited line must equal what checkout would
  have stored for that variant; two price helpers that round tax differently would
  leave a small, silent difference.

## Consequences

- The customer edit cannot simply call `CombinedOrderCouponUtility::recalculateForOrder`:
  that re-splits over every Child Order. The spec needs a "siblings frozen" variant of the
  recalculation (decide D over the whole Combined Order, write only the edited Child
  Order and the Combined Order's totals).
- When D shrinks (e.g. a percentage coupon on a smaller basket) and the siblings' frozen
  shares already exceed it, the edited Child Order's share is 0 and the customer keeps a
  little more discount than a full re-derivation would give. Accepted: bounded and in the
  customer's favour, and no sibling moves.
- The admin screens (`OrderController::updateQty` / `deleteItem`) keep re-splitting over
  every Child Order, and `updateQty` still leaves `purchase_price` at the old quantity.
  These are existing behaviours outside this feature; the customer edit must not copy them.
- Checkout shipping (`cart_shipping_cost` / `getShippingCost`) is computed from cart rows
  plus an address, so S0 and S1 are computed from transient cart-shaped rows built from
  the order lines, never real `carts` rows. Because only the difference is charged, a
  shipping cost staff set by hand (`edit_order_shipping_cost`) is kept and only the
  edit's own effect is added.
