# Promotions, combo sets, coupons and promo codes

> **Резюме по-русски.** Скидка в Vizen — это ПРАВИЛО магазина («условие → эффект»),
> а не поле товара. Видов правил пять: четыре считают скидку, пятый (`issue_key`)
> ничего не считает, а выдаёт купон за покупку. У каждого есть класс — ступень, на
> которой оно срабатывает: построчная, заказная и «за ключом» (промокод/купон).
> Промокод и купон — одна сущность, различаются владельцем. Комбо-набор — это
> отдельный ТОВАР с составом, а не скидка. Пустой `/promotions` означает «у этого
> магазина нет правил», а не «платформа такого не умеет» — см. §5. Общую картину
> скидок (что действует само, что ждёт промокод, лучшие предложения) отдаёт один
> запрос `/promotions/landscape`, и ранжирует её сервер, а не клиент — §3.7.
> Цену товара ВНУТРИ набора карточка отдаёт готовыми полями `in_combos[].item_*`:
> в подстановках своей вёрстки нет выражений, поэтому «в наборе вдвое дешевле»
> иначе посчитать нечем — §3.8.

**Status:** current · **Verified:** 2026-08-22, full integration suite against 18
isolated PostgreSQL databases (46 integration files) · **Owner:** promo line (`Track: promo`)
**Serves:** `GET /docs/promotions` (live: 200 `text/markdown`) · **Machine reference:**
`promotions.BuildPromoReference()` — kinds, the kind × class matrix, the ladder,
reject reasons, rounding, contract field sets and limits all come from the same
constants, validator and engine the server runs on.

Every enumerable list below sits between `<!-- gate:… -->` markers, and
`reference_test.go` compares the **set** in the document against the reference —
so a list here cannot go stale in silence. What the gates check is the fact, not
the wording: the set of codes, the set of fields, the order of the steps. Prose
between the markers is yours to rewrite.

## 1. What you can do here

Read which rules a shop has, create and edit them, attach promo codes to a rule,
cap several rules with one campaign budget, have the shop grant a personal coupon
for a purchase or hand one to a named buyer yourself, apply a code to the buyer's
cart, and read the resulting prices.

The server owns every price: a client never sends money amounts for the cart or
the order, and a discount you compute yourself will not survive checkout. This is
worth internalising before writing anything here — most mistakes in this area come
from trying to reproduce the arithmetic on the client.

The same reasoning gives you two ready-made answers instead of a calculation: the
shop's whole discount picture with its offers already ranked (§3.7), and the price
of a product **inside** a combo set on its card (§3.8).

## 2. Decide first (forks)

| If you want… | Take this path | Cost |
|---|---|---|
| A price cut that applies **by itself** | promotion with `class` `item` or `order` | Nothing extra. Shown on cards and in the cart automatically. |
| A price cut the buyer **types in** | promotion with `class: "key"` + one or more `promo_keys` | The rule stays silent until a code is applied. Two objects to create instead of one. |
| A discount **for one person** (loyalty, apology, reward) | **one** named buyer, right now → `POST /promo-keys` with `owner_user_id` (§4.10); **everyone** who qualifies → a promotion of `kind: "issue_key"`, which grants the coupon when an order is completed (§4.6) | Either way the coupon's discount is described by a separate `class: "key"` rule the key unlocks — written once, reused by every coupon that points at it. Do not fake it with a shared code: a shared code cannot be limited to one buyer reliably. |
| An automatic promotion **only for a buyer role** | set `Promotion.audience_id` to a reusable shop audience | The platform account remains global, while customer membership and roles are isolated per shop. Guests, blocked customers and buyers without a matching role do not receive or discover the promotion. |
| A **spending cap** over several promotions ("we issued a million worth") | a campaign (`/promo-campaigns`) with `budget_kind` `spend` or `count` | Rules keep working until the budget runs out, then they stop by themselves. The cap is per campaign, not per rule. |
| A "3 items for the price of 2" style **bundle** | promotion `kind: "bundle"` (a rule) | Prices stay per-product; the discount lands on the participating lines. |
| A **set sold as one thing** with its own page and photos | a combo **product** (`product_combo_items`) — see `catalogue.md` | It is a product, not a discount: it has its own card, its own price, one cart line. |
| A "what is on sale" page or a **best offers** block | `GET /promotions/landscape` — the server ranks (§3.7) | One call. The alternative — read the rules and match products yourself — means re-implementing the `item → order → key` ladder, and disagreeing with the cart on the first product that two rules touch. |
| A "**в наборе вдвое дешевле**" line on a product card | `in_combos[].item_price` / `item_regular_price` / `item_times_cheaper_x100` (§3.8) | Nothing extra, the numbers arrive ready. In own markup this is the only option: substitutions have no expressions, so nothing there can divide one price by another. |
| Show the promo price in **platform widgets** | nothing to do — works today | — |
| Show the promo price in **your own markup** | needs promo fields in the page scope — see `own-markup.md` and §8 below | Not available yet. Do **not** invent your own transport for it. |

The last row is the fork that cost a whole rebuild once. Read `own-markup.md`
before you decide to hand-render prices.

## 3. Objects and where they live

### 3.1 The rule — `/promotions`

```
GET    /promotions            list of this shop's rules      → promotions:read
GET    /promotions/{id}       one rule (foreign → 404)       → promotions:read
POST   /promotions            body {"item": {...}}           → promotions:write
PUT    /promotions/{id}       body {"id":…, "item": {...}}   → promotions:write
DELETE /promotions/{id}       body {}                        → promotions:write
```

A rule has two independent axes. Getting them confused is the most common
mistake in this area:

- **`kind`** — *what* it does: `product_discount`, `order_discount`, `bundle`,
  `gift`, and `issue_key` — which is not a discount at all, see the table below.
- **`class`** — *when* it fires: `item` (per line) → `order` (whole order) → `key`
  (only when a code is applied). Empty `class` means "the class this `kind` had
  before classes existed": `order_discount` → `order`, everything else → `item`.

Within one class only the single best rule applies. Classes stack in the order
above. `class: "key"` currently requires `kind: "order_discount"` — the key step
reads an order-shaped condition, and other combinations are rejected on write.

The three classes are the first three steps of a **five-step ladder**. The other
two are not rules and have no class, which is exactly why they get forgotten:

<!-- gate:ladder -->

| Step | What happens | Why it matters to you |
|---|---|---|
| `item` | one best per-line rule (`product_discount` or a fired `bundle`) per line | this is the only step that touches `unit_price` |
| `order` | one best order rule, computed on the subtotal **after** `item` | a `min_subtotal` of 6 000 will not fire on a cart that costs 5 000 after a line discount |
| `key` | the applied code, computed on the subtotal after `order`; its threshold is measured where `order`'s is, after `item` | by default the base excludes lines that already got a discount (`applies_to_discounted: false`) |
| `shop_cap` | the shop-wide margin guard (`max_total_discount_percent`) trims **top-down**: the code first, then the order discount | per-line discounts are never trimmed — see §4.7 |
| `gift` | gift lines are materialised last | they are in no subtotal, no threshold and no `applied[]` |

<!-- /gate -->

⚠️ **`condition` and `effect` are `bytes` on the wire — base64 of raw JSON**, not
objects. This is the single most common 400 in this area. The shape depends on
`kind`; unknown keys are rejected, not ignored:

<!-- gate:kinds -->

| kind | class | condition | effect |
|---|---|---|---|
| `product_discount` | `item` | `{product_ids?, category_ids?, excluded_product_ids?}` (empty = all products) | `{percent}` XOR `{amount}` (per unit) |
| `order_discount` | `order`, `key` | `{min_subtotal?, min_items?}` (threshold is measured **after** per-line discounts) | `{percent}` XOR `{amount}`, plus optional `{max_discount}` |
| `bundle` | `item` | `{sets: [{product_ids?, category_ids?, min_qty}]}` — every set must be satisfied, and **each set needs at least one non-empty id list** | `{percent}` XOR `{amount}` on participating lines |
| `gift` | `item` | same as `bundle` | `{gift_product_id, quantity}` |
| `issue_key` | `item` | `{min_subtotal?, min_items?}` — measured on what was actually **paid** | `{grant_promotion_id, valid_days}` — the `class: "key"` rule the granted coupon unlocks |

<!-- /gate -->

A `bundle` or `gift` set with `product_ids: []` and `category_ids: []` is refused
on write, not stored and quietly matched against nothing. The `class` column is
the whole matrix: any other combination is a 400 — the machine reference carries
it as `Kinds[].Classes`, and a gate test replays every pair through the real
validator, so this column cannot become a wish.

Compatibility flags on the rule: `stack_with_key` (default **true**; `false`
means promo codes do not apply while this rule fires), `exclude_combo` (default
false; skip combo products), `applies_to_discounted` (class `key` only, default
**false**: the code is computed only from lines without a per-line discount).

