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)
Custom Promotion Rules
Rules determine whether a promotion is eligible for a given order. Each rule implementseligible? which returns true or false.
Step 1: Create the Rule Class
Create a new class inheriting fromSpree::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.
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
:string, :integer, :decimal, :boolean, :array.
Step 2: Register the Rule
server/config/initializers/spree.rb
/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:
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
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), implementperform 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
/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
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
Related Documentation
- 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

