Skip to content

Ваучеры в Saleor: три уровня модели и открытая гонка

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

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

Разбор по срезу 0a11eb911e006199daa1352ff5b76a07214f43fa (ветка main); библиотека денег — mirumee/prices на 95fd4436.

Модель трёхуровневая, и это ключ ко всему остальному:

  • Voucher — правило (тип, даты, флаги). Денег в нём нет.
  • VoucherCode — сам код-строка со своим счётчиком used. Кодов у одного ваучера может быть много.
  • VoucherChannelListing — сумма и минимальный чек на канал.

Плюс VoucherCustomer — факт «этот адрес уже применял этот код».

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

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

Сумма рождается в методе модели, а клиент шлёт только код
python
1 def get_discount(self, channel: Channel):
2 """Return proper discount amount for given channel.
3 ...
4 if self.discount_value_type == DiscountValueType.FIXED:
5 discount_amount = Money(
6 voucher_channel_listing.discount_value, voucher_channel_listing.currency
7 )
8 return partial(fixed_discount, discount=discount_amount)
9 if self.discount_value_type == DiscountValueType.PERCENTAGE:
10 return partial(
11 percentage_discount,
12 percentage=voucher_channel_listing.discount_value,
13 rounding=ROUND_HALF_UP,
14 )
15 raise NotImplementedError("Unknown discount type")
16
17 def get_discount_amount_for(self, price: Money, channel: Channel) -> Money:
18 discount = self.get_discount(channel)
19 after_discount = discount(price)
20 if after_discount.amount < 0:
21 return price
22 return price - after_discount
Why: У API нет входного поля «сумма скидки» для ваучера вообще. Заметьте также `if after_discount.amount < 0: return price` — третий эшелон защиты от отрицательной цены.

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

Инкремент атомарный, а проверка — нет

Инкремент через F(), то есть одним UPDATE:

python
1def increase_voucher_code_usage_value(code: "VoucherCode") -> None:
2 """Increase voucher code uses by 1."""
3 code.used = F("used") + 1
4 code.save(update_fields=["used"])

А проверка лимита — подзапрос без блокировки, по сумме used по всем кодам ваучера:

python
1class VoucherQueryset(models.QuerySet["Voucher"]):
2 def active(self, date, validate_usage_limit=True):
3 subquery = (
4 VoucherCode.objects.filter(voucher_id=OuterRef("pk"))
5 .order_by()
6 .values("voucher_id")
7 .annotate(total_used=Sum("used"))
8 .values("total_used")
9 )
10 ...
11 if validate_usage_limit:
12 lookup &= Q(usage_limit__isnull=True) | Q(
13 usage_limit__gt=Subquery(subquery)
14 )
15 return self.filter(lookup)
Why: Атомарный инкремент не спасает, если решение «можно ли» принято раньше и не под замком. Лимит здесь общий на ваучер, а не на код — именно из-за этого проверку нельзя свести к одному `UPDATE ... WHERE used < limit`.
Блокировка есть, но стоит после проверки
python
1 if voucher.usage_limit is not None and with_lock:
2 code = (
3 VoucherCode.objects.using(database_connection_name)
4 .select_for_update()
5 .get(code=checkout.voucher_code)
6 )

Проверка лимита — строки 566–571, блокировка — 586–591. А внешняя транзакция блокирует чекаут, а не ваучер:

python
1 if voucher := checkout_info.voucher:
2 with transaction.atomic():
3 checkout = (
4 Checkout.objects.select_for_update().filter(pk=checkout_pk).first()
5 )
Why: Два разных чекаута с одним кодом не конкурируют за строку чекаута; при READ COMMITTED оба видят состояние до чужого коммита и проходят проверку, а `FOR UPDATE` дальше их лишь сериализует — проверку никто не переспрашивает. Соответствующего CHECK-констрейнта в миграциях тоже нет. Это реконструкция по коду и семантике изоляции, а не заявленное поведение: теста, который бы это фиксировал, в репозитории не искали.

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

Здесь гонка закрыта — уникальным индексом, а не блокировкой
python
1def add_voucher_usage_by_customer(
2 code: "VoucherCode", customer_email: str | None
3) -> None:
4 if not customer_email:
5 raise NotApplicable("Unable to apply voucher as customer details are missing.")
6
7 _, created = VoucherCustomer.objects.get_or_create(
8 voucher_code=code, customer_email=customer_email
9 )
10 if not created:
11 raise NotApplicable("This offer is only valid once per customer.")

Держит это ограничение в модели:

python
1 class Meta:
2 ...
3 unique_together = (("voucher_code", "customer_email"),)
Why: Контраст с предыдущим пунктом показателен: где есть уникальный ключ — гонка закрыта надёжно и без транзакционной дисциплины; где счётчик — не закрыта. Три строки вместо `select_for_update`. Это главное, что стоит скопировать буквально.
Почта берётся из сессии, а не из формыRecommended
python
1 if source_object.user:
2 return source_object.user.email
3 return source_object.get_customer_email()

«Только для новых покупателей» в Saleor нет: грепы по first_order, new_customer, only_for_new не дают ничего. Ближайшее по смыслу — single_use (код сгорает глобально) и only_for_staff.

Why: Почта залогиненного приоритетнее введённой в чекауте — иначе ограничение «один раз на клиента» обходится сменой адреса в форме.

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

В снимке лежат и тип, и значение, и сумма, и код строкой
python
1 value_type = models.CharField(
2 max_length=10,
3 choices=DiscountValueType.CHOICES,
4 default=DiscountValueType.FIXED,
5 )
6 value = models.DecimalField(...)
7 amount_value = models.DecimalField(...)
8 amount = MoneyField(amount_field="amount_value", currency_field="currency")
9 currency = models.CharField(...)
10 name = models.CharField(max_length=255, null=True, blank=True)
11 reason = models.TextField(blank=True, null=True)
12 ...
13 voucher = models.ForeignKey(
14 Voucher, related_name="+", blank=True, null=True, on_delete=models.SET_NULL
15 )
16 voucher_code = models.CharField(
17 max_length=255, null=True, blank=True, db_index=False
18 )
Why: Внешний ключ — `SET_NULL`, а `voucher_code` продублирован строкой **и** на заказе, **и** на строке заказа, **и** на каждой скидке. Удалили ваучер — чёк за прошлый месяц остался читаемым.
Зачем им процент в снимке: пересчёт правленного заказаRecommended

Когда менеджер правит уже размещённый заказ, скидка считается по снимку, а не по нынешнему ваучеру:

python
1def _fetch_denormalized_voucher_info(
2 lines_info: list[EditableOrderLineInfo], voucher: Voucher
3):
4 voucher_discounts = [
5 discount
6 for line_info in lines_info
7 for discount in line_info.discounts
8 if discount.voucher == voucher
9 ]
10 if not voucher_discounts:
11 return None
12
13 voucher_discount = voucher_discounts[0]
14 return VoucherDenormalizedInfo(
15 discount_value=voucher_discount.value,
16 discount_value_type=voucher_discount.value_type,
17 ...
18 )

И вызывается это с явным флагом:

python
1 if is_line_level_voucher(order.voucher):
2 create_or_update_voucher_discount_objects_for_order(
3 order, use_denormalized_data=True
4 )
Why: Снимок с процентом — не архив ради архива, на нём работает целый сценарий: курьер довёз не всё, менеджер убрал позицию — скидку надо пересчитать по тому правилу, которое действовало в день заказа.

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

Одна строка решает весь вопрос
python
1 checkout.discount = (
2 min(discount, subtotal)
3 if voucher.type != VoucherType.SHIPPING
4 else discount
5 )

Итог тоже защищён, и скидка вычитается из подытога, а не из доставки:

python
1 # Discount is subtracted from both gross and net values, which may cause negative
2 # net value if we are having a discount that covers whole price.
3 if discount_not_included:
4 subtotal = max(zero_money(currency), subtotal - discount)
5 return subtotal + shipping_price

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

Why: Ограничение продублировано трижды: в библиотеке (`fixed_discount` не даёт отрицательной цены), в методе модели и здесь. Повторение тут уместно: каждый уровень защищает свою величину.

Округление

Дефолт библиотеки переопределяется в каждой точке

Библиотека prices по умолчанию округляет вниз:

python
1def percentage_discount(base: T, percentage: Numeric, *, from_gross=True, rounding=ROUND_DOWN) -> T:
2 """Apply a percentage discount based on either gross or net amount."""
3 factor = Decimal(percentage) / 100
4 return fractional_discount(base, factor, from_gross=from_gross, rounding=rounding)

Saleor везде передаёт своё:

python
1 if value_type == DiscountValueType.PERCENTAGE:
2 discount_method = percentage_discount
3 discount_kwargs = {"percentage": value, "rounding": ROUND_HALF_UP}

А точность валюты берётся из babel, а не хардкодом «два знака».

Why: Смысл не в том, какое округление правильнее, а в том, что умолчанию библиотеки нельзя доверять: оно может поменяться в минорной версии. Округляется сумма скидки, а не цена после скидки: при ROUND_HALF_UP копейка при 50/50 достаётся покупателю.
Остаток копеек целиком идёт последней строкеOptional
python
1 remaining_discount = total_discount
2 for idx, line_info in enumerate(lines):
3 line = line_info.line
4 if not total_price:
5 yield line, zero_money(currency)
6 elif idx < lines_count - 1:
7 line_total_price = lines_total_prices[idx]
8 share = line_total_price / total_price
9 discount = quantize_price(
10 min(share * total_discount, line_total_price), currency
11 )
12 yield (line, max((line_total_price - discount), zero_money(currency)))
13 remaining_discount -= discount
14 else:
15 line_total_price = lines_total_prices[idx]
16 yield (
17 line,
18 max((line_total_price - remaining_discount), zero_money(currency)),
19 )

В докстринге прямым текстом: «Ensure that the sum of discounts is equal to the discount amount».

Why: Приём проще, чем алгоритм наибольшего остатка: ведём остаток и отдаём его целиком последней строке. Сумма сходится точно, но последняя позиция систематически получает чуть больше или чуть меньше остальных — в отличие от Vendure, где копейка достаётся тому, кого округление обидело сильнее.

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

Пересчёт по TTL, а не на каждый запросRecommended
python
1 if not force_update and checkout.price_expiration > timezone.now():
2 return Promise.resolve((checkout_info, lines))

TTL по умолчанию — час. При пересчёте ваучер валидируется заново и молча снимается, если стал неприменим:

python
1 try:
2 discount = get_voucher_discount_for_checkout(...)
3 return discount
4 except NotApplicable:
5 remove_voucher_from_checkout(checkout)
6 checkout_info.voucher = None
7 return None

А в момент оформления — явный отказ:

python
1 if checkout.voucher_code and not voucher_code:
2 msg = "Voucher expired in meantime. Order placement aborted."
3 raise NotApplicable(msg)
Why: TTL нужен им потому, что налоги считает внешнее приложение синхронным вебхуком и это дорого. Если у вас внешних вызовов нет, кэшировать нечего — и исчезает целый класс проблем «в корзине одна цена, при оплате другая». Не копируйте кэш ради кэша.
Симметричный возврат счётчика при любой неудачеRecommended

_release_checkout_voucher_usage вызывается из пяти мест: ошибка налогов, нехватка товара, неприменимая подарочная карта, провал оплаты, удаление черновика.

А от двойного инкремента одного чекаута стоит флаг:

python
1 # Prevent race condition when two different threads are processing the same checkout
2 # with limited usage voucher assigned, both threads increasing the
3 # voucher usage which causing `NotApplicable` error for voucher.
4 if checkout.is_voucher_usage_increased:
5 return
6
7 increase_voucher_usage(voucher, voucher_code, customer_email)
8 checkout.is_voucher_usage_increased = True
9 checkout.save(update_fields=["is_voucher_usage_increased"])
Why: Если счётчик растёт до оплаты — обязаны уметь откатывать, иначе лимитированные коды сгорают на брошенных корзинах. Отдельно любопытно: свежая заблокированная строка чекаута читается в локальную переменную, а флаг проверяется у объекта, загруженного до блокировки — похоже на то, что защита читается устаревшей (реконструкция, не подтверждённая тестом).

Срок действия

Последний день целиком НЕ включаетсяRecommended
python
1 start_date = models.DateTimeField(default=timezone.now)
2 end_date = models.DateTimeField(null=True, blank=True)

В API это DateTime, а не Date. Если админ поставил end_date = 2026-09-01T00:00:00Z, ваучер умирает в полночь, а не в конце первого сентября.

Единственная валидация — конец не раньше начала, причём равенство разрешено:

python
1 if start_date > end_date:
2 raise ValidationError("End date cannot be before the start date.")
Why: Забота «включить последний день» лежит на том, кто заполняет форму. Если ваш владелец выбирает дату, а не момент времени, границу надо строить самим — и по времени заведения, а не сервера.
Что стоит унести

Уникальный индекс вместо блокировки для «один раз на клиента» — главное. Дублирование кода строкой везде, где он влияет на деньги, при SET_NULL на внешнем ключе. Хранение value_type + value и amount_value, а не «или-или». Явное округление поверх дефолта библиотеки. Симметричный возврат счётчика.

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

Мультиканальность: из-за VoucherChannelListing простой вопрос «сколько скидка» требует джойна. Разделение Voucher / VoucherCode — ради десяти тысяч кодов с общим лимитом; побочный эффект — неатомарная проверка лимита. SPECIFIC_PRODUCT с четырьмя many-to-many. apply_once_per_order (скидка на самую дешёвую позицию). TTL пересчёта цен.

Чего здесь нет вовсе

«Только для новых покупателей». Ограничение по дням недели или времени суток (обеденные акции). Стек нескольких кодов — и это запрещено нарочно: # only one voucher can be applied.

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

updated Sep 2, 2026

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

updated Sep 2, 2026

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

updated Sep 2, 2026

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

updated Sep 2, 2026

Как проект живёт и принимает чужие правки, и кто в нём встречался в разборах — по поступкам, датам и ссылкам

updated Sep 2, 2026

Saleor: товар сняли с продажи — и строку из корзины покупателя стирали в фоне. Три с половиной года до теста, поменявшего знак

updated Sep 2, 2026