`audience_id` is an optional reference to the shop's reusable buyer audience.
Omitted on create means public. Omitted on edit preserves the current binding,
so an older client cannot publish a private promotion by renaming it; explicit
`0` makes it public. A foreign/deleted audience is rejected with
`PROMOTION_AUDIENCE_INVALID`. Audience filtering happens in the repository
before rules reach the pricing engine and is repeated for cards, lists,
marketplace/wishlist projections, guest quote, signed-in cart, promo-code
lookup, coupon issue and checkout. The owner-only list and landscape still show
all rules for management. The staged `customer_audiences_enabled` shop flag is
fail-closed: while off, only public promotions apply.

None of those three flags reaches a **gift**, and that is structural rather than
an oversight. A gift is not a discount: the engine materialises it as a virtual
line *after* the key step, so its value is in no subtotal, in no threshold, in no
`applied[]`, and in neither base a promo code can be computed from — with
`applies_to_discounted` on or off. `stack_with_key: false` on a `gift` rule
therefore changes nothing (§5). The ladder order is what keeps this safe:
computing gifts one step earlier would make every gift kill every promo code in
the shop, because a gift line carries no `stack_with_key` of its own.

Money guard: a `class: "key"` rule with `{amount}` **must** carry
`condition.min_subtotal` strictly greater than that amount, otherwise the write
is rejected. A coupon worth 10 000 with no threshold hands over goods for free.

### 3.2 The key — `/promo-keys`

A key does not define a discount; it unlocks a rule. Promo code and coupon are
one object: a key without an owner is a shared promo code, a key with an owner is
a personal coupon. Coupons appear two ways, and both end in the same row of the
same table: the platform grants one for a completed purchase (§4.6), or the
seller hands one to a named buyer (§4.10).

```
GET    /promo-keys?promotion_id=&with_coupons=   → promotions:read
POST   /promo-keys      body {"item": {...}}     → promotions:write
PUT    /promo-keys/{id} body {"id":…,"item":{…}} → promotions:write
DELETE /promo-keys/{id} body {}                  → promotions:write
```

`PromoKey` — the whole field set: `id`, `promotion_id` (a `class: "key"` rule,
immutable after creation), `code`, `owner_user_id`, `expires_at`, `usage_limit`
(0 = unlimited), `per_customer_limit`, `active`, and read-only `used`,
`created_at`, `issued_by_order_id`. `id` and `promotion_id` arrive as **strings**
(int64 on the wire).

`owner_user_id` is the entire difference between the two kinds of key: `0` (or
omitted) creates a shared promo code, a real account id creates a personal coupon
for that account. It is **checked on creation** — an id that belongs to nobody is
refused rather than stored, because a coupon nobody can redeem still occupies its
code in the shop — and **immutable afterwards**: send it back unchanged on `PUT`,
and to move a coupon to another person delete it and issue a new one. A coupon
created without limits gets `usage_limit: 1` and `per_customer_limit: 1`; a shared
code gets neither.

Codes are matched case- and whitespace-insensitively (`upper(trim())`), and are
unique per shop.

### 3.3 The campaign — `/promo-campaigns`

A campaign is a **spending cap over several rules**: "we issued a million worth of
coupons". When the budget is out, its rules stop applying by themselves.

```
GET    /promo-campaigns          → promotions:read
POST   /promo-campaigns          → promotions:write
PUT    /promo-campaigns/{id}     → promotions:write
DELETE /promo-campaigns/{id}     → promotions:write
```

`PromoCampaign`: `name`, `budget_kind` (`none` | `spend` | `count`), `budget_limit`
(0 for `none`), `active`, `starts_at?`, `ends_at?`, and read-only `budget_used`,
`budget_warned` (true from 80% — you want to know **before** it stops, not after)
and `promotions_count`.

Attach a rule by setting `Promotion.campaign_id`. Deleting a campaign does **not**
disable its rules — it removes the cap.

Two things worth knowing before you rely on it:

- the budget grows from **every** applied rule of the campaign, not only from
  promo codes;
- only the promo-code step can be cancelled outright, so only it refuses an order
  when the budget is short (`CAMPAIGN_BUDGET_SPENT`). For an ordinary promotion
  the order still goes through — the buyer has already seen that price — so the
  budget may overshoot by at most one sale, and the next orders no longer see the
  rule.

### 3.4 The buyer's coupons — `/my-coupons`

```
GET /my-coupons?company_id=&include_used=   → the buyer's own coupons only
```

`MyCoupon` — the whole field set: `id`, `code`, `name` and `description` (taken
from the rule the coupon unlocks — the buyer reads the same wording they will see
in the cart), `expires_at?`, `used`, `expired`, `issued_by_order_id`. Someone
else's coupon is neither listed nor findable. `issued_by_order_id: 0` means the
coupon was **not** granted by a purchase — a seller handed it over (§4.10); do
not render "for order #0".

### 3.5 The buyer side — `/cart/promo`, and `/cart/quote` before login

```
POST   /cart/promo    {"company_id":…, "code":"SUMMER25"}   → the whole cart
DELETE /cart/promo    {"company_id":…}                      → the whole cart
POST   /cart/quote    {"company_id":…, "items":[…], "promo_code":"SUMMER25"}
                                                            → the whole cart, no token
```

Both return the same body as `GET /cart/items`. Two fields matter:
`result.promo_code` (applied code, empty if none) and `result.promo_rejected` —
a stable **reason code**, not a message:

<!-- gate:reject-reasons -->

`NOT_FOUND` · `EXPIRED` · `EXHAUSTED` · `PER_CUSTOMER` · `RULE_INACTIVE` ·
`BELOW_THRESHOLD` · `NO_ELIGIBLE` · `NOT_STACKABLE` · `NO_EFFECT` · `BUDGET_SPENT`

<!-- /gate -->

The authoritative list is generated — `promotions.BuildPromoReference().RejectReasons`
— and a gate test fails if the server sends a code this document does not know, or
if the document promises a code the server never sends. That check exists because
both mistakes already happened here.

The first four and the last six are **different kinds of refusal**, and the
interface should say so (`RejectReasons[].stage`): `NOT_FOUND`, `EXPIRED`,
`EXHAUSTED` and `PER_CUSTOMER` are verdicts on the code itself — the cart cannot
fix them. The other six come from the engine and are verdicts on **this cart**:
"add 1 500 more" is honest advice, "the code is invalid" is not.

A rejection is **not** an HTTP error: the cart is always returned, so the buyer
keeps seeing their items. A foreign personal coupon answers `NOT_FOUND` on
purpose — a distinct reason would confirm that the code exists.

**A guest hears the same answers.** `POST /cart/quote` (`orders.md` §4.4) takes a
`promo_code` with no token and fills the same `promo_code` / `promo_rejected`
pair from the same code path, so the verdict on a code does not change at login —
that is the point of the endpoint: a visitor should not have to sign in to find
out whether the code from the newsletter works. Two consequences to design for:

- the code is **shown, never spent**. A quote moves neither `promo_keys.used` nor
  `promo_redemptions`, however many times it is called; redemption happens inside
  the checkout transaction and nowhere else. A "code applied" screen is therefore
  not a reservation — the last use can still be taken by somebody else, and
  `expected_total` at checkout is what protects the buyer from finding out
  silently (§3.6);
- a **personal** coupon (§3.4) answers `NOT_FOUND` to a guest. There is no buyer
  to match `owner_user_id` against, and the mask is the same one a foreign coupon
  gets. Word it as "sign in to use this code", not "invalid code" — the visitor
  holding a coupon addressed to them is the one person for whom the mask is
  misleading.

### 3.6 Checkout

`POST /cart/confirm` accepts `expected_total` — the sum the buyer saw. If the
recomputed order differs (a promotion ended, the last use of a code was taken
by someone else), the call fails with `PRICE_CHANGED` instead of quietly
creating an order at a different price. Send 0 or omit it to keep the old
behaviour. Two more refusals can arrive here, both `FailedPrecondition`:
`PROMO_CODE_EXHAUSTED` (the code was taken between the cart and the order) and
`CAMPAIGN_BUDGET_SPENT` (the campaign budget ran out in the same window).

The order that comes back does **not** spread order-level discounts over its
lines. Read §3.9 before you sum anything on it.

### 3.7 The whole picture in one call — `GET /promotions/landscape`

```
GET /promotions/landscape?limit=24     → promotions:read
```

Answers "what is on sale in this shop right now" without you matching rules to
products. The shop is the one behind the token — there is **no `company_id`
parameter and no public variant**: this is a cabinet/agent endpoint, and a token
without a company gets `PermissionDenied`.

`result`:

| Field | What it is |
|---|---|
| `promotions[]` | active rules that fire **by themselves** — the same `Promotion` objects `/promotions` returns |
| `code_promotions[]` | active rules of `class: "key"`. Separate on purpose: printing them as a discount promises the buyer a price they will not get without typing the code |
| `top_offers[]` | offers ranked by the server, see below |
| `scanned_products` | how many published products were actually run through the engine |
| `truncated` | `true` when the scan hit its ceiling — there may be discounted products it never looked at |

