Ошибки логики промокодов возникают из-за стэкинга, исключений и крайних случаев. Разберем частые баги и как задавать безопасные, тестируемые правила.

Ошибки в промокодах обычно появляются не из-за «сложных скидок», а из-за мелких допущений. Правило написали под один сценарий, а потом добавили доставку, налоги, подарки, возвраты и новый тип купона. Формула начинает жить своей жизнью.
Пользователь замечает это сразу: сумма в корзине «прыгает» при смене адреса доставки, скидка то есть, то пропадает после авторизации, а на последнем шаге оплаты цена внезапно меняется. Иногда скидка применяется дважды (и это тихо съедает выручку), а иногда итог уходит в минус или становится меньше минимально допустимого.
Риск не только в деньгах. Поддержка получает вал обращений, маркетинг спорит с разработкой о «правильной» сумме, а покупатель начинает подозревать обман. Даже если ошибка в пользу клиента, доверие все равно падает: завтра он увидит другую «странную» цену и уйдет.
Чаще всего ломается стык нескольких частей системы. Типовые зоны риска:
Простой пример: в корзине два товара, один уже со скидкой, и промокод «-10% на все». Если система сначала применяет промокод к каждой позиции, а потом еще раз к итогу заказа, получится двойной дисконт. А если промокод не должен работать на уценку, но проверка сделана по «старой цене», скидка случайно применится туда, где ее быть не должно.
Чтобы не ловить такие вещи в проде, правила должны быть однозначными и проверяемыми тестами: что считается базой для скидки, где границы применения и в какой момент фиксируется результат расчета. Удобный подход - разложить расчет на шаги с явными входами и выходами: так проще покрывать логику тестами и не ломать ее при новых акциях.
Часто спор начинается не про расчет, а про слова. Под одним и тем же названием команда может иметь в виду разные вещи, и из этого рождаются «мистические» расхождения в суммах.
Самое базовое:
Дальше важен стэкинг (суммирование скидок): можно ли сочетать две и более акции одновременно и в каком порядке. Варианты обычно такие: можно все, нельзя ничего, либо можно только определенные комбинации (например, «купон + бесплатная доставка», но не «купон + еще один купон»). Если это не описать явно, появляются двойные скидки, разные итоги на фронте и на бэке и непонятные жалобы.
Еще один ключевой термин - скоуп скидки, то есть что именно она меняет. На практике чаще всего встречаются четыре уровня: товар (SKU), категория, корзина (подытог или сумма), доставка.
Когда правила конфликтуют, нужен приоритет. Это заранее выбранный принцип для спорных кейсов: что делать, если на один и тот же товар попадает две акции, но стэкинг запрещен? Частые варианты: «более выгодная» или «более приоритетная по бизнесу». Важно хранить приоритет в данных, а не прятать его в коде.
И последнее, но критичное: идемпотентность. Повторное применение одного и того же правила не должно менять результат. Если при каждом пересчете скидка «растет», суммы начинают прыгать, а totals могут уйти в минус.
Чтобы промокоды не превратились в бесконечный набор «если-иначе» в коде, правила должны описывать не только размер скидки, но и условия, при которых она считается. Чем точнее это формализовано, тем меньше шансов получить двойной дисконт или итог «-37 рублей» из-за мелкой неточности.
«Выгоду» лучше разделять по типам, потому что они считаются по-разному: процент, фиксированная сумма, подарок (товар в корзину), бесплатная доставка. Для каждого типа важно явно задать базу расчета. Процент применяется к чему именно (позиции, подытог, категории), а фикс не должен опускать сумму ниже нуля.
Пример: «-10% на товары, но не на доставку» и «-10% на весь заказ» выглядят почти одинаково для пользователя, но в расчете это разные базы и разные места, где чаще всего появляются ошибки.
Хорошие правила должны уметь выразить три группы вещей:
Отдельная зона риска - налоги и доставка. Решите заранее и зафиксируйте: скидка считается до или после налогов, применяется ли к доставке и как округляются копейки. Иначе два сервиса (витрина и бэкенд) начнут считать по-разному, и проблемы всплывут именно на пограничных корзинах.
Если вы описываете правила в TakProsto, удобно держать рядом тип выгоды, базу расчета, исключения и политику стэкинга. Так проще проверять изменения и покрывать их тестами.
Чтобы расчет не стал «магией», полезно строить его как конвейер из понятных стадий. Тогда любой спорный чек можно разобрать по шагам, а тесты пишутся по правилам, а не по догадкам.
Практичный вариант - четыре стадии:
Правила стоит хранить в явном виде: условия, действие, приоритет и совместимость. «Совместимость» должна отвечать на конкретные вопросы: можно ли суммировать с бесплатной доставкой, с бонусами, с другими купонами, и если да - в каком порядке.
Критично иметь единый источник правды для расчета. Если скидка считается по-разному в корзине, на странице оплаты и в админке, вы получите фантомные баги: у клиента одно, у поддержки другое, у бухгалтерии третье.
Отдельно фиксируйте порядок применения и округление. Например: сначала скидки на позиции, потом купон на корзину, затем доставка; округление по каждой позиции или только по итогу. Без этого легко поймать «минусовую сумму» на мелких ценах.
Наконец, собирайте объяснение расчета: не просто «скидка применена», а почему. Какая проверка прошла, какая не прошла, какой приоритет победил. Такой trace можно показывать в админке или сохранять в заказе - он же помогает поддержке и тестам.
Главная защита от сюрпризов в проде - сделать расчет предсказуемым: одни и те же входные данные всегда дают один и тот же результат, без скрытых зависимостей.
Начните с нормализации корзины. Приведите цены к одной модели (например, в копейках), явно храните уценку, доставку, налоги, количество, валюту и признаки товаров (категория, бренд, признак «акционный»).
Дальше отдельно проверьте валидность промокода как факт доступа, а не как скидку. Обычно проверяют срок действия, лимиты применений (на пользователя и общий), требования к пользователю (новый/возвратный), канал (приложение/сайт), регион, минимальную сумму.
В итоге у вас должен получиться простой пайплайн:
После отбора акций опишите конфликты явно: что нельзя сочетать, что вытесняет другое, какие приоритеты. Выбор комбинации должен быть функцией от входных данных и правил, а не от порядка в списке.
Что помогает не ловить регрессии:
Пример для тестов: в корзине есть товар уже со скидкой и купон «-20% на все, кроме уценки», плюс бесплатная доставка от 3000. Проверьте, что купон не трогает уцененный товар; порог доставки считается от суммы после товарных скидок (или до них, если так решено); итог не уходит в минус.
Если вы собираете такую логику в TakProsto, удобно держать правила и расчет отдельным модулем, а перед изменениями сохранять снимок, чтобы быстро откатиться при регрессии.
Самые дорогие ошибки появляются в обычных акциях: процент на корзину, фикс на товар, бесплатная доставка. Причина проста: правила стэкинга, округление и порядок применения часто живут «в головах», а не в проверяемой модели.
Один из лидеров по частоте - двойная скидка. Например, купон дает -10% на категорию, и та же категория уже участвует в акции «-10% при покупке от 2 штук». Если система сначала пересчитала цену позиции, а потом снова применила купон к уже сниженной цене, итог станет ниже, чем ожидается. Еще хуже, когда купон можно применить дважды из-за повторной отправки запроса или из-за того, что правило срабатывает и на фронте, и на бэке.
Отрицательный итог - классика при фиксированных скидках. Корзина на 199 рублей, купон на 300 рублей, плюс бесплатная доставка. Если нет жесткого «пола» по каждой позиции и по корзине, итог уходит в минус или превращается в «-0,01». Такие баги потом ломают оплату и возвраты.
Округление - тихий убийца маржи. Когда скидка считается по позициям, а показывается по корзине (или наоборот), копейки начинают гулять. Сегодня покупатель видит одну сумму, завтра в чеке она другая.
Еще одна группа - смешение сущностей: скидка на доставку вдруг применена к товарам, а скидка на товар уменьшила стоимость доставки. Обычно это признак того, что в расчетах нет явного типа скидки и области применения.
Наконец, частичные возвраты. Если пользователь вернул один товар из двух, а скидка была «на второй товар» или «от суммы корзины», без корректного перерасчета вы либо переплатите, либо неверно удержите.
Минимальный набор защитных проверок:
Стэкинг - главный источник сюрпризов, потому что он зависит от порядка и контекста. Один и тот же набор скидок может дать разные итоги, если сначала применить процент, а потом фикс, или наоборот. Поэтому порядок должен быть явным правилом, закрепленным в спецификации и тестах.
Частая ловушка - сочетание промокода с уценкой. Если у товара уже есть скидка, нужно заранее решить: промокод запрещен, разрешен только на полную цену, или разрешен, но с ограничением (например, не ниже минимальной цены). Иначе вы получите двойной дисконт и минусовую маржу.
Редко проверяют вручную, но именно эти случаи ломают расчет:
Каждое правило должно отвечать на три вопроса: к чему применяется (товары, доставка, подарки), в каком порядке, и какие есть стоп-условия (минимальная сумма, исключенные категории, нельзя с уценкой). Удобно кодировать это как шаги расчета, где каждый шаг оставляет трассировку: что применили, к какой базе, почему отказали.
Пример: корзина с уцененным товаром, обычным товаром и доставкой. Процентная скидка применяется только к обычному товару, фиксированная скидка не опускает итог ниже нуля, а бесплатная доставка включается только если все позиции с одного склада. Такие сценарии легко гоняются автотестами.
Самый надежный способ не выпускать ошибки в прод - сделать расчет скидок чистой функцией. То есть функция получает корзину, набор промокодов и правила, а на выходе дает детальную раскладку и итог. Без базы, сети, времени на сервере и скрытых глобальных настроек.
Начните с табличных тестов: на один кейс - одна корзина, примененные промокоды и ожидаемый результат (скидки по строкам, общая скидка, итог). Такие тесты легко читать и дополнять.
Хорошая практика - хранить вход и ожидаемый ответ как структуру данных, а не собирать корзину в коде теста.
Инварианты, которые стоит проверять почти в каждом тесте:
Табличные тесты ловят известные баги, но не все странные сочетания. Добавьте генеративные тесты: случайно собирайте корзины (цены, количества, категории, признак распродажи, доставка) и прогоняйте сотни вариантов, проверяя инварианты. Так часто всплывают минусовые итоги и двойное списание, когда два правила уменьшают одну и ту же базу.
Чтобы быстро понимать причину регрессии, делайте трассировку расчета. В результате функции полезно возвращать:
Перед запуском промокода выгодно прогнать один и тот же набор проверок. Это дешевле, чем разбирать ошибки на бою, когда уже пошли заказы и возвраты.
Сначала зафиксируйте, что именно считается базой скидки: сумма товаров, сумма без доставки, сумма после применения баллов, цена до распродажи или после. Затем проверьте порядок применения правил: перестановка местами часто меняет итог на рубли и копейки.
Отдельно проверьте, что объяснение совпадает во всех местах: в корзине, в оформлении, в письме и в чеке. Частый провал: расчет верный, но UI пишет другое, и в поддержку летят вопросы.
Возьмите корзину из 3-5 позиций: одна на распродаже, одна из исключенной категории, одна дорогая, одна с количеством 2. Примените процентный промокод, затем добавьте автоакцию и попробуйте второй промокод. Такой сценарий хорошо ловит конфликты, порядок применения и проблемы округления.
Представим корзину из 3 товаров и платной доставки. Такие кейсы быстро показывают типовые ошибки: система случайно применяет две акции, считает скидку от уже сниженной базы или уходит в отрицательные суммы.
Корзина:
Доставка: 390 ₽.
Правила:
Шаг 1. Считаем базу без скидок (только товары): 3 200 + 2 300 + 450 = 5 950 ₽. Порог 5 000 ₽ выполнен.
Шаг 2. Считаем кандидатов независимо (на одной и той же базе, без «наслаивания»):
Шаг 3. Разрешаем конфликт (не суммируется). Безопасное правило: выбираем одну скидку с максимальной выгодой для клиента, вторую помечаем как отклоненную.
В примере выгоднее SHOE10 (320 ₽ > 300 ₽), значит применяем SHOE10, а AUTO300 отклоняем.
Пояснение для интерфейса: «Автоакция AUTO300 не применена, потому что не суммируется с процентными скидками. Применена скидка SHOE10, так как она выгоднее».
Шаг 4. Итоговый расчет и округление.
Сумма товаров после скидки: 5 950 - 320 = 5 630 ₽. Добавляем доставку (на нее скидка не действует): 5 630 + 390 = 6 020 ₽.
Процентные скидки округляйте предсказуемо (например, до рублей по каждому товару). Тогда итог совпадает с тем, что видит клиент в строках корзины, и не появляются «копейки из воздуха».
Если промокоды уже работают, но иногда «чудят», не начинайте с переписывания всего. Начните с прозрачности: что у вас вообще есть и как это считается.
Соберите инвентаризацию всех акций: промокоды, автоскидки, подарки, бесплатная доставка, персональные цены. Для каждой акции зафиксируйте один и тот же набор полей: условия применения, список исключений, приоритет, совместимость с другими акциями и формулу расчета. Важно договориться об одном формате, чтобы не было «эта скидка описана в коде, а эта в таблице у маркетинга».
Дальше вынесите расчет скидок в отдельный модуль с чистыми входами и выходами: на вход - корзина (позиции, цены, количество, пользователь, регион, доставка), на выход - детальная раскладка (какие правила сработали, сколько сняли по каждой позиции, итоговые суммы). Когда расчет живет отдельно, его проще тестировать и проще менять правила без риска сломать оформление заказа.
Чтобы быстрее закрыть самые дорогие провалы, заведите таблицу тест-кейсов и начните с типовых проблем:
Параллельно настройте логирование причин отказа промокода. Поддержке нужен понятный ответ, а не «не применился»: не прошел порог суммы, товар в исключениях, истек срок, превышен лимит, конфликт с другой акцией.
Если нужен быстрый прототип движка правил и тестов, это можно собрать в TakProsto (takprosto.ai): описать правила в режиме планирования, сгенерировать сервис и базовые тесты, развернуть и при необходимости быстро откатиться к снимку.
Чаще всего на стыке: пересчет корзины на разных экранах, доставка, налоги/сборы, частичные оплаты, возвраты. Ошибка обычно появляется, когда правило писали под один сценарий, а потом добавили новые условия без четкой базы расчета и порядка применения.
Заранее зафиксируйте правила стэкинга:
И храните это в данных правил, а не в «скрытом» порядке вызовов кода.
Скоп (область применения) — это что именно меняет скидка. Обычно это один из уровней:
Если скоуп не задан явно, скидка легко «утечет» на доставку или, наоборот, не применится там, где пользователь ее ждет.
Идемпотентность — повторное применение одного и того же правила не меняет результат. На практике это значит:
Технически помогает уникальный идентификатор применения правила и расчет как чистая функция от входных данных.
Сразу задайте политику:
Без этого разные части системы начнут считать по-разному, и цена будет «прыгать» между корзиной и оплатой.
Потому что их часто применяют к уже сниженной базе или даже дважды: сначала к позициям, потом к итогу. Безопаснее считать кандидатов независимо на одной и той же базе, а затем выбрать лучший (или приоритетный) вариант и только после этого применять изменения.
Всегда ставьте «пол» (минимум):
Для фиксированных скидок дополнительно решите, как распределять их по позициям (пропорционально, по приоритету товаров) и сохраняйте это распределение в заказе.
Нужно сохранять, как скидка была распределена по позициям на момент покупки. Тогда при частичном возврате вы возвращаете «ту же математику», а не пытаетесь пересчитать скидку задним числом по другой корзине и другим условиям.
Сделайте расчет промо конвейером с явными шагами:
Такую схему проще тестировать и расширять без бесконечных «если-иначе».
Полезный минимум:
В TakProsto удобно держать правила и расчет отдельным модулем, а перед изменениями сохранять снимок, чтобы быстро откатиться при регрессии.