spree-payments について
Spree Paymentsは、Spree Commerceの決済ワークフローを実装する機能で、ECの決済を構築・運用する開発者や事業者向けです。決済方法、セッション、売上確定、返金、ストアクレジットを扱い、ワークフックで処理を拡張できます。
主な機能
- 01直接決済とセッション型決済に対応
- 02チェックアウト時・発送時・手動の売上確定
- 03ゲートウェイWebhookと冪等な決済確定
- 04返金、ストアクレジット、ギフトカードの管理
- 05ワークフックによる決済処理の拡張
活用シーン
- 01店舗の決済方法と売上確定時期の設定
- 02カートのチェックアウトでゲートウェイ決済を処理
- 03ゲートウェイイベントに基づく決済確定
- 04注文の返金とストアドバリュー決済の管理
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ spree/ agent-skills --skill spree-paymentsインストール後、Claude Code で/spree-paymentsを入力して読み込まれたか確認します。
詳細
Spree Payments
PaymentMethod (pm_, belongs_to :store) — a configured way to pay (STI: SpreeStripe::Gateway, Spree::PaymentMethod::Check, …)
├── PaymentSession (ps_) — gateway-side session (e.g. Stripe PaymentIntent); owned by a cart or order
├── PaymentSetupSession (pss_) — save a payment method for later (e.g. SetupIntent); belongs to a customer
└── GatewayCustomer — provider customer id per customer (internal, encrypted at rest)
Payment (py_) — one attempt against a cart/order (cart_id + order_id; #owner)
├── source: CreditCard (card_) | PaymentSource (wallets, BNPL, bank) | StoreCredit (credit_)
└── Refund (re_) × n — with a RefundReason (rr_)
支払いは payment.response_code == payment_session.external_id によってセッションに紐付けられます。カートには複数の支払いを含めることができます(例:ストアクレジット+カード)。
支払いステータス(ステートマシンなし)
status は単なる文字列(Spree::HasStatus)で、支払いワークフローと Spree::Payment::Processing のみが書き込みます。
| ステータス | 意味 |
|---|---|
checkout | 作成済み、未処理(デフォルト) |
processing | ゲートウェイ呼び出しのために確保済み(アトミックな確保により二重送信を防止) |
pending | オーソリ済み、未キャプチャ |
completed | キャプチャ済み — 支払い済みとして計上されるのはこれだけです |
failed | ゲートウェイに拒否されました(同じ行で再試行可能) |
void | 無効化済み |
invalid | 新しいチェックアウト支払いに置き換えられました(has_invalid_status? — invalid? は ActiveModel のものです) |
注文の payment_status(none、authorized、partially_paid、paid、partially_refunded、refunded、overcharged、voided)は、支払いと返金から Spree::Orders::UpdateStatuses によって導出されます(支払い/返金/フルフィルメントのイベント時、および注文確定後の金額編集(手数料、手動割引、商品)のたびに再計算されます)。直接設定しないでください。「返金済み」には両方の台帳が含まれます。Spree::Refund の行と返金として発行されたストアクレジット(order.store_credit_refunds、StoreCredit#refunded_order_id)の両方です。そのため、ストアクレジットで返金された注文は paid ではなく、partially_refunded/refunded と表示されます。Spree::Order.refunded/.partially_refunded スコープはこのステータスで絞り込みます。
キャプチャのタイミング
capture_method はストアの設定(preferred_capture_method、デフォルトは checkout)で、支払い方法による上書きも可能です(capture_method、nil = 継承)。
| 値 | 動作 |
|---|---|
checkout | 注文確定時に課金 → completed |
on_dispatch | チェックアウト時にオーソリ(pending)、商品発送時にキャプチャ |
manual | チェックアウト時にオーソリ(pending)。スタッフが任意のタイミングでキャプチャ |
await admin.store.update({ preferred_capture_method: 'on_dispatch' })
await admin.paymentMethods.update('pm_xxx', { capture_method: 'manual' }) // null → inherit store
await admin.orders.payments.capture('or_xxx', 'py_xxx')
await admin.orders.payments.void('or_xxx', 'py_xxx')
Ruby では、payment_method.resolved_capture_method、Spree.payment_capture_workflow.call(payment:, amount: nil) を使います — amount の単位はセントです。金額を小さくすると一部がキャプチャされ、残額は新しい保留中の支払いに分割されます。Spree.payment_void_workflow.call(payment:)。
支払い方法
| 属性 | 備考 |
|---|---|
type | API では短縮名(stripe、check、store_credit、…)を送信します。カラムにはクラスが格納されます |
store | 所有者は1つです — current_store.payment_methods |
active | 無効な支払い方法はどこにも表示されません |
storefront_visible | false = スタッフ専用(例:管理画面で作成した注文の「請求書」)。display_on に代わる属性です。Store API の支払いおよび支払いセッションのエンドポイントは active.storefront_visible の支払い方法のみ受け付けます — スタッフ専用の支払い方法の ID は、抜け道として使えるのではなく、そこで 404 になります |
capture_method | 上記を参照してください |
preferences | ゲートウェイの認証情報 — 暗号化されていないプレーンテキストのカラム内の YAML です。DB ダンプは秘密情報として扱ってください |
サブクラスでオーバーライドするフック:session_required?、setup_session_supported?、source_required?、available_for_order?(order)、available_for_store?(store)、payment_icon_name。Store API はカートのレスポンスに payment_methods(session_required を含む)を埋め込みます。
ゲートウェイのインストール
spree bundle add spree_stripe && spree generate spree_stripe:install
spree bundle add spree_adyen --github spree/spree_adyen && spree generate spree_adyen:install
spree bundle add spree_paypal_checkout && spree generate spree_paypal_checkout:install
spree bundle add spree_razorpay_checkout && spree generate spree_razorpay_checkout:install
spree restart
標準的な Rails アプリでは、bundle add … && bin/rails g …:install を実行します。その後、ダッシュボード(Settings → Payments)で支払い方法を作成し、プロバイダーに Webhook URL(payment_method.webhook_url → /api/v3/webhooks/payments/pm_…)を登録します。spree_stripe は参考実装です(セッション、セットアップセッション、Webhook、ウォレット、Stripe Connect の支払い)。
2つのフロー
セッションベース(Stripe、Adyen、PayPal…)— session_required? == true
const opts = { spreeToken: cart.token }
// 1. After delivery is chosen (amount must include shipping); recreate/update if the total changes
const session = await client.carts.paymentSessions.create(cart.id, { payment_method_id: 'pm_xxx' }, opts)
// 2. Customer pays in the provider SDK with session.external_data.client_secret (3DS/redirects handled there)
// 3. Confirm — creates/settles the Payment, does NOT place the order
await client.carts.paymentSessions.complete(cart.id, session.id, { session_result: '…' }, opts)
// 4. Place the order (Order | OrderGroup on a multi-seller marketplace cart — narrow with isOrderGroup)
const result = await client.carts.complete(cart.id, opts)
セッションのステータス:pending、processing、completed、failed、canceled、expired(キャンセル用エンドポイントはありません。キャンセルや期限切れはプロバイダー、または expires_at によって処理されます)。
Webhook を優先して完了します。 プロバイダーの Webhook(POST /api/v3/webhooks/payments/:payment_method_id)が確定的なシグナルです。Spree は接頭辞付き ID のみを使って決済方法を特定します(プロバイダーは API キーやストアヘッダーを送信しません)。Spree は Spree::Current.store に決済方法のストアを設定し、その決済方法固有のシークレットに対して署名を同期的に検証します(失敗時は 401)。次に Spree::Payments::HandleWebhookJob をエンキューして 200 を返し、その後、決済を確定して Carts::Complete を通じてカートを完了します。このジョブも決済方法のストアで実行されるため、デフォルト以外のストアの Webhook も、ホストやヘッダーによるルーティングなしで機能します。ブラウザーと Webhook のどちらが先に到着しても、先に到着したほうが処理され、もう一方は何も行いません。リプレイは 2 層で処理されます。鮮度の検証は parse_webhook_event 内のプロバイダーの署名方式が担います(Stripe の Stripe::Webhook.construct_event は 5 分より古いタイムスタンプを拒否します。カスタムゲートウェイでは、プロバイダーに相当する仕組みを実装する必要があります)。また、Spree::Payments::HandleWebhook は決済に対して冪等です(すでに完了した決済への再配信は、注文ロックのもとで何も行いません)。そのため、リプレイされたイベントによって二重に支払いや完了が発生することはありません。プロバイダーからのリダイレクトを、支払いの証明として扱ってはいけません。
直接決済(小切手、代金引換、銀行振込、発注書)— session_required? == false
await client.carts.payments.create(cart.id, {
payment_method_id: 'pm_check',
metadata: { purchase_order_number: 'PO-12345' }, // optional; amount defaults to total minus store credit
}, opts)
await client.carts.complete(cart.id, opts)
422 コード:payment_session_required(この方法ではセッションが必要です)、payment_method_unavailable(この方法の available_for_order?(cart) が false です。決済セッション作成時にも確認されます)。完了時、決済は pending になります(キャプチャーが checkout の場合は completed になります)。入金後、スタッフがキャプチャーします。
保存済み決済方法
client.customer.paymentSetupSessions.create({ payment_method_id }, { token: jwt }) → プロバイダー SDK を使って external_client_secret で確認 → .complete(id, { external_data: {} }) により CreditCard/PaymentSource を作成します。サインイン済みの顧客が必要です。
カスタム決済方法の構築
# app/models/my_gateway.rb
class MyGateway < Spree::PaymentMethod
preference :api_key, :string
preference :webhook_secret, :string
def session_required? = true
def source_required? = false
def payment_session_class = Spree::PaymentSessions::MyGateway # STI < Spree::PaymentSession
# `order:` receives the Cart during checkout
def create_payment_session(order:, amount: nil, external_data: {})
total = amount.presence || order.total_minus_store_credits
remote = client.create_session(amount_cents: Spree::Money.new(total, currency: order.currency).cents,
currency: order.currency, reference: order.prefixed_id)
payment_session_class.create!(owner: order, payment_method: self, amount: total, currency: order.currency,
status: 'pending', external_id: remote.id, customer: order.customer,
external_data: { client_secret: remote.client_secret })
end
def update_payment_session(payment_session:, amount: nil, external_data: {})
client.update_session(payment_session.external_id, amount: amount) if amount.present?
payment_session.update!(amount: amount) if amount.present?
payment_session
end
# Verify with the provider, settle the Payment, move the session. Must NOT complete the order.
def complete_payment_session(payment_session:, params: {})
remote = client.retrieve_session(payment_session.external_id)
case remote.status
when 'succeeded', 'authorized'
payment_session.process if payment_session.can_process?
payment_session.settle_payment!(captured: remote.status == 'succeeded') # false → payment stays pending
payment_session.complete unless payment_session.completed?
else
payment_session.fail if payment_session.can_fail?
end
payment_session
end
# Return { action: :captured|:authorized|:failed|:canceled, payment_session:, metadata: {} } or nil to ignore
def parse_webhook_event(raw_body, headers)
raise Spree::PaymentMethod::WebhookSignatureError unless client.valid_signature?(raw_body, headers['HTTP_X_SIGNATURE'], preferred_webhook_secret)
event = JSON.parse(raw_body)
session = payment_sessions.find_by(external_id: event.dig('data', 'session_id'))
return unless session
{ 'payment.succeeded' => { action: :captured, payment_session: session },
'payment.failed' => { action: :failed, payment_session: session } }[event['type']]
end
def payment_icon_name = 'my-gateway'
private
def client = MyProvider::Client.new(preferred_api_key)
end
# config/initializers/spree.rb — core reassigns this registry in its own after_initialize,
# so register inside after_initialize or the entry is wiped
Rails.application.config.after_initialize do
Spree.payment_methods << MyGateway
end
settle_payment!(captured:)は確認処理と Webhook で共有される唯一の決済確定経路です。所有者をロックし、決済を検索または作成して確認します。競合しても安全です。- セッションの遷移(
process、complete、fail、cancel、expire)は例外を発生させない比較交換方式の書き込みです。競合に負けた場合はfalseを返します。ガードにはcan_process?、can_complete?、can_fail?などがあります。 - セットアップセッションの場合:
setup_session_supported?、payment_setup_session_class、create_payment_setup_session(customer:, external_data:)、complete_payment_setup_session(setup_session:, params:)。 - レガシーなカードゲートウェイは
Spree::Gatewayをサブクラス化します(プロバイダー上で ActiveMerchant 形式のauthorize/purchase/capture/void/creditを使用します)。
ワークフローフックを使うと、サブクラス化せずに動作を拡張できます:payments.process.validate、payments.capture.before_capture、payments.void.after_void、payment_sessions.complete.after_complete、payments.handle_webhook.after_handle(Spree.hooks.register('payments.capture.validate', ->(wf) { wf.reject!('…') if … }))。spree-workflows を参照してください。
返金
返金は Admin 専用です(Store API のルートはありません)。Spree::Refunds::Create(Spree.refund_create_workflow)を通じて処理されます。まず返金レコードがコミットされ、その後ゲートウェイに入金されます。ゲートウェイで問題なく失敗した場合は、入金されていないレコードが削除されます。
await admin.orders.refunds.create('or_xxx', { payment_id: 'py_xxx', amount: '25.00', refund_reason_id: 'rr_xxx' })
パラメーターは refund_reason_id です(古い @spree/admin-sdk リリースでは reason_id も受け付けていましたが、API はこれを通知なく破棄します。その場合、返金にはストアの最初の返金理由が使用されます)。
Spree.refund_create_workflow.call(payment: payment, amount: 25, reason: reason, refunder: admin_user)
# amount nil → full creditable balance (payment.credit_allowed)
フック:refunds.create.validate、.before_refund、.after_refund。payment.refunded を公開し、注文の状態は partially_refunded/refunded に移行します。返品による返金(元の支払い方法への返金、またはストアクレジットへの返金)は Spree::Returns::Refund を通じて処理されます。spree-returns を参照してください。
ストアクレジットとギフトカード
ストアクレジット(credit_) | ギフトカード(gc_) | |
|---|---|---|
| 所属先 | 顧客(およびストア) | ストア。顧客の指定は任意 |
| 発行元 | Admin(admin.customers.storeCredits.create('cust_xxx', { amount, currency, memo }))、または返品/交換ワークフロー(originator) | Admin(admin.giftCards.create。一括発行は admin.giftCardBatches.create) |
| チェックアウト時 | client.carts.storeCredits.apply(cartId, amount?)/.remove — サインイン済みの顧客 | client.carts.giftCards.apply(cartId, code)/.remove — ゲストも利用可能。discount_codes とは別です |
| 有効期限 | なし。古いものから使用(Spree::StoreCredit.oldest_first) | カードごとの expires_at |
| ステータス | — | active、partially_redeemed、redeemed、canceled(日付による期限切れもあります) |
どちらも Spree::PaymentMethod::StoreCredit を通じて支払いに使用され、注文合計ではなく支払い残額が減ります。どちらも顧客が支払い方法を選ぶ前に適用するもので、支払い方法として選択するものではありません。ストアクレジットの支払い方法は cart.payment_methods から除外されているため、これを指定して POST /carts/:id/payments を実行すると payment_method_unavailable が返されます。残った amount_due は実際の支払い方法で徴収してください。カートではギフトカードかストアクレジットのどちらか一方だけが使用でき、両方を同時には使用できません(どちらかがカートにある間、もう一方の適用は拒否されます)。ギフトカードによる支払い額は min(spendable balance, total) となり、配送、割引、税によって金額が変わるたびに合計の再計算に合わせて調整されます。支払いが残っていない場合は解放されます。商品の変更時には、どちらも適用が解除されます(spree-checkout を参照)。ワークフロー:Spree.gift_card_apply_workflow/gift_card_remove_workflow/gift_card_redeem_workflow/gift_card_cancel_workflow、Spree.store_credit_apply_service/store_credit_remove_service。ギフトカードは、使用済みの金額がある場合、キャンセルできません。ギフトカードの code は所持者が使用できる認証情報です。Admin API のレスポンスでは、read_gift_cards を持つ呼び出し元にのみ全桁が表示され、それ以外では末尾4文字のみが表示されます(注文上など)。Webhook の配信ログでは [REDACTED] として保存され、会社のメンバーが同僚の注文を閲覧する場合もマスクされます。Admin API でギフトカードに customer_id を設定するには、read_customers も必要です。権限がない場合は 403 required_permission が返されます。Spree::StoreCreditCategory/StoreCreditType は非推奨のシェルです。
イベント
payment.completed、payment.paid、payment.captured、payment.voided、payment.refunded、order.paid(注文の支払いがすべて完了したとき。マーケットプレイスで注文が分割されるチェックアウトでは、子注文ごとにも発生します)、payment_session.{processing,completed,failed,canceled,expired}、payment_setup_session.*、gift_card.*。ゲートウェイログのテーブルはありません。取引の調査には payment.gateway_dashboard_payment_url または PaymentSession の external_data を使用してください。spree-events-webhooks を参照してください。
トラブルシューティング
- 支払いが
processingのまま止まっている:請求処理は開始されましたが、ゲートウェイの呼び出しまたはクラッシュの処理が完了していません。failedとprocessingは再試行可能な状態です。ワークフローを再実行すると(Spree.payment_process_workflow.call(payment:))、同じ行で再試行されます。最初にプロバイダーのダッシュボードを確認してください。 - 請求済みなのに注文がない:Webhook によって処理が完了するはずです。プロバイダーの Webhook URL とシークレット、およびジョブが実行中であること(Solid Queue /
/jobs)を確認してください。payment_session.statusとcart.completed_atも確認してください。 - Webhook 401:
parse_webhook_eventがWebhookSignatureErrorを発生させています。シークレットが間違っているか、raw_bodyではなく再シリアライズされた本文を解析しています。 - 直接支払いが
payment_session_requiredで拒否される:その決済方法ではセッションフローが必要です。 - セッションの金額が一致しない:配送またはクーポンによって合計金額が変更される前にセッションが作成されています。セッションの金額を更新するか、新しいセッションを作成してください。
さらに詳しく読むには
node_modules/@spree/docs/dist/developer/core-concepts/payments.mdnode_modules/@spree/docs/dist/developer/how-to/custom-payment-method.mdnode_modules/@spree/docs/dist/developer/core-concepts/store-credits-gift-cards.mdnode_modules/@spree/docs/dist/integrations/payments/stripe.md(adyen.md、paypal.md、razorpay.mdも参照)- ソース:
spree_coreのSpree::PaymentMethod、Spree::Payment、Spree::PaymentSession、Spree::Payments::*、Spree::Refunds::Create。完全なゲートウェイの実装例はspree/providers/stripeにあります。 - 関連スキル:
spree-checkout、spree-workflows、spree-returns、spree-events-webhooks、spree-security