`PromoOffer`: `kind` (`product` | `combo`), `product_id`, `name`, `slug`,
`price`, `old_price`, `benefit` (money), `benefit_percent` (whole percent,
rounded DOWN — a benefit shown to a buyer is a promise, and rounding it up would
advertise a discount the shop does not give), `promotion_name` (the rule that gave
the cut; empty for `combo`).
`preview` is filled: the product's own preview when it has one, otherwise the first
image of its gallery — the same rule the card and the listings use, so the same
product carries the same picture everywhere. It is empty only when the product has
neither. Pictures are resolved after the ranking cut, so a large catalogue costs one
batch for the offers actually returned, not for everything scanned.

Ranking is deterministic and belongs to the server: `benefit_percent` desc →
`benefit` desc → `product_id` asc. Percent leads because "−50%" is an offer,
while "−5 000 ₽" without a base is nothing. `limit` is 0…100 (0 or omitted = 24;
above 100 → 400, it is a validated field, not a clamp).

How the list is built, because it explains every surprise below: products are
**not** selected by SQL "where there is a discount" — a discount is produced by
the engine, not stored in a column. The server takes published products in
batches, newest first (`ORDER BY id DESC`), and runs each through the very same
engine that prices a product card. Ceilings: **500 products** and **200 combo
sets**. They are reported (`scanned_products`, `truncated`) because a silent cut
would read as "this shop has nothing else on sale".

What never appears in `top_offers`, so you do not go hunting:

- rules of class `order` or `key` — an offer is a per-product price, and those
  two are computed against a whole cart;
- `kind: "issue_key"` rules — they are missing from **both** rule lists as well:
  a coupon granted on a completed order is an issuance, not a price cut anyone
  can see today;
- unpublished or deleted products, and combo sets with an unavailable component
  or with no benefit at all.

One more thing that is true today and costs nothing to know: **a dev-contour PAT
reads the live shop here.** This endpoint builds no dev contour, so what it
returns is production data even for a key that sees your draft elsewhere.

An offer does not have to come from a rule. The test is "`old_price` is above
`price`", so a product the seller marked down by hand (its own `base_old_price`)
is an offer too, with an empty `promotion_name` — which is exactly what its card
shows as well. For a `product` offer, `price` / `old_price` are byte-for-byte the
card's numbers: same engine, same base. A `combo` offer goes through a separate
path, and both of them now measure "sum of parts" the same way — see below.

**Two defects this section used to warn about are fixed** (2026-08-20, commit
`ac88c77`), and the warnings are gone with them. They are worth one paragraph
because both were of the kind that reads as correct data:

1. the rule lists were filtered by `active` and campaign budget only, so a rule
   whose `ends_at` was yesterday was still advertised as live. The lists now run
   the same period predicate the engine does — a rule that has ended, or has not
   started, is in neither list. Green:
   `TestB5Landscape_ExpiredRuleIsNotAdvertisedAsActive`;
2. a combo offer measured "sum of parts" from **base** prices while the card
   measured it from **showcase** prices, so a discounted component made the
   landscape advertise a set that was more expensive than buying the parts apart.
   Both now measure it from showcase prices. Green:
   `TestB5Landscape_ComboBenefitMatchesCardWhenComponentIsDiscounted`.

### 3.8 The price of a product inside a set — `in_combos[].item_*`

On a product card (`GET /products/{id}`, `GET /products/slug/{slug}`)
`in_combos[]` lists the sets this product belongs to. Beside the set-level
`combo_price` / `components_total` / `benefit`, three fields describe **this**
product inside that set:

| Field | Meaning |
|---|---|
| `item_price` | what this product costs inside the set, per unit — its `price_mode` / `price_value` applied to the **base** price (`inherit` → base, `fixed` → value, `percent` → floor(base·(100−p)/100), `free` → 0) |
| `item_regular_price` | what it costs on its own right now — the showcase price, i.e. **after** per-item promotions |
| `item_times_cheaper_x100` | `item_regular_price / item_price` × 100. `250` = "2.5× cheaper". `0` = say nothing about cheaper |

Why fields and not arithmetic: substitutions in own markup have no expressions —
`{{ }}` cannot divide or compare — so a "в наборе вдвое дешевле" line cannot be
written any other way; and a second computation on the storefront would drift
from the cart on rounding, because the in-set price is floored server-side.

`item_times_cheaper_x100` is `0` in exactly the cases where "cheaper" would be a
lie: the in-set price is not lower than the regular one, either price is zero, or
the component is `price_mode: "free"` — a gift has no ratio. Render a gift from
`item_price == 0`, not from the ratio.

The value is an integer scaled by 100 on purpose (money and shares are integers
here, Р-43). An agent that generates markup reads it and writes the wording once,
at build time; a template that can only substitute should place `item_price` and
`item_regular_price` ("в наборе N ₽ вместо M ₽") and treat the ratio as a signal,
not as text — nothing on the storefront reformats hundredths for you.

One asymmetry worth remembering: **the shop owner sees sets with no benefit at
all** (`benefit` may even be negative — a promotion on the components made them
cheaper apart than together). That is deliberate: the admin list "Входит в комбо"
has to show exactly the sets that are broken. A public visitor never sees them.
So "in_combos is non-empty" does not mean "there is a benefit" when the caller is
the shop.

### 3.9 Reading the money on an order

An order line is a **snapshot of what the position cost** — a fact of the deal,
not a running total. Order-level steps (an order discount and a promo code)
reduce `items_total` but are **not** spread across the lines. So the first thing
every importer does — sum `line_total` — returns the total *before* the code, and
nothing in the payload shouts about it.

That is deliberate. Rewriting a line snapshot after the fact would rewrite the
price the buyer agreed to. Use the identities below instead; they hold whatever
combination of mechanics fired.

**Telling the steps apart** — by `kind` inside `promotions[]`:

| Entry | Meaning |
|---|---|
| `kind: "order_discount"`, `code` empty | order discount — **not** in the lines |
| `kind: "order_discount"`, `code` set | promo code / coupon — **not** in the lines |
| any other `kind` (`product_discount`, `bundle`) | per-line — already inside each line's `unit_price` / `line_total` |

`kind` is a reliable discriminator, not a coincidence: the validator refuses
`class: "order"` and `class: "key"` on any kind but `order_discount`, so an
order-level entry can never arrive wearing `product_discount` or `bundle`.

**Telling a gift apart** — `promotion.kind == "gift"` on the line. A gift has
`unit_price: 0`, `line_total: 0`, and `base_unit_price` holding its *value* — a
number that appears in **no** total of the order. Exclude gift lines from every
sum below. (The cart says this with a dedicated `is_gift` flag; the order does
not have one yet.)

Let `R` = lines that are not gifts, `K` = `promotions[]` entries with
`kind == "order_discount"`, `I` = the remaining `promotions[]` entries.

| # | 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 any discount. The order has no `subtotal_before` at all, and on the cart that field is not the one to read it from — one formula serves both contours |
| 3 | `Σ promotions[].amount == discount_total` | the promotion snapshot accounts for the discount with no remainder |
| 4 | `Σ_R promotion.amount == Σ_I amount` | the per-rule aggregate matches the per-line shares |

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

**The total before discounts is a sum you compute, not a field you read.** On an
order it is `items_total + discount_total` (identity 2); on a cart, the same
thing is `subtotal + discount_total`. The cart does carry a field of that name,
and it is the wrong number to strike through: the server fills `subtotal_before`
while it computes discounts, and a shop with no live promotion never runs that
step at all — such a cart can answer `subtotal_before: 0` with real goods in it,
and a storefront that trusts it strikes through zero and advertises "−100 %".
The field starts carrying a number the moment the shop has one active rule, even
one this cart does not match, so it follows the seller's settings rather than the
cart's contents: "it was right when I checked" says nothing about the next shop.
The sum of the two fields is right on every server, and it is the only form the
order side offers at all — so write the formula once and use it in both places.

Worked example (−15 % on one position, −10 % on the order, −10 % by code):

```json
{ "items_total": 3130, "discount_total": 870,
  "items": [
    {"product_id":1,"unit_price":850,"base_unit_price":1000,"quantity":2,"line_total":1700,
     "promotion":{"id":7,"kind":"product_discount","amount":300}},
    {"product_id":3,"unit_price":2000,"base_unit_price":0,"quantity":1,"line_total":2000}
  ],
  "promotions": [
    {"id":7,"kind":"product_discount","amount":300},
    {"id":8,"kind":"order_discount","amount":370},
    {"id":9,"kind":"order_discount","amount":200,"code":"SUMFIX"}
  ] }
```

`3700 − (370 + 200) = 3130`. Summing the lines alone gives **3700** — 570 more
than the order is worth.

**There is no per-line share of an order discount in the contract, on purpose.**
The platform does compute one, but only for the fiscal receipt, where the law
requires the sum of positions to match the amount charged to the kopeck: it is
proportional to `line_total`, with the rounding remainder placed in the last
non-zero position. Need such a share yourself — compute it the same way, and
treat it as derived: a different rounding gives different numbers, and only
identities 1–4 agree on both sides.

