Vizen Shop

Orders and the cart: one snapshot, four statuses

/docs/ordersdraftEN· проверено 2026-08-20

Резюме по-русски. Корзина считается сервером при каждом чтении, заказ — НЕ считается никогда: при оформлении он фиксирует снимок цен, скидок и состава, и после этого его денежные колонки не переписывает ни один путь кода (§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` (rules, keys, campaigns, rounding), `catalogue.md` (products and their prices), matrix of discount intersections.

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 pathCost
are an agent with a personal token (PAT) and want to work with ordersPOST /orders, GET /orders/{id}, PUT /orders/{id}/status, POST /orders/{id}/refund, POST /cart/confirm — those five are the whole PAT surfaceEverything 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 sessionthe whole cart surface, /cart/confirm, then the order endpointsNothing extra. This is the only supported checkout path today.
are building an unsigned-in (guest) cartkeep the contents on the client, price them with POST /cart/quote, and store them with POST /cart/merge on loginThe 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 inPOST /cart/quote — public, no token, writes nothingNothing 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 sawsend expected_total on /cart/confirmNothing 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 discountscompute items_total + discount_totalThere 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 coderead promotions[] and look at codeThe lines will not tell you: order-level steps are not spread over them.
want a partial refundthere is nonePOST /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 arriveswrite the status yourselfA confirmed payment does not touch orders.status; it sends two letters and fires order.paid (§8.2).
want a shop's whole order listPOST /orders with filter.company_idNote 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:

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

#SourceWho owns the number
1combo set — the product is a combo, so its price is derived: Σ of its components' effective pricesserver, always; a hand-typed base_price on a combo product is ignored
2variantdata.variant_id resolves to a live, active variant of *this* productserver: COALESCE(variants.price, products.base_price); the variant also overrides the preview
33D configurationdata.price.total is presentthe client, see the warning below
4base price — none of the aboveserver: 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

FieldWhat it is
subtotalpayable — after every discount step. The name is historical
subtotal_beforebefore discounts — but see the warning
discount_totalsubtotal_before − subtotal
applied[]the promotions that fired, per rule, with amount, kind, code
promo_codethe code stored on this cart (uppercased by the server)
promo_rejectedwhy the code did not produce a discount — the stored one, or the one posted to /cart/quote — codes in `promotions.md` §3.5
countunits, including gift units
totalnumber of lines, including gift lines
items[].is_giftthis 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_priceprice 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:

CallTokenWritesAnswers
POST /cart/quotenonenothingthe priced cart
POST /cart/mergesessionthe buyer's cart rowsthe priced cart

Pricing a guest cart — POST /cart/quote

{ "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.gocomputeCart 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

{ "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 sendWhat happens
the sum the buyer sawmismatch → PRICE_CHANGED, no order created, the cart is left intact for a re-read
0, or nothingcheck skipped — this is how clients written before the field keep working
a negative numbercheck 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 -->

MessagegRPC codeHTTPMeaning
CONTACT_REQUIREDInvalidArgument400name or phone missing after trim
CART_EMPTYFailedPrecondition400nothing to order — including "everything in it was deleted from the catalogue"
COMPANY_UNAVAILABLEFailedPrecondition400the shop is blocked or deleted. Its cart still reads fine
ORDER_TOO_LARGEFailedPrecondition400the sum before discounts passed 2 000 000 000 (§13)
PRICE_CHANGEDFailedPrecondition400expected_total did not match the recomputed total
PROMO_CODE_EXHAUSTEDFailedPrecondition400the last use of the code was taken between pricing and writing
CAMPAIGN_BUDGET_SPENTFailedPrecondition400the campaign behind the code ran out in the same window
out_of_stockFailedPrecondition400a 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 -->

#IdentityWhat it gives you
1Σ_R line_total − Σ_K amount == items_totalthe only correct way to reach the payable total from the lines
2Σ_R (base_unit_price or unit_price) × quantity == items_total + discount_totalthe total before discounts — an order has no field for it
3Σ promotions[].amount == discount_totalthe snapshot explains the discount with no remainder
4Σ_R promotion.amount == Σ_I amountthe 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.

StepArithmeticRunning total
base prices1000 + 2000subtotal_before = 3000
item: −15 % on AA: 1000 → 850, benefit 1502850
order: −10 % of 2850benefit 2852565
key: −20 %base = lines with no per-line discount = 2000 → benefit 4002165
shop cap 30 %allowance = 3000 × 30 % = 900; discount so far = 835 ≤ 900 → does not fire2165
gift300-₽ line at unit_price 0, materialised last2165

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

ValueNameWho can set it
1newseller (any → any); buyer never
2processingseller only
3doneseller only
4cancelledseller 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 -->

TransitionEffectsA repeat does…
cancelledrefuses 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 freednothing. 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 → anythingre-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)
doneissues the coupons of every issue_key rule this order satisfies, mails them to the buyer, fires key.issued per couponno 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 valueallowed; updated_at, status_changed_at, status_by all move; the order.status_changed webhook fires again with old_status == new_statussee 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, 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

# 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

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

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

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

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

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 sendWhat happensWhy
quantity above 9999 (add, update, merge)clamped to 9999, 200Anti-overflow ceiling, not a validation. Measured: 100 000 → 9999, and PUT with 50 000 → 9999.
quantity of 0 or negative on add or mergebecomes 1Same 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) twicequantities add, the second data replaces the firstOne ON CONFLICT DO UPDATE. The consequence for merge is in §4.4: merging twice doubles.
page on GET /cart/itemsignored entirelyThe 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 /orderssilently 20The proto declares 1…100, but GetOrders never calls the validator. Measured: limit: 500 → 20 rows, limit: 0 → 20 rows, limit: 5 → 5.
filter.status: 0no status filter at all0 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 numberthe price check is skipped0 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/mergeeverything past the 100th is droppedAnti-DoS on a client-supplied list. Measured: 123 items in, 100 processed.
a merge line with unparsable or oversized datathat line is skipped, the rest of the merge succeedsOne 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 unpublishednothing is inserted and nothing is saidThe 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 shopthat line is skipped; the rest is priced, 200Merge'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/quotequantities add, clamped at 9999; the later data winsDeliberately 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/quotethe discount is shown; the code is not spentpromo_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/quotepromo_rejected: "NOT_FOUND", no discount, 200Quote 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 linebecomes 1 / 9999Same 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/quote400, InvalidArgument, nothing is pricedThe 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 shopit lands in that shop's cart and is therefore absent from this responseA 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 cartsit stays in every cart and goes into ordersThe 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 cartit disappears from the cart on the next read, and its rows are swept at checkoutOtherwise 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 resolvethose three keys are stripped from the echo and the price falls back to base_price; your other keys surviveAnti-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_idit becomes the priceThe 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 in200; no second stock movement, no second coupon; but updated_at moves and order.status_changed fires with old == newTransitions are unrestricted by design, so the *effects* were made idempotent instead. A webhook consumer that counts status changes will over-count.
cancelling twicestock is returned onceThe ledger key is (order, product, reason, round). Measured over three cancels in a row.
contact.email on the ordernever used for the coupon letterThe 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 orderthe status does not moveThe seller ships, so the seller advances the stage. The payment sends two letters and order.paid instead.
POST /orders/{id}/refundonly the payment row changesNo 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 checkoutaccepted unbounded and unvalidatedMeasured: 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 inempty 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 itThe 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 -->

LimitValueWhere it is enforced
quantity of one cart line9999domain.MaxCartQty (domain/order.go:31), and again as LEAST(…, 9999) in SQL
data of one cart/order line65 536 bytesdomain.MaxCartDataBytes (domain/product_variant.go:126)
items accepted by one POST /cart/merge100merge_cart.go:23 — silently truncates
items accepted by one POST /cart/quote100proto repeated.max_items, and this one refuses with InvalidArgument instead of truncating
order total (before discounts)2 000 000 000maxOrderTotal (pg/orders.go:17) → ORDER_TOO_LARGE
client configuration price2 000 000 000maxConfigPrice (domain/cart.go:41)
any money field on the wire2 147 483 647clampInt32 — clamps, does not refuse
POST /orders page sizedefault 20, max 100get_orders.go; out of range → 20, silently
order message body1…5000 charactersproto validator
GET /orders/{id}/messages page sizedefault 20, 1…100 enforcedpg/order_messages.go:21 + the proto validator, which this handler does call — measured: limit: 500InvalidArgument, unlike POST /orders
payment link lifetime30 minutesdomain.PaymentLinkTTL — an expired link does not block a new one
payment currencyRUB only (empty treated as roubles)domain.PaymentCurrency
POST /cart/promorate-limited per IP, own bucket, 30/minute by defaultservices/ratelimit.go (rlProtected), API_RATELIMIT_AUTH_PER_MIN (cmd/core/main.go:874)
POST /cart/quoterate-limited per IP, its own bucket, 60/minute by defaultservices/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 documentCheck
§3: no code path rewrites an order's moneythe 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-deniedgrep -c per method in guard.go; only the chat is covered by a test (TestDealsChatPATDenied)
§12: contact.email is never used for the coupon lettergrep -n ContactEmail internal/api/catalog/coupon_notify.go → must stay empty
§12: paying does not move the order statusgrep -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 couponno 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 flagre-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 areare-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/promogrep -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.

Исходник: https://api.vizen.shop/docs/orders

Работает в демо-режиме