spree-promotions について
spree-promotions は、Spree 開発者向けのプロモーション設定・拡張ガイドです。ルール、割引アクション、計算機、クーポンの扱いに加え、カート再計算やチェックアウト時の割引競合と保存方法を説明します。
主な機能
- 01自動・クーポンコード型の設定
- 02ルール、アクション、計算機の定義
- 03割引の競合と配分の処理
- 04一括クーポンの管理と適用
- 05独自ルール、アクション、計算機の登録
活用シーン
- 01カート金額や商品を条件にした販促
- 02一度だけ使えるクーポンの一括作成
- 03独自の割引ルールや計算機の開発
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ spree/ agent-skills --skill spree-promotionsインストール後、Claude Code で/spree-promotionsを入力して読み込まれたか確認します。
詳細
Spree Promotions
プロモーション(promo_)はキャンペーンです。ルールによって適用されるかどうかが決まり、アクションによって何をするかが決まります。カート/注文に反映されるのは割引(Spree::Discount、disc_)です。これは、プロモーションが編集または削除された後も残る金額の行です。
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
プロモーションの適用方法
カートが変更されるたびに Spree::Carts::RecalculateTotals が実行され、Spree::Adjusters::Promotion が呼び出されます。
- 候補 — カートに関連付けられたプロモーション、カートに保存された
coupon_codeのプロモーション、および有効化される自動プロモーションが対象です。それぞれについて、有効期間、使用上限、そしてmatch_policyに応じたルールが確認されます。 - 競合、勝者のみ — 競合グループごとに、最もマイナスの金額が勝ちます(同額の場合は、新しいアクションが勝ちます)。
:line_itemアクションは明細項目ごとに競合します(ルールを通過したactionable?の項目のみ)。:fulfillmentアクションはフルフィルメントごとに競合します。:orderアクションは注文全体で競合し、勝者の割引は按分方式(最大剰余法)で明細項目に配分されます。注文に関連付けられた割引行はありません。
- 永続化 — 勝者の行が保存され、明細項目またはフルフィルメントの金額がゼロを下回らないように調整されます。古くなったプロモーション行は削除されます。敗者は保存されず、次回も再び競合します。その後、割引後の金額に基づいて税額が見積もられます。
- 注文確定 —
Spree::Orderが作成されると、その割引行は固定され、使用実績がプロモーションに記録されます。
6.0 では併用できません。同じ明細項目に複数のプロモーションを適用しても、割引は合算されません。
プロモーションの作成
// "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(コードなしで、ルールに合致すると適用)またはcoupon_codeです。codeを設定すると、自動プロモーションはcoupon_codeに切り替わります。コードは小文字に変換して保存・照合されます。- 複数コード(バッチ)プロモーション:
multi_codes: true, number_of_codes: 1000, code_prefix: 'SUMMER'を指定すると、Spree::CouponCodeの行が生成されます(大規模なバッチはバックグラウンドジョブで処理されます)。各コードは1回限り使用可能で(unused→used)、usage_limitは適用されません。コードが使用済みになるのは、そのコードを使って注文が確定したときだけです。カートに適用しただけでは仮押さえされるにとどまります(coupon_code.holder)。GET /promotions/:id/coupon_codesで一覧を取得できます。 - API で使用する
typeの値はapi_typeの省略形(item_total、create_item_adjustments、flat_rate)です。Ruby のクラス名は使用しないでください。 - プロモーションは単一のストアに属します(
current_store.promotions)。リクエスト外ではSpree::Current.storeを設定してください。
組み込みルール
currency、country、channel、market、item_total、product(any/all/none)、category(子の数を数えます。Spree::Promotion::Rules::Taxon は非推奨の別名です)— これらの product_ids=/category_ids= は、プロモーションのストア内でプレフィックス付きまたはプレフィックスなしの ID を解決します。そのため、別のストアのレコードを指定すると RecordNotFound が発生し、バリデーションに失敗します。レコードを関連付けるカスタムルールでは、ids_within_store(ids, promotion.store.products) を再利用できます — option_value、user、customer_group、first_order、user_logged_in、one_use_per_user。
組み込みアクション
アクション(api_type) | discount_scope | デフォルトの計算機 | 備考 |
|---|---|---|---|
create_adjustment — 注文割引 | :order | FlatPercentItemTotal | FlatRate、FlexiRate、TieredPercent、TieredFlatRateも使用できます。各明細に按分されます。 |
create_item_adjustments — 商品割引 | :line_item | PercentOnLineItem | FlatRate、FlexiRateも使用できます。適用可能な商品にのみ適用されます。 |
free_shipping | :fulfillment | — | 行はゼロのまま保持されます。order.has_free_shipping?で行の存在を確認します。 |
create_line_items — 無料ギフト | :line_item | — | カート内の商品数をギフト数量まで補充し(在庫確認あり)、ギフト対象ユニット分の行レベル割引も記録します(ギフト対象分のみ。同じバリアントの追加分は有料です)。「ギフト代を支払うため」に注文割引と組み合わせないでください。二重に割引されます。適用条件によってプロモーションが適用されるかどうかが決まり、どの明細に割引を適用するかは決まりません。また、ギフト自体で条件を満たすことはできません。ギフトとして提供されたユニットは、プロモーションの計算対象に含まれません:ItemTotalのしきい値(このプロモーション自体のしきい値も含みます。支払い対象の商品がしきい値を下回るとギフトは取り消されます)と、注文レベルの割合割引の基準額(Purchase::Totals#amountはgift_amountを差し引きます)が対象です。購入者が代金を支払う同一商品のコピーは、他の商品と同様にほかのプロモーションの対象になります。購入者がすでにそのバリアントをカートに入れている場合は、重複分を追加するのではなく、その商品が無料になります。プロモーションが適用されなくなると、カートの再計算で変更が元に戻り、ギフト分が取り除かれます(実際にカートに追加されたプロモーションに限ります。購入者が自分で購入したそのバリアントの商品が削除されることはありません)。 |
計算機には通貨が設定されています。 FlatRate(およびほかの金額ベースの計算機)は、preferred_currencyがカートの通貨と一致しない場合、0を返します。通貨ごとにプロモーションまたは計算機を設定してください。そうしないと、一部の顧客にはプロモーションが何も適用されません。
チェックアウト時のクーポンコード
const cart = await client.carts.discountCodes.apply(cart.id, 'SUMMER20', { spreeToken: cart.token })
await client.carts.discountCodes.remove(cart.id, 'SUMMER20', { spreeToken: cart.token })
コードはcart.coupon_codeに保存され(order.coupon_codeにもコピーされます)。カートがまだ適用条件を満たしていない場合(例:最低金額未満の場合)、コードはcoupon_code_not_eligible警告とともにカートに保持され、初めて条件を満たしたときの再計算で有効になります。ギフトカードには専用のエンドポイント(carts.giftCards)を使用します。詳しくはspree-paymentsを参照してください。
バッチ(1回限り使用可能)コードは、カートが適用条件を満たす前でも、カートに保存された時点で確保されます。割引が適用されるのは確保した購入者のみです(コードを確保していない購入ではPromotion#eligible?が失敗し、これはSpree::CouponCode.held_byによって判定されます)。コードを置き換えるか削除すると、コードは解放されます。放棄されたカートの場合、別のオープン状態のカートが確保しているコードは、次にそのコードを提示した人が引き継ぎます(以前のカートでは割引が適用されなくなります)。最初に注文が確定した購入者がコードを保持します。確定処理中(completion_claimed?)のカート、完了済みのカート、下書き注文では、確保したコードは解放されません。この場合、新しい購入者にはcoupon_code_usedが返されます。注文確定によってコードが使用済みになった場合や、コードの適用処理中の場合も同様です。コードを失ったカート(cart.coupon_code_unavailable?)では、カートを読み取るか変更する次のStore APIリクエスト時にコードが削除され、coupon_code_unavailable警告が返されます。carts.completeは、購入者に表示した金額を上回る請求を防ぐため、そのようなカートをcoupon_code_unavailableで拒否します。サーバー側のハンドラーはSpree.coupon_handler(Spree::PromotionHandler::Coupon)です。
手動割引
スタッフは、適用元となるプロモーションがない割引を注文確定後の注文に追加できます(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' })
編集または削除できるのは手動の行のみです。プロモーションの行では422が返されます。
カスタムルール
# 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
- プロモーション対象に対して
applicable?ではないルールは失敗ではなくスキップされます。Spree::Orderのみに対応するルールはカートでは無視されるため、条件を満たさなくてもプロモーションが適用されます(そのルールしかない場合は、すべての対象に適用されます)。 - 購入者は
promotable.customer、合計はitem_totalから取得してください。無料ギフトを除いた購入金額のしきい値を適用する場合は、ItemTotalと同様にpromotable.gift_amount(promotion: promotion)を差し引きます。明細はline_itemsから取得してください。これらは、カートと注文で共通のSpree::Purchase::*インターフェースです。 - 設定項目(
:string、:integer、:decimal、:boolean、:array)から、ダッシュボードのフォームが自動生成されます。関連付けに基づく設定(例:brand_ids)では、Admin API で許可されるよう、サブクラスにself.additional_permitted_attributes = [brand_ids: []]も必要です。
カスタムアクション
割引アクションでは、割引の対象と金額を宣言します。競合処理、上限調整、書き込み、クリーンアップは調整役が行います。
# 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
ロイヤルティポイントや通知など、割引以外のアクションでは discount_scope を nil のままにし、perform(options)(options[:order]、options[:promotion])で処理を実行します。適用された場合は true を返し、必要に応じて revert(options) を実装します。副作用は冪等にしてください。perform は有効化のたびに繰り返し呼び出されることがあります。
カスタム計算機
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
計算機は正の金額を返し、アクション側で符号を反転します。
登録(すべてを一か所にまとめる)
Core は独自の after_initialize 内でこれらのレジストリを再設定します。イニシャライザーのトップレベルや to_prepare で追加すると、消去されます。必ず次のようにしてください。
# 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
次にロケールキーを追加します。ダッシュボードのプロモーションエディターは /types エンドポイントから選択肢と設定フォームを生成するため、UI コードは不要です。
# 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
キーはクラスの api_type(クラス名の末尾部分をスネークケースにしたもの)です。クラス名を変更しても API 上の名前を維持するには、def self.api_type = 'min_qty' をオーバーライドしてください。
カスタム調整役(プロモーション以外の請求/割引)
ロイヤルティ価格、ギフト包装料、決済手数料にはルールやコードがないため、調整役として実装します。Spree::Adjusters::Base のサブクラスを作成し、order(チェックアウト中はカート)の独自の行を書き込み/削除する冪等な update を実装して、after_initialize 内で Spree.adjusters << MyAdjuster により登録してください。kind: 'promotion' の行は作成しないでください(プロモーション調整役は、自身が作成していない行を削除します)。Spree::Discount で許可される kind は promotion/manual のみで、商品明細または配送に関連付ける必要があります。手数料と合計については spree-order-totals を参照してください。
テスト
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
ファクトリー::promotion、:promotion_with_item_adjustment、:promotion_with_order_adjustment、:promotion_with_item_total_rule、:free_shipping_promotion、:cart_with_line_items。適用条件は注文だけでなく、カートを使ってテストしてください。
「プロモーションが適用されない」
- 有効ですか?
starts_at/expires_at、promotion.usage_limit_exceeded?(cart)を確認してください。クーポンプロモーションの場合、cart.coupon_codeは一致していますか(コードは小文字に変換されます)。 - ルール:まず
promotion.eligible?(cart)、次にpromotion.eligibility_errorsを確認してください。カスタムルールのapplicable?はSpree::Cartに対応していますか? - 通貨:金額計算機は、別の通貨では 0 を返します。
- 競合に負けていませんか? 同じ商品明細/配送/注文のグループに対して、別のプロモーションの割引額が大きい場合は、そのプロモーションだけが適用されます。
- 商品アクション:ルールの
actionable?により、対象の明細が除外されていませんか(例:category/productルールが該当商品と一致しない)。 - 注文はすでに確定していますか? 確定済みの注文では、プロモーションは再実行されません。
- カスタムタイプがダッシュボードに表示されませんか?
after_initialize内で登録されていないか、サーバーが再起動されていない可能性があります。
さらに詳しく読むには
node_modules/@spree/docs/dist/developer/core-concepts/promotions.md、.../discounts.md、.../calculators.mdnode_modules/@spree/docs/dist/developer/how-to/custom-promotion.md- ソース:
spree_coreのSpree::Adjusters::Promotion、Spree::PromotionRule、Spree::PromotionAction、Spree::Promotion::Rules::*、Spree::Promotion::Actions::* - 関連スキル:
spree-order-totals、spree-checkout、spree-pricing(価格リストとプロモーション)、spree-testing