Skip to content

Промокоды в интернет-магазине: что проверить в своей реализации

Тринадцать проверок, собранных из кода Medusa, Saleor, Spree и Vendure — включая две гонки, которые у зрелых движков открыты до сих пор.

v2 0 stars 0 forks 0 watchers 1 branch 0 runs Public
Ссылки на четыре разбора с кодомv2

Промокод выглядит простой задачей ровно до первой ночной смены: «код на один раз» уехал на три заказа, в чеке скидка не та, что на экране, а гость платит больше, чем видел.

Этот список собран не из документации, а из кода четырёх зрелых движков — по одним и тем же вопросам, чтобы ответы стояли рядом.

Подробные разборы с цитатами кода — отдельными списками:

Самое полезное там — не «как у них сделано», а где у них открыто. Две гонки из списка ниже живут в проектах с тысячами звёзд прямо сейчас.

Порядок пунктов не важен: это набор проверок, а не последовательность.

Что присылает клиент

Убедиться, что суммы скидки во входе нет

В схеме запроса на применение кода должна быть строка кода и больше ничего денежного. Не «мы её игнорируем» — её не должно быть в типе.

Why: Пока поле принимается, кто-нибудь однажды начнёт ему верить: в обработчике, в логе, в отчёте. Убрать возможность дешевле, чем каждый раз проверять, что ею не воспользовались.
Check
  • В типе входа нет ни одного поля с суммой, процентом или итогом
  • Лишние поля отвергаются схемой, а не отбрасываются молча
  • Сумма позиций для расчёта берётся из базы, а не из тела запроса
Проверить, что предпросмотр кода не выдаёт чужие данные

Кнопка «Применить» обычно доступна без входа. Если в неё передаётся телефон или почта, а ответ зависит от истории этого человека — действие превращается в справочную по чужим клиентам.

Why: У кода с условием «только для первого заказа» ответ существующему клиенту и новому разный. Зная любой действующий код, перебором номеров можно выяснить, кто уже заказывал у заведения. Это персональные данные, и отдаёт их ваш собственный публичный эндпоинт.
Check
  • Предпросмотр не принимает чужой идентификатор параметром — только свой, из сессии
  • Ответ гостю не зависит от чужой истории заказов
  • Настоящая проверка условия делается при оформлении, до создания заказа

Предел применений

Расходовать код одним условным UPDATE

Проверка «не исчерпан ли» и захват применения должны быть одной операцией:

sql
1update promo_codes set uses = uses + 1
2where id = $1 and (max_uses is null or uses < max_uses)
3returning id

Пустой returning — код не достался, заказ отменяется целиком.

Why: Проверка отдельно от захвата — это check-then-act, который два одновременных запроса проходят оба. Одна операция не оставляет промежутка, в который можно встать.
Check
  • Количество изменённых строк проверяется, а не игнорируется
  • Предел читается из строки в базе, а не из снимка, прочитанного раньше
  • Есть тест с двумя одновременными оформлениями последнего применения
Проверить, что предел спрашивается там же, где занимается

Классическая ошибка: сначала SELECT с проверкой лимита без блокировки, потом SELECT ... FOR UPDATE, потом инкремент. Блокировка стоит после проверки и потому ничего не защищает.

Why: Ровно так сделано в Saleor: лимит проверяется подзапросом `usage_limit > SUM(used)` без блокировки, и только затем берётся FOR UPDATE. При READ COMMITTED два чекаута видят состояние до чужого коммита и оба проходят. Переполнение равно числу одновременных оформлений.
Check
  • Между проверкой предела и его расходованием нет незаблокированного промежутка
  • Если используется FOR UPDATE — строка перечитывается ПОСЛЕ взятия блокировки
  • Блокировка берётся на строку кода, а не на корзину
Решить, считаются ли заказы в процессе оплатыRecommended

Если оплата онлайн отделена во времени от создания заказа, между ними открыто окно: код формально ещё не израсходован, а заказ уже в пути.

