skillZs
★ LIVE SKILL TAGS ★
>>> LIVE SKILLS INDEX <<<
* OPEN SOURCE *
NO LOGIN, NO TRACKING
※ REAL INSTALL DATA ※
← back to all skills
spree/agent-skills124 installs

spree-promotions

Use when the user is working with Spree 6 promotions and discounts — creating promotions (automatic or coupon code), bulk/multi-use coupon codes, promotion rules and actions, calculators, how competing promotions are resolved, manual discounts, writing a custom promotion rule, action or calculator, or a custom adjuster. Common phrasings include "create promotion", "coupon code", "discount code", "generate 1000 codes", "BOGO", "free shipping promotion", "10% off category", "promotion not applying", "custom promotion rule", "custom promotion action", "custom calculator", "stack promotions", "best discount wins", "manual discount", "goodwill discount", "Spree::Discount", "Taxon rule".

How do I install this agent skill?

npx skills add https://github.com/spree/agent-skills --skill spree-promotions
view source ↗

Is this agent skill safe to install?

  • Gen Agent Trust Hubpass

    This skill provides technical documentation and code templates for the Spree E-commerce promotion system. It contains no malicious patterns, external network requests, or dangerous execution methods.

  • Socketpass

    No alerts

  • Snykpass

    Risk: LOW · No issues

What does this agent skill do?

Spree Promotions

A promotion (promo_) is a campaign: rules decide whether it applies, actions decide what it does. What lands on a cart/order is a discount (Spree::Discount, disc_) — a money row that survives the promotion being edited or deleted.

Promotion (belongs_to :store; kind coupon_code|automatic; starts_at/expires_at; usage_limit; match_policy all|any)
  ├── PromotionRule (prorule_, STI)      — eligible?(cart_or_order)
  ├── PromotionAction (pact_, STI)       — discount_scope + compute_amount, or a side effect in perform
  │     └── Calculator (calc_)            — how much (FlatRate, FlatPercentItemTotal, PercentOnLineItem, …)
  └── CouponCode (coupon_) × n           — multi-code promotions; state unused|used
Discount — kind 'promotion' | 'manual'; attached to exactly one LineItem or Fulfillment; amount ≤ 0

How promotions apply

