# Orders and the cart: one snapshot, four statuses

> **Резюме по-русски.** Корзина считается сервером при каждом чтении, заказ —
> НЕ считается никогда: при оформлении он фиксирует снимок цен, скидок и
> состава, и после этого его денежные колонки не переписывает ни один путь кода
> (§3 — там же команда, которой это проверяется). Отсюда всё остальное: строки
> заказа не суммируются в его итог (заказная скидка и промокод по строкам не
> разносятся, §6.2), подарок лежит строкой, но ни в одной сумме не участвует,
> а правка акции задним числом не меняет ни один уже созданный заказ. Статусов
> четыре и переходы между ними СВОБОДНЫЕ в обе стороны (§7) — включая переход в
> тот же статус; необратимы только выданный купон и деньги. Корзина по
> персональному токену недоступна вовсе (§2) — это сессионная поверхность.

**Status:** draft · **Verified:** 2026-08-20, 25 measured scenarios against a real
Postgres (`tz_m4`) plus code anchors on `feature/promo-platform` (migration 0116)
**Owner:** `promo` · **Serves:** `GET /docs/orders` · **Skill:** none yet
**Neighbours:** [`promotions.md`](promotions.md) (rules, keys, campaigns, rounding),
[`catalogue.md`](catalogue.md) (products and their prices),
[matrix of discount intersections](../технические-паттерны/матрица-пересечений-скидок.md).

Draft, not `current`: the route now exists (`GET /docs/orders`, registered
2026-08-20), but no external run has used this document yet, so no one has failed
on it. Promote it when one has.

## 1. What you can do here

Read a shop's cart and change what is in it, apply and remove a promo code, turn
the cart into an order at a price the buyer agreed to, read orders as the seller
or as the buyer, move an order between four statuses (in either direction),
cancel it and reopen it, mark a refund, start an online payment, and exchange
messages on the order.

What you cannot do: rewrite an order. There is no endpoint that changes an
order's items, prices or totals, and there is no endpoint that deletes an order.
See §3 — this is the fact the whole area is built on.

## 2. Decide first (forks)

| If you… | Take this path | Cost |
|---|---|---|
| are an **agent with a personal token (PAT)** and want to work with orders | `POST /orders`, `GET /orders/{id}`, `PUT /orders/{id}/status`, `POST /orders/{id}/refund`, `POST /cart/confirm` — those five are the whole PAT surface | Everything else in this area answers `PAT_METHOD_NOT_ALLOWED`. **You cannot fill a cart with a token**, so `/cart/confirm` is reachable but useless to you: it turns *the token owner's own* cart into an order, and the only way to put things into that cart is a live session. Do not plan an "agent places an order" flow. |
| are a **storefront with a live session** | the whole cart surface, `/cart/confirm`, then the order endpoints | Nothing extra. This is the only supported checkout path today. |
| are building an **unsigned-in (guest) cart** | keep the contents on the client, **price** them with `POST /cart/quote`, and **store** them with `POST /cart/merge` on login | The server still has no anonymous cart — every cart row is keyed by `user_id` — so those are two different jobs, not two ways to do one. Merge **sums** quantities into whatever is already on the server, and is **not idempotent** — merging the same local cart twice doubles it (§4.4). |
| want a guest to see the **gift line and the code discount before logging in** | `POST /cart/quote` — public, no token, writes nothing | Nothing extra. Pricing it in the browser instead is the expensive branch: that is a second engine, it disagrees with the server's, and the buyer discovers the disagreement at checkout, in money. Quote runs the *same* function as `GET /cart/items` (§4.4). |
| want to be sure the buyer pays **the number they saw** | send `expected_total` on `/cart/confirm` | Nothing extra, and skipping it is the expensive branch: without it the server silently creates the order at whatever the recomputed price turns out to be (§5.1). |
| need an order's **total before discounts** | compute `items_total + discount_total` | There is no such field on an order, and summing the lines gives a third, wrong number (§6.2). |
| need to know **which discount was a promo code** | read `promotions[]` and look at `code` | The lines will not tell you: order-level steps are not spread over them. |
| want a **partial** refund | there is none | `POST /orders/{id}/refund` is a full-amount marker on the payment row and nothing else (§8.3). Money is moved by hand in the provider's cabinet; the platform only records that it happened. |
| want the order to move when **money arrives** | write the status yourself | A confirmed payment does not touch `orders.status`; it sends two letters and fires `order.paid` (§8.2). |
| want a **shop's whole order list** | `POST /orders` with `filter.company_id` | Note the verb: `POST /orders` **lists** orders. Creating one is `POST /cart/confirm`. |

The first row is the fork that costs a whole integration. Everything an agent
wants to do to a cart — add, remove, apply a code — is session-only.

## 3. The main fact: an order is a snapshot, and nothing rewrites it

**Every money column of an order is written once, inside the transaction that
creates it, and no code path updates it afterwards.** Not when a promotion is
edited, not when it is deleted, not when the shop's currency changes, not when
the product's price changes, not when the order is cancelled and reopened.

This is not a policy statement — it is a property of the code, and one command
shows it:

```bash
grep -rn 'UPDATE order_items' --include='*.go' internal/   # → nothing at all
grep -rn 'UPDATE orders'      --include='*.go' internal/   # → exactly two places
grep -rn 'DELETE FROM orders' --include='*.go' internal/   # → nothing at all
```

The two `UPDATE orders` are `pg/orders.go:473` (`status`, `status_changed_at`,
`status_by`, `updated_at`) and `pg/order_messages.go:79` (the "seen" timestamps
of the chat). Neither touches `items_total`, `discount_total`, `promotions` or
`currency`. `order_items` is insert-only, written in one place
(`pg/orders.go`). There are no database triggers on either table (checked with
`\d`, 0 rows in `pg_trigger`).

Two consequences worth internalising before you read anything else:

- the identities in §6.2 hold a year later, on orders whose rules no longer
  exist. Measured: an order priced by a `−20 %` rule keeps `items_total: 800`,
  `discount_total: 200` and `promotions[0].name: "-20%"` after the rule is
  deleted through the API;
- an order is a *fact of a deal*, not a view of the catalogue. If you need to
  change what was agreed, the model has no answer except a new order — which is
  also why "reopening" a cancelled order (§7.3) restores the *old* price.

## 4. The cart

### 4.1 The endpoints

```
GET    /cart/items?company_id=N          the whole cart of one shop
POST   /cart/items/{product_id}/{key}    add (body: {quantity, data})
PUT    /cart/items/{product_id}/{key}    set exact quantity (body: {quantity})
DELETE /cart/items/{product_id}/{key}    remove one line
POST   /cart/merge                       {company_id, items[]} → the whole cart
POST   /cart/promo                       {company_id, code}    → the whole cart
DELETE /cart/promo                       {company_id}          → the whole cart
POST   /cart/quote                       {company_id, items[], promo_code} → the whole cart
```

Those first seven are **session-only** (§2). `POST /cart/quote` is the one
endpoint in this area that takes **no token at all** and writes **nothing** — it
prices a cart the client is holding (§4.4). All of them answer with the same
`GetCartResponse` shape except add/update/remove, which answer `{}` — read the
cart afterwards.

⚠️ **`{key}` is a path segment and must not be empty.** A plain product has no
configuration, and the platform storefront sends the literal string `default`
for it (`vizen-market/storefront/src/store/cart.ts`, `add()`: `line.key || 'default'`). An empty segment
(`POST /cart/items/12/`) does not match the route: chi answers **404** — measured
against the same router version (`go-chi/chi/v5 v5.2.1`) with the pattern the
generated code registers, `"/cart/items/{product_id}/{key}"`.

