Skip to content

Промокоды в Vendure: замок на акции и отказ вместо тихого пересчёта

Разбор промо-подсистемы Vendure по коду: пессимистическая блокировка с документированным поведением по СУБД, prorate на сорок строк и снимок без процента.

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

Разбор по срезу 1ad4ba80d1d30060f459e74d95bd7faf6a1d14f2 (ветка master).

У Vendure самый честный код из четырёх в одном отношении: авторы прямо в комментариях пишут, как их защита ведёт себя на разных СУБД — включая случай, где она «перезащищает» и не даёт скидку никому.

Счётчика-колонки здесь нет: расход считается COUNT-ом по заказам — но, в отличие от Spree, под блокировкой и с учётом заказов в процессе оплаты.

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

В Shop API денежных полей нет вовсе
graphql
1 addItemToOrder(productVariantId: ID!, quantity: Int!): UpdateOrderItemsResult!
2...
3 applyCouponCode(couponCode: String!): ApplyCouponCodeResult!

grep по всему shop.api.graphql на price|amount|discount не даёт ни одного совпадения. Денежный вход есть только в Admin API (SurchargeInput), и это не промокод.

Сама сумма рождается в execute() действия:

ts
1 execute(ctx, order, args) {
2 const orderTotal = ctx.channel.pricesIncludeTax ? order.subTotalWithTax : order.subTotal;
3 return -orderTotal * (args.discount / 100);
4 },
Why: Разделение по API: покупательская схема вообще не знает слова «сумма». Проверить у себя легко: греп по схеме публичного входа на денежные слова.

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

Счётчика нет — есть COUNT, включающий заказы в оплатеRecommended

В сущности только пределы, без израсходованного:

ts
1 @Column({ nullable: true })
2 perCustomerUsageLimit: number;
3
4 @Column({ nullable: true })
5 usageLimit: number;

А расход считается двумя запросами — оформленные заказы плюс те, что сейчас в ArrangingPayment:

ts
1 const pendingPaymentQb = this.connection
2 .getRepository(ctx, Order)
3 .createQueryBuilder('order')
4 .innerJoin('order.promotions', 'promotion')
5 .where('promotion.id = :promotionId', { promotionId })
6 .andWhere('order.state = :state', { state: 'ArrangingPayment' as OrderState })
Why: Между «заказ создан» и «деньги пришли» есть окно, в которое код формально ещё не израсходован. Учёт `ArrangingPayment` — дешёвый способ его закрыть. Если у вас оплата при получении, окна нет и усложнять не надо — но решение лучше принять осознанно.
Блокировка строки акции — по первичному ключу и с мягкой деградацией
ts
1 try {
2 await this.connection
3 .getRepository(ctx, Promotion)
4 .createQueryBuilder('promotion')
5 .setLock('pessimistic_write')
6 .where('promotion.id = :id', { id: promotion.id })
7 .getOne();
8 } catch (e) {
9 if (!(e instanceof LockNotSupportedOnGivenDriverError)) {
10 throw e;
11 }
12 // Lock not supported (e.g. SQLite) — continue without it
13 }

Рядом — assertInTransaction, то есть проверка, что замок вообще будет держаться.

Why: Блокировка именно по PK — умышленно, чтобы не ловить gap-локи MySQL. Проверка «мы внутри транзакции» — тот же урок, что и у Medusa: `FOR UPDATE` без транзакции молча ничего не делает.
Авторы сами описали исход гонки по каждой СУБДRecommended
code
1 * - Postgres (READ COMMITTED): ... Net: exactly one winner,
2 * specifically whichever order acquires the lock last ("last-wins").
3 *
4 * - MySQL/MariaDB (REPEATABLE READ): ... Result: every contender sees `count >= 1`
5 * and strips, yielding zero winners. This is over-protective rather
6 * than buggy
Why: Образец того, как надо писать про свои защиты: не «гонка закрыта», а что именно происходит на каждом уровне изоляции, включая неприятный исход «скидку не получил никто». Такой комментарий стоит десяти тестов.

Один раз на клиента

У гостя проверка молча пропускается — и это признаноRecommended
ts
1 // perCustomerUsageLimit can only be checked if we know the customer.
2 // For guest checkouts without a customer, we skip this check
3 // (matching the existing behavior in validateCouponCode).

Поэтому код перепроверяют в тот момент, когда клиент наконец известен:

ts
1 // Check that any applied couponCodes are still valid now that
2 // we know the Customer.
3 if (order.active && order.couponCodes) {
4 for (const couponCode of order.couponCodes.slice()) {
5 const validationResult = await this.promotionService.validateCouponCode(
6 ctx,
7 couponCode,
8 customer.id,
9 );
10 if (isGraphQlErrorResult(validationResult)) {
11 updatedOrder = await this.removeCouponCode(ctx, order.id, couponCode);

Условия «только первый заказ» нет: встроенных условий пять, и такого среди них нет.

Why: Важный урок для гостевых заказов: условие, привязанное к личности, бессмысленно, пока личности нет. Если у вас ключ опознания — телефон, он известен всегда, и этой дыры у вас нет.

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

Сумма, название и код — есть, процента нет
ts
1 if (amount !== 0) {
2 return {
3 amount,
4 type: this.type,
5 description: this.name,
6 adjustmentSource: this.getSourceId(),
7 data: {},
8 };
9 }

Код купона лежит строкой на заказе, а связь с акцией — обычный ManyToMany на живую строку:

ts
1 @Column('simple-array')
2 couponCodes: string[];
3...
4 @ManyToMany(type => Promotion, promotion => promotion.orders)
5 @JoinTable()
6 promotions: Promotion[];

Аргументы условий и действий (discount: 15) остаются в Promotion как simple-json и в заказ не копируются.

Why: Удаление мягкое, оформленный заказ не пересчитывается — чек цел. Но есть тонкость (реконструкция по коду, не заявленное поведение): если админ **изменит** акцию, а потом откроет заказ на редактирование, `OrderModifier` подтянет текущие определения и перепишет скидку по новым правилам. Снимок с процентом этого бы не допустил — сравните с `use_denormalized_data` у Saleor.

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

Кламп есть на позиции и нет на доставке

На позиции — жёстко:

ts
1 addAdjustment(adjustment: Adjustment) {
2 // We should not allow adding adjustments which would
3 // result in a negative unit price
4 const maxDiscount =
5 (this.listPriceIncludesTax ? this.proratedLinePriceWithTax : this.proratedLinePrice) * -1;
6 const limitedAdjustment: Adjustment = {
7 ...adjustment,
8 amount: Math.max(maxDiscount, adjustment.amount),
9 };

А на доставке — никак:

ts
1 addAdjustment(adjustment: Adjustment) {
2 this.adjustments = this.adjustments.concat(adjustment);
3 }
Why: Штатная `free_shipping` возвращает ровно `-price` и в минус не уходит, но кастомная shipping-акция предохранителя не имеет (реконструкция по коду). Полезный вопрос к своему коду: есть ли у вас величина, которую защищает только «штатный случай так не делает».

Копейки

Округляется цена×количество, а не цена за единицуRecommended
ts
1 if (promotionAction instanceof PromotionItemAction) {
2 if (this.isOrderItemArg(args)) {
3 const { orderLine } = args;
4 amount += roundMoney(
5 await promotionAction.execute(ctx, orderLine, action.args, state, this),
6 orderLine.quantity,
7 );

Дефолтная стратегия:

ts
1 round(value: number, quantity = 1): number {
2 return Math.round(value * quantity);
3 }

Обратное деление на количество уже не округляется.

Why: `execute` возвращает дробную цену за единицу, а округляют произведение — так исключён дрейф «×3 позиции по копейке». Дешёвый приём, который стоит взять сразу.
prorate: floor плюс добор по максимальной ошибкеOptional
ts
1 for (const w of weights) {
2 actual[i] = totalWeight === 0 ? amount / weights.length : amount * (w / totalWeight);
3 rounded[i] = Math.floor(actual[i]);
4 error[i] = actual[i] - rounded[i];
5 added += rounded[i];
6 i += 1;
7 }
8
9 while (added < amount) {
10 let maxError = 0.0;
11 let maxErrorIndex = -1;
12 for (let e = 0; e < length; ++e) {
13 if (error[e] > maxError) {

Проверено на копии кода: prorate([1,1,1], -100)[-33, -33, -34], prorate([300,200,100], -100)[-50, -33, -17]. Сумма всегда точно равна amount.

Why: Сорок строк, гарантия «сумма частей = целому», и лишняя копейка достаётся тому, кого округление обидело сильнее — справедливее, чем «всё последней строке» у Saleor. При фискализации построчная скидка обязана сходиться до копейки — берите этот приём.

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

Отказ вместо тихого повышения суммы
ts
1 if (removedCouponCodes.length && totalWithTaxBeforeRevalidation < freshOrder.totalWithTax) {
2...
3 return new CouponRemovedDuringCheckoutError({
4 removedCouponCodes,
5 previousTotalWithTax: totalWithTaxBeforeRevalidation,
6 newTotalWithTax: freshOrder.totalWithTax,
Why: Лучшее решение во всём разборе. Ошибка в сторону «гость платит больше, чем видел» недопустима, и ответ — типизированная ошибка с обеими суммами, чтобы витрина могла показать разницу.
Проверяются четыре вещи из шести — без startsAt и без условий
ts
1 const promotion = await this.connection.getRepository(ctx, Promotion).findOne({
2 where: {
3 couponCode: Raw(alias => `LOWER(${alias}) = LOWER(:couponCode)`, { couponCode }),
4 enabled: true,
5 deletedAt: IsNull(),
6 channels: { id: ctx.channelId },
7 },
8...
9 if (promotion.endsAt && +promotion.endsAt < +new Date()) {
10 return new CouponCodeExpiredError({ couponCode });
11 }

А Promotion.test() — тот, что работает при пересчёте корзины — проверяет и начало, и условия:

ts
1 if (this.startsAt && this.startsAt > new Date()) {
2 return false;
3 }

Пересчёт при оплате запускается только если код сняли:

ts
1 if (removedCodes.length > 0) {
2 await this.applyPriceAdjustments(ctx, order);
3 }
4 return removedCodes;
Why: Комментарий в коде обещает защиту «если купон сняли из-за несоответствия условиям акции» — а `validateCouponCode` условий не смотрит вовсе. Комментарий описывает намерение, а не поведение — самый опасный вид комментария, потому что ему верят на ревью. (Реконструкция по коду: суммы к оплате берутся посчитанными ранее; поднятый порог `minimumOrderAmount` между последним изменением корзины и оплатой купон не снимет.)

Мелочи, которые стоит заметить

Код сравнивается без учёта регистра, а хранится каноническийOptional

LOWER(alias) = LOWER(:couponCode) в запросе, а в заказ пишется promotion.couponCode — то, как код заведён, а не то, что набрал гость.

И отдельно — мёртвый код, который не стоит повторять:

ts
1 const itemDistribution = prorate(itemWeights, shareOfAmount);
2...
3 data: { itemDistribution },

grep по всему packages/core даёт ровно эти две строки — потребителей нет. Мёртвый вес в JSON каждой строки заказа.

Why: Первое — правильный приём: человек переписывает код с листовки и о регистре не думает. Второе — напоминание, что даже в зрелом проекте в JSON каждой строки заказа может лежать то, что никто не читает.
Что стоит унести

Отказ вместо тихого повышения суммы (CouponRemovedDuringCheckoutError). prorate целиком. Округление произведения, а не цены за единицу. Учёт заказов в процессе оплаты при подсчёте лимита. Блокировка строки по PK с мягкой деградацией. Регистронезависимое сравнение при каноническом хранении. Мягкое удаление акции. И, отдельно, привычка описывать поведение защиты по каждой СУБД прямо в коде.

Что нужно им, а маленькому магазину — нет

Мультиканальность и seller-orders. Мультивалютность и MoneyStrategy. Налоговый слой (pricesIncludeTax, taxLines, TaxZone) — половина сложности OrderLine. i18n акций. Плагинная система условий с priorityScore. Несколько кодов на заказ. Прораченное распределение скидки по позициям — но prorate всё равно пригодится.

Разбор промо-подсистемы Spree по коду: STI-реестр правил, полный снимок скидки, наибольший остаток — и лимит, который переполняется при параллельных заказах.

updated Sep 2, 2026

Разбор скидок Saleor по коду: где уникальный индекс закрыл гонку, где счётчик её не закрыл, и зачем в снимке заказа лежит процент.

updated Sep 2, 2026

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

updated Sep 2, 2026

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

updated Sep 2, 2026

Разбор бага из плагина лояльности Medusa: кнопка «убрать скидку» выдавала максимальную. Одна строка, три исправления за три дня и верная проверка, лежавшая тремя строками ниже.

updated Sep 1, 2026