Why: Vendure считает расход COUNT-ом по заказам и намеренно включает те, что в состоянии ArrangingPayment — именно чтобы закрыть это окно. Если у вас оплата при получении, окна нет и усложнять не нужно; но решение надо принять осознанно, а не по умолчанию.
A person is needed here: Есть ли в вашем магазине оплата онлайн и какой зазор между созданием заказа и подтверждением платежа answer from experience
Check
  • Ясно, в какой момент код считается израсходованным
  • Если оплата может не пройти — есть возврат расхода, и он идемпотентен

Снимок в заказе

Скопировать в заказ код, вид скидки и её значение

В строку применения кладите не только посчитанную сумму, но и код строкой, вид (процент или фикс) и значение на день заказа.

Why: Из «−34 ₽» через полгода не видно, была это десятая часть или фикс, а спрашивают именно об этом. В справочник смотреть бесполезно: условия кода правят. Medusa хранит только сумму — и восстановить «это было −20 %» из заказа у него нельзя вовсе.
Check
  • Код лежит в заказе строкой, а не только внешним ключом
  • Вид и значение скидки записаны на момент заказа
  • Чек читается, даже если справочник кодов недоступен
Заменить удаление кода выключением

На промокод ссылаются заказы. Удаление либо порвёт эти ссылки, либо утащит за собой строки применения — в обоих случаях старые чеки перестанут читаться.

Why: Все четверо решают это одинаково: мягкое удаление (deletedAt) или обнуляемый внешний ключ плюс копия кода строкой. Каскад на строку скидки не ставит никто.
Check
  • Кнопка «удалить» в админке либо отсутствует, либо выключает код
  • Внешний ключ на код стоит RESTRICT или SET NULL, но не CASCADE
  • Выключенный код перестаёт применяться, но остаётся виден в прошлых заказах

Границы скидки

Ограничить скидку суммой позиций и не дать ей тронуть доставку

Скидка вычитается из стоимости блюд или товаров, но не из доставки. Код на 500 ₽ при заказе на 300 ₽ не должен обнулять доставку.

Why: Иначе заведение платит курьеру из своего кармана за право отдать товар бесплатно. Бесплатная доставка — это отдельный вид акции с отдельной проверкой, а не побочный эффект большой скидки.
Check
  • Скидка ограничена сверху суммой позиций
  • Скидка на доставку — отдельный вид акции, если он вообще нужен
  • Итог не может уйти в минус ни при каком значении кода
Закрепить границы констрейнтом в базеRecommended

Правило, которое держится порядком строк в коде, живёт до первой перестановки этих строк. Констрейнт живёт дольше:

sql
1check (discount between 0 and items_total)
2check (total = items_total + delivery_fee - discount)
Why: Medusa и Spree дублируют «скидка не бывает отрицательной» CHECK-констрейнтом. Второй констрейнт — согласованность итога со слагаемыми — не делает никто из четверых, а он ловит самый неприятный класс ошибок: заказ, где на бумажке кухни одна сумма, а с гостя берут другую.
Check
  • Констрейнты действительно созданы миграцией, а не только описаны в схеме
  • Проверено запросом: INSERT с нарушением отвергается
  • Существующие строки констрейнту удовлетворяют — миграция не упала

Копейки

Назвать направление округления вслухRecommended

Округление процента должно быть выбрано явно и в одном месте, с комментарием — почему вверх или вниз.

Why: Дело не в том, какое направление правильнее, а в том, что умолчанию нельзя доверять: Saleor в каждой точке применения явно переопределяет ROUND_DOWN библиотеки на ROUND_HALF_UP. Спор «почему в чеке рублём меньше» решается ссылкой на строку кода, а не пересчётом на салфетке.
Check
  • Округление задано явно, а не унаследовано от языка или библиотеки
  • Направление объяснено в комментарии рядом
  • Округление происходит в одном месте, а не в трёх
Округлять цену на количество, а не цену за единицуRecommended

Если скидка считается на единицу товара и потом умножается на количество, копейка округления умножается вместе с ней.

