Skip to main content

Overview

Spree’s promotion system has three extension points:
  • Rules — conditions that decide when a promotion applies
  • Actions — what an applied promotion does, usually writing Discount rows
  • Adjusters — for discounts and fees that aren’t promotions at all (loyalty pricing, gift wrap fees, payment surcharges)
Spree ships with a comprehensive set of built-in rules and actions. This guide shows how to build your own of each kind.

Custom Promotion Rules

Rules determine whether a promotion is eligible for a given order. Each rule implements eligible? which returns true or false.

Step 1: Create the Rule Class

Create a new class inheriting from Spree::PromotionRule:
server/app/models/spree/promotion/rules/minimum_quantity.rb

Key Methods to Implement

The options hash passed to eligible? can include :user, :email, and other context from the checkout flow.
Accept both Spree::Cart and Spree::Order in applicable?. Promotions are evaluated on every cart change, long before the cart becomes an order, and a cart is its own model rather than an unfinished order. A rule that isn’t applicable is skipped rather than failed, so a rule guarding on Spree::Order alone is silently ignored during checkout — its condition is never checked and the promotion applies as if the rule weren’t there. Every built-in rule accepts both.

Using Preferences

Rules use Spree’s preference system for configuration. Each preference creates getter/setter methods automatically:
server/app/models/spree/promotion/rules/minimum_quantity.rb
Available types: :string, :integer, :decimal, :boolean, :array.

Step 2: Register the Rule

server/config/initializers/spree.rb
Registered rules are discoverable at /api/v3/admin/promotion_rules/types together with their preference schema, and the dashboard’s promotion editor is built on that endpoint — your rule appears there with a generated preferences form, no UI work needed. The API names your rule by its shorthand — the class name demodulized and underscored, so Spree::Promotion::Rules::MinimumQuantity is minimum_quantity. That is what /types returns, what a promotion payload sends, and what the locale key below is named after. The Ruby class name is never accepted on the wire:
Override self.api_type on the class when you need the wire value to stay put across a rename.

Step 3: Add Translations

The rule’s display name and description in the dashboard come from your locale file:
server/config/locales/en.yml

Example: Rule with actionable?

When your rule targets specific line items (not the whole order), implement actionable? so that item-level actions only discount matching items:
server/app/models/spree/promotion/rules/brand.rb

Custom Promotion Actions

Actions define what happens when a promotion is applied. A discount action doesn’t write rows itself — it declares where its discount belongs and how much it is, and Spree’s promotion engine does the rest: running the competition between promotions, clamping amounts so nothing goes below zero, writing the winning Discount rows, and removing stale ones on every recalculation.

Discount Action (with Calculator)

Three declarations make a discount action:
server/app/models/spree/promotion/actions/tiered_discount.rb
With discount_scope :order, the winning amount is shared out proportionally across the line items — you never handle the distribution yourself. With :line_item, compute_amount is called once per line item, and only items passing your rules’ actionable? receive rows.
There is nothing to clean up either. If the promotion stops being eligible — the cart shrinks below the threshold, the code is removed — the next recalculation deletes its rows. If your action loses to a better promotion, its candidate simply isn’t written that round, and it competes again on the next one.

Non-Discount Action

For actions that don’t create discounts (awarding points, sending notifications), implement perform alone:
server/app/models/spree/promotion/actions/add_loyalty_points.rb
perform receives :order and :promotion in its options and should return true if the action was applied. Optionally implement revert(options = {}) to undo side effects when the promotion is deactivated.

Register and Translate

server/config/initializers/spree.rb
server/config/locales/en.yml
Like rules, registered actions surface automatically in the dashboard’s promotion editor via /api/v3/admin/promotion_actions/types.

Custom Adjusters

Not every charge or reduction is a promotion. A gift wrap fee or a payment surcharge has no rules, no coupon codes, and no competition — it just needs to be on the order whenever it applies. That’s an adjuster: a class invoked on every recalculation that owns a family of Fee rows.
server/app/models/my_app/adjusters/gift_wrap.rb
server/config/initializers/spree.rb
The contract is one method: update. It runs on every recalculation, so it must be idempotent — write the rows that should exist, remove the ones that shouldn’t (that’s why the example uses find_or_initialize_by keyed by kind rather than create). The order it receives is the cart during checkout. Adjusters don’t run once an order is placed — a placed order’s discount, fee and tax rows are frozen and only re-summed, so post-placement changes go through the admin discount and fee endpoints. There’s no totals bookkeeping to do: after all adjusters run, Spree re-sums the typed rows into the order totals and the tax provider estimates tax on the result — a fee you write here gets taxed in the same pass, like any other fee. Discount rows are limited to two kinds, promotion and manual, and promotion rows belong to the promotion engine, which removes any it didn’t write itself. Reductions that need rules, codes or stacking are best modeled as a custom promotion action; note that the order’s discount_total counts promotion discounts only.

Testing

server/spec/models/spree/promotion/rules/minimum_quantity_spec.rb
server/spec/models/spree/promotion/actions/tiered_discount_spec.rb
  • Promotions — promotion architecture and built-in rules and actions
  • Discounts and Fees — the rows actions and adjusters write
  • Calculators — available calculator types for promotion actions
  • Events — subscribe to promotion events