Промокоды в Medusa: как это сделано на самом деле
Разбор промо-модуля Medusa по коду: блокировки строк, снимок скидки в заказе, и место, где акция уходит в заказ, но не списывается.
miki/promokody-v-medusa-kak-eto-sdelano-na-samom-dele · v1
Разбор промо-модуля Medusa по коду: блокировки строк, снимок скидки в заказе, и место, где акция уходит в заказ, но не списывается.
Разбор по срезу ecc9f07e9340059a5337265746a32a749f7585b7 (ветка develop). Все ссылки ведут в этот коммит и не собьются, когда ветка уедет вперёд.
Модель у Medusa трёхслойная: promotion с application_method (как считать и к чему применять), campaign с campaign_budget (общий кошелёк на акцию) и campaign_budget_usage (счётчик на каждое значение атрибута — например, на покупателя).
Самое интересное здесь — registerUsage: четыре решения в тридцати строках, каждое против своей беды. И рядом — два места, где гарантии кончаются.
Где считается скидка
Zod-схема со .strict() и единственным полем:
Роут не делает ничего, кроме передачи кодов в воркфлоу:
Предел применений
На промоакции (появилось в 2.12.0):
Отдельно — бюджет кампании: по сумме скидок (spend), по числу применений (usage) или по атрибуту (use_by_attribute).
Самое поучительное место во всём модуле:
И сразу после блокировки — перечтение с refresh: true:
При вводе кода — просто чтение, без блокировки, и ответ — структурированная причина, а не исключение:
А при оформлении — под замком:
Откат расхода блокировок не берёт — обычное чтение-запись:
Расход идёт параллельно созданию заказа, а не в одной транзакции с ним:
Один раз на покупателя
Условия «только первый заказ» у Medusa нет вовсе. Есть более общее: бюджет типа use_by_attribute — «не больше N применений на одно значение»:
Счётчик — строка на пару, с частичным уникальным индексом:
Значение атрибута берётся из корзины, а не из тела запроса:
Снимок в заказе
Строка корректировки заказа хранит свои code и amount, а promotion_id — обычный text без FK:
Перенос из корзины в заказ — построчное копирование:
Скидка и доставка
Значения: order, items, shipping_methods. Скидка на заказ раскладывается по товарным позициям и до доставки не дотягивается физически.
Ограничение снизу есть и в базе:
Округление
Вся функция целиком:
grep по Math.round|Math.floor|Math.ceil|toFixed в промо-модуле и в core/utils/src/totals/ не даёт ни одного попадания. Есть только отсечка микроскопических остатков:
Момент оформления
completeCartWorkflow берёт корректировки из корзины как есть:
Суммы для списания лимита — тоже из сохранённых, а не из свежего расчёта:
updateCartPromotionsWorkflow в complete-cart.ts не вызывается вовсе — только при изменении корзины.
Если к моменту оформления код перестал быть активным, registerUsage просто не найдёт его в existingPromotionsMap и молча пропустит (if (!promotion) continue, строки 401–403).
Заказ при этом пройдёт со скидкой, а расход не спишется.
Два уровня проверки лимита — мягкий в корзине и жёсткий под замком. Тройку FOR UPDATE + orderBy("id") + SET LOCAL lock_timeout: три строчки против гонки, дедлока и подвисания пула. Проверку «мы точно внутри транзакции, иначе ошибка». Снимок без FK. CHECK amount >= 0. Частичные уникальные индексы с WHERE deleted_at IS NULL. Запрет понижать limit ниже уже израсходованного.
Кампании и бюджеты (три таблицы ради «не больше 100 000 ₽ за март»). Общий use_by_attribute вместо прямой строки (код, клиент). Тип BUYGET — 665 строк ради «2+1». Распределённый замок на корзине, пока инстанс один. Отсутствие округления и отсутствие пересчёта при оформлении — это не образцы, а предупреждения.
Related lists
Тринадцать проверок, собранных из кода Medusa, Saleor, Spree и Vendure — включая две гонки, которые у зрелых движков открыты до сих пор.
Разбор промо-подсистемы Vendure по коду: пессимистическая блокировка с документированным поведением по СУБД, prorate на сорок строк и снимок без процента.
Разбор промо-подсистемы Spree по коду: STI-реестр правил, полный снимок скидки, наибольший остаток — и лимит, который переполняется при параллельных заказах.
Разбор скидок Saleor по коду: где уникальный индекс закрыл гонку, где счётчик её не закрыл, и зачем в снимке заказа лежит процент.
Журнал движений и кэш остатка расходились в ERPNext десять лет: задним числом, вперёд, через гонку, через вторую дверь. Последний коммит удаляет мёртвую функцию — только за то, что она второй путь записи.
Как проект живёт и принимает чужие правки, и кто в нём встречался в разборах — по поступкам, датам и ссылкам