The cart is **per shop**: a cart row's `company_id` comes from the product, not
from your request, and `GET /cart/items` filters by it. Asking for a shop where
this buyer has nothing — or omitting `company_id` entirely — returns an empty
cart with `200`, never an error.

### 4.2 Where a cart line's price comes from

The engine that applies promotions never chooses a base price; it works on top
of whatever these four sources resolved to, in this order of priority
(`get_cart.go:113-152`, mirrored in `pg/orders.go:130-152`):

<!-- gate:price-sources -->

| # | Source | Who owns the number |
|---|---|---|
| 1 | **combo set** — the product is a combo, so its price is derived: Σ of its components' effective prices | server, always; a hand-typed `base_price` on a combo product is ignored |
| 2 | **variant** — `data.variant_id` resolves to a live, active variant of *this* product | server: `COALESCE(variants.price, products.base_price)`; the variant also overrides the preview |
| 3 | **3D configuration** — `data.price.total` is present | **the client**, see the warning below |
| 4 | **base price** — none of the above | server: `products.base_price` |

<!-- /gate -->

⚠️ **Source 3 is a client-supplied number and it becomes the price.** Measured:
adding a product whose `base_price` is 1000 with
`data = {"price":{"total":7,"oldTotal":99}}` produces a cart line priced **7**,
and `POST /cart/confirm` writes an order with `items_total: 7`. This is the
documented state of the 3D path — the configuration document is accepted *as a
snapshot*, and the server-side recompute against the published artefact is a
planned stage (`domain/cart.go:47`). The only sanitising applied is rounding
and a clamp to `[0, 2 000 000 000]`. If you are writing a storefront, treat this
as a hole to keep closed, not a feature to use; it is named again in §16.

A `variant_id` that does **not** resolve (deleted, inactive, belonging to
another product) falls back to source 4 and strips the client's `price`,
`breakdown` and `display` keys from what is echoed back — other client keys
survive. Measured: `{"variant_id":"999999","price":{...},"breakdown":{...},
"display":"красный","note":"…"}` on a 1000-₽ product comes back as
`{"variant_id":999999,"note":"…"}` priced 1000, in the cart and in the order
alike.

### 4.3 Reading the cart

| Field | What it is |
|---|---|
| `subtotal` | **payable** — after every discount step. The name is historical |
| `subtotal_before` | before discounts — but see the warning |
| `discount_total` | `subtotal_before − subtotal` |
| `applied[]` | the promotions that fired, per rule, with `amount`, `kind`, `code` |
| `promo_code` | the code stored on this cart (uppercased by the server) |
| `promo_rejected` | why the code did not produce a discount — the stored one, or the one posted to `/cart/quote` — codes in [`promotions.md` §3.5](promotions.md) |
| `count` | units, **including gift units** |
| `total` | number of lines, **including gift lines** |
| `items[].is_gift` | this line is a gift: `price.price = 0`, `base_unit_price` = its value |
| `items[].is_combo`, `combo_items[]` | this line is a combo set and what is inside it |
| `items[].base_unit_price` | price before the per-line discount; **0 means "no per-line discount"**, not "free" |

⚠️ `subtotal_before` can answer **0 with goods in the cart** — in a shop that has
no active promotion at all, because the step that fills it never runs. Strike
through `subtotal + discount_total` instead. Same trap, same fix, as on the order
side (§6.2). `promo_rejected: "NEED_AUTH"` is **not** a server code: the platform
storefront invents it locally for an anonymous visitor who typed a code it
could not get a verdict for — no `/cart/quote` on that page, or the quote call
failed (`storefront/src/store/cart.ts`, grep `NEED_AUTH`). On the cart and
checkout pages the verdict comes from the engine instead, in `promo_rejected`
of the `/cart/quote` answer.

### 4.4 Guest against signed-in: quote to show it, merge to keep it

A guest cart lives in the browser. Two server calls touch it, and they are not
alternatives — one computes, the other stores:

| Call | Token | Writes | Answers |
|---|---|---|---|
| `POST /cart/quote` | none | **nothing** | the priced cart |
| `POST /cart/merge` | session | the buyer's cart rows | the priced cart |

#### Pricing a guest cart — `POST /cart/quote`

```json
{ "company_id": 12,
  "items": [ {"product_id": 41, "key": "default", "quantity": 2, "data": {}} ],
  "promo_code": "SUMMER25" }
```

The body carries the **same `items[]` as merge**, and the answer is the **same
`GetCartResponse` as `GET /cart/items`**: same `subtotal` / `subtotal_before` /
`discount_total`, same `applied[]`, same gift lines, same `promo_rejected`
codes. That is not a coincidence of shape. The handler hydrates the posted lines
into cart lines and hands them to the *same* function that serves the signed-in
cart (`quote_cart.go` → `computeCart` in `get_cart.go`), so **the same contents
produce the same money before and after login** — gated by
`TestB5Quote_ParityWithSignedIn`, which puts a category sale, a combo set, a
gift rule and a promo code into one basket and compares every money field.

What it deliberately does **not** do:

- **it writes nothing** — no cart row, no `cart_promo`, and above all no
  redemption. A code here is *shown*, not spent: `promo_keys.used` does not move
  and `promo_redemptions` stays empty however many times you call it (gated by
  `TestB5Quote_PromoCodeShownButNotRedeemed` and `TestB5Quote_NothingWritten`).
  Redemption lives in the checkout transaction and nowhere else;
- **it has no buyer**, so a **personal** coupon answers
  `promo_rejected: "NOT_FOUND"` — the same mask a foreign coupon gets, on
  purpose (`promotions.md` §3.5). Tell the visitor to sign in, not that the code
  is invalid;
- **it does not filter by stock.** A line whose product has run out is still
  priced — exactly as it is for a signed-in buyer. Shortage is a *checkout*
  refusal (§5.2); filtering here would make a guest's cart differ from their own
  cart one second later, after login.

Lines are dropped **silently** when the product is unknown, deleted, unpublished
or belongs to another shop — merge's tolerance, for merge's reason: a stale
`localStorage` must not cost the buyer the whole calculation. It buys one more
thing here — an id either prices or vanishes, so the endpoint cannot be walked
as an "is this draft real?" oracle. Duplicate `(product_id, key)` lines are
**summed and clamped at 9999**, later `data` winning, which is the arithmetic of
merge's `ON CONFLICT DO UPDATE`: one basket must not become two different totals
across a login.

One thing is **stricter** than merge: **101 items is a 400**
(`InvalidArgument`, from the proto validator) where merge silently drops
everything past the 100th. Merge replays a cart its own owner already had; quote
is posted by anyone, and "we priced the first 100 of your 5 000" is a wrong
number wearing the clothes of a right one.

It is **rate-limited per IP in its own bucket**, 60/minute by default (§13) —
this is the only place in the platform where a promo code can be probed with no
account at all.

#### Keeping it — `POST /cart/merge`

There is no anonymous cart on the server. The storefront keeps one in
`localStorage` and, on login, posts it to `POST /cart/merge`, which replays every
line through the same insert `AddToCart` uses and then returns the resulting
cart.

That insert is `ON CONFLICT … DO UPDATE SET quantity = LEAST(cart.quantity +
EXCLUDED.quantity, 9999), data = EXCLUDED.data`, and three things follow:

- **quantities add**, so merging twice doubles. Nothing marks a merge as done;
- **`data` of the later line wins** — adding the same `(product_id, key)` again
  replaces the stored configuration and keeps the summed quantity;
