Skip to content

Скидки в Spree 6: типизированные строки и COUNT вместо счётчика

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

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

Разбор по срезу e1839c40630742ed5f497a15188a37294d31717b.

Важное предупреждение: модели Spree::Adjustment, про которую написаны все старые статьи, в этом коммите больше нет. Её заменили типизированные строки — Spree::Discount, Spree::TaxLine, Spree::Fee. Всё ниже про эту, новую архитектуру 6.0.

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

Архитектура правил

Правила и действия — STI-классы с реестром в конфигеRecommended
ruby
1module Spree
2 class PromotionRule < Spree.base_class
3 registers_subclasses_via { Spree.promotions.rules }
4
5 belongs_to :promotion, class_name: 'Spree::Promotion', inverse_of: :promotion_rules, touch: true
6
7 validates :type, uniqueness: { scope: [:promotion_id, *spree_base_uniqueness_scope] }
8
9 def applicable?(_promotable)
10 raise 'applicable? should be implemented in a sub-class of Spree::PromotionRule'
11 end
12
13 def eligible?(_promotable, _options = {})
14 raise 'eligible? should be implemented in a sub-class of Spree::PromotionRule'
15 end

Реестр — обычный массив в конфиге Rails, куда расширение дописывает свой класс из инициализатора:

ruby
1 # We need to define promotions rules here so extensions and existing apps
2 # can add their custom classes on their initializer files
3 initializer 'spree.promo.environment' do |app|
4 app.config.spree.promotions = PromoEnvironment.new
5 app.config.spree.promotions.rules = []
6 end

В коробке тринадцать правил, среди них FirstOrder и OneUsePerUser.

Why: Гибко: своё правило = наследник плюс строка в инициализаторе, а форма в админке собирается сама из `preference`-деклараций. Цена: `validates :type, uniqueness` означает **одно правило каждого типа на промо** — «сумма ≥ 1000 ИЛИ ≤ 100» описать нельзя.
match_policy объявлен, но не валидируетсяRecommended
ruby
1 MATCH_POLICIES = %w(all any)
ruby
1 def match_all?
2 match_policy == 'all'
3 end

Обычная строковая колонка без validates :inclusion — хотя у соседних CollectionRule и TaxonRule такая валидация есть.

Why: Опечатка в значении молча превращает «все условия» в «любое условие»: всё, что не равно строке `'all'`, считается `any`. Классический случай «перечисление объявлено, но не применено».
Двухступенчатый фильтр: applicable? потом eligible?Optional
ruby
1 def eligible_rules(promotable, options = {})
2 # Promotions without rules are eligible by default.
3 return [] if rules.none?
4
5 specific_rules = rules.select { |rule| rule.applicable?(promotable) }
6 return [] if specific_rules.none?
7
8 rule_eligibility = Hash[specific_rules.map do |rule|
9 [rule, rule.eligible?(promotable, options)]
10 end]
11
12 if match_all?
13 unless rule_eligibility.values.all?
14 @eligibility_errors = specific_rules.map(&:eligibility_errors).detect(&:present?)
15 return nil
16 end
17 specific_rules
18 else
19 ...
20 end
21 end
Why: Правило, неприменимое к данному объекту (например, `ItemTotal` к строке заказа), просто выпадает из выборки. А если выпали все — промо считается подходящим «по умолчанию». Тонкость, которую легко прочитать наоборот.

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

Счётчика нет вообще — каждый раз COUNTRecommended
ruby
1 def usage_limit_exceeded?(promotable)
2 usage_limit.present? && usage_limit > 0 && adjusted_credits_count(promotable) >= usage_limit
3 end
ruby
1 def checkouts_credited(scope)
2 orders = Spree::Order.arel_table
3 checkout = Arel::Nodes::NamedFunction.new('COALESCE', [orders[:order_group_id], orders[:id]])
4
5 scope.joins(:order).distinct.count(checkout)
6 end

То есть на каждой проверке выполняется SELECT COUNT(DISTINCT COALESCE(order_group_id, id)) FROM spree_discounts JOIN spree_orders.

Why: Нечего рассинхронизировать и отмена заказа автоматически возвращает слот — это плюс. Минус в следующем пункте. Считаются чекауты, а не заказы: корзина на нескольких продавцов становится несколькими заказами, и считать их по отдельности значило бы потратить лимитированный код трижды за одно погашение.
Замок берётся на корзину, а не на промоакцию
ruby
1 cart.with_lock do
2 step :guard_concurrent_completion
3 step :recalculate_in_lock
4 step :verify_expected_total
5 step :validate_cart
6 step :mark_completing
7 step :create_draft_order, on_flow_failure: :rollback_draft_order
8 end

