Promotions, combo sets, coupons and promo codes
/docs/promotionscurrentEN· проверено 2026-08-22
Резюме по-русски. Скидка в 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:writeA 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, andissue_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). Emptyclassmeans "the class thiskindhad 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:writePromoKey — 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:writePromoCampaign: 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 onlyMyCoupon — 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 tokenBoth 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.usednorpromo_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, andexpected_totalat checkout is what protects the buyer from finding out silently (§3.6); - a personal coupon (§3.4) answers
NOT_FOUNDto a guest. There is no buyer to matchowner_user_idagainst, 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:readAnswers "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
orderorkey— 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:
- the rule lists were filtered by
activeand campaign budget only, so a rule whoseends_atwas 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; - 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):
{ "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: nullmeans stock is not tracked, hence available — §5). The plaque itself stays,descriptionand 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.
giftandtriggersnever 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 —
giftfilled,triggersempty. 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
slugempty still means publish no link; - a category that no longer resolves (deleted, or belonging to another shop —
products.categorieshas 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:
GET /promotions→ rules withactive: trueand a live period. An empty array means this shop has no rules, see §5.- For a rule of
kind: "product_discount", decodeconditionand readproduct_ids/category_ids. For prices as the buyer sees them, read the products:price.priceis already discounted andprice.old_pricecarries the pre-discount value, withprice.promotion_namenaming 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
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
# 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.
# 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_usedalso 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.
# 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:
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.
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:
- take the first offer with
kind == "product"and read its card:GET /products/{product_id}must return exactly the sameprice.priceandprice.old_price. If they differ, the page and the card are quoting two prices to the same buyer — do not ship, report it. - check the arithmetic of one row by hand:
benefit == old_price - price, andbenefit_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. - read
truncated.true(withscanned_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
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_pricemust be exactly500(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_x100must be exactly200, i.e. "вдвое";- now start a
product_discountof −20% on that component and read the card again:item_regular_pricedrops to800and the ratio falls to160. 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.
# 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=trueand find yourowner_user_idon it. Withoutwith_coupons=truethe 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"}answerspromo_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-keysand/cart/promoanswer 401 anonymously, not 404); rule and key creation return the shapes documented above; duplicate code → 409; key on a non-keyrule → 400; fixed-amount coupon without a threshold → 400;max_discounton 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 → stringwire 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.issuedwebhook 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_ManualCouponRedeemedByOwnerfollows one coupon from issuing to the money (seller sees it, buyer sees it, a stranger getsNOT_FOUND, the owner's order carries the discount,usedbecomes 1, the second attempt isEXHAUSTED);TestB53_ManualCouponUnknownOwnerRejectedproves the dead coupon is not stored at all;TestB53_ManualCouponOwnerImmutableOnEditproves both refusals of the owner field and that an honest edit still goes through;TestB53_PublicCodeKeepsNoLimitsis 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_*ininternal/core/services/promotions/apply_gift_money_test.goasserts 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,Σ appliedequals the discount, the key base is the same withapplies_to_discountedon 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.gofollows 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, andinCombosDesc/timesCheaperX100ininternal/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 answersitem_price 250,item_regular_price 500,item_times_cheaper_x100 200, and a component the set does not discount answers0). Two of the nine were red when this section was first written; both defects were fixed the same day (commitac88c77) 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_priceare 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_beforeis deliberately not on the list: the struck-through total issubtotal + 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.
Исходник: https://api.vizen.shop/docs/promotions