- **bad lines are skipped, not refused.** Measured: of 123 posted items (one
  unknown product, one with `quantity: -5`, and 120 more), the call returned
  `200` with 28 lines and 101 units — the first 100 items were processed
  (`merge_cart.go:23` truncates), the unknown product was dropped, and `-5`
  became `1`.

## 5. Checkout — `POST /cart/confirm`

```json
{ "company_id": 12, "contact": {"name":"…","phone":"…","email":"…"},
  "comment": "…", "address": { …any JSON… }, "expected_total": 2165 }
```

`contact.name` and `contact.phone` are required after trimming — whitespace-only
answers `CONTACT_REQUIRED`. `contact.email` is optional and an order is created
without it. `comment` and `address` are not validated or bounded by this area:
20 000 characters of comment and 5 KB of address were accepted.

### 5.1 `expected_total` — the only thing standing between the buyer and a surprise

The server prices the order **again**, from the live rules, inside the checkout
transaction. Between the moment the cart was shown and the moment Confirm was
pressed, a promotion may have ended, a single-use code may have been taken by
somebody else, or a campaign budget may have run out. Send the sum you last
showed the buyer and the server refuses instead of quietly charging a different
number:

| You send | What happens |
|---|---|
| the sum the buyer saw | mismatch → `PRICE_CHANGED`, **no order created**, the cart is left intact for a re-read |
| `0`, or nothing | check **skipped** — this is how clients written before the field keep working |
| a negative number | check **skipped** as well (the condition is `expectedTotal > 0`) |

Compare against `result.subtotal` of the cart you rendered — that is the payable
total, the same number the order will carry as `items_total`. Measured: a cart
worth 1000 confirmed with `expected_total: 999` answers
`FailedPrecondition / PRICE_CHANGED` and leaves the cart with its one line
intact; `-5` and `0` both create the order.

### 5.2 Refusals

<!-- gate:refusals -->

| Message | gRPC code | HTTP | Meaning |
|---|---|---|---|
| `CONTACT_REQUIRED` | `InvalidArgument` | 400 | name or phone missing after trim |
| `CART_EMPTY` | `FailedPrecondition` | 400 | nothing to order — including "everything in it was deleted from the catalogue" |
| `COMPANY_UNAVAILABLE` | `FailedPrecondition` | 400 | the shop is blocked or deleted. Its cart still reads fine |
| `ORDER_TOO_LARGE` | `FailedPrecondition` | 400 | the sum **before discounts** passed 2 000 000 000 (§13) |
| `PRICE_CHANGED` | `FailedPrecondition` | 400 | `expected_total` did not match the recomputed total |
| `PROMO_CODE_EXHAUSTED` | `FailedPrecondition` | 400 | the last use of the code was taken between pricing and writing |
| `CAMPAIGN_BUDGET_SPENT` | `FailedPrecondition` | 400 | the campaign behind the **code** ran out in the same window |
| `out_of_stock` | `FailedPrecondition` | 400 | a line's stock — or a gift's — ran out between pricing and writing. The whole transaction rolls back: measured, the cart keeps its lines and the stock does not move. **Derived, not measured on the wire**: the handler returns a bare wrapped `domain.ErrOutOfStock` (measured as `CreateOrderFromCart: out of stock`) and `apierr.UnaryErrorMapper` maps it to this code — note the spelling differs from the cart's, §12 |

<!-- /gate -->

⚠️ **`FailedPrecondition` arrives as HTTP 400, not 412.** grpc-gateway maps it
that way on purpose (`runtime.HTTPStatusFromCode`, v2.22.0), and this project
installs no custom mapper — its error writer (`internal/api/apierr/gateway_error.go`)
reuses the library's table verbatim. The body is
`{"error":"rpc error: code = FailedPrecondition desc = PRICE_CHANGED"}`, so
**match on the code string, not on the HTTP status**: eight different outcomes
above share 400.

### 5.3 What one `POST /cart/confirm` actually does

All of it in a single transaction, serialised per `(user, shop)` by an advisory
lock — a double submit cannot create two orders (`pg/orders.go:36`):

1. checks the shop is `active` and not deleted; takes its `currency` as the
   order's currency snapshot;
2. re-reads the cart `FOR UPDATE`, **excluding deleted products** but *not*
   unpublished ones (a product taken off the shelf still ships);
3. resolves prices by §4.2 and sums; over 2 000 000 000 → `ORDER_TOO_LARGE`;
4. runs the promotion engine — the *same* `promotions.ApplyWith` the cart runs,
   with rules, shop margin cap and the presented key all read inside this
   transaction, so the order cannot disagree with the cart it was made from;
5. compares against `expected_total` (§5.1);
6. inserts `orders` + one `order_items` row per line;
7. redeems the key: one conditional `UPDATE promo_keys SET used = used + 1` that
   carries the limit check inside it — zero rows means somebody else took the
   last use, and the whole order is refused rather than created at a different
   price. Writes a `promo_redemptions` row and clears the code off the cart;
8. charges every campaign that had a rule fire — not just the code's. If a
   budget cannot cover the deal, only the **code's** campaign refuses the order;
   for ordinary promotions the budget goes negative by at most one deal, because
   the buyer was already shown that price;
9. takes stock for every line, gifts included, in the same transaction;
10. deletes the cart rows that went into the order, plus rows pointing at
    deleted products (otherwise they would be invisible and undeletable
    forever). A line added concurrently by another tab survives.

## 6. The order

### 6.1 Shape

`Order`: `id`, `buyer_id`, `company_id`, `company_name`, `status`, `contact{}`,
`comment`, `address`, `items_total`, `currency`, `items[]`, `discount_total`,
`promotions[]`, `payment_status`, `can_pay`, `receipt_sent`, `created_at`,
`updated_at`.

`OrderItem`: `product_id`, `name`, `key`, `data`, `unit_price`, `quantity`,
`line_total`, `preview`, `base_unit_price`, `promotion{}`.

`items_total` is **payable**, after every discount. `name`, `preview` and `data`
are snapshots taken at checkout; renaming the product later does not change a
placed order. `currency` is likewise frozen: measured — an order placed in `RUB`
still reads `RUB` after the shop switched to `USD`, and only the next order
reads `USD`.

`buyer_id` carries the id of the account that placed the order. The contract
reserves `0` for a guest order; today `/cart/confirm` requires an authenticated
session (the method sits under the `Catalog/*` role wildcard and reads its user
from the passport), so no path produces one — see §15.

### 6.2 Identities · use these instead of your own arithmetic

Let **R** = lines that are not gifts, **K** = `promotions[]` entries whose
`kind == "order_discount"`, **I** = the rest of `promotions[]`.

<!-- gate:identities -->

| # | Identity | What it gives you |
|---|---|---|
| 1 | `Σ_R line_total − Σ_K amount == items_total` | the only correct way to reach the payable total from the lines |
| 2 | `Σ_R (base_unit_price or unit_price) × quantity == items_total + discount_total` | the total **before** discounts — an order has no field for it |
| 3 | `Σ promotions[].amount == discount_total` | the snapshot explains the discount with no remainder |
| 4 | `Σ_R promotion.amount == Σ_I amount` | the per-rule aggregate matches the per-line shares |

<!-- /gate -->

Three rules that make those identities usable:

- **order-level steps are not spread over the lines.** An order discount and a
  promo code reduce `items_total` and appear in `promotions[]`, and no line
  knows about them. `Σ line_total` is therefore *someone else's number* — in the
  worked example below it is 685 too high, and nothing in the payload says so;
