spree-checkout について
Spree 6のチェックアウトを扱う開発者向けスキルです。カート要件の設定、拡張方法、カートを注文に変換するワークフローと、その実装上の保護策を説明します。
主な機能
- 01データ駆動型のカート要件
- 02参考情報としてのチェックアウト手順
- 03要件と手順のカスタマイズ
- 04完了フックと障害復旧
- 05カートから注文へのライフサイクル
活用シーン
- 01顧客入力項目をチェックアウト要件に追加
- 02ストアのチェックアウト手順を変更
- 03注文完了時の再試行や失敗に対応
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ spree/ agent-skills --skill spree-checkoutインストール後、Claude Code で/spree-checkoutを入力して読み込まれたか確認します。
詳細
Spree Checkout
Spree 6 のチェックアウトはステートマシンではなく、データ駆動型です。Spree::Cart は、アイテム、住所、配送、支払いを任意の順序で収集します。カートは、まだ必要なもの(requirements)を報告します。そして、唯一のハードゲートである Spree::Carts::Complete ワークフローが、カートを変更不可の Spree::Order に変換します。
5.x(
order.next!、checkout_flow、state)から移行する場合、これらの API はなくなりました。spree-upgrade-5-to-6を参照してください。
カートと注文
Spree::Cart(cart_…) | Spree::Order(or_…) | |
|---|---|---|
| フェーズ | ショッピング+チェックアウト | 購入済み(財務記録) |
| ライフサイクルマーカー | completed_at のみ — ステータスカラムなし | status(draft/placed/canceled)+payment_status / fulfillment_status |
| 変更可能? | 完了するまでは可能。完了後は読み取り専用(readonly? がすべての書き込みをブロック) | アイテム/価格は固定されます。管理者による編集には明示的なサービスを使用します |
| チェックアウトの情報取得 | requirements、checkout_steps、current_checkout_step、completed_checkout_steps | なし — すでに購入済みです |
| 金額 | 再計算のたびに行が再生成されます | 行は固定され、合計のみ再集計されます |
両方のフェーズを通じて存続するレコード(LineItem、Fulfillment、Payment、PaymentSession、StockReservation、TaxLine、Discount、Fee)は、2 つの NULL 許容外部キー(cart_id+order_id。設定されるのはどちらか一方のみ)を持ちます。親レコードは常に #owner を通じて取得してください。
line_item.owner # => Spree::Cart during checkout, Spree::Order after
line_item.order # nil during checkout — don't assume it's present
住所、税金、ストアクレジット、ギフトカード、支払い処理、通貨/マーケットといった共通の振る舞いは、両方のモデルに含まれる Spree::Purchase::* の concern にあります。Spree::Order だけではなく、concern をデコレートするか、フックを使用してください。
order.cart は元のカートを参照します。spree_orders.cart_id は一意であり、完了処理の冪等性キーです。
Requirements:チェックアウトで適用される唯一の条件
カートを読み取るたびに Spree::Checkout::Requirements.new(cart).call が計算され、{ step:, field:, code:, message: } の配列が返されます。code に基づいて分岐してください。message は翻訳された文言で、step はグループ化の目安です。
組み込みの助言的要件(カートを読み取るたびに報告されます):
code | step | 発生条件 |
|---|---|---|
line_items_required | cart | カートが空 |
email_required | address | メールアドレスがない |
ship_address_required | address | 配送対象があり、住所がない |
delivery_method_required | delivery | フルフィルメントに配送方法が選択されていない |
payment_required | payment | 有効な支払いが amount_due_at_checkout をカバーしていない |
po_number_required | address | 購入者の会社が PO 番号を必須としている(スタッフキーで作成されたカートは対象外) |
order_minimum_not_met | cart | B2B の注文最低額を下回っている |
完了時のみのチェック(call(completion: true)。すべてのラインアイテムを読み込むため、Carts::Complete 内で実行されます):out_of_stock、discontinued、quantity_rule_violated(いずれも step: 'cart')、および guest_checkout_not_allowed。
requirements 配列が空でも、完了処理の成功が保証されるわけではありません。 完了処理の失敗も通常のフローとして扱ってください。
Steps は助言的なものです
cart.checkout_steps # => ["address", "delivery", "payment", "complete"]
cart.current_checkout_step # => step of the first unmet requirement ("cart" reports as "address")
cart.completed_checkout_steps # => steps before the current one
Store API はこれらを current_step と completed_steps として公開します。組み込みステップは Spree::Checkout::Registry.base_steps から取得されます。
| Step | 表示される条件 |
|---|---|
address | 常に |
delivery | cart.delivery_step_required?(アイテムがあり、すべてがデジタル商品ではない) |
payment | cart.payment_required?(合計が 0 より大きい) |
confirm | cart.confirmation_required?(支払い方法またはストア設定で確認が求められる) |
complete | 常に |
ステップを理由にサーバーが書き込みを拒否することはありません。クライアントはメールアドレス、住所、配送、支払いを任意の順序で設定できます。無料のデジタル注文では address → complete が報告されます。ストアフロントで 5 つのステップをハードコードしないでください。
カスタマイズ:Spree::Checkout::Registry
server/config/initializers/spree.rbで設定します。1つの宣言が、requirements配列と完了ゲートの両方に反映されます。
登録タイミング:Registryはlib/から読み込まれる、クラスレベルの通常の状態です。コアは起動時にリセットしません(テスト用のRegistry.reset!のみ)。そのため、イニシャライザーのトップレベルで呼び出しても、Rails.application.config.after_initializeブロック内で呼び出しても機能します。config.to_prepareは使用しないでください。開発環境でのコード再読み込みのたびに再実行され、add_requirementとregister_stepは重複を排除せずに追加するため、再読み込みのたびに要件が重複します。
既存のステップに要件を追加する
# Require accepting terms before placing the order (stored in cart metadata,
# written by the storefront via PATCH /carts/:id { metadata: { terms_accepted: true } })
Spree::Checkout::Registry.add_requirement(
step: :payment,
field: :terms_accepted, # code becomes "terms_accepted_required"
message: 'You must accept the terms of sale',
satisfied: ->(cart) { ActiveModel::Type::Boolean.new.cast(cart.metadata['terms_accepted']) },
applicable: ->(cart) { cart.market&.name == 'Europe' } # optional, checked before satisfied:
)
- 追加した要件の
codeは、常に"#{field}_required"として導出されます。 messageは起動時にそのまま保存されます。ここでSpree.tを呼び出すと、起動時のロケールで一度だけ解決されます。リクエストごとに翻訳するには、register_stepを使用してください(そのrequirements:ラムダはカートごとに実行されます)。Spree::Checkout::Registry.remove_requirement(step:, field:)を使うと、自分(または拡張機能)が登録した要件を1つ削除できます。
PO番号は組み込み済みです。会社にpo_number_requiredを設定すると、カートはpo_number_requiredを報告します(spree-b2bを参照)。再登録しないでください。
ステップ全体を追加する
Spree::Checkout::Registry.register_step(
name: :loyalty,
before: :payment, # or after:; before: wins if both
applicable: ->(cart) { cart.customer.present? },
satisfied: ->(cart) { cart.metadata['loyalty_number'].present? },
requirements: ->(cart) {
[{ step: 'loyalty', field: 'loyalty_number', code: 'loyalty_number_missing',
message: Spree.t('loyalty.number_required', default: 'Enter your loyalty number') }]
}
)
アンカーのないステップや、このカートに存在しないアンカー(無料カートでのbefore: :paymentなど)は、completeの直前に配置されます。Registry.remove_step(:loyalty)で取り消せます。
組み込みステップの順序を変更または削除する
Spree::Checkout::Registry.base_steps.delete('confirm')
Spree::Checkout::Registry.base_steps['payment'] = ->(cart) { cart.total > 0 }
Spree::Checkout::Registry.base_step_names = %w[address payment delivery complete]
ステップを削除すると、ラベルのみが削除されます。そのステップに登録された要件は引き続き完了を妨げます。paymentを削除しても、未払いのカートを完了できるようにはなりません。
要件のチェック対象となるデータの保存
Spree::Cartにはmetadata JSONカラムがあり、Store APIではPATCH /api/v3/store/carts/:idで受け付けます(置き換えではなくマージされます)。カートにはSpree::HasCustomFieldsが含まれていません。完了時に、カートのmetadataは注文にディープコピーされます(販売者ごとにチェックアウトが分割される場合は、すべての子注文とOrderGroupにもコピーされます)。そのため、注文の配置後もorder.metadata['vat_number']は保持されます。その後、カートと注文は独立し、一方を編集してももう一方は変わりません。**これらのキーは顧客からの入力として扱ってください。**購入者はStore APIを通じてカートのメタデータを書き込めるため、後でコードが信頼する情報(不正・承認フラグ、価格、権利など)は決してそこに保存しないでください。そのような情報には、スタッフ専用フィールド、カスタムフィールド、または独自のカラムを使用してください。
完了:Spree::Carts::Complete
result = Spree.carts_complete_workflow.call(cart: cart) # optional expected_total:, payment_pending:
if result.success?
order = result.value # Spree::Order — or Spree::OrderGroup when a multi-seller cart split
else
result.error.to_s # why: requirements, payment failure, hook rejection, cart_changed...
end
明示的なトランザクション境界を持つ、3つのフェーズがあります。
- 準備:リプレイチェックと契約価格の確認を最初にロックの外で実行します。次に、
cart.with_lock内で、同時完了ガード → ロック内での再計算(請求額はこの時点で計算し、以前のリクエストの値を決して信頼しません) → 任意のexpected_total差分ガード(cart_changed) →Requirements#call(completion: true)→carts.complete.validateフック →completing_atの記録 → 明細、フルフィルメントと選択済みレート、TaxLine/Discount/Feeの各行、プロモーション、住所スナップショット、税識別番号、PO書類をコピーしたdraft注文の作成、支払い、支払いセッション、予約、クーポンコードの付け替え、の順に処理します。 - 支払い(
external_step、トランザクションの外):まだ決済されていない場合は支払いを処理します。 - 確定:
carts.complete.before_finalizeフック →Spree.order_complete_workflow(在庫、draft → placed、ステータス、order.placedイベント) → カートのcompleted_at→ クーポンコードを使用済みに設定 →tax_provider.commit→carts.complete.after_finalizeフック、の順に処理します。
信頼できる保証
- 冪等性。 ダブルクリック、再試行、または顧客の戻りと競合する決済ウェブフックが発生しても、同じ注文が返されます — リプレイ処理は
cart.orderを返し、一意制約のあるspree_orders.cart_idインデックスが同時実行時の競合を検出します(RecordNotUniqueが再度発生してリプレイされます)。また、有効なcompleting_atのロック(TTL 5 分)がある場合はcompletion_in_progressが返されます(API:409)。 - クラッシュからの復旧。 注文のコミット後にプロセスが終了した場合、次の試行(または
Spree::Orders::FinalizeStaleDraftsJob)で、再度課金するのではなく Finalize が再実行されます。 - キャプチャ前の失敗はロールバックされます。 金額が一切キャプチャされる前に決済が失敗した場合、下書き注文が削除され、決済/セッション/予約/クーポンがカートに再関連付けされ、
completing_atがクリアされます — 顧客は修正して再試行できます。 - ゲストトークンは引き継がれます。 注文には
token: cart.tokenが設定されるため、ゲストは同じX-Spree-Tokenで注文を参照できます。 - 合計金額は最後の時点で再計算されます。そのため、確認中に価格やプロモーションが変更されても、誤った金額が請求されることはありません。
チェックアウト周辺のワークフローフック
イニシャライザーに登録します。ハンドラーは call(workflow) を持つクラス名文字列です(リロードに対応)。フックの一覧については spree-workflows を参照してください。
| キー | 種類 | 用途 |
|---|---|---|
carts.add_item.validate | 検証 | 購入制限、適格性 — ラインアイテムの作成前 |
carts.upsert_items.validate | 検証 | 数量の編集/削除/一括変更にも同じルールを適用します(両方のキーを登録してください) |
carts.add_item.after_item_added | ライフサイクル(トランザクション内) | 関連レコードをアトミックに書き込みます |
carts.complete.validate | 検証 | 直前の拒否(不正利用スコア、外部信用審査)— 金銭が動く前 |
carts.complete.before_finalize | ライフサイクル | 決済後に実行されます。データを workflow.order にコピーします |
carts.complete.after_finalize | ライフサイクル | 注文の確定後 |
carts.merge.validate / after_merge | 検証/ライフサイクル | マージポリシー |
module MyApp
class CreditCheck
def call(workflow)
cart = workflow.cart
return unless cart.company && MyApp::Credit.on_hold?(cart.company) # your own service
workflow.reject!('Your account is on credit hold. Contact your account manager.')
end
end
end
Spree.hooks.register('carts.complete.validate', 'MyApp::CreditCheck')
before_finalize または after_finalize で拒否しないでください。 カードへの請求はすでに行われており、拒否するとデータベースはロールバックされますが、請求は取り消されません。拒否する場合は validate で行ってください。また、顧客が修正できる内容には、validate フックではなく Registry の要件を優先してください。要件はカートを読み取るたびに表示されますが、フックが失敗するのは最後の時点だけです。
副作用(メール、ERP への送信)はフックではなく、order.placed イベントのサブスクライバーで処理してください(spree-events-webhooks)。
住所
Spree::Purchase::Addresses(Cart と Order で共有):
- 重複排除:
ship_address_attributes=/bill_address_attributes=は、重複を作成する代わりに、顧客の住所録にある同一のレコードを再利用します。ゲストが編集できるのは、現在そのスロットに設定されているレコードだけです。 - デフォルトに設定: サインイン済みの顧客のチェックアウト住所は、デフォルトの配送先/請求先住所になります(ウォレット/クイックチェックアウトの住所は除外)。
- 所有権の確認:
ship_address_id=/bill_address_id=で受け付けるのは、顧客本人(またはその会社)の住所録にある住所だけです。それ以外は暗黙的にnilとなり、ゲストが ID を指定して住所を選択することはできません。 use_shipping: trueは配送先住所を請求先住所にコピーします(配送先が正となります)。use_billingは非推奨です。- 住所フィールド:
first_name、last_name、address1、city、postal_code、country_code、state_code。 - サインイン済みの顧客は、空欄のスロットに保存済みのデフォルト住所が自動入力されます。
住所を変更すると、アイテムの価格が再計算され、配送候補が再構築され、税額が再見積もりされます(cart.recalculate_for_address_change!)。
決済セッション
セッションはカートにスコープが限定されます。POST /api/v3/store/carts/:cart_id/payment_sessions は最初にカートを検索するため、別のカートのセッション ID を指定すると 404 が返ります。作成と更新はカートの行ロック内で実行されます。complete はゲートウェイ呼び出しの間、意図的にロックを保持しません。PaymentSession#settle_payment! がローカルで処理を直列化するため、ゲートウェイへの確認とウェブフックが競合しても、キャプチャは一度だけ記録されます。完了済みのカートを解決するのは complete のみです(ウェブフックが先にチェックアウトを完了している場合があります)。完了済みカートでセッションを作成すると 404 が返ります。spree-payments を参照してください。
Store API / SDK のチェックアウト手順
import { createClient } from '@spree/sdk'
const client = createClient({ baseUrl, publishableKey: 'pk_…' })
let cart = await client.carts.create()
const opts = { spreeToken: cart.token } // guests: persist cart.id + cart.token
await client.carts.items.create(cart.id, { variant_id: 'variant_…', quantity: 1 }, opts)
cart = await client.carts.update(cart.id, {
email: '[email protected]',
shipping_address: { first_name: 'Jane', last_name: 'Doe', address1: '1 Main St',
city: 'Austin', postal_code: '78701', country_code: 'US', state_code: 'TX' },
use_shipping: true,
}, opts)
for (const f of cart.fulfillments) {
await client.carts.fulfillments.update(cart.id, f.id,
{ selected_delivery_rate_id: f.delivery_rates[0].id }, opts)
}
const session = await client.carts.paymentSessions.create(cart.id, { payment_method_id: 'pm_…' }, opts)
// confirm with the provider's SDK (Stripe.js etc.), then:
await client.carts.paymentSessions.complete(cart.id, session.id, {}, opts)
cart = await client.carts.get(cart.id, opts)
if (cart.requirements.length) { /* route the user by requirements[0].step / code */ }
const result = await client.carts.complete(cart.id, opts) // Order | OrderGroup — narrow with isOrderGroup from @spree/sdk
書き込みリクエストはすべて、新しい合計額と requirements を含むカート全体を返します。「再計算」の呼び出しを別途行う必要はありません。割引コード、ギフトカード、ストアクレジット(carts.discountCodes、carts.giftCards、carts.storeCredits)は完了前であればいつでも適用できます。ストアクレジットは専用エンドポイントから適用し、cart.payment_methods のいずれにもなりません。カートにはギフトカードかストアクレジットのどちらか一方を適用できます。配送、割引、税額が変わると、ギフトカードの負担額も合計額に合わせて変わります。
ログイン:関連付けと統合
client.carts.associate(cartId, { token: jwt, spreeToken: cart.token })(PATCH /carts/:id/associate)は、ゲストカートをログイン済みの顧客に紐付けます。JWT とカートトークンの両方が必要で、ゲストカート(または呼び出し元自身のカート)のみを紐付けます。また、顧客のデフォルト値を使ってメールアドレスと空の住所を補完します。- API は、顧客が持つほかのオープン中のカートと自動的に統合しません(
customer.carts.where(store: current_store)で一覧表示されるのはSpree::Cartの行です)。2つのカートを統合するには、独自のエンドポイントまたはサブスクライバーからSpree.cart_merge_workflow.call(cart: survivor, other_cart: guest_cart, customer:)(またはsurvivor.merge!(guest_cart))を呼び出します。通貨が一致しない場合は失敗し、両方のカートが保持されます。方針(大きい方のカートを残す、数量に上限を設けるなど)はcarts.merge.validateフックで定義します。
完了後
- 完了済みカートは読み取り専用で、Store API のすべてのカートエンドポイントで 404 が返ります(カートのスコープは
incompleteです)。注文はclient.orders.get(order.id, {}, { spreeToken })で読み取ります。 POST /carts/:id/completeは例外です。再度 POST すると(カート ID、または返された注文 ID/グループ ID を使用)、既存の注文が返されます。- ストアフロントでは、
completeが成功したら保存済みのカート ID/トークンを破棄してください。
トラブルシューティング:「このカートを完了できないのはなぜですか?」
cart.requirementsを確認します(spree console:Spree::Checkout::Requirements.new(cart).call(completion: true))。完了時のみ適用される項目では、在庫切れ/販売終了/数量ルール/ゲストポリシーに関するエラーも追加されます。completion_in_progress/409→ 別の試行がcompleting_atを保持しています(TTL は 5 分)。再試行してください。決済処理が進行中の可能性がある間は、手動で解除しないでください。- 顧客が支払い済みなのに
payment_requiredが表示される → ギフトカードまたはストアクレジットが削除されています。商品を変更すると、必ずSpree::Carts::Recalculateが実行され、ギフトカードとチェックアウト時のストアクレジットが適用解除されます。商品を編集した後に再適用してください。 coupon_code_unavailable→ カートが単回使用のバッチコードを保持できなくなっています(別のカートが引き継いだか、注文で使用されました)。次回カートを読み込むと、警告とともにコードが削除されます。購入者は合計額を再確認してください(spree-promotionsを参照)。- 配送料金がないのに
delivery_method_requiredが表示される → 住所に一致する配送ゾーンがありません。cart.warningsには、商品ごとにdelivery_unavailableが含まれます。 - カスタム要件が解除されない →
satisfied:ラムダがストアフロントでは書き込まれないデータ(例:カートのcustom_fields。metadataを使用してください)を読み取っています。 - フックによって拒否される → エラーメッセージは、
carts.complete.validateハンドラー内のworkflow.reject!/workflow.errorsに由来します。登録内容はSpree.hooks.keysで確認できます。
テスト
cart = create(:cart_ready_to_complete)
result = Spree.carts_complete_workflow.call(cart: cart)
expect(result).to be_success
expect(result.value).to be_placed
expect(Spree.carts_complete_workflow.call(cart: cart.reload).value).to eq(result.value) # replay
# Registry state is global — reset in an after hook
after { Spree::Checkout::Registry.reset! }
ファクトリー::cart、:cart_with_line_items、:cart_ready_for_delivery、:cart_ready_to_complete。spree-testing を参照してください。
よくある間違い
order.state、next!、checkout_flowを探すこと。これらは廃止されています。ステップは導出され、要件がゲートになります。- チェックアウトのフィールドについて
Spree::Cartにモデルバリデーションを追加すること。これにより、すべての部分的な書き込みがブロックされます。Registry の要件を使用してください。 before_finalizeで拒否すること。金銭はすでに移動しています。- チェックアウト中に実行されるコードで
line_item.orderを読み取ること。ownerを使用してください。 - ストアフロントでステップの一覧をハードコードすること。
completed_steps/current_stepからレンダリングし、requirements[].codeに基づいてルーティングしてください。 - 完了したカートに書き込むこと(
ActiveRecord::ReadOnlyRecordが発生します)。代わりに、管理者向けサービスを通じて注文を編集してください。
さらに詳しく読むには
node_modules/@spree/docs/dist/developer/core-concepts/carts.mdとorders.mdnode_modules/@spree/docs/dist/developer/customization/checkout.md— Registrynode_modules/@spree/docs/dist/developer/customization/workflows.md— フックnode_modules/@spree/docs/dist/developer/sdk/store/cart-checkout.md- 関連スキル:
spree-order-totals、spree-taxes、spree-payments、spree-promotions、spree-fulfillment、spree-workflows、spree-b2b - ソース:
spree/core/app/workflows/spree/carts/complete.rb、spree/core/lib/spree/checkout/