An order freezes every sum at checkout and is **never** recomputed when the rules
change afterwards, so these identities still hold a year later. Money is whole
currency units (roubles, not kopecks).

Verified by `internal/api/catalog/promo_order_sums_integration_test.go`
(`TestB5Sum*`): a promo code plus an order discount, a gift line, and a shop
margin cap that trims the order step.

### 3.10 Rounding — where the fraction goes

Money here is whole currency units, so every percentage has to land somewhere.
The rule is not the same in every place, and the difference is exactly one unit —
invisible in a contract, visible in a cart. Recomputing a price on the storefront
with "the obvious" rounding is how a page and the cart start quoting two numbers
for the same item.

<!-- gate:rounding -->

| Rule | Mode | What is rounded | Where |
|---|---|---|---|
| `line_percent` | half-up | the **price** per unit; the discount is what is left over | `promotions.perUnitDiscount` |
| `order_percent` | half-up | the **discount** on the order | `promotions.orderDiscountAmount` |
| `key_percent` | half-up | the **discount** from a promo code — same helper as the order step | `promotions.cappedAmount` |
| `shop_cap_percent` | **down** | the **allowance** the shop tolerates (`max_total_discount_percent`), not the discount itself | `promotions.clampByShopCap` |
| `combo_component_percent` | **down** | the price of a component inside a set, from its **base** price | `domain.EffectiveComponentUnitPrice` |
| `benefit_percent` | **down** | the percent printed on an offer | `catalog.percentOf` |
| `times_cheaper_x100` | **down** | "N times cheaper in the set", ×100 | `catalog.timesCheaperX100` |

<!-- /gate -->

Two consequences worth reading twice:

- the per-line step rounds the **price**, the order and key steps round the
  **discount** — opposite halves of the same subtraction. The same "−50%" on a
  3 ₽ position takes 1 ₽ off as a per-line rule and 2 ₽ off as an order rule.
  Neither is a bug; asking which one is "correct" is asking which of the two
  numbers the shop promised;
- everything a **buyer reads as a promise** rounds down: the offer's percent, the
  "twice cheaper" ratio, the in-set price. 49.6 % prints as "−49 %", because a
  shop that advertises more than it gives is caught by the first person who adds
  up their cart.

A gate test calls the three reachable formulas on a number where the two modes
disagree, so the table cannot describe a rounding the code no longer does.

### 3.11 One cart through every layer

Every section above answers one question. This one answers the question sellers
actually ask: *what will my buyer pay?* One cart, four paid lines, run through
all seven price layers, with the number at every step and the arithmetic that
produced it. The last number is the one that reaches the till.

**The shop.** Catalogue:

| Product | Setting | Value |
|---|---|---|
| Sofa "Nord" | `base_price` | 30 000 |
| Sofa "Nord" | variant "XL" `price` | 40 000 |
| Set "Corner" | armchair 20 000 at −50 % in the set + pouf 10 000 at its own price | derived **20 000**, "separately" 30 000 |
| Lamp | `base_price` | 10 000 |
| Wardrobe | `base_price` — the "from" price on the card | 8 000 |
| Armchair | `base_price` | 10 000 (never added to this cart) |
| Plaid | `base_price` = its value as a gift | 5 000 |

Rules:

| Rule | Kind | Condition | Effect |
|---|---|---|---|
| −25 % on the sofa "Nord" | `product_discount` | that product only | −25 % per unit |
| −30 % on armchairs | `product_discount` | category "Armchairs" | −30 % per unit |
| −10 % on orders from 70 000 | `order_discount` | `min_subtotal: 70000` | −10 % |
| A plaid with the sofa | `gift` | sofa in the cart | one plaid, price 0 |
| `VIZEN20` | `order_discount`, class `key` | `min_subtotal: 60000` | −20 %, `max_discount` 7 000 |
| Shop margin cap | `companies.max_total_discount_percent` | — | 25 % |

**The cart.** Four paid lines, each priced by a different layer:

| Line | What it is | Which layer sets the price | Price |
|---|---|---|---|
| А | sofa "Nord", variant XL | **2** — the variant overrides `base_price` 30 000 | 40 000 |
| Б | set "Corner" | **4** — computed by the server from component **base** prices | 20 000 |
| В | lamp | **1** — plain `base_price` | 10 000 |
| Г | wardrobe, configured in 3D | **3** — the configuration snapshot overrides `base_price` 8 000 | 10 000 |

Line Г is the one that surprises people. The card advertises 8 000 and the cart
charges 10 000, and nothing is broken: `base_price` on a configurable product is
a "from" price, while the buyer pays for the thing they assembled —
`data.price.total` from the configurator. That snapshot is not a hint. It is the
price, and it is the number that flows into every layer below: into the subtotal
before discounts, into the point where both thresholds are measured, and into the
promo code base. Layers 2 and 3 are mutually exclusive **on one line** — a
product has either variants or a configurator — but not in one cart, which is why
both А and Г are here.

**Step by step.**

| # | Step | Number | Where it comes from |
|---|---|---|---|
| 1 | subtotal before discounts | **80 000** | 40 000 + 20 000 + 10 000 + 10 000 |
| 2 | per-line rule, line А | −10 000 | 25 % of **40 000** — the variant price, not `base_price` 30 000 |
| 3 | line А after the rule | 30 000 | 40 000 − 10 000 |
| 4 | subtotal after per-line rules | **70 000** | 30 000 + 20 000 + 10 000 + 10 000. **Both thresholds are measured here** |
| 5 | order rule −10 % | −7 000 | 10 % of 70 000; the 70 000 threshold is met exactly |
| 6 | running total | 63 000 | 70 000 − 7 000 |
| 7 | promo code base | **40 000** | lines with no per-line discount: Б 20 000 + В 10 000 + Г 10 000. Line А is out — the code does not stack on discounted lines by default |
| 8 | the code would give | 8 000 | 20 % of 40 000 |
| 9 | the rule's own cap | **7 000** | `max_discount: 7000` trims 8 000 |
| 10 | total if the shop had no margin cap | 56 000 | 63 000 − 7 000 |
| 11 | discounts asked for | 24 000 | 10 000 + 7 000 + 7 000 |
| 12 | what the shop tolerates | **20 000** | 25 % of 80 000, rounded down |
| 13 | the shop cap trims | 4 000 | 24 000 − 20 000, taken **top down**: the code first |
| 14 | the code, finally | **3 000** | 7 000 − 4 000. The order step (7 000) and the per-line step (10 000) are untouched |
| 15 | `discount_total` | **20 000** | exactly the allowance |
| 16 | `subtotal` — **what the buyer pays** | **60 000** | 80 000 − 20 000 |

Plus a gift line: the plaid, `unit_price: 0`, `base_unit_price: 5000` (its
value), `is_gift: true`. It appears in **no** total, does not move any threshold,
and is not in `applied[]`. The cart's `count` is 5 — four paid lines and the gift.

Checkout changes none of it. The order comes back with `items_total: 60000` and
`discount_total: 20000`, three entries in `promotions[]` (10 000 / 7 000 /
3 000 — the last one carrying `code: "VIZEN20"`), and all four identities of
§3.9 hold on it. The configuration snapshot of line Г travels to the order
untouched, price and breakdown both: `unit_price` is what you charge, `data` is
what you build.

**What did not fire, and why.** The "−30 % on armchairs" rule is live for the
whole time this cart exists. It is returned by `GET /promotions/landscape` as an
active promotion, and the armchair really is offered at 7 000 in that shop. It
simply never touches this cart, because there is no armchair in it — and the lamp
therefore pays its full 10 000, with `promotion` absent and `base_unit_price: 0`
on its line. This is the most useful line in the whole example: a discount is a
**rule of the shop with a condition**, not a property of a line. "The rule is on"
and "the rule applied" are two different facts, and only the second one is in
`applied[]`.

Two more things that did not fire, and their absence is measured: nothing merged
line А with line Г (layer 0, `product_groups`, never touches price — it only
switches the buyer between cards), and no second order rule competed, because
"one best" is decided inside a class.

**Change one thing.** Same shop, same rules, same settings — remove the lamp:

| # | Step | Number | Against the cart above |
|---|---|---|---|
| 1 | subtotal before discounts | 70 000 | −10 000 |
| 2 | per-line rule, line А | −10 000 | unchanged |
| 3 | subtotal after per-line rules | **60 000** | below the 70 000 threshold → **the order step does not fire at all** |
| 4 | promo code threshold | passes | 60 000 ≥ 60 000, met exactly |
| 5 | promo code base | 30 000 | Б 20 000 + Г 10 000 |
| 6 | the code gives | **6 000** | 20 % of 30 000, under `max_discount` 7 000 → **the rule cap does nothing** |
| 7 | what the shop tolerates | 17 500 | 25 % of 70 000; only 16 000 is asked for → **the shop cap does nothing** |
| 8 | `discount_total` | 16 000 | 10 000 + 6 000 |
| 9 | `subtotal` | **54 000** | |