- **tell the steps apart by `kind`**: `order_discount` with an empty `code` is
  the order step, `order_discount` with a `code` is the promo code or coupon,
  anything else is already inside the lines. This is reliable, not a
  coincidence: the validator refuses `class: "order"` and `class: "key"` on any
  other kind, so an order-level entry cannot arrive wearing `product_discount`;
- **a gift is a real line that is in no total.** `unit_price: 0`,
  `line_total: 0`, `base_unit_price` = its value, `promotion.kind == "gift"`.
  Exclude it from every sum. The order has **no `is_gift` flag** — the cart does;
  on the order the discriminator is the promotion kind.

`base_unit_price: 0` means "this line got no per-line discount", not "the price
was zero" — fall back to `unit_price`.

### 6.3 Worked example — one cart through every layer, with the numbers

Measured, not composed. Shop: `max_total_discount_percent = 30`. Cart: product A
(1000 ₽ ×1), product B (2000 ₽ ×1). Rules: per-line `−15 %` on A; order `−10 %`
from 2000; a `class: "key"` rule `−20 %` behind the code `SPRING`; a gift rule
handing out a 300-₽ product; the code applied.

| Step | Arithmetic | Running total |
|---|---|---|
| base prices | 1000 + 2000 | `subtotal_before = 3000` |
| **item**: −15 % on A | A: 1000 → 850, benefit **150** | 2850 |
| **order**: −10 % of 2850 | benefit **285** | 2565 |
| **key**: −20 % | base = lines with **no** per-line discount = 2000 → benefit **400** | 2165 |
| **shop cap 30 %** | allowance = 3000 × 30 % = 900; discount so far = 835 ≤ 900 → **does not fire** | 2165 |
| **gift** | 300-₽ line at `unit_price 0`, materialised last | 2165 |

The order that comes out: `items_total: 2165`, `discount_total: 835`,
three `promotions[]` entries (150 `product_discount`, 285 `order_discount`,
400 `order_discount` with `code: "SPRING"`), and three lines — A at 850, B at
2000, and the gift at 0 with `base_unit_price: 300`.

Check the identities against it:

```
Σ line_total (all three lines)      = 850 + 2000 + 0 = 2850   ← NOT the total
identity 1: 2850 − (285 + 400)      = 2165 = items_total      ✓
identity 2: 1000 + 2000             = 3000 = 2165 + 835       ✓
identity 3: 150 + 285 + 400         = 835  = discount_total   ✓
```

**The layer that did not fire explains the model better than the ones that did.**
The shop's margin guard allowed 900 and only 835 was given, so it stayed out of
the way. Change that one condition — cap **20 %** instead of 30 % — and the same
cart measures: allowance 600, and the guard trims **top-down**, taking it out of
the code first: the key's benefit drops 400 → **165**, the order step keeps its
285, the per-line 150 is never touched. `subtotal: 2400`, `discount_total: 600`
= 150 + 285 + 165. Per-line discounts are exempt on purpose: trimming them would
mean redistributing money across lines, and the line snapshot would stop being
the price the buyer agreed to.

The gift, meanwhile, is in none of it: not in `subtotal_before`, not in any
threshold, not in `applied[]`, not in `discount_total`. It is in the stock ledger
— the gift product's `stock_quantity` went 5 → 4.

### 6.4 Reading orders

```
POST /orders            body {"filter":{"company_id":12,"status":1},"page":{"number":1,"limit":20}}
GET  /orders/{id}
```

`POST /orders` **lists**; `filter.company_id` means "as the seller" and requires
that shop to be the caller's main company, no filter means "my own orders as a
buyer". Newest first, tie-broken by id so pages cannot repeat or lose a row.
Access to a single order: the buyer of it, or the seller whose main company it
belongs to; anyone else gets `PermissionDenied / DENIED` — measured. Note that
this **confirms the order exists**, while the chat on the very same order
answers `NotFound / ORDER_NOT_FOUND` to the same stranger. Two masks, one
resource; §16.

## 7. Statuses

### 7.1 The four, and who may move them

<!-- gate:statuses -->

| Value | Name | Who can set it |
|---|---|---|
| 1 | new | seller (any → any); buyer never |
| 2 | processing | seller only |
| 3 | done | seller only |
| 4 | cancelled | seller from anywhere; **buyer only from `new`, on their own order** |

<!-- /gate -->

**Every valid status may follow every valid status**, including itself:
`AllowedOrderTransition` is literally `ValidOrderStatus(from) && ValidOrderStatus(to)`
(`domain/order.go:26`). The owner's decision was that stages behave like buttons,
backwards included. **All sixteen pairs were run, each on its own order: zero
refusals.** Refusals come from four other places instead:

- `BAD_STATUS` — the number is not 1…4 (measured with `0` and `7`, both `InvalidArgument`);
- `BUYER_CAN_ONLY_CANCEL_NEW` — a buyer asked for anything but cancelling a new
  order of their own;
- `STATUS_CONFLICT` — somebody changed the status between your read and your
  write (the update is conditional on the status you saw);
- `REFUND_REQUIRED` — cancelling an order whose payment is `paid`. Mark the
  refund first (§8.3).

⚠️ **"Seller" here means the caller's `main_company_id`, not membership.** The
chat on the same order uses membership instead (`resolveDealAccess`), so a staff
member whose main company is elsewhere can read and write the order's messages
and still get `DENIED` on its status. Named again in §16.

### 7.2 What each transition does, and whether a repeat is safe

<!-- gate:transitions -->

| Transition | Effects | A repeat does… |
|---|---|---|
| → `cancelled` | refuses if the payment is `paid`; returns stock inside the status transaction; **after** the commit, rolls back the redemptions: `promo_keys.used − 1`, campaign budget refunded, the per-customer limit freed | nothing. Stock movements are unique per `(order, product, reason, round)`; the rollback only touches rows with `reverted_at IS NULL`. Measured: three consecutive cancels of a 2-unit order left stock at 10, not 12 or 14 |
| `cancelled` → anything | re-applies the redemptions **unconditionally** (`used + 1`, budget re-charged) and deducts the stock again under its own reason `reorder`. A **gift** line whose product sold out meanwhile is not re-deducted: the line stays in the order, the reopen goes through (`TestB54Reopen_SoldOutGiftDoesNotBlockReopen`) | nothing: the re-apply only touches rows with `reverted_at IS NOT NULL` (`TestB54Reopen_Idempotent`). The next cancellation puts back exactly what the order still holds, and never the withheld gift: when the gift is a separate product it wrote no ledger row at all (`TestB54Reopen_WithheldGiftKeepsLedgerBalanced`); when it is the *same* product as a paid line the movement is shared, so the reopen writes a **reduced** `reorder` row and the cancellation returns by the last deduction rather than by the checkout row (`TestB54Reopen_MixedGiftOfSameProductLedger`) |
| → `done` | issues the coupons of every `issue_key` rule this order satisfies, mails them to the buyer, fires `key.issued` per coupon | no second coupon. Idempotency rests on a unique index (`idx_promo_keys_issued_once`), not on a check — a double click produces two *parallel* calls, and the loser is logged as a normal outcome, not an error |
| **any → the same value** | allowed; `updated_at`, `status_changed_at`, `status_by` all move; the `order.status_changed` webhook fires again with `old_status == new_status` | see the rows above — the *effects* are idempotent, the *bookkeeping* is not |

<!-- /gate -->

Two asymmetries are deliberate and worth knowing before you build retries:

- **paid stock refuses; promo and gifts do not.** Reopening an order whose
  **purchased** goods were sold in the meantime fails the whole transition — there
  is nothing to ship (`TestStockRound_SoldOutReopenChangesNothing`). Reopening an
  order whose single-use code was taken in the meantime succeeds and lets the
  counter overspend by exactly one deal — the buyer already has that price in a
  placed order, and no further order will see the rule
  (`TestB54Reopen_ExhaustedKeyOverspendsByOneDeal`). Reopening an order whose
  **gift** sold out also succeeds: the gift is not reserved again, its line stays
  in the order as a record of what was handed out, and the seller decides at
  shipping. Owner's decision №6 of 2026-08-20 — a free line the buyer never
  picked and cannot remove must not lock a placed order in `cancelled`
  (`TestB54Reopen_SoldOutGiftDoesNotBlockReopen`). When the gift is the **same
  product** as a paid line ("buy two, the third is free"), the paid part is still
  deducted in full or the reopen fails: a shortage that reaches the purchased
  units is a refusal, not a degradation
  (`TestB54Reopen_SecondRoundKeepsPaidUnitsPaid`). One case is **not** covered by
  that decision: a gift product the seller *deleted* while the order was cancelled
  still fails the reopen, with `not_found` rather than `out_of_stock` — measured,
  ungated, named in `stock.md` D10 (a);
- **the rollback of redemptions runs after the status commit**, in its own
  transaction, and its failure is logged rather than raised — the seller's status
  change must not fail over a counter. The cost is a race named in §16.

### 7.3 What is irreversible

- **an issued coupon.** Cancelling a completed order does not revoke it — the
  key row stays, the letter is sent. Nothing in the code deletes an issued key
  on cancellation;
- **money.** A payment marked `refunded` cannot go back to `paid` through this
  API, and `can_pay` stays `false` for a refunded order — a new order is the
  answer;
- **the price.** Reopening a cancelled order brings back its snapshot, not
  today's catalogue. That is the point of §3.

Everything else — status, stock, key counters, campaign budgets — goes both ways.

## 8. Payment

### 8.1 Starting one

`POST /orders/{id}/pay` → `{"pay_url": "…"}`. Session-only, buyer-only: even the
seller gets `ORDER_NOT_FOUND` on somebody else's order, because a stranger has no
business learning that it exists. The link is built and signed by the server with
the **shop's** provider keys — the platform never touches the money, it belongs
to the merchant's own contract with the provider.

Refusals: `PAYMENTS_NOT_CONFIGURED` (the service is not wired at all — measured
on a stand without it), `PAYMENTS_DISABLED` (the shop has not switched the till
on), `ORDER_NOT_PAYABLE` (cancelled or done), `CURRENCY_NOT_SUPPORTED` (the till
is rouble-only and there is no conversion), `ORDER_ALREADY_PAID`,
`PAYMENT_ALREADY_STARTED` (`AlreadyExists` → HTTP 409; a live link blocks a
second one, because two charges leave an unanswerable question).

The amount is `items_total × 100` kopecks. The receipt address is
`contact.email` if present, otherwise the account's address — a receipt has to go
somewhere and the order does not require an email.

`payment_status` and `can_pay` come back on every order read and are computed by
the server (`domain.CanPayOrder`): `''` means no payment was ever started;
`pending`, `paid`, `expired`, `failed`, `cancelled`, `superseded`, `overpaid`
are the rest. Both fields are silently absent when the payment service is not
wired — an order still reads, it just answers `payment_status: ""` and
`can_pay: false`.

### 8.2 A confirmed payment does not move the order

It sends two letters (buyer: "paid"; seller: "money arrived, you can ship") and
fires the `order.paid` webhook. The status stays where the seller left it — see
the note in `services/shop_payments.go:406`: *"the order stays in status 'new'
and gives no sign of itself"*, which is exactly why the seller's letter exists.

### 8.3 `POST /orders/{id}/refund` — what it does and what it does not

It flips **one** row: the order's `paid` payment becomes `refunded`. That is the
entire effect. Measured on an order worth 1800 with a per-line discount:

- order `status` unchanged (`1`), `items_total` unchanged (1800),
  `discount_total` unchanged (200), lines unchanged;
- stock unchanged (still 5 of 7 after a 2-unit order), stock ledger still one
  movement;
- **the coupon stays spent and the campaign's budget stays charged.**
  `promo_keys.used` does not go back, `promo_campaigns.budget_used` does not go
  back, no row in `promo_redemptions` gets a `reverted_at`, and presenting the
  same single-use code again still answers `EXHAUSTED`. This is **owner's decision
  №1 of 2026-08-20, not an oversight**: the order *happened*. The buyer used the
  code, the deal went through, and handing the counter back because the money was
  returned would give a one-time code a second life for every refund. Gated by
  `TestB54Refund_PartialRefundDoesNotReturnTheCoupon`;
- a second call answers `NOTHING_TO_REFUND`, as does a call on an unpaid order;
- afterwards, `→ cancelled` passes the `REFUND_REQUIRED` gate and *then* returns
  the stock **and** rolls the redemptions back, the way any cancellation does.
  Cancelling is the action that undoes the deal; the refund only unlocks it.

There is **no partial refund**: no amount is accepted, and the payment row has
one status for the whole sum. A merchant refunding half the order in the
provider's cabinet has nowhere to record that here. This is a named gap, not an
oversight — see §16.

## 9. Messages on an order

```
GET  /orders/{order_id}/messages    oldest first, page.limit default 20
POST /orders/{order_id}/messages    {"body": "…"}  1…5000 characters
POST /orders/{order_id}/seen        clears the badge of the calling side
GET  /deals/badge                   unread counter
```

`author_role` (1 buyer, 2 staff) and `kind` (1 text, 2 system) are set by the
server from the caller's access, never accepted from the client. Access is the
buyer of the order **or** any staff member of the shop by membership
(`owner|admin|editor`); anyone else gets `ORDER_NOT_FOUND` rather than a denial,
so a stranger cannot probe which order ids exist. A body that is whitespace-only
answers `EMPTY_BODY`.

All four are **session-only** — PAT-denied by design, and that is guarded:
`TestDealsChatPATDenied` fails the day one of them is added to the scope map.

## 10. Whole numbers and rounding

Money in this area is whole currency units — roubles, not kopecks — in every
field of the cart and the order. The one place kopecks appear is the payment
amount handed to the till (`items_total × 100`).

Rounding never happens *in* this area: every fraction is produced by the
promotion engine, and the single source of truth for which way each step rounds
is [`promotions.md` §3.10](promotions.md), whose table is gate-checked against
the engine's own helpers. Two clamps do belong here:

- money fields on the wire are `int32`. A cart total above 2 147 483 647 is
  **clamped**, not refused: a cart of 9 999 × 250 000 reads `subtotal:
  2147483647`;
- checkout refuses the same cart with `ORDER_TOO_LARGE`, and it measures the
  ceiling **before** discounts. Measured: the same cart with a `−100 %` rule on
  it is still refused.

## 11. Recipes

`$API` is the shop API root, `$TOKEN` a session token (the cart is session-only,
§2). `$CID` is the shop id.

### 11.1 Place an order at the price the buyer saw