Why: Vendure округляет произведение (`round(value * quantity)`) именно чтобы убрать этот дрейф. Три одинаковые позиции по копейке дают расхождение в три копейки — мелочь до того дня, когда сумма позиций не сойдётся с итогом.
Check
  • Округляется итог строки, а не цена за единицу
  • Обратное деление на количество не округляется повторно
Раскидать остаток так, чтобы сумма долей сошлась точноOptional

Если скидка показывается или фискализируется построчно, сумма построчных долей обязана совпасть с общей до копейки. Приём один и тот же у троих: floor по всем долям, остаток раздаётся по одной копейке.

Why: Повторное округление каждой доли даёт расхождение с общей суммой, и в фискальном чеке это ошибка, а не косметика. Разница между движками только в том, кому достаётся лишняя копейка: Spree — по индексу, Vendure — тому, кого округление обидело сильнее, Saleor — последней строке.
Check
  • Сумма долей равна общей скидке точно, а не приблизительно
  • Распределение детерминированно: одинаковый вход даёт одинаковый выход
  • Ни одна доля не делает позицию отрицательной

Момент оформления

Перечитать правила кода внутри транзакции заказа

Правила — срок, процент, условия, признак «включён» — должны читаться в той же транзакции, что создаёт заказ, а не браться из проверки, сделанной раньше.

Why: Medusa при оформлении промо не пересчитывает вовсе: корректировки берутся из корзины как есть. Владелец выключил акцию — гость, чья корзина с тех пор не менялась, оформится по старой скидке. Vendure перечитывает частично: срок окончания и лимиты да, а startsAt и условия акции — нет, хотя комментарий рядом обещает обратное.
Check
  • Правила читаются внутри транзакции, а не до неё
  • Перечитываются ВСЕ условия, а не только срок и лимит
  • Проверено вручную: применить код, выключить его в базе, подтвердить заказ — приходит отказ, заказ не создан
Не проводить оплату молча, если скидка исчезла

Если при перепроверке код оказался негодным и сумма из-за этого выросла — оплату проводить нельзя. Нужен явный отказ с прежней и новой суммой.

Why: Ошибка в сторону «гость платит больше, чем видел на экране» недопустима. Vendure возвращает типизированную CouponRemovedDuringCheckoutError с обеими суммами; молчаливое «скидка исчезла, с вас на 300 рублей больше» — худший из возможных исходов.
Check
  • Есть отдельный вид отказа именно для этого случая
  • В отказе видны обе суммы — была и стала
  • Заказ при этом не создан и деньги не списаны
Убедиться, что один заказ не может получить два кодаRecommended

Если правило «один код на заказ» держится только порядком вызовов в коде, закрепите его уникальным индексом на идентификатор заказа в таблице применений.

Why: Saleor запрещает стек прямо в коде комментарием «only one voucher can be applied». Уникальный индекс превращает нарушение из тихой двойной скидки в отвергнутый INSERT — то есть в ошибку, которую видно.
Check
  • Уникальный индекс на заказ в таблице применений существует
  • Попытка записать второе применение к тому же заказу отвергается базой

Разбор промо-модуля Medusa по коду: блокировки строк, снимок скидки в заказе, и место, где акция уходит в заказ, но не списывается.

updated Sep 2, 2026

Разбор промо-подсистемы Vendure по коду: пессимистическая блокировка с документированным поведением по СУБД, prorate на сорок строк и снимок без процента.

updated Sep 2, 2026

Разбор промо-подсистемы Spree по коду: STI-реестр правил, полный снимок скидки, наибольший остаток — и лимит, который переполняется при параллельных заказах.

updated Sep 2, 2026

Разбор скидок Saleor по коду: где уникальный индекс закрыл гонку, где счётчик её не закрыл, и зачем в снимке заказа лежит процент.

updated Sep 2, 2026

Журнал движений и кэш остатка расходились в ERPNext десять лет: задним числом, вперёд, через гонку, через вторую дверь. Последний коммит удаляет мёртвую функцию — только за то, что она второй путь записи.

updated Sep 2, 2026

Как проект живёт и принимает чужие правки, и кто в нём встречался в разборах — по поступкам, датам и ссылкам

updated Sep 2, 2026