spree-promotions
Use when the user is working with Spree promotions, discounts, or coupon codes — configuring a promo, writing a custom promotion rule, building a custom action, applying a discount programmatically. Common phrasings include "create promotion", "coupon code", "discount", "BOGO", "free shipping promotion", "promotion not applying", "custom promotion rule", "stack promotions". Provides the Promotion / PromotionRule / PromotionAction / Calculator graph and the customization points documented at `docs/developer/how-to/custom-promotion.mdx`.
Skill body
Spree Promotions
A promotion is “if X is true about the cart, do Y.” Spree breaks that into:
Promotion — the campaign (name, dates, code, usage limits)
├── PromotionRule × n — eligibility checks: "if X is true"
├── PromotionAction × n — what to do: "create a discount", "free shipping"
│ └── Calculator — how much (flat $, %, per-item, etc.)
└── PromotionCategory — admin grouping
A promo can have multiple rules (ANDed or ORed via match_policy) and multiple actions (all fire when eligible).
Promotion attributes
Spree::Promotion.create!(
name: 'Summer Sale 2026',
code: 'SUMMER20', # required for the default kind: :coupon_code; pass kind: :automatic for no-code promos
starts_at: Date.new(2026, 6, 1),
expires_at: Date.new(2026, 9, 1),
usage_limit: 1000, # max total redemptions; nil = unlimited
match_policy: 'all', # 'all' (AND) or 'any' (OR) for rule matching
advertise: true # show in store header / banners
)
- Coupon codes vs automatic: promotions have a
kindenum —coupon_code(default) orautomatic. Coupon-code promos requirecode(normalized to lowercase; matched case-insensitively). For promos that apply automatically when rules match, passkind: :automatic— leavingcodenil without it fails validation, sincekinddefaults tocoupon_code. - Per-customer limits: add a
Spree::Promotion::Rules::OneUsePerUserrule. The customer must be logged in (anonymous orders can’t enforce per-customer limits — no identity).
Built-in PromotionRule subclasses
Each rule subclasses Spree::PromotionRule and implements eligible?(promotable, options = {}).
| Rule | Eligibility |
|---|---|
Country |
The order’s shipping country is in the configured ISO code list (defaults to the store’s default country) |
Currency |
The cart’s currency matches |
CustomerGroup |
The customer is in a specific group |
FirstOrder |
The customer hasn’t completed an order before |
ItemTotal |
Order subtotal meets a threshold (configurable operator) |
OneUsePerUser |
The customer hasn’t used this promo before |
OptionValue |
At least one variant in the cart has a matching option value |
Product |
At least one matching product is in the cart |
Taxon |
At least one matching category is in the cart |
User |
The specific customer is on the order |
UserLoggedIn |
The customer is authenticated |
Rules combine via the promo’s match_policy:
all— every rule must be eligible (default)any— at least one rule must be eligible
Built-in PromotionAction subclasses
Each action subclasses Spree::PromotionAction and implements perform(options = {}).
| Action | Effect |
|---|---|
CreateAdjustment |
One adjustment on the whole order (e.g. $10 off the total) |
CreateItemAdjustments |
One adjustment per eligible line item (e.g. 20% off matching products) |
CreateLineItems |
Auto-add configured variants to the cart when eligible (added at normal price — pair with a discount action to make them free / BOGO) |
FreeShipping |
Zero out shipping cost |
Discount actions consult a Calculator for the amount. Spree::Calculator::FlatRate gives a flat amount off; Spree::Calculator::PercentOnLineItem gives a percentage off matching items; Spree::Calculator::FlatPercentItemTotal gives a percentage off the cart total. The full calculator catalog lives at Spree::Calculator subclasses in spree_core/app/models/spree/calculator/.
Custom PromotionRule
Subclass Spree::PromotionRule, implement applicable? + eligible?, register in an initializer, add an admin partial:
# app/models/spree/promotion/rules/minimum_quantity.rb
module Spree
class Promotion
module Rules
class MinimumQuantity < Spree::PromotionRule
preference :quantity, :integer, default: 5
def applicable?(promotable)
promotable.is_a?(Spree::Order)
end
def eligible?(order, options = {})
total_quantity = order.line_items.sum(&:quantity)
return true if total_quantity >= preferred_quantity
eligibility_errors.add(:base, "Order must contain at least #{preferred_quantity} items")
false
end
end
end
end
end
# config/initializers/spree.rb
Rails.application.config.after_initialize do
Spree.promotions.rules << Spree::Promotion::Rules::MinimumQuantity
end
# app/views/spree/admin/promotion_rules/forms/_minimum_quantity.html.erb
# The partial name must match the rule's `key` (`api_type` — demodulized, underscored class name by default).
# Action partials go in app/views/spree/admin/promotion_actions/forms/.
<div class="row mb-3">
<%= f.spree_number_field :preferred_quantity, label: Spree.t(:minimum_quantity) %>
</div>
Add locale entries under spree.minimum_quantity and spree.promotion_rule_types.minimum_quantity.{name,description}. The admin selector reads from those keys.
Key methods on a rule
| Method | Required | Description |
|---|---|---|
applicable?(promotable) |
Yes | Whether this rule can evaluate the promotable (usually promotable.is_a?(Spree::Order)) |
eligible?(promotable, options = {}) |
Yes | Whether the promotable meets the rule. Add messages to eligibility_errors to explain false |
actionable?(line_item) |
No | Whether a specific line item should receive the action’s adjustment. Default true. Override for rules that target specific items (product, category) |
eligible? is called per-order during the cart pipeline. Keep it cheap — N+1 queries here are a common cart-pipeline performance issue.
Custom PromotionAction
Subclass Spree::PromotionAction and implement perform. Discount actions also include Spree::CalculatedAdjustments + Spree::AdjustmentSource:
# app/models/spree/promotion/actions/tiered_discount.rb
module Spree
class Promotion
module Actions
class TieredDiscount < Spree::PromotionAction
include Spree::CalculatedAdjustments
include Spree::AdjustmentSource
before_validation -> { self.calculator ||= Calculator::FlatRate.new }
def perform(options = {})
order = options[:order]
return false unless order.present?
create_unique_adjustment(order, order)
end
def compute_amount(order)
discount = case order.item_total
when 100..Float::INFINITY then 25
when 50..99.99 then 10
else 0
end
# Discounts are negative; cap to prevent negative orders
[discount, order.item_total].min * -1
end
end
end
end
end
# config/initializers/spree.rb
Rails.application.config.after_initialize do
Spree.promotions.actions << Spree::Promotion::Actions::TieredDiscount
end
Non-discount actions (award points, send notifications) don’t need the calculator includes — just implement perform.
Key methods on an action
| Method | Required | Description |
|---|---|---|
perform(options = {}) |
Yes | Called when the promotion activates. options includes :order and :promotion. Return true if the action was applied |
compute_amount(adjustable) |
For discount actions | Return the adjustment amount (negative for discounts). Cap at the adjustable’s total |
revert(options = {}) |
No | Called when the promotion deactivates. Use to undo side effects (e.g., remove added line items) |
perform runs during cart recalculate. Keep it cheap and idempotent — recalculate fires on many cart changes.
Helper methods from the includes
# Spree::AdjustmentSource:
create_unique_adjustment(order, adjustable)
create_unique_adjustments(order, order.line_items)
create_unique_adjustments(order, order.line_items) do |line_item|
promotion.line_item_actionable?(order, line_item)
end
# Spree::CalculatedAdjustments:
compute(adjustable) # calls calculator.compute(adjustable)
self.calculator_type = 'Spree::Calculator::FlatRate'
self.class.calculators # available calculators
Custom Calculator
Calculators answer “given X, how much?” — the same calculator subclass can be used by multiple action types.
module Spree
class Calculator::PercentWithCap < Spree::Calculator
preference :percent, :decimal, default: 10
preference :cap_amount, :decimal, default: 50
def compute(object)
base = object.amount.to_d
raw = base * (preferred_percent / 100.0)
[raw, preferred_cap_amount].min
end
end
end
Register so the action’s “available calculators” picker shows it:
Rails.application.config.after_initialize do
Spree.calculators.promotion_actions_create_item_adjustments << Spree::Calculator::PercentWithCap
# order-level actions read from Spree.calculators.promotion_actions_create_adjustments
end
Promotion stacking
Multiple promotions can each create adjustments, but Spree does not stack them on the same target. During recalculation Spree::Adjustable::Adjuster::Promotion (registered by default in Rails.application.config.spree.adjusters) keeps only the single best (largest-discount) eligible promo adjustment per adjustable — order, line item, or shipment — and marks competing promo adjustments eligible: false; on a tie the most recently created wins. Promotions targeting different adjustables can still combine (e.g. an order-level discount plus a line-item discount on the same order).
To change this behavior (e.g. allow stacking on one adjustable), swap in a custom adjuster via Rails.application.config.spree.adjusters.
Common promotion problems
“Promotion isn’t applying”
Walk this list:
- Is the code right? Coupon codes are matched case-insensitively but must otherwise match exactly.
- Within the window?
promotion.starts_at < Time.current && (promotion.expires_at.nil? || promotion.expires_at > Time.current). - Usage limit not exceeded?
promotion.usage_limit_exceeded?(order)should be false (nil limit = unlimited). To inspect manually,promotion.credits_countis the number of distinct orders that have used the promo — compare it againstpromotion.usage_limitwhen a limit is set. - Every rule eligible? With
match_policy: 'all', every rule must return true. Walkpromotion.rules.map { |r| [r.class.name, r.eligible?(order)] }to see which fails. Checkr.eligibility_errors.full_messagesfor the reason. - Action ran during recalculate? Check
order.all_adjustments.promotion—order.adjustmentsonly holds order-level adjustments;CreateItemAdjustmentswrites to line items andFreeShippingto shipments. If empty, the action never fired — recalculate to retry.
“Custom rule isn’t showing in admin UI”
Confirm registration ran: Spree.promotions.rules.include?(Spree::Promotion::Rules::MyRule) should be true after Rails boot. Confirm the admin partial exists at app/views/spree/admin/promotion_rules/forms/_<key>.html.erb (the rule’s key / api_type). Confirm locale keys under spree.promotion_rule_types.<underscored_class>.{name,description} are present.
Where to read further
- Core concepts:
node_modules/@spree/docs/dist/developer/core-concepts/promotions.mdx - Custom rules + actions tutorial:
node_modules/@spree/docs/dist/developer/how-to/custom-promotion.mdx - Source:
Spree::Promotion,Spree::PromotionRule,Spree::PromotionAction,Spree::Calculatorin the installedspree_coregem - Adjustments: see the
spree-data-modelskill — promotions create Adjustments tied to Orders, LineItems, or Shipments
Work with this as data
Every skill here is available over the APIs.io API and to AI agents over MCP.
MCP server
One button, every client — Claude, Cursor, VS Code and the rest.
https://apis.io/mcp
Tools for agent skills
4 MCP tools reach this
find_skillsBrowse and filter every skill in the catalog.apis_io_searchSTART HERE — APIs, providers and tags for one query, each with its total.resolveTurn a domain, URL or GitHub org into the provider it belongs to.find_cohortsEvery scored population of providers in the catalog.
Call it yourself
curl for this page
curl "https://apis.io/api/v1/skills/spree-promotions"
curl "https://apis.io/api/v1/skills?limit=25"
Discovery needs no key. Ratings and market analysis are Pro.