Skip to content

Промокоды в Medusa: как это сделано на самом деле

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

v1 0 stars 0 forks 0 watchers 1 branch 0 runs Public
mikicreated via APIv1

Разбор по срезу ecc9f07e9340059a5337265746a32a749f7585b7 (ветка develop). Все ссылки ведут в этот коммит и не собьются, когда ветка уедет вперёд.

Модель у Medusa трёхслойная: promotion с application_method (как считать и к чему применять), campaign с campaign_budget (общий кошелёк на акцию) и campaign_budget_usage (счётчик на каждое значение атрибута — например, на покупателя).

Самое интересное здесь — registerUsage: четыре решения в тридцати строках, каждое против своей беды. И рядом — два места, где гарантии кончаются.

Где считается скидка

Клиент присылает только строки кодов

Zod-схема со .strict() и единственным полем:

ts
1export const StoreAddCartPromotions = z
2 .object({
3 promo_codes: z.array(z.string()),
4 })
5 .strict()

Роут не делает ничего, кроме передачи кодов в воркфлоу:

ts
1 await we.run(updateCartPromotionsWorkflowId, {
2 input: {
3 promo_codes: payload.promo_codes,
4 cart_id: req.params.id,
5 action:
6 payload.promo_codes.length > 0
7 ? PromotionActions.ADD
8 : PromotionActions.REPLACE,
Why: Ни одного денежного поля во входе нет вовсе — не «мы его игнорируем», а нечего игнорировать. Самый дешёвый вид защиты: снятая возможность.
Несуществующий код — ошибка, а не тишинаRecommended
ts
1 promo_codes.forEach((code) => {
2 if (!validPromoCodes.has(code)) {
3 throw new MedusaError(
4 MedusaError.Types.INVALID_DATA,
5 `The promotion code ${code} is invalid`
6 )
7 }
Why: Гость, опечатавшийся в коде, должен узнать об этом сразу, а не из чека. Тихое игнорирование неверного кода — самый частый способ получить звонок «а где скидка».

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

Счётчик лежит на самом коде, а бюджет — на кампанииRecommended

На промоакции (появилось в 2.12.0):

ts
1 /**
2 * @since 2.12.0
3 */
4 limit: model.number().nullable(),
5 /**
6 * @since 2.12.0
7 */
8 used: model.number().default(0),

Отдельно — бюджет кампании: по сумме скидок (spend), по числу применений (usage) или по атрибуту (use_by_attribute).

Why: Два независимых механизма: «код сработает N раз» и «на всю акцию потратим не больше X рублей». Первое нужно всем, второе — только тем, у кого есть маркетинговый бюджет.
Четыре решения в тридцати строках registerUsage

Самое поучительное место во всём модуле:

ts
1 // The transaction context is required: a FOR UPDATE issued on a pooled
2 // (autocommit) connection would release the lock immediately and silently
3 // stop serializing, so fail explicitly instead of falling back.
4 const manager = sharedContext.transactionManager as SqlEntityManager
5 const knex = manager?.getTransactionContext()
6 if (!knex) {
7 throw new MedusaError(
8 MedusaError.Types.UNEXPECTED_STATE,
9 "registerUsage must run inside a transaction to serialize concurrent usage registration."
10 )
11 }
12 // Bound the wait for these row locks so a contended registration fails fast
13 // instead of hanging: `SET LOCAL` scopes the timeout to this transaction.
14 await knex.raw("SET LOCAL lock_timeout = '3s'")
15 if (lockPromotionIds.length) {
16 await knex("promotion")
17 .whereIn("id", lockPromotionIds)
18 .orderBy("id")
19 .forUpdate()
20 .select("id")
21 }

И сразу после блокировки — перечтение с refresh: true:

ts
1 existingPromotions = await this.listActivePromotions_(
2 { code: promotionCodes },
3 {
4 relations: ["campaign", "campaign.budget", "campaign.budget.usages"],
5 options: { refresh: true },
6 },
7 sharedContext
8 )
Why: Четыре разные беды, каждая закрыта одной строчкой: `orderBy("id")` — против дедлока при нескольких кодах; `SET LOCAL lock_timeout` — чтобы контенция падала быстро, а не вешала пул; `refresh: true` — потому что ORM иначе отдаст закешированное `used` и проверка пройдёт по устаревшему числу; отсутствие транзакции — ошибка, а не тихий фолбэк, потому что FOR UPDATE на autocommit-соединении отпускает лок мгновенно и молча перестаёт работать.
Два уровня проверки: мягкий в корзине, жёсткий при оформленииRecommended

При вводе кода — просто чтение, без блокировки, и ответ — структурированная причина, а не исключение:

ts
1 // Check promotion usage limit
2 if (!skipUsageLimitChecks && typeof promotion.limit === "number") {
3 if ((promotion.used ?? 0) >= promotion.limit) {
4 computedActions.push({
5 action: ComputedActions.PROMOTION_LIMIT_EXCEEDED,
6 code: promotion.code!,
7 })
8 continue
9 }
10 }

А при оформлении — под замком:

ts
1 if (typeof promotion.limit === "number") {
2 const newUsedValue = (promotion.used ?? 0) + 1
3
4 if (newUsedValue > promotion.limit) {
5 throw new MedusaError(
6 MedusaError.Types.NOT_ALLOWED,
7 "Promotion usage exceeds the limit."
8 )
9 }
Why: Гость видит «код исчерпан» сразу и понятной причиной, а деньги считаются честно один раз. Структурированная причина вместо текста ошибки — отдельно ценно: её можно показать на своём языке.
Где гарантии всё-таки кончаютсяRecommended

Откат расхода блокировок не берёт — обычное чтение-запись:

ts
1 if (typeof promotion.limit === "number") {
2 const newUsedValue = Math.max(0, (promotion.used ?? 0) - 1)

Расход идёт параллельно созданию заказа, а не в одной транзакции с ним:

ts
1 const [, , createdReservations] = parallelize(
2 createRemoteLinkStep(linksToCreate),
3 updateCartsStep([updateCompletedAt]),
4 reserveInventoryStep(formatedInventoryItems),
5 registerUsageStep(promotionUsage),
Why: Два одновременных отката могут «потерять» декремент — ошибка в безопасную сторону, но всё же ошибка. А атомарность «заказ создан ⇔ применение засчитано» держится на компенсации саги, а не на базе. Если вам хватает одной базы — одна транзакция проще и надёжнее.

Один раз на покупателя

Уникальный индекс по значению атрибута, а замок — на родителеRecommended

Условия «только первый заказ» у Medusa нет вовсе. Есть более общее: бюджет типа use_by_attribute — «не больше N применений на одно значение»:

ts
1 attribute: model.text().nullable(), // e.g. "customer_id", "customer_email"

Счётчик — строка на пару, с частичным уникальным индексом:

ts
1 .indexes([
2 {
3 on: ["attribute_value", "budget_id"],
4 unique: true,
5 where: "deleted_at IS NULL",
6 },
7 ])

Значение атрибута берётся из корзины, а не из тела запроса:

ts
1 registrationContext: {
2 customer_id: cart.customer?.id || null,
3 customer_email: cart.email || null,
4 },
Why: Строки-счётчика может ещё не быть (первый заказ), поэтому замок вешают на родителя — на бюджет. Побочный эффект: такой замок сериализует всех покупателей кампании между собой. Для частного случая «один раз на телефон» дешевле строка (код, клиент) с уникальным индексом и замок ровно на ней.

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

Копируются значения, а внешнего ключа нет

Строка корректировки заказа хранит свои code и amount, а promotion_id — обычный text без FK:

ts
1 id: model.id({ prefix: "ordliadj" }).primaryKey(),
2 version: model.number().default(1),
3 description: model.text().nullable(),
4 promotion_id: model.text().nullable(),
5 code: model.text().nullable(),
6 amount: model.bigNumber(),
7 provider_id: model.text().nullable(),
8 is_tax_inclusive: model.boolean().default(false),

Перенос из корзины в заказ — построчное копирование:

ts
1export function prepareAdjustmentsData(data: CreateOrderAdjustmentDTO[]) {
2 return data.map((d) => ({
3 code: d.code,
4 amount: d.amount,
5 description: d.description,
6 promotion_id: d.promotion_id,
7 provider_id: d.provider_id,
8 is_tax_inclusive: d.is_tax_inclusive,
9 }))
10}
Why: Промокод удалили — чек цел. Но в снимке **нет ни процента, ни типа скидки** — только результат в деньгах. Восстановить «это было −20 %» из заказа нельзя, только через `promotion_id`, который может указывать в никуда. Это тот случай, когда чужое решение стоит не копировать, а улучшить.

Скидка и доставка

Разделено устройством модели, а не проверкой
ts
1 target_type: model
2 .enum(PromotionUtils.ApplicationMethodTargetType)
3 .index("IDX_application_method_target_type"),

Значения: order, items, shipping_methods. Скидка на заказ раскладывается по товарным позициям и до доставки не дотягивается физически.

Ограничение снизу есть и в базе:

ts
1 .checks([(columns) => `${columns.amount} >= 0`])
Why: Не проверка «а не съел ли я доставку», а устройство, при котором съесть нечего. Съесть доставку может только код, явно нацеленный на доставку. CHECK в БД — второй эшелон, дёшевый и вечный. Любопытно, что на аналогичной модели заказа такого CHECK нет — похоже на недосмотр.

Округление

Округления нет вообщеOptional

Вся функция целиком:

ts
1function getPromotionValueForPercentage(promotion, lineItemAmount) {
2 return MathBN.mult(MathBN.div(promotion.value, 100), lineItemAmount)
3}

grep по Math.round|Math.floor|Math.ceil|toFixed в промо-модуле и в core/utils/src/totals/ не даёт ни одного попадания. Есть только отсечка микроскопических остатков:

ts
1export const MEDUSA_EPSILON = new BigNumber(
2 process.env.MEDUSA_EPSILON || "0.0001"
3)
Why: Сумма остаётся десятичной дробью произвольной точности и так уезжает в БД. Для мультивалютного маркетплейса с BigNumber сквозь всю систему это осознанно. Для одной валюты в копейках это плохой образец: «10 % от 349 ₽» будет жить в базе как `34.9`, а в чеке как `34.90`.

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

Правила при оформлении НЕ перепроверяются

completeCartWorkflow берёт корректировки из корзины как есть:

ts
1 taxLines: item.tax_lines ?? [],
2 adjustments: item.adjustments ?? [],

Суммы для списания лимита — тоже из сохранённых, а не из свежего расчёта:

ts
1 for (const adjustment of itemAdjustments) {
2 promotionUsage.push({
3 amount: adjustment.amount,
4 code: adjustment.code!,
5 })
6 }

updateCartPromotionsWorkflow в complete-cart.ts не вызывается вовсе — только при изменении корзины.

Why: Админ поменял процент, выключил акцию или сдвинул даты — гость, чья корзина с тех пор не менялась, оформится по старой скидке.
Неактивный код пропускается молча

Если к моменту оформления код перестал быть активным, registerUsage просто не найдёт его в existingPromotionsMap и молча пропустит (if (!promotion) continue, строки 401–403).

Заказ при этом пройдёт со скидкой, а расход не спишется.

Why: Это не решение, а дефект: истёкшая акция уходит в заказ и при этом не учитывается в бюджете кампании. У себя такой случай должен быть либо ошибкой оформления, либо явно залогированным расхождением — но не тишиной.
Что стоит унести

Два уровня проверки лимита — мягкий в корзине и жёсткий под замком. Тройку 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». Распределённый замок на корзине, пока инстанс один. Отсутствие округления и отсутствие пересчёта при оформлении — это не образцы, а предупреждения.

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

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