{
  "kinds": [
    {
      "id": "bundle",
      "condition": "{\"sets\":[{\"product_ids\":[1,2],\"min_qty\":2},{\"category_ids\":[42],\"min_qty\":1}]}",
      "effects": [
        "{\"percent\":10}",
        "{\"amount\":500}"
      ],
      "classes": [
        "item"
      ],
      "note": "Скидка за набор: срабатывает, когда КАЖДЫЙ set набран корзиной. Кратность комплектов не учитывается."
    },
    {
      "id": "gift",
      "condition": "{\"sets\":[{\"product_ids\":[1,2],\"min_qty\":2}]}",
      "effects": [
        "{\"gift_product_id\":42,\"quantity\":1}"
      ],
      "classes": [
        "item"
      ],
      "note": "Подарок: виртуальная строка корзины с ценой 0. Исчезает сам, если условие нарушено."
    },
    {
      "id": "issue_key",
      "condition": "{\"min_subtotal\":50000,\"min_items\":1}",
      "effects": [
        "{\"grant_promotion_id\":7,\"valid_days\":30}"
      ],
      "classes": [
        "item"
      ],
      "note": "Купон за покупку. НЕ скидка: ничего не считает в корзине, а выдаёт именной купон, когда заказ доведён до «выполнен». Что даёт купон — описывает правило grant_promotion_id (класс key)."
    },
    {
      "id": "order_discount",
      "condition": "{\"min_subtotal\":10000,\"min_items\":3}",
      "effects": [
        "{\"percent\":10,\"max_discount\":5000}",
        "{\"amount\":500}"
      ],
      "classes": [
        "order",
        "key"
      ],
      "note": "Скидка на заказ. Порог считается ПОСЛЕ построчных скидок. max_discount — потолок на заказ."
    },
    {
      "id": "product_discount",
      "condition": "{\"product_ids\":[1,2],\"category_ids\":[42],\"excluded_product_ids\":[3]}",
      "effects": [
        "{\"percent\":15}",
        "{\"amount\":500}"
      ],
      "classes": [
        "item"
      ],
      "note": "Скидка на товары или категории. Пустое условие = весь каталог. Эффект — за единицу товара."
    }
  ],
  "classes": [
    {
      "id": "item",
      "order": 1,
      "note": "Построчные: применяется одна лучшая на строку."
    },
    {
      "id": "order",
      "order": 2,
      "note": "Заказная: одна лучшая, считается от суммы после построчных."
    },
    {
      "id": "key",
      "order": 3,
      "note": "За ключом: без предъявленного промокода или купона правило молчит."
    }
  ],
  "ladder": [
    {
      "id": "item",
      "order": 1,
      "class": "item",
      "note": "Одна лучшая построчная (product_discount или сработавший bundle) на каждую строку."
    },
    {
      "id": "order",
      "order": 2,
      "class": "order",
      "note": "Одна лучшая заказная от суммы ПОСЛЕ построчных: там же считается и её порог."
    },
    {
      "id": "key",
      "order": 3,
      "class": "key",
      "note": "Скидка по предъявленному коду — от суммы после заказной; порог берётся от суммы после построчных, как у заказной. По умолчанию строки со сработавшей акцией в базу не входят (applies_to_discounted=false)."
    },
    {
      "id": "shop_cap",
      "order": 4,
      "note": "Предохранитель магазина (companies.max_total_discount_percent): срезает ступени СВЕРХУ ВНИЗ — сначала ключ, потом заказную. Построчные не трогает: их урезание пришлось бы размазывать по строкам и рассогласовало бы построчный снимок заказа."
    },
    {
      "id": "gift",
      "order": 5,
      "note": "Виртуальные строки-подарки добавляются последними: они не участвуют ни в порогах, ни в subtotal, ни в повторном матчинге."
    }
  ],
  "reject_reasons": [
    {
      "code": "NOT_FOUND",
      "stage": "key",
      "note": "Такого кода нет. Сюда же маскируется чужой именной купон — иначе ответ подтверждал бы, что код существует."
    },
    {
      "code": "EXPIRED",
      "stage": "key",
      "note": "Срок действия кода истёк."
    },
    {
      "code": "EXHAUSTED",
      "stage": "key",
      "note": "Лимит использований выбран."
    },
    {
      "code": "PER_CUSTOMER",
      "stage": "key",
      "note": "Этот покупатель уже пользовался кодом."
    },
    {
      "code": "RULE_INACTIVE",
      "stage": "engine",
      "note": "Правило за кодом выключено, вне периода или удалено."
    },
    {
      "code": "BELOW_THRESHOLD",
      "stage": "engine",
      "note": "Корзина не добрала порог правила."
    },
    {
      "code": "NO_ELIGIBLE",
      "stage": "engine",
      "note": "Дисконтировать нечего: все строки уже со скидкой либо корзина пуста."
    },
    {
      "code": "NOT_STACKABLE",
      "stage": "engine",
      "note": "Сработала акция, помеченная «не сочетать с промокодом»."
    },
    {
      "code": "NO_EFFECT",
      "stage": "engine",
      "note": "Скидка вышла нулевой."
    },
    {
      "code": "BUDGET_SPENT",
      "stage": "engine",
      "note": "Бюджета кампании не хватает на эту скидку."
    }
  ],
  "rounding": [
    {
      "id": "line_percent",
      "mode": "half_up",
      "rounded": "price",
      "where": "internal/core/services/promotions/apply.go:perUnitDiscount",
      "note": "Построчный процент округляет ЦЕНУ за единицу half-up, а скидка = база − цена. Из-за этого на копеечных ценах скидка получается на 1 меньше, чем «процент от цены»."
    },
    {
      "id": "order_percent",
      "mode": "half_up",
      "rounded": "discount",
      "where": "internal/core/services/promotions/apply.go:orderDiscountAmount",
      "note": "Заказной процент округляет СУММУ СКИДКИ half-up — противоположную величину по сравнению с построчной ступенью. Один и тот же «−50%» на 3 единицы даёт скидку 1 построчно и 2 на заказе."
    },
    {
      "id": "key_percent",
      "mode": "half_up",
      "rounded": "discount",
      "where": "internal/core/services/promotions/apply.go:cappedAmount",
      "note": "Ступень ключа считает ту же формулу, что заказная (один хелпер на обе: разъедься они, потолок max_discount работал бы у промокода и молчал у заказной)."
    },
    {
      "id": "shop_cap_percent",
      "mode": "floor",
      "rounded": "allowance",
      "where": "internal/core/services/promotions/apply.go:clampByShopCap",
      "note": "Предохранитель округляет САМ ДОПУСК (сколько скидки магазин терпит), а не скидку, и ВНИЗ: при 33% от суммы 15 допуск 4, а не 5. Режим здесь несимметричен остальным ступеням намеренно — half-up пропускал бы на единицу больше объявленного потолка, то есть предохранитель нарушал бы собственную настройку."
    },
    {
      "id": "combo_component_percent",
      "mode": "floor",
      "rounded": "price",
      "where": "internal/core/domain/combo.go:EffectiveComponentUnitPrice",
      "note": "Цена компонента в наборе: floor(base·(100−p)/100), от БАЗОВОЙ цены — цена набора не должна прыгать от акций на компоненты."
    },
    {
      "id": "benefit_percent",
      "mode": "floor",
      "rounded": "share",
      "where": "internal/api/catalog/get_promo_landscape.go:percentOf",
      "note": "Процент выгоды в подборке — ВНИЗ: это число печатается покупателю как обещание, и 49,6% под видом «−50%» было бы рекламой скидки, которой магазин не даёт."
    },
    {
      "id": "times_cheaper_x100",
      "mode": "floor",
      "rounded": "share",
      "where": "internal/api/catalog/combo_repack.go:timesCheaperX100",
      "note": "«Во сколько раз дешевле в наборе» ×100, вниз (197 → «1,9», не «2»). 0 = говорить «дешевле» нельзя вовсе."
    }
  ],
  "contracts": [
    {
      "message": "PromoKey",
      "fields": [
        "id",
        "promotion_id",
        "code",
        "owner_user_id",
        "expires_at",
        "usage_limit",
        "per_customer_limit",
        "active",
        "used",
        "created_at",
        "issued_by_order_id"
      ],
      "note": "id и promotion_id едут строками (int64 на проводе). owner_user_id пишется ТОЛЬКО на создании (реальный id = именной купон, 0/пусто = общий промокод), дальше неизменен: на правке его сверяют с хранимым и отвечают PROMO_KEY_OWNER_IMMUTABLE, поэтому в PUT его надо присылать обратно как есть. used, created_at, issued_by_order_id — read-only, на записи игнорируются."
    },
    {
      "message": "PromoCampaign",
      "fields": [
        "id",
        "name",
        "budget_kind",
        "budget_limit",
        "active",
        "starts_at",
        "ends_at",
        "budget_used",
        "created_at",
        "budget_warned",
        "promotions_count"
      ],
      "note": "budget_used, budget_warned, promotions_count — read-only: факт продаж, а не настройка."
    },
    {
      "message": "MyCoupon",
      "fields": [
        "id",
        "code",
        "name",
        "description",
        "expires_at",
        "used",
        "expired",
        "issued_by_order_id"
      ],
      "note": "name/description берутся из правила, которое купон отпирает: покупатель читает ту же формулировку, что увидит в корзине."
    },
    {
      "message": "PromoOffer",
      "fields": [
        "kind",
        "product_id",
        "name",
        "slug",
        "preview",
        "price",
        "old_price",
        "benefit",
        "benefit_percent",
        "promotion_name"
      ],
      "note": "preview объявлен, но сегодня не заполняется — картинку берут из товара. promotion_name пуст у комбо и у ручной уценки продавца."
    },
    {
      "message": "GetPromoLandscapeResponse.Result",
      "fields": [
        "promotions",
        "code_promotions",
        "top_offers",
        "scanned_products",
        "truncated"
      ],
      "note": "scanned_products/truncated обязательны к чтению: молчаливое усечение читается как «в магазине больше нет скидок»."
    }
  ],
  "limits": {
    "campaign_name_max_len": 255,
    "campaign_warn_percent": 80,
    "code_max_len": 64,
    "code_min_len": 3,
    "combo_item_quantity_max": 100,
    "combo_item_quantity_min": 1,
    "combo_items_max": 20,
    "condition_max_bytes": 16384,
    "description_max_len": 2000,
    "effect_max_bytes": 16384,
    "gift_quantity_max": 10,
    "ids_per_list_max": 1000,
    "issue_valid_days_max": 365,
    "issue_valid_days_min": 1,
    "landscape_limit_default": 24,
    "landscape_limit_max": 100,
    "landscape_scan_combo_sets": 200,
    "landscape_scan_products": 500,
    "name_max_len": 255,
    "percent_max": 100,
    "set_min_qty_max": 1000,
    "sets_max": 10,
    "usage_limit_max": 1000000
  },
  "budget_kinds": [
    {
      "id": "none",
      "note": "Без потолка: кампания только группирует правила."
    },
    {
      "id": "spend",
      "note": "Потолок по СУММЕ выданных скидок."
    },
    {
      "id": "count",
      "note": "Потолок по ЧИСЛУ погашений."
    }
  ],
  "landscape": {
    "offer_kinds": [
      "product",
      "combo"
    ],
    "ranking": [
      "benefit_percent desc",
      "benefit desc",
      "product_id asc"
    ],
    "excluded_classes": [
      "order",
      "key"
    ],
    "excluded_kinds": [
      "issue_key"
    ],
    "note": "Товары не выбираются SQL-ом «где есть скидка»: скидку даёт движок, а не колонка. Берутся опубликованные товары свежими сверху и прогоняются через тот же движок, что считает карточку; потолки обхода отдаются наружу полями scanned_products/truncated."
  }
}