Промокоды в интернет-магазине: что проверить в своей реализации
Тринадцать проверок, собранных из кода Medusa, Saleor, Spree и Vendure — включая две гонки, которые у зрелых движков открыты до сих пор.
miki/promokody-v-internet-magazine-chto-proverit-v-svoey-realizat · v2
Тринадцать проверок, собранных из кода Medusa, Saleor, Spree и Vendure — включая две гонки, которые у зрелых движков открыты до сих пор.
Промокод выглядит простой задачей ровно до первой ночной смены: «код на один раз» уехал на три заказа, в чеке скидка не та, что на экране, а гость платит больше, чем видел.
Этот список собран не из документации, а из кода четырёх зрелых движков — по одним и тем же вопросам, чтобы ответы стояли рядом.
Подробные разборы с цитатами кода — отдельными списками:
| Проект | Коммит | Разбор |
|---|---|---|
| medusajs/medusa | ecc9f07e | Блокировки, снимок без FK и тихо пропущенная акция |
| saleor/saleor | 0a11eb91 | Три уровня модели и открытая гонка |
| spree/spree | e1839c40 | Типизированные строки и COUNT вместо счётчика |
| vendure-ecommerce/vendure | 1ad4ba80 | Замок на акции и отказ вместо тихого пересчёта |
Самое полезное там — не «как у них сделано», а где у них открыто. Две гонки из списка ниже живут в проектах с тысячами звёзд прямо сейчас.
Порядок пунктов не важен: это набор проверок, а не последовательность.
Что присылает клиент
В схеме запроса на применение кода должна быть строка кода и больше ничего денежного. Не «мы её игнорируем» — её не должно быть в типе.
- В типе входа нет ни одного поля с суммой, процентом или итогом
- Лишние поля отвергаются схемой, а не отбрасываются молча
- Сумма позиций для расчёта берётся из базы, а не из тела запроса
Кнопка «Применить» обычно доступна без входа. Если в неё передаётся телефон или почта, а ответ зависит от истории этого человека — действие превращается в справочную по чужим клиентам.
- Предпросмотр не принимает чужой идентификатор параметром — только свой, из сессии
- Ответ гостю не зависит от чужой истории заказов
- Настоящая проверка условия делается при оформлении, до создания заказа
Предел применений
Проверка «не исчерпан ли» и захват применения должны быть одной операцией:
Пустой returning — код не достался, заказ отменяется целиком.
- Количество изменённых строк проверяется, а не игнорируется
- Предел читается из строки в базе, а не из снимка, прочитанного раньше
- Есть тест с двумя одновременными оформлениями последнего применения
Классическая ошибка: сначала SELECT с проверкой лимита без блокировки, потом SELECT ... FOR UPDATE, потом инкремент. Блокировка стоит после проверки и потому ничего не защищает.
- Между проверкой предела и его расходованием нет незаблокированного промежутка
- Если используется FOR UPDATE — строка перечитывается ПОСЛЕ взятия блокировки
- Блокировка берётся на строку кода, а не на корзину
Если оплата онлайн отделена во времени от создания заказа, между ними открыто окно: код формально ещё не израсходован, а заказ уже в пути.
- Ясно, в какой момент код считается израсходованным
- Если оплата может не пройти — есть возврат расхода, и он идемпотентен
Снимок в заказе
В строку применения кладите не только посчитанную сумму, но и код строкой, вид (процент или фикс) и значение на день заказа.
- Код лежит в заказе строкой, а не только внешним ключом
- Вид и значение скидки записаны на момент заказа
- Чек читается, даже если справочник кодов недоступен
На промокод ссылаются заказы. Удаление либо порвёт эти ссылки, либо утащит за собой строки применения — в обоих случаях старые чеки перестанут читаться.
- Кнопка «удалить» в админке либо отсутствует, либо выключает код
- Внешний ключ на код стоит RESTRICT или SET NULL, но не CASCADE
- Выключенный код перестаёт применяться, но остаётся виден в прошлых заказах
Границы скидки
Скидка вычитается из стоимости блюд или товаров, но не из доставки. Код на 500 ₽ при заказе на 300 ₽ не должен обнулять доставку.
- Скидка ограничена сверху суммой позиций
- Скидка на доставку — отдельный вид акции, если он вообще нужен
- Итог не может уйти в минус ни при каком значении кода
Правило, которое держится порядком строк в коде, живёт до первой перестановки этих строк. Констрейнт живёт дольше:
- Констрейнты действительно созданы миграцией, а не только описаны в схеме
- Проверено запросом: INSERT с нарушением отвергается
- Существующие строки констрейнту удовлетворяют — миграция не упала
Копейки
Округление процента должно быть выбрано явно и в одном месте, с комментарием — почему вверх или вниз.
- Округление задано явно, а не унаследовано от языка или библиотеки
- Направление объяснено в комментарии рядом
- Округление происходит в одном месте, а не в трёх
Если скидка считается на единицу товара и потом умножается на количество, копейка округления умножается вместе с ней.
- Округляется итог строки, а не цена за единицу
- Обратное деление на количество не округляется повторно
Если скидка показывается или фискализируется построчно, сумма построчных долей обязана совпасть с общей до копейки. Приём один и тот же у троих: floor по всем долям, остаток раздаётся по одной копейке.
- Сумма долей равна общей скидке точно, а не приблизительно
- Распределение детерминированно: одинаковый вход даёт одинаковый выход
- Ни одна доля не делает позицию отрицательной
Момент оформления
Правила — срок, процент, условия, признак «включён» — должны читаться в той же транзакции, что создаёт заказ, а не браться из проверки, сделанной раньше.
- Правила читаются внутри транзакции, а не до неё
- Перечитываются ВСЕ условия, а не только срок и лимит
- Проверено вручную: применить код, выключить его в базе, подтвердить заказ — приходит отказ, заказ не создан
Если при перепроверке код оказался негодным и сумма из-за этого выросла — оплату проводить нельзя. Нужен явный отказ с прежней и новой суммой.
- Есть отдельный вид отказа именно для этого случая
- В отказе видны обе суммы — была и стала
- Заказ при этом не создан и деньги не списаны
Если правило «один код на заказ» держится только порядком вызовов в коде, закрепите его уникальным индексом на идентификатор заказа в таблице применений.
- Уникальный индекс на заказ в таблице применений существует
- Попытка записать второе применение к тому же заказу отвергается базой
Related lists
Разбор промо-модуля Medusa по коду: блокировки строк, снимок скидки в заказе, и место, где акция уходит в заказ, но не списывается.
Разбор промо-подсистемы Vendure по коду: пессимистическая блокировка с документированным поведением по СУБД, prorate на сорок строк и снимок без процента.
Разбор промо-подсистемы Spree по коду: STI-реестр правил, полный снимок скидки, наибольший остаток — и лимит, который переполняется при параллельных заказах.
Разбор скидок Saleor по коду: где уникальный индекс закрыл гонку, где счётчик её не закрыл, и зачем в снимке заказа лежит процент.
Журнал движений и кэш остатка расходились в ERPNext десять лет: задним числом, вперёд, через гонку, через вторую дверь. Последний коммит удаляет мёртвую функцию — только за то, что она второй путь записи.
Как проект живёт и принимает чужие правки, и кто в нём встречался в разборах — по поступкам, датам и ссылкам