```bash
# 1) put something in the cart — note "default" as the key of a plain product
curl -X POST "$API/cart/items/101/default" -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' -d '{"quantity":2}'

# 2) read the cart and take the payable total from it
curl -s "$API/cart/items?company_id=$CID" -H "Authorization: Bearer $TOKEN" \
  | jq '{subtotal, subtotal_before, discount_total, promo_code, promo_rejected}'

# 3) confirm with exactly that number
curl -X POST "$API/cart/confirm" -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' -d "{\"company_id\":$CID,
      \"contact\":{\"name\":\"Иван\",\"phone\":\"+70000000000\"},
      \"expected_total\":2165}"
```

**Verify:** the response carries `result.items_total == 2165` and
`result.id`. If it answered `PRICE_CHANGED` instead, re-read the cart — the
price moved, and *not* creating the order was the point. Do not retry with
`expected_total: 0` to "make it go through": that is the branch that charges a
number nobody saw.

### 11.2 Read an order's money correctly

```bash
curl -s "$API/orders/321" -H "Authorization: Bearer $TOKEN" | jq '
  .result as $o
  | ($o.items | map(select(.promotion.kind != "gift"))) as $R
  | ($o.promotions | map(select(.kind == "order_discount")) | map(.amount) | add // 0) as $K
  | { payable:        $o.items_total,
      lines_sum:      ($R | map(.line_total) | add // 0),
      from_lines:     (($R | map(.line_total) | add // 0) - $K),
      before_discount:($R | map(((.base_unit_price // 0) | if . == 0 then .unit_price else . end) * .quantity) | add // 0),
      check_2:        ($o.items_total + $o.discount_total) }'
```

**Verify:** `from_lines == payable` (identity 1) and
`before_discount == check_2` (identity 2). `lines_sum` is printed next to them
on purpose — it is the number a naive importer would book, and it differs by the
order-level steps.

### 11.3 Walk an order to "done" and see the coupon appear

```bash
curl -X PUT "$API/orders/321/status" -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' -d '{"id":321,"status":2}'   # processing
curl -X PUT "$API/orders/321/status" -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' -d '{"id":321,"status":3}'   # done
```

**Verify:** as the buyer, `GET /my-coupons` now lists a coupon if the shop has a
live `issue_key` rule whose threshold this order's **payable** total met.
Sending `status: 3` a second time is allowed and grants nothing more — the
issuance is guarded by a unique index, not by a check.

### 11.4 Cancel and watch the stock and the code come back

```bash
curl -X PUT "$API/orders/321/status" -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' -d '{"id":321,"status":4}'
```

**Verify:** `GET /products/{id}` shows `stock_quantity` back up by the ordered
quantity, and the single-use code used on that order works again for the same
buyer. If the call answered `REFUND_REQUIRED`, the order is paid — mark the
refund first (11.6). Cancelling twice changes nothing further: measured, stock
stays put across three consecutive cancels.

### 11.5 Reopen it

```bash
curl -X PUT "$API/orders/321/status" -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' -d '{"id":321,"status":2}'
```

**Verify:** stock goes down again and the key counter goes back up — the order
returns to work *at its own snapshot price*, which is why the code has to be
spent again. Expect a refusal if the **purchased** goods were sold while the order
lay cancelled: reopening what cannot be shipped is a promise the shop cannot keep.
Two things never cause that refusal — the code (it overspends by one deal instead)
and a **gift** whose product sold out: the reopen passes, the gift is simply not
reserved again, and its line stays in the order for you to deal with at shipping
(§7.2).

### 11.6 Mark a refund — and see how little it does

```bash
curl -X POST "$API/orders/321/refund" -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' -d '{"id":321}'
```

**Verify:** re-read the order. `payment_status` is `refunded`; `status`,
`items_total`, `discount_total` and every line are **unchanged**, the stock has
**not** moved and the promo counters have **not** been given back — the coupon
stays spent by decision, not by omission (§8.3). A second call answers
`NOTHING_TO_REFUND`. If you wanted the goods back on the shelf and the coupon
freed, that is the cancellation (11.4) — the refund only unlocks it.

## 12. Silently ignored

Every row was measured on 2026-08-20 against a live handler, not read off the
code. "200" below includes the empty `{}` bodies of the cart mutations.

| What you send | What happens | Why |
|---|---|---|
| `quantity` above 9999 (add, update, merge) | **clamped to 9999**, 200 | Anti-overflow ceiling, not a validation. Measured: 100 000 → 9999, and `PUT` with 50 000 → 9999. |
| `quantity` of `0` or negative on **add** or **merge** | becomes **1** | Same clamp from the other side; a merge line with `-5` arrived as 1. **`PUT` is different**: its validator does run, so `quantity: 0` is refused with `InvalidArgument` and the line keeps its old quantity — measured. |
| adding the same `(product_id, key)` twice | quantities **add**, the second `data` **replaces** the first | One `ON CONFLICT DO UPDATE`. The consequence for merge is in §4.4: merging twice doubles. |
| `page` on `GET /cart/items` | **ignored entirely** | The cart is never paginated. Measured: `{number:3, limit:1}` over a 3-line cart returned all 3 with `current_page: 1`. |
| `page.limit` outside 1…100 on `POST /orders` | silently **20** | The proto declares `1…100`, but `GetOrders` never calls the validator. Measured: `limit: 500` → 20 rows, `limit: 0` → 20 rows, `limit: 5` → 5. |
| `filter.status: 0` | no status filter at all | 0 is "not sent". Any other number is accepted without validation: `99` answers `200` with an empty list, not an error. |
| `expected_total: 0` or a negative number | the price check is **skipped** | 0 means "not sent" — clients written before the field keep working. The condition is `> 0`, so a negative disables it too. |
| more than 100 items in `POST /cart/merge` | everything past the 100th is **dropped** | Anti-DoS on a client-supplied list. Measured: 123 items in, 100 processed. |
| a merge line with unparsable or oversized `data` | that line is **skipped**, the rest of the merge succeeds | One bad line out of `localStorage` must not cost the buyer the whole cart. The same `data` sent to `POST /cart/items/…` answers `DATA_TOO_LARGE` instead — measured with 70 KB. |
| a merge line naming a product that is unknown, deleted or unpublished | **nothing is inserted** and nothing is said | The insert is a `SELECT … FROM products WHERE …` that matches no row. Measured with an unknown id. |
| a `POST /cart/quote` line naming a product that is unknown, deleted, unpublished or **of another shop** | that line is **skipped**; the rest is priced, 200 | Merge's tolerance (a stale `localStorage` must not cost the whole calculation), plus one more effect: an id either prices or vanishes, so the public endpoint is not a "does this draft exist?" oracle. `TestB5Quote_ForeignAndUnpublishedSkipped`. |
| the same `(product_id, key)` twice in one `POST /cart/quote` | quantities **add**, clamped at 9999; the later `data` wins | Deliberately the arithmetic of merge's `ON CONFLICT DO UPDATE` — one basket must not have two totals depending on whether the buyer is logged in. `TestB5Quote_DuplicateLinesMergedLikeAddToCart`. |
| a promo code on `POST /cart/quote` | the discount is **shown**; the code is **not spent** | `promo_keys.used` does not move and `promo_redemptions` stays empty. Showing is not redeeming; redemption is in the checkout transaction only. |
| a **personal** coupon on `POST /cart/quote` | `promo_rejected: "NOT_FOUND"`, no discount, 200 | Quote has no buyer to match `owner_user_id` against. A distinct reason would confirm that the coupon exists. |
| a `quantity` of 0 or above 9999 on a `POST /cart/quote` line | becomes **1** / **9999** | Same clamps as add and merge, and for the parity reason: the guest's basket must hydrate into exactly the rows `AddToCart` would have written. |
| **more than 100** items in `POST /cart/quote` | **400**, `InvalidArgument`, nothing is priced | The one place quote is *stricter* than merge (§4.4). Loud on purpose: a truncated basket returns a plausible wrong total. |
| a merge line naming a product of **another shop** | it lands in **that shop's** cart and is therefore absent from this response | A cart row's `company_id` comes from the product, not from your `company_id` — that field only chooses which cart is read back (`pg/cart.go:24-27`). Derived from the SQL, not measured; the row will look lost rather than misplaced. |
| unpublishing a product that is already in carts | it **stays** in every cart and goes into orders | The owner's decision: taking a product off the shelf does not erase history. Only a **new** `AddToCart` is refused. |
| deleting a product that is in a cart | it disappears from the cart on the next read, and its rows are **swept** at checkout | Otherwise those rows would be invisible and undeletable forever. If it was the only line, checkout answers `CART_EMPTY`. |
| `data.price` / `breakdown` / `display` on a line whose `variant_id` does not resolve | those three keys are **stripped** from the echo and the price falls back to `base_price`; your other keys survive | Anti-spoofing: an unresolved set must not be able to price itself. Measured with `variant_id: "999999"`. |
| `data.price.total` on a line with **no** `variant_id` | it **becomes the price** | The 3D configuration path (§4.2). Not an oversight to route around — a hole named in §16. |
| moving an order to the status it is already in | 200; no second stock movement, no second coupon; but `updated_at` moves and `order.status_changed` fires with `old == new` | Transitions are unrestricted by design, so the *effects* were made idempotent instead. A webhook consumer that counts status changes will over-count. |
| cancelling twice | stock is returned **once** | The ledger key is `(order, product, reason, round)`. Measured over three cancels in a row. |
| `contact.email` on the order | never used for the **coupon** letter | The coupon is personal and goes to the account's address; sending it to an address typed into an order form would hand somebody else's discount to a stranger. `grep -n ContactEmail internal/api/catalog/coupon_notify.go` → nothing. It *is* used for the fiscal receipt and shown in the seller's letter. |
| paying the order | the **status does not move** | The seller ships, so the seller advances the stage. The payment sends two letters and `order.paid` instead. |
| `POST /orders/{id}/refund` | only the payment row changes | No stock, no promo, no status, no amount — §8.3. The coupon and the campaign budget stay spent **by owner's decision №1**, gated by `TestB54Refund_PartialRefundDoesNotReturnTheCoupon`. |
| `comment` and `address` on checkout | accepted unbounded and unvalidated | Measured: 20 000 characters of comment and 5 KB of arbitrary JSON address. This area sets no limit of its own. |
| `GET /cart/items` without `company_id`, or with a shop the buyer has no cart in | empty cart, 200, `currency: ""` | The cart is per shop and 0 is "not sent". Nothing errors, so an integration pointed at the wrong shop looks like an empty cart, not like a mistake. |
| `promo_rejected: "NEED_AUTH"` | the **server never sends it** | The platform storefront invents it locally for an anonymous visitor who typed a code. The server's set is in `promotions.md`. |