Every cart change runs Spree::Carts::RecalculateTotals, which runs Spree::Adjusters::Promotion:

  1. Candidates — promotions connected to the cart, plus the cart's persisted coupon_code promotion, plus automatic ones being activated. Each is checked: active window, usage limit, then rules per match_policy.
  2. Competition, winner-only — per competition group the most negative amount wins (ties → newest action):
    • :line_item actions compete per line item (only items passing the rules' actionable?),
    • :fulfillment actions compete per fulfillment,
    • :order actions compete order-wide; the winner is spread across line items proportionally (largest remainder) — there are no order-attached discount rows.
  3. Persistence — winning rows written, clamped so no line/fulfillment goes below zero; stale promotion rows deleted. Losers aren't stored and simply compete again next time. Tax is then estimated on discounted amounts.
  4. Placement — once Spree::Order exists its discount rows are frozen; usage is recorded against the promotion.

There is no stacking in 6.0 — two promotions on the same line don't combine.

Creating promotions

// "20% off orders over $100 with code SUMMER20"
await admin.promotions.create({
  name: 'Summer Sale',
  kind: 'coupon_code',
  code: 'SUMMER20',
  starts_at: '2027-06-01T00:00:00Z',
  expires_at: '2027-09-01T00:00:00Z',
  rules: [{ type: 'item_total', preferences: { amount_min: 100 } }],
  actions: [{ type: 'create_adjustment',
              calculator: { type: 'flat_percent_item_total', preferences: { flat_percent: 20 } } }],
})
await admin.promotions.rules.create('promo_xxx', { type: 'first_order' })
await admin.promotions.actions.create('promo_xxx', { type: 'free_shipping' })
spree api get /promotion_rules/types        # registered rule types + preference schemas
spree api get /promotion_actions/types
spree api get "/promotion_actions/calculators?type=create_adjustment"
  • kind: automatic (no code, applies when rules match) or coupon_code. Setting a code flips an automatic promotion to coupon_code; codes are stored and matched lowercased.
  • Multi-code (batch) promotions: multi_codes: true, number_of_codes: 1000, code_prefix: 'SUMMER' generates Spree::CouponCode rows (large batches in a background job); each code is single-use (unused → used), usage_limit doesn't apply. A code is spent only when an order is placed with it — applying it to a cart just holds it (coupon_code.holder). List them with GET /promotions/:id/coupon_codes.
  • Wire type values are the api_type shorthand (item_total, create_item_adjustments, flat_rate) — never Ruby class names.
  • Promotions are single-store (current_store.promotions). Outside a request set Spree::Current.store.

Built-in rules

currency, country, channel, market, item_total, product (any/all/none), category (children count; Spree::Promotion::Rules::Taxon is a deprecated alias) — their product_ids= / category_ids= resolve prefixed or raw IDs through the promotion's store, so another store's record raises RecordNotFound (and fails validation); a custom rule that links records can reuse ids_within_store(ids, promotion.store.products) — option_value, user, customer_group, first_order, user_logged_in, one_use_per_user.

Built-in actions

Action (api_type)discount_scopeDefault calculatorNotes
create_adjustment — order discount:orderFlatPercentItemTotalAlso FlatRate, FlexiRate, TieredPercent, TieredFlatRate. Spread over line items.
create_item_adjustments — item discount:line_itemPercentOnLineItemAlso FlatRate, FlexiRate. Only actionable items.
free_shipping:fulfillment—Row persists at zero; order.has_free_shipping? tests row existence.
create_line_items — free gift:line_item—Tops the cart up to the gift quantity (stock-checked) and writes a line-level discount covering the gifted units (only those — extra units of the same variant are paid for). Don't pair it with an order discount "to pay for the gift" — that discounts twice. Rules decide whether it applies, not which lines it pays for, and the gift itself can't satisfy the rules. Given-away units don't count toward what promotions measure: ItemTotal thresholds (this promotion's own too, so the gift is taken back once the paid goods drop to the threshold) and the base an order-level percentage is taken from (Purchase::Totals#amount subtracts gift_amount). A copy the shopper pays for counts toward other promotions like any item. A shopper already holding the variant gets it free instead of a duplicate. When the promotion stops applying, cart recalculation reverts it and takes the gift units back out (only for promotions actually joined to the cart, so a shopper's own purchase of that variant is never deleted).

Calculators carry a currency. FlatRate (and other amount-based calculators) return 0 when their preferred_currency doesn't match the cart — set one promotion/calculator per currency or the promotion silently does nothing for some customers.

Coupon codes at checkout

const cart = await client.carts.discountCodes.apply(cart.id, 'SUMMER20', { spreeToken: cart.token })
await client.carts.discountCodes.remove(cart.id, 'SUMMER20', { spreeToken: cart.token })

The code is stored on cart.coupon_code (and copied to order.coupon_code). If the cart doesn't qualify yet (e.g. below the minimum), the code stays on the cart with a coupon_code_not_eligible warning and activates on the recalculation where it first qualifies. Gift cards use their own endpoint (carts.giftCards) — see spree-payments.

Batch (single-use) codes are held from the moment they're saved on a cart, even before the cart qualifies, and only the holder is discounted (Promotion#eligible? fails for a purchase that doesn't hold the code, via Spree::CouponCode.held_by). A replaced or removed code is given back. Abandoned carts: a code held by another open cart is taken over by whoever presents it next (the old cart loses the discount), and whichever order is placed first keeps it. A cart already in completion (completion_claimed?), a completed cart and a draft order never give up a held code — the newcomer gets coupon_code_used, as they do when the code was spent by a placed order (or while it was being applied). A cart that lost its code (cart.coupon_code_unavailable?) has it dropped on its next Store API request that reads or changes the cart, with a coupon_code_unavailable warning. carts.complete refuses such a cart with coupon_code_unavailable rather than charging more than the shopper was shown. Server side the handler is Spree.coupon_handler (Spree::PromotionHandler::Coupon).