Read the two carts side by side and the machinery becomes visible. Removing a
10 000 lamp lowers the bill by **6 000**, not by 10 000, because that lamp was
holding up three separate things: it carried the cart over the 70 000 order
threshold, it contributed a quarter of the promo code base, and it made the
discounts large enough for both limiters to bite. The seller who priced this cart
by subtracting the lamp's price would be 4 000 out.

The 70 000 threshold is the sharpest edge here. Both carts have a subtotal
**before** discounts of 80 000 and 70 000 — measured there, the order rule would
fire in both. It is measured **after the per-line step**, so it fires in one and
not the other. The promo code threshold is measured at the very same point,
70 000 rather than the 63 000 that is left after the order step — which is worth
knowing before you set a code's `min_subtotal` next to an order rule's.

Verified by `internal/api/catalog/promo_worked_example_integration_test.go`
(`TestB5Doc_WorkedExample`): every number above is asserted against a live
Postgres — 96 assertions across the two carts, including the two numbers this
section computes but the cart response does not return as fields (the threshold
reference point and the promo code base), the amount the shop cap trimmed
(measured by switching the cap off on the same cart), and the point at which the
code's threshold is measured (measured by raising it into the gap between 63 000
and 70 000). Change the engine and the build tells you which line of this section
went stale.

### 3.12 The gift on a product card — `product_promotions[].gift`

A product card (`GET /products/{id}`, `GET /products/slug/{slug}`) carries
`product_promotions[]` — the live rules that touch this product, for a "why is
this interesting" block. No money in them: `{id, name, description, kind, theme,
icon}`. A `kind: "gift"` entry carries one thing more — **the gift product
itself**, so the card can show it the way a set shows its components instead of
describing it in prose:

| Field of `gift` | Meaning |
|---|---|
| `product_id` | the product that is handed out |
| `name` | its name |
| `slug` | for a link to its own card; **empty = publish no link** |
| `preview` | `{id, url}` — its own preview, else the first gallery image (the same rule `combo_items[]` uses, §3.8) |
| `value` | its `base_price` — the number to strike through. The very same one the cart puts into the gift line's `base_unit_price` (§3.1) |
| `quantity` | how many units the rule hands out — `2` reads as "× 2", not as two separate gifts |

`gift` is **absent** in exactly three cases, and all three are deliberate:

- the rule is not a `gift`. No other kind hands a product out, so no other kind
  has anything to put there;
- **this card is the gift itself** — then the plaque carries the reverse side,
  `triggers[]` (below), instead of showing the product to itself;
- the gift is not available **right now** — unpublished, or stock does not cover
  the rule's own `quantity` (`stock_quantity: null` means stock is not tracked,
  hence available — §5). The plaque itself stays, `description` and all; what
  stops is the promise of a specific item. Availability is decided by the same
  resolver that hands gifts out in the cart and in the order, so the card cannot
  drift away from them into advertising a gift checkout would withhold.

One asymmetry worth knowing: **the card cannot see the cart.** If the buyer is
already paying for units of that same product, the stock the gift needs grows
(`giftNeed`, §5), and a gift the card showed can still be withheld at checkout —
without moving any total, and without the buyer paying anything else. The card
measures the smallest possible need, the level below which the rule can never
fire; the opposite error would be both worse and invisible — staying silent about
a gift that does work.

#### The other side of the same plaque — `triggers`, on the gift's own card

A gift product is still a product: buyers land on its card from search, from a
category, from a link. There the interesting question is the reverse one — **what
do I have to buy to get this for free?** So on the card of the gift itself the
same `kind: "gift"` plaque arrives with `gift` **empty** and three fields filled
instead:

| Field | Meaning |
|---|---|
| `triggers[]` | the trigger products, same `GiftPreview` shape as `gift`. `value` here is the trigger's own `base_price` — what the buyer **pays**, not what they receive. `quantity` is not used (`0`) |
| `triggers_total` | how many trigger products there are in total — `triggers[]` is capped at **12** |
| `trigger_categories[]` | names of the categories the condition names ("any product from Sofas"). A category is never expanded into products |

Read the pair as: show a couple of strips inline, and if `triggers_total` is
larger than what you showed, offer the rest — the array holds at most 12, and if
`triggers_total > len(triggers)` even your "show all" list is a sample, not the
whole set.

Rules of the reverse side:

- **one side at a time.** `gift` and `triggers` never arrive together on the same
  plaque: the card either tells you what you get, or what to buy for it;
- **a product that is both the trigger and the gift** ("buy two, the third is
  free") stays on the **gift** side — `gift` filled, `triggers` empty. The
  reverse side there would be a strip pointing the card at itself;
- **only published, live triggers** are listed and counted, for the same reason
  the gift disappears when it is unpublished: a strip is an invitation to buy,
  and `slug` empty still means publish no link;
- a category that no longer resolves (deleted, or belonging to another shop —
  `products.categories` has no FK) is dropped rather than shown nameless.

Verified against a live Postgres by
`internal/api/catalog/promo_gift_money_integration_test.go`:
`TestB5Gift_CardShowsTheGiftItself` (name, `value`, `quantity`, gallery-fallback
preview and `slug` on the trigger's card — and the same product and value in the
cart line the rule then produces), `TestB5Gift_CardKeepsTextPlaqueWhenGiftIsOutOfStock`
(one unit in stock against a rule that gives two: `gift` gone, plaque and
`description` still there, cart hands out nothing),
`TestB5Gift_CardHidesTheGiftOfAnUnpublishedProduct`,
`TestB5Gift_CardCarriesNoGiftForOtherKinds`,
`TestB5Gift_CardOfTheGiftShowsWhatEarnsIt` (the gift's own card: `gift` empty,
one trigger with its purchase price, gallery-fallback preview and `slug` — and
nothing at all once that trigger is unpublished),
`TestB5Gift_CardOfTheGiftCapsTheTriggerList` (15 triggers → 12 listed,
`triggers_total: 15`), `TestB5Gift_CardOfTheGiftNamesTheTriggerCategory`
(a category condition arrives as a name, not as products),
`TestB5Gift_CardOfTheSelfGiftStaysOnTheGiftSide`.

---

## 4. Recipes

### 4.1 "Which products are on sale right now?"

There is no single "sale" endpoint. Ask two questions:

1. `GET /promotions` → rules with `active: true` and a live period. An **empty
   array means this shop has no rules**, see §5.
2. For a rule of `kind: "product_discount"`, decode `condition` and read
   `product_ids` / `category_ids`. For prices as the buyer sees them, read the
   products: `price.price` is already discounted and `price.old_price` carries
   the pre-discount value, with `price.promotion_name` naming the rule.

**Verify:** pick one product id from the rule's condition, `GET /products/{id}`,
and check `price.old_price > price.price`. If they are equal, the rule did not
match that product — check the category ids, not the endpoint.

### 4.2 Create a 15% category sale

```bash
COND=$(printf '{"category_ids":[42]}' | base64)
EFF=$(printf '{"percent":15}' | base64)
curl -X POST "$API/promotions" -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' -d "{\"item\":{
    \"name\":\"Summer −15%\",\"description\":\"On sofas until 31.08\",
    \"kind\":\"product_discount\",\"active\":true,
    \"condition\":\"$COND\",\"effect\":\"$EFF\"}}"
```

**Verify:** `GET /products?filter.category_id=42` and check that `price.old_price`
appeared on the items. Seeing 200 on the POST proves only that the rule was
stored.

### 4.3 Create a promo code

```bash
# 1) a rule that waits for a code: −10% on orders from 5 000, at most 2 000 off
COND=$(printf '{"min_subtotal":5000}' | base64)
EFF=$(printf '{"percent":10,"max_discount":2000}' | base64)
curl -X POST "$API/promotions" -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' -d "{\"item\":{
    \"name\":\"Promo −10%\",\"kind\":\"order_discount\",\"class\":\"key\",
    \"active\":true,\"condition\":\"$COND\",\"effect\":\"$EFF\"}}"
# → note result.id

# 2) the code itself
curl -X POST "$API/promo-keys" -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' -d '{"item":{
    "promotion_id":<RULE_ID>,"code":"SUMMER25","active":true,
    "usage_limit":100,"per_customer_limit":1}}'
```

**Verify:** as a buyer with a cart above the threshold,
`POST /cart/promo {"company_id":…,"code":"summer25"}` (lower case on purpose) and
check `result.discount_total > 0` and `result.promo_code == "SUMMER25"`. If
`promo_rejected` came back instead, its code says exactly what is wrong.

### 4.4 Order with a code

Apply the code, then `POST /cart/confirm` with `expected_total` = the
`result.subtotal` you last showed. **Verify:** read the created order — it
carries `discount_total` and `promotions[]` as a snapshot; later edits to the
rule never change a placed order.

### 4.5 Cap several promotions with one budget

A campaign is the only way to say "we issued a million worth of discounts". Note
the order: create the campaign first, then point rules at it.

```bash
# 1) the budget itself
curl -X POST "$API/promo-campaigns" -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' -d '{"item":{
    "name":"Summer campaign","budget_kind":"spend","budget_limit":1000000,"active":true}}'
