Skip to content

Compare versions

From:To:
+108
11¶ Промокод выглядит простой задачей ровно до первой ночной смены: «код на один раз» уехал на три заказа, в чеке скидка не та, что на экране, а гость платит больше, чем видел.
22
3 Этот список собран не из документации, а из кода четырёх зрелых движков — по одним и тем же вопросам, чтобы ответы стояли рядом. Срезы, на которых всё проверено:
3+ Этот список собран не из документации, а из кода четырёх зрелых движков — по одним и тем же вопросам, чтобы ответы стояли рядом.
44
5 | Проект | Коммит |
6 |---|---|
7 | medusajs/medusa | `ecc9f07e` |
8 | saleor/saleor | `0a11eb91` |
9 | spree/spree | `e1839c40` |
10 | vendure-ecommerce/vendure | `1ad4ba80` |
5+ **Подробные разборы с цитатами кода — отдельными списками:**
116
12 Самое полезное здесь — не «как у них сделано», а **где у них открыто**. Две гонки из списка ниже живут в проектах с тысячами звёзд прямо сейчас.
7+ | Проект | Коммит | Разбор |
8+ |---|---|---|
9+ | medusajs/medusa | `ecc9f07e` | [Блокировки, снимок без FK и тихо пропущенная акция](https://setfork.com/miki/promokody-v-medusa-kak-eto-sdelano-na-samom-dele) |
10+ | saleor/saleor | `0a11eb91` | [Три уровня модели и открытая гонка](https://setfork.com/miki/vauchery-v-saleor-tri-urovnya-modeli-i-otkrytaya-gonka) |
11+ | spree/spree | `e1839c40` | [Типизированные строки и COUNT вместо счётчика](https://setfork.com/miki/skidki-v-spree-6-tipizirovannye-stroki-i-count-vmesto-schetc) |
12+ | vendure-ecommerce/vendure | `1ad4ba80` | [Замок на акции и отказ вместо тихого пересчёта](https://setfork.com/miki/promokody-v-vendure-zamok-na-aktsii-i-otkaz-vmesto-tihogo-pe) |
1313
14+ Самое полезное там — не «как у них сделано», а **где у них открыто**. Две гонки из списка ниже живут в проектах с тысячами звёзд прямо сейчас.
15+
1416 Порядок пунктов не важен: это набор проверок, а не последовательность.
1517## Что присылает клиент
1618• Убедиться, что суммы скидки во входе нет
1719 В схеме запроса на применение кода должна быть **строка кода и больше ничего денежного**. Не «мы её игнорируем» — её не должно быть в типе.
1820 why: Пока поле принимается, кто-нибудь однажды начнёт ему верить: в обработчике, в логе, в отчёте. Убрать возможность дешевле, чем каждый раз проверять, что ею не воспользовались.
1921 - [ ] В типе входа нет ни одного поля с суммой, процентом или итогом
2022 - [ ] Лишние поля отвергаются схемой, а не отбрасываются молча
2123 - [ ] Сумма позиций для расчёта берётся из базы, а не из тела запроса
2224 → Medusa: .strict() и единственное поле promo_codes — https://github.com/medusajs/medusa/blob/ecc9f07e9340059a5337265746a32a749f7585b7/packages/medusa/src/api/store/carts/validators.ts#L31-L36
2325 → Vendure: в Shop API денежных полей нет вовсе — https://github.com/vendure-ecommerce/vendure/blob/1ad4ba80d1d30060f459e74d95bd7faf6a1d14f2/packages/core/src/api/schema/shop-api/shop.api.graphql#L72-L82
2426• Проверить, что предпросмотр кода не выдаёт чужие данные
2527 Кнопка «Применить» обычно доступна без входа. Если в неё передаётся телефон или почта, а ответ зависит от истории этого человека — действие превращается в справочную по чужим клиентам.
2628 why: У кода с условием «только для первого заказа» ответ существующему клиенту и новому разный. Зная любой действующий код, перебором номеров можно выяснить, кто уже заказывал у заведения. Это персональные данные, и отдаёт их ваш собственный публичный эндпоинт.
2729 - [ ] Предпросмотр не принимает чужой идентификатор параметром — только свой, из сессии
2830 - [ ] Ответ гостю не зависит от чужой истории заказов
2931 - [ ] Настоящая проверка условия делается при оформлении, до создания заказа
3032## Предел применений
3133• Расходовать код одним условным UPDATE
3234 Проверка «не исчерпан ли» и захват применения должны быть **одной операцией**:
3335
3436 ```sql
3537 update promo_codes set uses = uses + 1
3638 where id = $1 and (max_uses is null or uses < max_uses)
3739 returning id
3840 ```
3941
4042 Пустой `returning` — код не достался, заказ отменяется целиком.
4143 why: Проверка отдельно от захвата — это check-then-act, который два одновременных запроса проходят оба. Одна операция не оставляет промежутка, в который можно встать.
4244 - [ ] Количество изменённых строк проверяется, а не игнорируется
4345 - [ ] Предел читается из строки в базе, а не из снимка, прочитанного раньше
4446 - [ ] Есть тест с двумя одновременными оформлениями последнего применения
4547 → Spree: счётчика нет вовсе, каждый раз COUNT — https://github.com/spree/spree/blob/e1839c40630742ed5f497a15188a37294d31717b/spree/core/app/models/spree/promotion.rb#L246-L279
4648• Проверить, что предел спрашивается там же, где занимается
4749 Классическая ошибка: сначала `SELECT` с проверкой лимита без блокировки, потом `SELECT ... FOR UPDATE`, потом инкремент. Блокировка стоит **после** проверки и потому ничего не защищает.
4850 why: Ровно так сделано в Saleor: лимит проверяется подзапросом `usage_limit > SUM(used)` без блокировки, и только затем берётся FOR UPDATE. При READ COMMITTED два чекаута видят состояние до чужого коммита и оба проходят. Переполнение равно числу одновременных оформлений.
4951 - [ ] Между проверкой предела и его расходованием нет незаблокированного промежутка
5052 - [ ] Если используется FOR UPDATE — строка перечитывается ПОСЛЕ взятия блокировки
5153 - [ ] Блокировка берётся на строку кода, а не на корзину
5254 → Saleor: проверка лимита без блокировки — https://github.com/saleor/saleor/blob/0a11eb911e006199daa1352ff5b76a07214f43fa/saleor/discount/models.py#L52-L68
5355 → Vendure: блокировка строки акции по первичному ключу — https://github.com/vendure-ecommerce/vendure/blob/1ad4ba80d1d30060f459e74d95bd7faf6a1d14f2/packages/core/src/service/services/order.service.ts#L1570-L1622
5456• Решить, считаются ли заказы в процессе оплаты [recommended]
5557 Если оплата онлайн отделена во времени от создания заказа, между ними открыто окно: код формально ещё не израсходован, а заказ уже в пути.
5658 why: Vendure считает расход COUNT-ом по заказам и намеренно включает те, что в состоянии ArrangingPayment — именно чтобы закрыть это окно. Если у вас оплата при получении, окна нет и усложнять не нужно; но решение надо принять осознанно, а не по умолчанию.
5759 - [ ] Ясно, в какой момент код считается израсходованным
5860 - [ ] Если оплата может не пройти — есть возврат расхода, и он идемпотентен
5961 → Vendure: ArrangingPayment учитывается в подсчёте — https://github.com/vendure-ecommerce/vendure/blob/1ad4ba80d1d30060f459e74d95bd7faf6a1d14f2/packages/core/src/service/services/promotion.service.ts#L506-L533
6062## Снимок в заказе
6163• Скопировать в заказ код, вид скидки и её значение
6264 В строку применения кладите не только посчитанную сумму, но и **код строкой**, **вид** (процент или фикс) и **значение на день заказа**.
6365 why: Из «−34 ₽» через полгода не видно, была это десятая часть или фикс, а спрашивают именно об этом. В справочник смотреть бесполезно: условия кода правят. Medusa хранит только сумму — и восстановить «это было −20 %» из заказа у него нельзя вовсе.
6466 - [ ] Код лежит в заказе строкой, а не только внешним ключом
6567 - [ ] Вид и значение скидки записаны на момент заказа
6668 - [ ] Чек читается, даже если справочник кодов недоступен
6769 → Saleor: value_type, value и amount_value рядом — https://github.com/saleor/saleor/blob/0a11eb911e006199daa1352ff5b76a07214f43fa/saleor/discount/models.py#L472-L515
6870 → Spree: code, value, value_type в строке скидки — https://github.com/spree/spree/blob/e1839c40630742ed5f497a15188a37294d31717b/spree/core/app/models/spree/discount.rb#L1-L22
6971• Заменить удаление кода выключением
7072 На промокод ссылаются заказы. Удаление либо порвёт эти ссылки, либо утащит за собой строки применения — в обоих случаях старые чеки перестанут читаться.
7173 why: Все четверо решают это одинаково: мягкое удаление (deletedAt) или обнуляемый внешний ключ плюс копия кода строкой. Каскад на строку скидки не ставит никто.
7274 - [ ] Кнопка «удалить» в админке либо отсутствует, либо выключает код
7375 - [ ] Внешний ключ на код стоит RESTRICT или SET NULL, но не CASCADE
7476 - [ ] Выключенный код перестаёт применяться, но остаётся виден в прошлых заказах
7577## Границы скидки
7678• Ограничить скидку суммой позиций и не дать ей тронуть доставку
7779 Скидка вычитается из стоимости блюд или товаров, но не из доставки. Код на 500 ₽ при заказе на 300 ₽ не должен обнулять доставку.
7880 why: Иначе заведение платит курьеру из своего кармана за право отдать товар бесплатно. Бесплатная доставка — это отдельный вид акции с отдельной проверкой, а не побочный эффект большой скидки.
7981 - [ ] Скидка ограничена сверху суммой позиций
8082 - [ ] Скидка на доставку — отдельный вид акции, если он вообще нужен
8183 - [ ] Итог не может уйти в минус ни при каком значении кода
8284 → Saleor: min(discount, subtotal) с исключением для доставки — https://github.com/saleor/saleor/blob/0a11eb911e006199daa1352ff5b76a07214f43fa/saleor/checkout/utils.py#L657-L661
8385 → Vendure: клампинг, чтобы цена позиции не ушла в минус — https://github.com/vendure-ecommerce/vendure/blob/1ad4ba80d1d30060f459e74d95bd7faf6a1d14f2/packages/core/src/entity/order-line/order-line.entity.ts#L353-L365
8486• Закрепить границы констрейнтом в базе [recommended]
8587 Правило, которое держится порядком строк в коде, живёт до первой перестановки этих строк. Констрейнт живёт дольше:
8688
8789 ```sql
8890 check (discount between 0 and items_total)
8991 check (total = items_total + delivery_fee - discount)
9092 ```
9193 why: Medusa и Spree дублируют «скидка не бывает отрицательной» CHECK-констрейнтом. Второй констрейнт — согласованность итога со слагаемыми — не делает никто из четверых, а он ловит самый неприятный класс ошибок: заказ, где на бумажке кухни одна сумма, а с гостя берут другую.
9294 - [ ] Констрейнты действительно созданы миграцией, а не только описаны в схеме
9395 - [ ] Проверено запросом: INSERT с нарушением отвергается
9496 - [ ] Существующие строки констрейнту удовлетворяют — миграция не упала
9597 → Medusa: CHECK amount >= 0 — https://github.com/medusajs/medusa/blob/ecc9f07e9340059a5337265746a32a749f7585b7/packages/modules/cart/src/models/line-item-adjustment.ts#L33
9698## Копейки
9799• Назвать направление округления вслух [recommended]
98100 Округление процента должно быть выбрано явно и в одном месте, с комментарием — почему вверх или вниз.
99101 why: Дело не в том, какое направление правильнее, а в том, что умолчанию нельзя доверять: Saleor в каждой точке применения явно переопределяет ROUND_DOWN библиотеки на ROUND_HALF_UP. Спор «почему в чеке рублём меньше» решается ссылкой на строку кода, а не пересчётом на салфетке.
100102 - [ ] Округление задано явно, а не унаследовано от языка или библиотеки
101103 - [ ] Направление объяснено в комментарии рядом
102104 - [ ] Округление происходит в одном месте, а не в трёх
103105 → Saleor: явный ROUND_HALF_UP поверх дефолта библиотеки — https://github.com/saleor/saleor/blob/0a11eb911e006199daa1352ff5b76a07214f43fa/saleor/discount/models.py#L144-L176
104106• Округлять цену на количество, а не цену за единицу [recommended]
105107 Если скидка считается на единицу товара и потом умножается на количество, копейка округления умножается вместе с ней.
106108 why: Vendure округляет произведение (`round(value * quantity)`) именно чтобы убрать этот дрейф. Три одинаковые позиции по копейке дают расхождение в три копейки — мелочь до того дня, когда сумма позиций не сойдётся с итогом.
107109 - [ ] Округляется итог строки, а не цена за единицу
108110 - [ ] Обратное деление на количество не округляется повторно
109111 → Vendure: roundMoney(value, quantity) — https://github.com/vendure-ecommerce/vendure/blob/1ad4ba80d1d30060f459e74d95bd7faf6a1d14f2/packages/core/src/entity/promotion/promotion.entity.ts#L160-L167
110112• Раскидать остаток так, чтобы сумма долей сошлась точно [optional]
111113 Если скидка показывается или фискализируется построчно, сумма построчных долей обязана совпасть с общей до копейки. Приём один и тот же у троих: `floor` по всем долям, остаток раздаётся по одной копейке.
112114 why: Повторное округление каждой доли даёт расхождение с общей суммой, и в фискальном чеке это ошибка, а не косметика. Разница между движками только в том, кому достаётся лишняя копейка: Spree — по индексу, Vendure — тому, кого округление обидело сильнее, Saleor — последней строке.
113115 - [ ] Сумма долей равна общей скидке точно, а не приблизительно
114116 - [ ] Распределение детерминированно: одинаковый вход даёт одинаковый выход
115117 - [ ] Ни одна доля не делает позицию отрицательной
116118 → Vendure: prorate — floor и добор по максимальной ошибке — https://github.com/vendure-ecommerce/vendure/blob/1ad4ba80d1d30060f459e74d95bd7faf6a1d14f2/packages/core/src/service/helpers/order-calculator/prorate.ts#L11-L47
117119 → Spree: largest_remainder_shares на Rational — https://github.com/spree/spree/blob/e1839c40630742ed5f497a15188a37294d31717b/spree/core/app/models/spree/adjusters/largest_remainder.rb#L9-L21
118120## Момент оформления
119121• Перечитать правила кода внутри транзакции заказа
120122 Правила — срок, процент, условия, признак «включён» — должны читаться **в той же транзакции**, что создаёт заказ, а не браться из проверки, сделанной раньше.
121123 why: Medusa при оформлении промо не пересчитывает вовсе: корректировки берутся из корзины как есть. Владелец выключил акцию — гость, чья корзина с тех пор не менялась, оформится по старой скидке. Vendure перечитывает частично: срок окончания и лимиты да, а startsAt и условия акции — нет, хотя комментарий рядом обещает обратное.
122124 - [ ] Правила читаются внутри транзакции, а не до неё
123125 - [ ] Перечитываются ВСЕ условия, а не только срок и лимит
124126 - [ ] Проверено вручную: применить код, выключить его в базе, подтвердить заказ — приходит отказ, заказ не создан
125127 → Medusa: в complete-cart пересчёт промо не вызывается — https://github.com/medusajs/medusa/blob/ecc9f07e9340059a5337265746a32a749f7585b7/packages/core/core-flows/src/cart/workflows/complete-cart.ts#L456-L469
126128 → Vendure: validateCouponCode проверяет четыре вещи из шести — https://github.com/vendure-ecommerce/vendure/blob/1ad4ba80d1d30060f459e74d95bd7faf6a1d14f2/packages/core/src/service/services/promotion.service.ts#L272-L306
127129• Не проводить оплату молча, если скидка исчезла
128130 Если при перепроверке код оказался негодным и сумма из-за этого выросла — оплату проводить нельзя. Нужен явный отказ с прежней и новой суммой.
129131 why: Ошибка в сторону «гость платит больше, чем видел на экране» недопустима. Vendure возвращает типизированную CouponRemovedDuringCheckoutError с обеими суммами; молчаливое «скидка исчезла, с вас на 300 рублей больше» — худший из возможных исходов.
130132 - [ ] Есть отдельный вид отказа именно для этого случая
131133 - [ ] В отказе видны обе суммы — была и стала
132134 - [ ] Заказ при этом не создан и деньги не списаны
133135 → Vendure: отказ вместо тихого пересчёта — https://github.com/vendure-ecommerce/vendure/blob/1ad4ba80d1d30060f459e74d95bd7faf6a1d14f2/packages/core/src/service/services/order.service.ts#L1471-L1496
134136• Убедиться, что один заказ не может получить два кода [recommended]
135137 Если правило «один код на заказ» держится только порядком вызовов в коде, закрепите его уникальным индексом на идентификатор заказа в таблице применений.
136138 why: Saleor запрещает стек прямо в коде комментарием «only one voucher can be applied». Уникальный индекс превращает нарушение из тихой двойной скидки в отвергнутый INSERT — то есть в ошибку, которую видно.
137139 - [ ] Уникальный индекс на заказ в таблице применений существует
138140 - [ ] Попытка записать второе применение к тому же заказу отвергается базой