Manual discounts

Staff can add discounts to a placed order with no promotion behind them (kind: 'manual'):

await admin.orders.discounts.create('or_xxx', { label: 'Goodwill', value: '10', value_type: 'percent', line_item_id: 'li_xxx' })
await admin.orders.discounts.create('or_xxx', { label: 'Price match', value: '15.00', value_type: 'flat' })

Only manual rows can be edited/deleted — promotion rows return 422.

Custom rule

# app/models/spree/promotion/rules/minimum_quantity.rb
module Spree
  class Promotion
    module Rules
      class MinimumQuantity < Spree::PromotionRule
        preference :quantity, :integer, default: 5

        # MUST accept both — promotions are evaluated on the Cart through checkout
        def applicable?(promotable)
          promotable.is_a?(Spree::Cart) || promotable.is_a?(Spree::Order)
        end

        def eligible?(promotable, _options = {})
          return true if promotable.line_items.sum(&:quantity) >= preferred_quantity

          eligibility_errors.add(:base, "Add at least #{preferred_quantity} items")
          false
        end

        # optional: restrict which line items item-level actions discount (default true)
        # def actionable?(line_item) = …
      end
    end
  end
end
  • Rules that aren't applicable? to the promotable are skipped, not failed — a rule guarding on Spree::Order only is ignored on carts, so the promotion can apply without your condition (and if it's the only rule, applies to everyone).
  • Read the buyer via promotable.customer, totals via item_total (subtract promotable.gift_amount(promotion: promotion) for a spend threshold that should ignore free gifts, as ItemTotal does), lines via line_items — the Spree::Purchase::* surface shared by Cart and Order.
  • Preferences (:string, :integer, :decimal, :boolean, :array) become the generated dashboard form. Association-backed config (e.g. brand_ids) also needs self.additional_permitted_attributes = [brand_ids: []] on the subclass so the Admin API permits it.

Custom action

A discount action declares where and how much; the adjuster does competition, clamping, writing and cleanup:

# app/models/spree/promotion/actions/tiered_discount.rb
module Spree
  class Promotion
    module Actions
      class TieredDiscount < Spree::PromotionAction
        preference :currency, :string, default: 'USD'

        def discount_scope = :order          # :line_item | :fulfillment | :order

        def perform(options = {})            # connects the promotion; returns true if it yields a candidate
          apply_via_adjuster(options)
        end

        # Called with the adjustable matching discount_scope (cart/order, line item, or fulfillment).
        # Return a NEGATIVE amount, or 0 for "no discount".
        def compute_amount(order)
          return 0 unless order.currency == preferred_currency

          discount = if order.item_total >= 100 then 25 elsif order.item_total >= 50 then 10 else 0 end
          -[discount, order.item_total].min
        end
      end
    end
  end
end

Non-discount actions (loyalty points, notifications) leave discount_scope nil and do their work in perform(options) (options[:order], options[:promotion]), returning true if applied; optionally revert(options). Keep side effects idempotent — perform can be called on repeated activations.

Custom calculator

class MyApp::Calculator::PerItemCap < Spree::Calculator
  preference :amount, :decimal, default: 0
  preference :currency, :string, default: -> { Spree::Store.default&.default_currency || 'USD' }

  def self.description = 'Flat amount per unit, capped at line amount'

  def compute(line_item)                 # item actions pass a line item; order actions pass the cart/order
    return 0 unless line_item.currency.casecmp?(preferred_currency)

    [preferred_amount * line_item.quantity, line_item.amount].min
  end
end

Calculators return a positive amount; actions negate it.