Поиск with_advisory_lock по репозиторию не даёт ни одного совпадения; SELECT ... FOR UPDATE на промоакции тоже нет.

Гашение одноразовых кодов — обычный update_all, а не условный UPDATE с проверкой числа строк:

ruby
1 def mark_coupon_codes_used
2 Spree::CouponCode.where(order_id: order.id).unused.update_all(state: 1)
3 end
Why: Два разных покупателя оформляются одновременно — каждый берёт свой замок на свою корзину, оба выполняют `usage_limit_exceeded?` (read-only COUNT при READ COMMITTED), оба видят `count = limit - 1`, оба проходят. Код с `usage_limit: 1` может быть погашен N раз. Вывод для тех, кто пишет своё: счётчик должен быть колонкой с `UPDATE ... SET used = used + 1 WHERE used < limit` и проверкой числа изменённых строк — либо строкой-«билетом» с уникальным индексом.

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

Проверка — тоже запрос, а не уникальный ключRecommended
ruby
1 def used_by?(user, excluded_orders = [])
2 # A cart is not an order and has no siblings; it simply has nothing to
3 # exclude, since its own discounts carry no order_id.
4 excluded_ids = excluded_orders.flat_map { |record| record.try(:sibling_order_ids) || [] }.uniq
5
6 user.orders.complete.joins(:discounts).
7 where.not(spree_orders: { id: excluded_ids }).
8 where(Spree::Discount.table_name => { promotion_id: id, kind: 'promotion' }).any?
9 end

Зато есть встроенное правило FirstOrder — единственный из четырёх движков, у кого оно вообще есть.

Why: Сравните с Saleor: там то же самое держит `unique_together` в БД — три строки и гонка закрыта. Здесь — запрос `.any?`, то есть тот же check-then-act.

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

Самый полный снимок из четырёх движков

Комментарий к модели говорит всё:

ruby
1 # A promotion or manual discount on a line item or fulfillment. Amount is
2 # enforced non-positive (validation + DB CHECK). Provenance snapshots
3 # (+code+, +value+, +value_type+) keep rows meaningful after the promotion
4 # is deleted — the promotion FKs are nullified, never cascaded.
5 class Discount < Spree.base_class
6 belongs_to :promotion_action, class_name: 'Spree::PromotionAction', optional: true
7 belongs_to :promotion, class_name: 'Spree::Promotion', optional: true
8 ...
9 validates :amount, numericality: { less_than_or_equal_to: 0 }

И в схеме — CHECK:

ruby
1 t.check_constraint 'amount <= 0', name: 'chk_spree_discounts_amount_nonpositive'

Снимок снимается явно:

ruby
1 def value_snapshot(action)
2 calculator = action.try(:calculator)
3 return [nil, nil] unless calculator
4
5 if calculator.respond_to?(:preferred_percent)
6 [calculator.preferred_percent, 'percent']
7 elsif calculator.respond_to?(:preferred_flat_percent)
8 [calculator.preferred_flat_percent, 'percent']
9 elsif calculator.respond_to?(:preferred_amount)
10 [calculator.preferred_amount, 'flat']
11 else
12 [nil, nil]
13 end
14 end
Why: `amount`, `label`, `code`, `value`, `value_type` — пять полей, которые делают строку читаемой без справочника вообще. Плюс CHECK в БД на знак. При оформлении строки физически копируются с корзины на заказ, с комментарием «the cart's rows are the record of what the sale was costed at».

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

Три независимых уровня клампинга

В калькуляторе:

ruby
1 def compute(object)
2 computed_amount = (object.amount * preferred_flat_percent / 100).round(2)
3
4 # We don't want to cause the promotion adjustments to push the order into a negative total.
5 if computed_amount > object.amount
6 object.amount
7 else
8 computed_amount
9 end
10 end

В действии:

ruby
1 def compute_amount(order)
2 [order_total(order), compute(order)].min * -1
3 end

В адъюстере — против остаточной базы, чтобы скидки не компаундились:

ruby
1 # Combined discounts never push an adjustable's net below zero.
2 def clamp(amount, base)
3 [amount, -[base, BigDecimal(0)].max].max
4 end

Бесплатная доставка — отдельный discount_scope, всегда 100 % стоимости, без калькулятора:

ruby
1 def compute_amount(fulfillment)
2 fulfillment.cost * -1
3 end
Why: Четыре уровня с учётом CHECK в БД. Отдельно ценна идея «остаточной базы»: кламп считается против того, что осталось после уже применённых скидок, а старые строки из предыдущих проходов исключаются — чтобы пересчёт не компаундил сам себя.