# → note result.id

# 2) point an existing rule at it (PUT is full-replace — send the whole item)
curl -X PUT "$API/promotions/<RULE_ID>" -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' -d '{"id":<RULE_ID>,"item":{
    "name":"Summer −15%","kind":"product_discount","active":true,
    "condition":"'"$COND"'","effect":"'"$EFF"'","campaign_id":<CAMPAIGN_ID>}}'
```

**Verify:** place a test order that the rule discounts, then
`GET /promo-campaigns` and check that `budget_used` grew by the discount amount.
If it stays at 0, the rule is not actually in the campaign — re-read the rule and
check `campaign_id`.

Two behaviours that surprise people:

- `budget_used` also grows from ordinary promotions, not only from promo codes;
- when the budget is short, only a promo-code discount refuses the order
  (`CAMPAIGN_BUDGET_SPENT`). An ordinary promotion still applies to the order the
  buyer is placing — they already saw that price — so the budget can overshoot by
  at most one sale, and the rule stops on the next order.

### 4.6 Grant a coupon for a purchase

Two rules, and the order matters: first describe **what the coupon gives**, then
the rule that **grants it**.

```bash
# 1) what the coupon gives: −15% off an order, unlocked by a key
COND=$(printf '{"min_subtotal":1000}' | base64); EFF=$(printf '{"percent":15}' | base64)
curl -X POST "$API/promotions" -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' -d "{\"item\":{
    \"name\":\"Thanks for the purchase −15%\",\"kind\":\"order_discount\",
    \"class\":\"key\",\"active\":true,\"condition\":\"$COND\",\"effect\":\"$EFF\"}}"
# → GRANT_ID

# 2) who gets it: buyers whose completed order is 50 000 or more
COND2=$(printf '{"min_subtotal":50000}' | base64)
EFF2=$(printf "{\"grant_promotion_id\":$GRANT_ID,\"valid_days\":30}" | base64)
curl -X POST "$API/promotions" -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' -d "{\"item\":{
    \"name\":\"Coupon for a purchase from 50 000\",\"kind\":\"issue_key\",
    \"active\":true,\"condition\":\"$COND2\",\"effect\":\"$EFF2\"}}"
```

**Verify:** place an order above the threshold, move it to status 3 (`done`), then
as that buyer call `GET /my-coupons?company_id=<ID>` — the coupon must be there
with the name of the granting rule. Nothing appears at checkout time: the coupon
is granted on completion, not on ordering, so that "order → get coupon → cancel"
does not work.

If the coupon does not appear: the threshold is measured on the amount **actually
payable** (after discounts), not on the pre-discount total.

**Where the coupon notification goes.** On issuance the platform emails the buyer
at their **account** address — not at the contact email typed into the order. A
coupon is personal: it is bound to `owner_user_id` and only that account can
redeem it, so sending it to an address a third party filled in at checkout would
hand someone else's discount instrument to the wrong person. The letter carries
the code, what it grants, the expiry date and a link to the buyer's coupons page.
A `key.issued` webhook fires in parallel for the shop's own systems.

Both belong to **this** path. A coupon you hand out yourself (§4.10) sends
neither letter nor webhook — you are already talking to that buyer, so tell them
the code.

### 4.7 Protect the margin of a whole shop

Layers of benefit stack (combo → promotion → promo code), and the only built-in
floor is a price of zero. The shop-wide cap is a separate setting:

```bash
curl -X PUT "$API/v1/orgs/<ID>" -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' -d '{"id":<ID>,"max_total_discount_percent":40}'
```

**Verify:** read the org back and check the field; then build a cart where the
stacked discounts would exceed the cap and confirm in `GET /cart/items` that
`discount_total` is exactly the cap's percent of `subtotal + discount_total` —
the pre-discount total, computed rather than read (§3.9). `0` removes the cap.

Note the deliberate limit: the cap trims the ladder from the top (promo code, then
order discount) and does **not** touch per-line discounts — trimming those would
require re-allocating money across lines and would desynchronise the per-line
snapshot stored in the order.

### 4.8 Build a "what is on sale" page for the whole shop

One call, and the ranking is already done. Ask for what you will show — the
default is 24, and a page that renders 8 tiles should ask for 8.

```bash
curl -s "$API/promotions/landscape?limit=8" -H "Authorization: Bearer $TOKEN" | jq '{
  scanned: .result.scanned_products,
  truncated: .result.truncated,
  live_rules: [.result.promotions[].name],
  code_only:  [.result.code_promotions[].name],
  offers: [.result.top_offers[] | {kind, product_id, name, price, old_price, benefit_percent}]}'
```

**Verify — three checks, all cheap, and each catches a different lie:**

1. take the first offer with `kind == "product"` and read its card:
   `GET /products/{product_id}` must return exactly the same `price.price` and
   `price.old_price`. If they differ, the page and the card are quoting two
   prices to the same buyer — do not ship, report it.
2. check the arithmetic of one row by hand: `benefit == old_price - price`, and
   `benefit_percent == floor(benefit * 100 / old_price)` — DOWN, never up: 49.6%
   prints as "−49%", so the shop never claims more than it gives. This is what
   you will print in large type, so it is worth one subtraction.
3. read `truncated`. `true` (with `scanned_products: 500`) means the answer is
   "the best of the 500 newest products", not "the best in the shop" — either say
   so in the wording or do not promise "лучшие предложения магазина".

Do not put `code_promotions[]` into the same list as `promotions[]`. The honest
wording for them is "−10% **по промокоду**": without the code the price on the
card will not change, and a buyer who does not find the discount in the cart
blames the shop, not the copy.

An empty `top_offers[]` with a non-empty `promotions[]` is a real, correct state
— rules exist but hit nothing published (typical cause: `category_ids` of a
category whose goods are still drafts). Check `scanned_products > 0` before you
blame the endpoint: `0` means the shop has no published products at all.

### 4.9 Write "в наборе вдвое дешевле" on a product card

```bash
curl -s "$API/products/$PID" -H "Authorization: Bearer $TOKEN" | jq '.result.in_combos[] | {
  set: .name, combo_price, components_total, benefit,
  item_price, item_regular_price, item_times_cheaper_x100}'
```

Then render from the fields as they are: `item_price` and `item_regular_price`
give "в наборе 500 ₽ вместо 1 000 ₽", and `item_times_cheaper_x100` tells you
whether the stronger wording is allowed at all.

**Verify** with numbers you control, not with a status code. Put the product into
a set with `price_mode: "percent"`, `price_value: 50`, on a base price of 1 000:

- `item_price` must be exactly `500` (floor of base·(100−p)/100 — the same
  formula the cart uses, which is why you must not repeat it on the storefront);
- `item_times_cheaper_x100` must be exactly `200`, i.e. "вдвое";
- now start a `product_discount` of −20% on that component and read the card
  again: `item_regular_price` drops to `800` and the ratio falls to `160`. That
  is correct and it is the point of the field — outside the set the buyer already
  pays 800, so "вдвое дешевле" would no longer be true.

If `item_times_cheaper_x100` comes back `0`, do not print a ratio: either the set
gives this product nothing, or it is a `free` component (`item_price == 0`) that
should read "В подарок".

### 4.10 Hand a coupon to one buyer

For a one-off — an apology for a late delivery, a thank-you to a regular — you do
not need a rule that grants coupons to everybody who qualifies. Describe what the
coupon gives (once, and reuse it afterwards), then mint a key onto one account.
You need that buyer's account id, and their orders carry it as `buyer_id` (the
seller's order list is `POST /orders`, not a GET). A **guest** order leaves
`buyer_id: 0` — there is no account to hang a coupon on, and issuing to `0`
creates a shared code, not a coupon.

```bash
# 1) what the coupon gives — skip if you already have a class:"key" rule to reuse
COND=$(printf '{"min_subtotal":1000}' | base64); EFF=$(printf '{"percent":10}' | base64)
curl -X POST "$API/promotions" -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' -d "{\"item\":{
    \"name\":\"Sorry for the delay −10%\",\"kind\":\"order_discount\",
    \"class\":\"key\",\"active\":true,
    \"condition\":\"$COND\",\"effect\":\"$EFF\"}}"
# → RULE_ID

# 2) the coupon itself, onto one account
curl -X POST "$API/promo-keys" -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' -d '{"item":{
    "promotion_id":<RULE_ID>,"code":"SORRY-2026","active":true,
    "owner_user_id":<BUYER_USER_ID>,
    "expires_at":"2026-12-31T23:59:59Z"}}'