Registration (all in one place)

Core reassigns these registries inside its own after_initialize — appending at the top level of an initializer or in to_prepare gets wiped. Always:

# config/initializers/spree.rb
Rails.application.config.after_initialize do
  Spree.promotions.rules   << Spree::Promotion::Rules::MinimumQuantity
  Spree.promotions.actions << Spree::Promotion::Actions::TieredDiscount
  Spree.calculators.promotion_actions_create_item_adjustments << MyApp::Calculator::PerItemCap
  # order-level calculators go in Spree.calculators.promotion_actions_create_adjustments
end

Then add locale keys — the dashboard's promotion editor builds its pickers and preference forms from the /types endpoints, so no UI code is needed:

# config/locales/en.yml
en:
  spree:
    promotion_rule_types:
      minimum_quantity:
        name: Minimum quantity
        description: Cart must contain at least N items
    promotion_action_types:
      tiered_discount:
        name: Tiered discount
        description: $10 off over $50, $25 off over $100

The key is the class's api_type (demodulized, underscored). Override def self.api_type = 'min_qty' to keep the wire name stable across a class rename.

Custom adjusters (non-promotion charges/discounts)

Loyalty pricing, gift-wrap fees, payment surcharges have no rules or codes — they're adjusters: subclass Spree::Adjusters::Base, implement an idempotent update that writes/removes its own rows on order (the cart during checkout), and register with Spree.adjusters << MyAdjuster inside after_initialize. Don't write kind: 'promotion' rows (the promotion adjuster deletes rows it didn't write); Spree::Discount only allows promotion/manual kinds and must attach to a line item or fulfillment. Fees and totals are covered in spree-order-totals.

Testing

RSpec.describe Spree::Promotion::Rules::MinimumQuantity do
  let(:rule) { described_class.new(preferred_quantity: 3) }
  let(:cart) { create(:cart_with_line_items, line_items_count: 1) }

  it 'is applicable to carts' do
    expect(rule.applicable?(cart)).to be(true)
  end

  it 'needs 3 units' do
    expect(rule.eligible?(cart)).to be(false)
    cart.line_items.first.update!(quantity: 3)
    expect(rule.eligible?(cart.reload)).to be(true)
  end
end

Factories: :promotion, :promotion_with_item_adjustment, :promotion_with_order_adjustment, :promotion_with_item_total_rule, :free_shipping_promotion, :cart_with_line_items. Test eligibility against a cart, not only an order.

"Promotion isn't applying"

  1. Active? starts_at/expires_at; promotion.usage_limit_exceeded?(cart); for coupon promotions, does cart.coupon_code match (codes are lowercased)?
  2. Rules: promotion.eligible?(cart) then promotion.eligibility_errors. Custom rule applicable? to Spree::Cart?
  3. Currency: amount calculators return 0 for another currency.
  4. Lost the competition? Another promotion gave a bigger saving on the same line/fulfillment/order group — only one wins.
  5. Item actions: rules' actionable? excluded the lines (e.g. category/product rule not matching those items).
  6. Order already placed? Placed orders don't re-run promotions.
  7. Custom type missing from the dashboard: not registered in after_initialize, or server not restarted.

Where to read further

  • node_modules/@spree/docs/dist/developer/core-concepts/promotions.md, .../discounts.md, .../calculators.md
  • node_modules/@spree/docs/dist/developer/how-to/custom-promotion.md
  • Source: Spree::Adjusters::Promotion, Spree::PromotionRule, Spree::PromotionAction, Spree::Promotion::Rules::*, Spree::Promotion::Actions::* in spree_core
  • Related skills: spree-order-totals, spree-checkout, spree-pricing (price lists vs promotions), spree-testing

Add the canonical catalog link to the repository README so users can inspect current installs and available audits. The publishing guide covers the complete discovery path.

<a href="https://skillzs.dev/skills/spree/agent-skills/spree-promotions">View spree-promotions on skillZs</a>