Loud failures, so you will notice: `CONTACT_REQUIRED`, `DATA_TOO_LARGE`,
`BAD_STATUS`, `EMPTY_BODY`, and the §5.2 table. Some of them are **not** stable
codes. Adding an unknown product answers the raw text `product not found` and
adding a sold-out one answers `out of stock` — both `FailedPrecondition`, both
formatted with `%v` straight from a domain error. The *same* shortage at
checkout arrives spelled `out_of_stock`, because that path goes through the
error mapper instead: one situation, two strings. The proto validator leaks its
own wording too — `quantity: 0` on `PUT /cart/items/…` answers `invalid
UpdateCartItemRequest.Quantity: value must be greater than or equal to 1`.
Match any of these at your peril; they are listed in §16.

## 13. Limits

Read from the constants on 2026-08-20. Nothing checks this table mechanically
yet — there is no `BuildOrderReference()` counterpart to the promotions
reference, and that gap is named in §16.

<!-- gate:limits -->

| Limit | Value | Where it is enforced |
|---|---|---|
| quantity of one cart line | 9999 | `domain.MaxCartQty` (`domain/order.go:31`), and again as `LEAST(…, 9999)` in SQL |
| `data` of one cart/order line | 65 536 bytes | `domain.MaxCartDataBytes` (`domain/product_variant.go:126`) |
| items accepted by one `POST /cart/merge` | 100 | `merge_cart.go:23` — silently truncates |
| items accepted by one `POST /cart/quote` | 100 | proto `repeated.max_items`, and this one **refuses** with `InvalidArgument` instead of truncating |
| order total (before discounts) | 2 000 000 000 | `maxOrderTotal` (`pg/orders.go:17`) → `ORDER_TOO_LARGE` |
| client configuration price | 2 000 000 000 | `maxConfigPrice` (`domain/cart.go:41`) |
| any money field on the wire | 2 147 483 647 | `clampInt32` — clamps, does not refuse |
| `POST /orders` page size | default 20, max 100 | `get_orders.go`; out of range → 20, silently |
| order message body | 1…5000 characters | proto validator |
| `GET /orders/{id}/messages` page size | default 20, **1…100 enforced** | `pg/order_messages.go:21` + the proto validator, which this handler does call — measured: `limit: 500` → `InvalidArgument`, unlike `POST /orders` |
| payment link lifetime | 30 minutes | `domain.PaymentLinkTTL` — an expired link does not block a new one |
| payment currency | `RUB` only (empty treated as roubles) | `domain.PaymentCurrency` |
| `POST /cart/promo` | rate-limited per IP, own bucket, 30/minute by default | `services/ratelimit.go` (`rlProtected`), `API_RATELIMIT_AUTH_PER_MIN` (`cmd/core/main.go:874`) |
| `POST /cart/quote` | rate-limited per IP, **its own** bucket, 60/minute by default | `services/ratelimit.go` (`RLClassQuote`), `API_RATELIMIT_QUOTE_PER_MIN` (`cmd/core/main.go:874`) |

<!-- /gate -->

No other endpoint in this area is rate-limited: the interceptor's list holds
five auth methods, `ApplyPromoCode` and `QuoteCart`, nothing else
(`grep -n rlProtected -A 16 internal/core/services/ratelimit.go`). The three
buckets are **separate**, keyed `(IP, class)`: a visitor re-pricing their cart
sixty times cannot lock the login of everyone behind the same NAT, and turning
one dial does not move the others — that is what `API_RATELIMIT_QUOTE_PER_MIN`
exists for.

## 14. How this was verified

- **25 measured scenarios** written against the real handlers over Postgres
  (`B5_TEST_DSN=…/tz_m4`, migrations applied by the fixture) on 2026-08-20:
  quantity clamps and data overwrite; `page` ignored on the cart; a client
  configuration price becoming the line price; `expected_total` in four
  variants; all sixteen status pairs plus three consecutive cancels with the
  stock ledger read after each; `POST /orders` page clamping and an out-of-range
  status filter; refund on an unpaid and on a paid order; a snapshot surviving
  the deletion of its rule; merge with 123 items including broken ones; buyer
  and foreign-seller access; unpublished and deleted products at checkout; an
  unresolved `variant_id`; oversized `data`; contact validation; the promo code
  lifecycle across a failed and a successful checkout; `ORDER_TOO_LARGE` with
  and without a 100 % discount; a blocked shop; the currency snapshot; where the
  proto validator does and does not run; **all sixteen status pairs, each on its
  own order**; and the full worked example of §6.3 at two different shop caps. The probe files were
  temporary and were removed; every number quoted above comes from their output.