```

The limits are left out on purpose: a key created **with** an owner and without
them is issued as one-off (`usage_limit: 1`, `per_customer_limit: 1`). Send
explicit numbers only if you really mean "three visits". `expires_at` is optional
too — without it the coupon lives as long as the rule it unlocks.

**Verify** — three checks, because each answers a different way of being wrong:

- read it back with `GET /promo-keys?with_coupons=true` and find your
  `owner_user_id` on it. Without `with_coupons=true` the list shows shared codes
  only, and a coupon that was created correctly looks like a call that failed;
- as **that** buyer, `GET /my-coupons?company_id=<ID>` — the coupon is there under
  the granting rule's name, which is what the buyer will read in the cart;
- as **anyone else**, `POST /cart/promo {"company_id":…,"code":"SORRY-2026"}`
  answers `promo_rejected: "NOT_FOUND"`. A personal coupon is not a bearer
  instrument, and the reply does not even admit the code exists.

Two refusals worth knowing before you script this: an `owner_user_id` that belongs
to no account is **rejected** (`PROMO_KEY_OWNER_NOT_FOUND`) instead of producing a
code nobody can redeem, and the owner of an existing key cannot be edited
(`PROMO_KEY_OWNER_IMMUTABLE`) — moving a coupon to another person is a delete plus
a new issue, not a `PUT`.

## 5. Silently ignored

Cases where the platform answers 200 and does nothing. This section is mandatory
and is written from the code, not from memory.

| What you send | What happens | Why |
|---|---|---|
| `usage_limit: 0` / `per_customer_limit: 0` **together with** an `owner_user_id` | both become **1** | A personal coupon is one-off by meaning, and until 2026-08-20 the platform was the only one issuing them — always as 1/1. On a hand-issued coupon "0 = unlimited" is a forgotten field far more often than a decision, and the decision is still available: send the number you mean. A shared code (no owner) keeps 0 = unlimited, unchanged. |
| `used`, `created_at`, `issued_by_order_id` on write | **ignored** | Read-only facts, not settings. |
| `expected_total: 0` on `/cart/confirm` | price check **skipped** | 0 means "not sent" — old clients keep working. Send the real sum to get the check. |
| Empty `class` on `POST /promotions` | filled in from `kind` | Backwards compatibility with clients written before classes existed. |
| Empty `class` on `PUT /promotions/{id}` | **the rule keeps its current class** | A client that does not know about classes must not be able to turn a coupon rule into a public discount by editing the name. |
| `stack_with_key` omitted on `PUT` | **the column is not touched** | The field is `optional` precisely so that "not sent" differs from "switched off". |
| A rule of `class: "key"` with no keys attached | rule is stored, never fires | Expected: it is waiting for a code. Not a defect — attach a key. |
| `campaign_id` omitted on `PUT /promotions/{id}` | **the campaign is kept** | The field is `optional` for the same reason as `stack_with_key`: a client that does not know about budgets must not lift the cap by editing a name. Send an explicit `0` to detach. |
| `audience_id` omitted on `PUT /promotions/{id}` | **the audience is kept** | An older client must not turn a role-only promotion public by editing another field. Send an explicit `0` to make it public. |
| `budget_used` sent on a campaign write | **ignored** | It is a fact of sales, not a setting. Raising the limit is allowed; forgiving what was already spent is not — otherwise the budget would mean nothing. |
| `stack_with_key: false` on a `kind: "gift"` rule | **ignored** — promo codes keep working while the gift is handed out | The key step gates itself on lines that already carry a promotion, and a gift line does not exist yet at that point (§3.1). There is also nothing to stack with: a gift reduces no total. Honouring the flag by computing gifts earlier would make *every* gift block *every* code, because a gift line carries no `stack_with_key` of its own. |
| `campaign_id` on a `kind: "gift"` rule | the campaign's `active` and dates still gate the rule, but its `budget_used` **never grows** | The budget is charged from the list of discounts, and a gift is deliberately absent from it. Consequence today: a `budget_kind: "count"` campaign over a gift rule hands out gifts without limit and the 80 % warning never fires. A known gap, not a decision — `TestB5Gift_CampaignBudgetDoesNotCountGifts` turns red the day it is closed. |
| A `kind: "gift"` rule whose gift product ran out of stock | the gift is **not handed out** — cart and order are both priced without it, and what the buyer pays does not change | Stock is a condition of handing a gift out, exactly like publication. The gift line is taken off stock like a bought one, so a short stock used to abort the **whole order** with `out of stock` — over a free line the buyer never picked and cannot remove (it is virtual, it is not in the cart table). "Ran out" is measured against what this cart actually needs: the rule's own `quantity`, plus any units of the same product the buyer is paying for (stock leaves in one movement per product, so "take two, the third is free" needs three), plus gifts of the same product granted by other rules **that fired in this cart**. A rule that did not fire holds nothing back. `stock_quantity: null` means "stock is not tracked", not zero — that gift is handed out as before. **The same answer holds when the last unit goes to someone else *while* this order is being written:** the check and the deduction are not one atomic step, so the shortage can surface at write-off instead — and there the gift is dropped too. The gift line is removed from the order, the paid lines are deducted, the order goes through, and no total moves (a gift is in none of them). A shortage on a **paid** line still aborts the whole order, as it always did. So the window is a fact of timing with no consequence for the buyer: median 0.12–0.15 ms (one paid line, stock untracked) to 1.67–1.71 ms (ten lines, stock tracked); upper bound median 0.36–2.04 ms across the four configurations, its p99 up to 7.3 ms per run. It grows with both the line count and stock tracking (`TestB5GiftRace_WindowMeasure`, 8 runs × 200 orders per cell, kept as a regression on the window's *width*). Closing the window itself with a lock at gift-resolve was **rejected**: resolve and write-off take product rows at different points of the checkout transaction, so two opposite orders ("gift G plus paid P" against "gift P plus paid G") would take two rows in opposite order and deadlock. `TestB5Gift_OutOfStockGiftIsWithheldAndOrderPasses`, `TestB5Gift_UnfiredSecondRuleDoesNotEatTheStock`, `TestB5GiftRace_ConcurrentStockCommitWithholdsTheGiftNotTheOrder`, `TestB5GiftRace_MixedMoveDeductsThePaidPartOnly`, `TestB5GiftRace_PaidLineShortageStillKillsTheOrder`, `TestB5Gift_WithheldGiftInTheWindowMovesNoMoney`. |
| A second `UpdateOrderStatus(done)` on the same order | no second coupon | Issuance is idempotent, enforced by a unique index rather than by a check — a double click would otherwise grant two coupons for one purchase. |
| Applying a code with an empty cart | cart returned, `promo_rejected: "BELOW_THRESHOLD"` or `NO_ELIGIBLE` | The code is not stored on the cart in that case only if the key itself is dead; a live key stays and starts working when the cart grows. |
| `limit` omitted or `0` on `/promotions/landscape` | 24 offers, not "all of them" | An unbounded "best offers" list is a page nobody renders and a query that grows with the catalogue. |
| A shop with more than 500 published products | only the **500 newest** are scanned; `truncated: true` | The ceiling is the price of running the real engine instead of a fake SQL "where discounted". It is reported rather than hidden — see §3.7. |
| A rule that only matches drafts or deleted products | the rule is listed in `promotions[]`, but produces **no offer** | The scan walks published goods only. The rule is fine; the goods are not published. |
| A rule of `kind: "issue_key"` | **absent from both rule lists** of the landscape | It grants a coupon on a completed order. Listing it among live discounts would advertise a price that does not exist yet. |
| A rule whose period has ended (or has not started), on `/promotions/landscape` | **absent** from `promotions[]` / `code_promotions[]` | The lists run the same period predicate as the engine, so a rule and the prices it produces always agree. Until 2026-08-20 they did not, and the endpoint advertised "yesterday's −50%" next to undiscounted prices — §3.7. |
| A dev-contour PAT on `/promotions/landscape` | reads the **live** shop | The endpoint has no dev overlay: what it returns is production data, even for a key that sees the draft elsewhere. |
| Expecting `preview` on a `PromoOffer` of a product with no image at all | empty | Filled since 2026-08-20 from the product's preview, falling back to the first gallery image. Empty means the product itself has no picture — not that the endpoint omits it. |
| Summing `items[].line_total` to get the order total | you get the total **before** the order discount and the promo code | Lines are price snapshots; order-level steps are not spread over them. Nothing errors — the number is simply someone else's. Use §3.9 identity 1. |
| Summing `base_unit_price × quantity` over **all** order lines | the sum is inflated by the value of every gift | A gift is a real `order_items` row with `line_total: 0` and `base_unit_price` = its value, and that value is in no total of the order. Filter by `promotion.kind == "gift"` — the order has no `is_gift` flag. |
| Reading `subtotal_before` on a cart of a shop with **no live promotion** | it can answer `0` with goods in the cart | Nothing computed it: the field is filled by the discount step, and the step does not run when the shop has no rules. It wakes up as soon as one active rule exists — even one this cart does not match — so the value tracks the seller's settings, not the cart. Strike through `subtotal + discount_total` instead (§3.9). |
| A `free` component in `in_combos[].item_*` | `item_price: 0` and `item_times_cheaper_x100: 0` | There is no ratio against zero. A gift is rendered from `item_price == 0`, not from the ratio. |
| Reading `in_combos[]` **as the shop owner** | sets with `benefit <= 0` are listed too | The owner has to see broken sets to fix them; a public visitor never gets them. Do not read "listed" as "profitable" in the cabinet. |

Loud failures (400, so you will notice): unknown keys inside `condition`/`effect`;
`max_discount` on a per-line rule; a key attached to a non-`key` rule; changing a
key's `promotion_id` or its `owner_user_id` (`PROMO_KEY_OWNER_IMMUTABLE` — including
sending `0` for a coupon, which would quietly turn it into a code for everybody);
an `owner_user_id` that belongs to no account (`PROMO_KEY_OWNER_NOT_FOUND`);
a fixed-amount coupon without a threshold above its value;
a `bundle`/`gift` set with both id lists empty; a `kind` × `class` pair outside
the matrix in §3.1 (`class: "key"` on anything but `order_discount`, `class` other
than `item` on a `gift` or an `issue_key`); `limit` above 100 on
`/promotions/landscape` (a validated field — it is refused, not clamped).

## 6. Limits

**Generated** — every row below is a key of `promotions.BuildPromoReference().Limits`,
whose value comes from the constant the validator (or the handler) enforces. A gate
test compares the **key set** of this table with the reference, so a limit cannot
be added to the code and forgotten here, or invented here and never enforced.

<!-- gate:limits -->

| Key | Value | Where it bites |
|---|---|---|
| `name_max_len` | 255 | rule and campaign `name` |
| `description_max_len` | 2000 | rule `description` |
| `percent_max` | 100 | `effect.percent`, which starts at 1 |
| `condition_max_bytes` | 16384 | raw `condition` |
| `effect_max_bytes` | 16384 | raw `effect` |
| `ids_per_list_max` | 1000 | each id list inside a condition |
| `sets_max` | 10 | `sets` of a bundle/gift |
| `set_min_qty_max` | 1000 | `min_qty` of one set |
| `gift_quantity_max` | 10 | gift `quantity`, which starts at 1 |
| `code_min_len` | 3 | promo code, letters/digits/`-`/`_` |
| `code_max_len` | 64 | promo code |
| `usage_limit_max` | 1000000 | `usage_limit` and `per_customer_limit` |
| `issue_valid_days_min` | 1 | `valid_days` of an issued coupon |
| `issue_valid_days_max` | 365 | `valid_days` of an issued coupon |
| `campaign_name_max_len` | 255 | campaign `name` |
| `campaign_warn_percent` | 80 | `budget_warned` flips from here |
| `combo_items_max` | 20 | components in a combo product |
| `combo_item_quantity_min` | 1 | quantity of one component |
| `combo_item_quantity_max` | 100 | quantity of one component |
| `landscape_limit_default` | 24 | `limit` omitted or 0 on `/promotions/landscape` |
| `landscape_limit_max` | 100 | `limit` above this → 400; a validated field, not a clamp |
| `landscape_scan_products` | 500 | products a landscape call runs through the engine |
| `landscape_scan_combo_sets` | 200 | combo sets a landscape call measures |

<!-- /gate -->

**Not generated** — these are SQL literals in the repository layer and a rate-limit
bucket, so nothing checks them mechanically; read as of 2026-08-20 from
`pg/promo_keys.go`, `pg/promo_issue.go` and the auth interceptor:

| Limit | Value |
|---|---|
| `GET /promo-keys` | returns at most 500 keys, no paging yet |
| `GET /my-coupons` | returns at most 200 coupons |
| `POST /cart/promo` | rate-limited per IP (its own bucket, separate from login), 30/minute by default |
| `POST /cart/quote` | rate-limited per IP in a **third** bucket, 60/minute by default (`API_RATELIMIT_QUOTE_PER_MIN`) — it is the only place a code can be probed with no account at all |

## 7. How this was verified

- 2026-08-20, live HTTP against an isolated instance (own port, own database,
  binary built from this branch): routes exist and are gated (`/promo-keys` and
  `/cart/promo` answer 401 anonymously, not 404); rule and key creation return
  the shapes documented above; duplicate code → 409; key on a non-`key` rule →
  400; fixed-amount coupon without a threshold → 400; `max_discount` on a
  per-line rule → 400.
- **39** integration scenarios over a real Postgres (`-run TestB5`), including the
  redemption counter and its rollback on order cancellation, the per-customer
  limit, the price-changed guard, the campaign budget stopping a rule and being
  refunded on cancellation, coupon issuance on completion (and its idempotency),
  and foreign coupons staying invisible; 18 more for combo sets.
- The `int64 → string` wire detail was confirmed by reading real responses, not
  by reasoning about proto.
- An independent audit of the whole area on 2026-08-20 found four money defects
  that this document would otherwise have described incorrectly: editing a
  promotion from the cabinet silently removed a campaign cap; the budget was only
  charged for promo codes, so a cap over an ordinary promotion did nothing; the
  `key.issued` webhook could never be delivered; and coupon issuance was
  idempotent only against sequential repeats. All four are fixed and covered by
  tests — the statements above describe the fixed behaviour, not the intended one.
- **Hand-issued coupons (§4.10, the `owner_user_id` statements of §3.2 and the
  refusals of §5)** were written on 2026-08-20 together with the code that allows
  them, and each statement has its scenario in
  `internal/api/catalog/promo_keys_integration_test.go`, green over a real
  Postgres: `TestB53_ManualCouponRedeemedByOwner` follows one coupon from issuing
  to the money (seller sees it, buyer sees it, a stranger gets `NOT_FOUND`, the
  owner's order carries the discount, `used` becomes 1, the second attempt is
  `EXHAUSTED`); `TestB53_ManualCouponUnknownOwnerRejected` proves the dead coupon
  is not stored at all; `TestB53_ManualCouponOwnerImmutableOnEdit` proves both
  refusals of the owner field and that an honest edit still goes through;
  `TestB53_PublicCodeKeepsNoLimits` is the regression that shared promo codes did
  not silently become one-off.
- Enumerable content (kinds, classes, reject reasons, limits) is **not** written
  by hand here: it comes from `promotions.BuildPromoReference()`, and gate tests
  fail if this document and the server disagree in either direction.
- **The gift statements of §3.1, §3.9 and §5** stopped being a claim on
  2026-08-20 and became a check. `TestB5Gift_*` in
  `internal/core/services/promotions/apply_gift_money_test.go` asserts identities
  over the engine result rather than fixed numbers — the struck-through total is
  built from real lines only, the payable total is those lines minus the order
  and key steps, `Σ applied` equals the discount, the key base is the same with
  `applies_to_discounted` on and off, and the shop margin cap is measured without
  the gift; a gift worth more than the whole cart is used on purpose.
  `internal/api/catalog/promo_gift_money_integration_test.go` follows the gift
  past the engine: a 54-FZ receipt whose order-discount remainder must not be
  dropped onto the zero-priced gift line (it was, and the receipt then failed to
  build at all), and an order with no money refusing to produce a receipt instead
  of an empty one. The campaign row of §5 is the same file's third scenario,
  written to fail when the gap closes.
- **§3.7, §3.8, §4.8, §4.9 and the landscape rows of §5**: written on 2026-08-20
  from the source (`internal/api/catalog/get_promo_landscape.go`,
  `internal/adapters/repositories/pg/promo_landscape.go`, and `inCombosDesc` /
  `timesCheaperX100` in `internal/api/catalog/combo_repack.go`) and then **run**
  against a real Postgres the same day: `TestB5Landscape*` — 9 scenarios, all
  green; `TestB49_InComboItemPrice` — green (a component at −50% inside the set
  answers `item_price 250`, `item_regular_price 500`, `item_times_cheaper_x100
  200`, and a component the set does not discount answers `0`). Two of the nine
  were red when this section was first written; both defects were fixed the same
  day (commit `ac88c77`) and this document describes the fixed behaviour.

## 8. What own markup still needs (request to `Track: agent-api`)

**Update 2026-08-20, measured, not assumed:** own markup already gets promo
prices. The `products[]` scope is built from `GetProducts`, whose repack runs the
promotion engine, so `p.price` is the discounted price and `p.old_price` is the
pre-discount one. Nothing new has to be transported for a "was/now" row.

What is still missing is one field that the API already returns and the scope
simply does not map — and the cart-side set, which only matters once own markup
gets its own cart. No second transport is being invented for either:

- per product: `price.promotion_name` — the rule's name, for a "why is it
  cheaper" badge (`price.price` / `price.old_price` are already in the scope);
- per product, optional: `product_promotions[]` `{name, description, kind}` —
  for a "why is it cheaper" block on a card;
- per cart: `subtotal`, `discount_total`, `applied[] {name, amount}`,
  `promo_code`, `promo_rejected` — enough for a custom cart summary and a code
  input with honest error text. `subtotal_before` is deliberately not on the list:
  the struck-through total is `subtotal + discount_total` (§3.9), so mapping the
  field would only give own markup a second, weaker way to reach the same number.

Nothing here is new data — all of it already leaves the API on the cart and
product endpoints; it only needs to be declared in the scope white-list.