Копейки

Алгоритм наибольшего остатка на RationalOptional

Одиннадцать строк, которые стоит унести целиком:

ruby
1 # @param total_cents [Integer]
2 # @param weights [Array<Numeric>] proportional bases (sum must be > 0)
3 # @return [Array<Integer>] per-weight shares in cents, summing to total_cents
4 def largest_remainder_shares(total_cents, weights)
5 weights_sum = weights.sum
6 raw = weights.map { |weight| Rational(total_cents) * Rational(weight) / Rational(weights_sum) }
7 shares = raw.map(&:floor)
8 remainder = total_cents - shares.sum
9 raw.each_with_index.sort_by { |value, index| [shares[index] - value, index] }.first(remainder).each do |_, index|
10 shares[index] += 1
11 end
12 shares
13 end

Вызов — в целых копейках, с ограничением суммой баз:

ruby
1 total_cents = [chosen[:amount].abs, bases_sum].min * 100
2 shares = Spree::Adjusters::LargestRemainder.largest_remainder_shares(total_cents.round, bases)
Why: Счёт в `Rational` — нет промежуточной потери точности. `floor` плюс раздача остатка, тай-брейк по индексу — детерминированно. Гарантия: сумма долей точно равна целому, и ни одна строка не уходит в минус.

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

Пересчёт с нуля внутри замка — лучшее здесь
ruby
1 def update
2 apply_line_item_discounts
3 apply_fulfillment_discounts
4 apply_order_level_discount
5 sync_coupon_promotion_joins
6 cleanup_stale_rows
7 end
ruby
1 # Everything not written in this pass is stale: losing candidates,
2 # promotions that stopped being eligible, and rows whose promotion was
3 # deleted (FKs nullified). Manual and custom-kind discounts are not ours.
4 def cleanup_stale_rows
5 order.discounts.promotion.where.not(id: @applied_row_ids).destroy_all
6 end

А перед созданием заказа — сверка с ожиданием клиента:

ruby
1 def verify_expected_total
2 if expected_total.present? && BigDecimal(expected_total.to_s) != cart.total
3 failure(cart, code: 'cart_changed', current_total: cart.total)
4 end
5 end

А после размещения деньги замораживаются: step :regenerate_typed_rows unless money_frozen?

Why: Скидки полностью перегенерируются внутри замка прямо перед списанием денег, «протухшие» строки удаляются, а расхождение с ожиданием клиента возвращает `cart_changed`. Это ровно то, чего не делает Medusa и что частично делает Vendure.
Код, ещё не дающий скидки, всё равно сохраняетсяRecommended
ruby
1 # The owner's PERSISTED coupon code keeps its promotion in candidacy
2 # even before it ever applied — the discount activates on the exact
3 # recalculation where the cart first qualifies, and deactivates the
4 # same way (Shopify-parity for cart-level discount codes).
5 def coupon_promotions
6 ...
7 promotion = order.store.promotions.active.with_coupon_code(code)
8 return [] if promotion.nil? || promotion.usage_limit_exceeded?(order)
9
10 [promotion]
11 end

А контроллер принимает такой код с отдельным состоянием:

ruby
1 # Applies a discount code. A real-but-not-yet-eligible code is
2 # accepted and persisted — recalculation activates the discount
3 # the moment the cart qualifies (Shopify-parity). Unknown/expired
4 # codes are rejected and never persisted.
Why: Гость вводит код «от 1000 ₽» при корзине на 800 — код принимается и срабатывает сам, как только он добавит ещё. А несуществующий код отвергается сразу. Разделение «кода нет» и «код есть, но условия пока не сошлись» — хорошая идея для витрины.
Что стоит унести

Полный снимок (amount, label, code, value, value_type) с обнуляемыми FK и CHECK на знак. Алгоритм наибольшего остатка. Эшелонированный клампинг с идеей остаточной базы. Пересчёт с нуля внутри замка плюс cart_changed при расхождении с ожиданием клиента. Заморозка денег после размещения. Сохранённый код, который сработает позже.

Главное предупреждение

Лимит применений не атомарен, и это не мелочь: COUNT без блокировки, замок на корзине, update_all без проверки числа строк. Если вы пишете своё — это тот случай, когда чужое решение надо не копировать, а исправить.

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

STI-реестр правил: вся eligibility гоняется в Ruby при каждом пересчёте, match_policy плоский и не валидируется, одно правило каждого типа на промо. Акции на конкретные товары через четыре many-to-many. Мультивендорность (order_group_id, sibling orders).

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

updated Sep 2, 2026

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

updated Sep 2, 2026

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

updated Sep 2, 2026

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

updated Sep 2, 2026