- **Existing suites re-run green on the same database**, and they are the ones
  that keep these statements true going forward:
  `TestB5Sum_*` (4 — the identities of §6.2, including a deliberately broken
  parse to prove the checker fails), `TestB54Reopen_*` (6 — reopening returns
  the key and the budget, is idempotent, overspends by exactly one deal, and
  since 2026-08-20 also passes when the order's gift sold out during the
  cancellation, keeping the ledger balanced across two further cycles),
  `TestStockRound_*` (4 — three cancel/reopen cycles, one ledger pair per cycle,
  a sold-out reopen changing nothing, parallel cancels returning stock once),
  `TestB5NoRules_*` (2), `TestB54_*` (9 — budgets, coupon issuance, foreign
  coupon masking, a code on an empty cart).
- **§8.3 now has a gate**, which it did not when this document was written:
  `TestB54Refund_PartialRefundDoesNotReturnTheCoupon` places an order with a
  single-use code inside a budgeted campaign, marks its payment `paid`, calls
  `POST /orders/{id}/refund`, and requires `promo_keys.used`,
  `promo_campaigns.budget_used`, the live redemption rows and the order's status
  to be exactly what they were before, with the code still answering `EXHAUSTED`.
  It records **owner's decision №1**, not merely current behaviour: it was green
  on the first run, and the day it turns red the decision is being reversed.
- **Code anchors** rather than memory for anything not reachable from a handler:
  the append-only property of §3 (three greps, quoted there), the PAT surface of
  §2 (`grep -c '"/vizenpro.api.catalog.v1.Catalog/<method>":' internal/core/services/guard.go`
  → 1 for the five allowed methods, 0 for the eight session-only ones, with
  `TestDealsChatPATDenied` proving what a 0 means at runtime), and the coupon
  letter's address (`grep -n ContactEmail internal/api/catalog/coupon_notify.go`
  → nothing).
- **HTTP status codes are derived, not measured over the wire**: no instance was
  booted for this draft. They come from `runtime.HTTPStatusFromCode`
  (grpc-gateway v2.22.0, the version in `go.mod`) plus the fact that this project
  installs no custom mapper — `internal/api/apierr/gateway_error.go` calls that
  same function for any error carrying a gRPC status. The gRPC codes and the
  message strings themselves were measured.
- **The empty-`{key}` 404** of §4.1 was measured against `go-chi/chi/v5 v5.2.1`
  with the pattern the generated router registers, not against a running server.
- Enumerable content here is **hand-written**, unlike `promotions.md`. That is a
  known weakness and the first item of §16.

## 15. Negations and claims to re-check after every wave (§3.9)

Each line below is a statement of the form "X does not happen". None of them is
guarded by a test that turns red when it stops being true, so each is checked by
hand after every wave — and the check is written next to it. Anything that
acquires a gate moves out of this list.

**One line left on 2026-08-20:** *"§8.3: the refund touches nothing but the
payment row"* is now gated by `TestB54Refund_PartialRefundDoesNotReturnTheCoupon`
(§14), which is why it is no longer in the table below.

| Claim in this document | Check |
|---|---|
| §3: no code path rewrites an order's money | the three greps of §3. Trivial to automate — this is the first gate that should exist here |
| §2: the cart surface, `StartOrderPayment` and the chat are PAT-denied | `grep -c` per method in `guard.go`; only the chat is covered by a test (`TestDealsChatPATDenied`) |
| §12: `contact.email` is never used for the coupon letter | `grep -n ContactEmail internal/api/catalog/coupon_notify.go` → must stay empty |
| §12: paying does not move the order status | `grep -rn 'UPDATE orders' internal/` — must stay at two hits, neither in a payment path |
| §7.3: cancelling a completed order does not revoke an issued coupon | no code deletes a key on cancellation — re-check by reading `UpdateOrderStatus` |
| §6.1: no path creates a `buyer_id: 0` order | `/cart/confirm` reads its user from the passport and sits under the `Catalog/*` role wildcard. If a guest checkout ever ships, this line and the proto comment both become true — and the identities of §6.2 must be re-measured for it |
| §6.2: an order has no `is_gift` flag | re-check `OrderItem` in the proto; the cart has one, and adding one to the order would be a good change that silently falsifies this text |
| §10: no rounding happens in this area | re-check that the cart and the checkout still call one `promotions.ApplyWith` each and compute nothing themselves |
| §13: nothing here is rate-limited except `/cart/promo` | `grep -n rlProtected -A 12 internal/core/services/ratelimit.go` |

## 16. Known gaps — named, so the next person is not surprised

1. **No machine-generated reference.** `promotions.md` cannot go stale because
   every enumerable list in it is compared against `BuildPromoReference()`.
   Nothing of the kind exists for orders: the statuses, the refusal codes and
   the limits of §13 are typed by hand and will drift. Six gate anchors are in
   place — `gate:price-sources`, `gate:refusals`, `gate:identities`,
   `gate:statuses`, `gate:transitions`, `gate:limits` — and **check nothing
   yet**. (Named here without their comment delimiters on purpose: an anchor
   must occur exactly once, and a gate that matched this sentence instead of the
   table would be worse than no gate at all.)
2. **`data.price.total` is a client-controlled price** (§4.2). Measured: 7 ₽ on
   a 1000-₽ product, straight into the order. The server-side recompute against
   the published configuration artefact is a planned stage and has not shipped.
3. **The rollback of redemptions runs outside the status transaction.** Its own
   code says what that leaves open: if a reopen wins the race against the
   cancellation's rollback, the rollback lands afterwards on a live order — the
   key is free again and the order keeps its discount. The fix (moving the
   rollback into the status transaction) changes an earlier decision — that a
   counter must never fail a seller's status change — and is therefore an
   owner's call, not a patch. **The size of the window has not been measured**
   and no number for it appears in this document.
4. **No partial refund** (§8.3): no amount is accepted, and the payment row has
   one status for the whole sum. The half of that cell that *was* open — whether a
   refunded order gives back the right to its coupon — was **decided on
   2026-08-20 (owner's decision №1): it does not**, because the order happened.
   That half is now gated (§8.3, §14); what remains a gap is recording a partial
   *amount* at all.
5. **Two different definitions of "the seller"** on the same order: the status
   path uses `main_company_id` from the token, the chat uses membership. A staff
   member can answer a buyer and not be able to move the stage.
6. **Two refusals are raw domain text**, not stable codes: `product not found`
   and `out of stock` from the cart handlers. Any client matching on them is
   matching on an English sentence. The proto validator leaks its own wording
   the same way — `invalid UpdateCartItemRequest.Quantity: value must be greater
   than or equal to 1` is what a client sees for `quantity: 0`.
7. **The same stranger gets two different answers about the same order**:
   `DENIED` (403, "it exists, not yours") from `GET /orders/{id}`, and
   `ORDER_NOT_FOUND` (404, a mask) from its chat. Whichever is right, they
   should not disagree — order ids are sequential and enumerable.
8. **Fiscalisation is not owned by this line.** The order side computes a
   per-line share of an order discount for the receipt only — proportional to
   `line_total`, remainder into the last non-zero line — and that share is
   deliberately absent from the contract. Everything else about 54-FZ is
   unassigned (§2 of the area-ownership table).
9. **No email on a status change.** The buyer learns nothing when an order moves
   to `processing` or `done`; the only letters this area sends are the seller's
   "new order" and the buyer's "here is your coupon". Whether that is a gap or a
   decision has never been stated.
