Промокоды в Vendure: замок на акции и отказ вместо тихого пересчёта
Разбор промо-подсистемы Vendure по коду: пессимистическая блокировка с документированным поведением по СУБД, prorate на сорок строк и снимок без процента.
miki/promokody-v-vendure-zamok-na-aktsii-i-otkaz-vmesto-tihogo-pe · v1
Разбор промо-подсистемы Vendure по коду: пессимистическая блокировка с документированным поведением по СУБД, prorate на сорок строк и снимок без процента.
Разбор по срезу 1ad4ba80d1d30060f459e74d95bd7faf6a1d14f2 (ветка master).
У Vendure самый честный код из четырёх в одном отношении: авторы прямо в комментариях пишут, как их защита ведёт себя на разных СУБД — включая случай, где она «перезащищает» и не даёт скидку никому.
Счётчика-колонки здесь нет: расход считается COUNT-ом по заказам — но, в отличие от Spree, под блокировкой и с учётом заказов в процессе оплаты.
Где считается скидка
grep по всему shop.api.graphql на price|amount|discount не даёт ни одного совпадения. Денежный вход есть только в Admin API (SurchargeInput), и это не промокод.
Сама сумма рождается в execute() действия:
Предел применений
В сущности только пределы, без израсходованного:
А расход считается двумя запросами — оформленные заказы плюс те, что сейчас в ArrangingPayment:
Рядом — assertInTransaction, то есть проверка, что замок вообще будет держаться.
Один раз на клиента
Поэтому код перепроверяют в тот момент, когда клиент наконец известен:
Условия «только первый заказ» нет: встроенных условий пять, и такого среди них нет.
Снимок в заказе
Код купона лежит строкой на заказе, а связь с акцией — обычный ManyToMany на живую строку:
Аргументы условий и действий (discount: 15) остаются в Promotion как simple-json и в заказ не копируются.
Скидка и доставка
На позиции — жёстко:
А на доставке — никак:
Копейки
Дефолтная стратегия:
Обратное деление на количество уже не округляется.
Проверено на копии кода: prorate([1,1,1], -100) → [-33, -33, -34], prorate([300,200,100], -100) → [-50, -33, -17]. Сумма всегда точно равна amount.
Момент оформления
А Promotion.test() — тот, что работает при пересчёте корзины — проверяет и начало, и условия:
Пересчёт при оплате запускается только если код сняли:
Мелочи, которые стоит заметить
LOWER(alias) = LOWER(:couponCode) в запросе, а в заказ пишется promotion.couponCode — то, как код заведён, а не то, что набрал гость.
И отдельно — мёртвый код, который не стоит повторять:
grep по всему packages/core даёт ровно эти две строки — потребителей нет. Мёртвый вес в JSON каждой строки заказа.
Отказ вместо тихого повышения суммы (CouponRemovedDuringCheckoutError). prorate целиком. Округление произведения, а не цены за единицу. Учёт заказов в процессе оплаты при подсчёте лимита. Блокировка строки по PK с мягкой деградацией. Регистронезависимое сравнение при каноническом хранении. Мягкое удаление акции. И, отдельно, привычка описывать поведение защиты по каждой СУБД прямо в коде.
Мультиканальность и seller-orders. Мультивалютность и MoneyStrategy. Налоговый слой (pricesIncludeTax, taxLines, TaxZone) — половина сложности OrderLine. i18n акций. Плагинная система условий с priorityScore. Несколько кодов на заказ. Прораченное распределение скидки по позициям — но prorate всё равно пригодится.
Related lists
Разбор промо-подсистемы Spree по коду: STI-реестр правил, полный снимок скидки, наибольший остаток — и лимит, который переполняется при параллельных заказах.
Разбор скидок Saleor по коду: где уникальный индекс закрыл гонку, где счётчик её не закрыл, и зачем в снимке заказа лежит процент.
Разбор промо-модуля Medusa по коду: блокировки строк, снимок скидки в заказе, и место, где акция уходит в заказ, но не списывается.
Тринадцать проверок, собранных из кода Medusa, Saleor, Spree и Vendure — включая две гонки, которые у зрелых движков открыты до сих пор.
Разбор бага из плагина лояльности Medusa: кнопка «убрать скидку» выдавала максимальную. Одна строка, три исправления за три дня и верная проверка, лежавшая тремя строками ниже.

