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_)
A payment is linked to its session by payment.response_code == payment_session.external_id. A cart can carry several payments (e.g. store credit + card).
Payment statuses (no state machine)
status is a plain string (Spree::HasStatus) written only by the payment workflows and Spree::Payment::Processing:
| Status | Meaning |
|---|---|
checkout |
Created, not yet processed (default) |
processing |
Claimed for a gateway call (atomic claim; blocks double submission) |
pending |
Authorized, not captured |
completed |
Captured — only these count toward what's paid |
failed |
Gateway rejected it (retryable on the same row) |
void |
Voided |
invalid |
Superseded by a newer checkout payment (has_invalid_status? — invalid? is ActiveModel's) |
The order's payment_status (none, authorized, partially_paid, paid, partially_refunded, refunded, overcharged, voided) is derived by Spree::Orders::UpdateStatuses from payments and refunds (re-run on payment/refund/fulfillment events and by every placed-order money edit: fees, manual discounts, items) — never assign it. "Refunded" counts both ledgers: Spree::Refund rows and store credit issued as a refund (order.store_credit_refunds, StoreCredit#refunded_order_id), so an order refunded to store credit reads partially_refunded / refunded, not paid. Spree::Order.refunded / .partially_refunded scopes filter on this status.
Capture timing
capture_method is a store preference (preferred_capture_method, default checkout) that a payment method can override (capture_method, nil = inherit):
| Value | Behaviour |
|---|---|
checkout |
Charged when the order is placed → completed |
on_dispatch |
Authorized at checkout (pending), captured when goods are dispatched |
manual |
Authorized at checkout (pending); staff capture when they choose |
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')
In Ruby: payment_method.resolved_capture_method, Spree.payment_capture_workflow.call(payment:, amount: nil) — amount is in cents; a smaller amount captures part and splits the remainder into a new pending payment. Spree.payment_void_workflow.call(payment:).
Payment methods
| Attribute | Notes |
|---|---|
type |
API sends the shorthand (stripe, check, store_credit, …); the column holds the class |
store |
Single owner — current_store.payment_methods |
active |
Inactive methods are hidden everywhere |
storefront_visible |
false = staff-only (e.g. "Invoice" for admin-created orders). Replaces display_on. The Store API payment and payment-session endpoints accept only active.storefront_visible methods — a staff-only method's ID is a 404 there, not a loophole |
capture_method |
Override, see above |
preferences |
Gateway credentials — YAML in a plain text column, not encrypted; treat DB dumps as secret |
Hooks to override on a subclass: session_required?, setup_session_supported?, source_required?, available_for_order?(order), available_for_store?(store), payment_icon_name. The Store API embeds payment_methods (with session_required) in the cart response.
Installing a gateway
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
Classic Rails app: bundle add … && bin/rails g …:install. Then create the payment method in the dashboard (Settings → Payments) and register its webhook URL (payment_method.webhook_url → /api/v3/webhooks/payments/pm_…) with the provider. spree_stripe is the reference implementation (sessions, setup sessions, webhooks, wallets, Stripe Connect payouts).
Two flows
Session-based (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)
Session statuses: pending, processing, completed, failed, canceled, expired (no cancel endpoint — cancel/expire come from the provider or expires_at).
Webhook-first completion. The provider's webhook (POST /api/v3/webhooks/payments/:payment_method_id) is the authoritative signal: Spree finds the payment method by its prefixed ID alone (providers send no API key or store header), sets Spree::Current.store to the payment method's store, verifies the signature against that method's own secret synchronously (401 on failure), enqueues Spree::Payments::HandleWebhookJob, returns 200, then settles the payment and completes the cart via Carts::Complete — the job also runs in the payment method's store, so a non-default store's webhooks work without any host/header routing. Whichever of browser or webhook arrives first wins; the other is a no-op. Replays are handled in two layers: freshness belongs to the provider's signature scheme inside parse_webhook_event (Stripe's Stripe::Webhook.construct_event rejects timestamps older than 5 minutes — a custom gateway must enforce its provider's equivalent), and Spree::Payments::HandleWebhook is idempotent on the payment (a redelivery for an already-completed payment is a no-op under the order lock), so a replayed event can't pay or complete twice. Never treat a redirect back from the provider as proof of payment.
Direct (Check, cash on delivery, bank transfer, PO) — 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 codes: payment_session_required (method needs a session), payment_method_unavailable (the method's available_for_order?(cart) is false — also checked when creating a payment session). On completion the payment lands pending (or completed when capture is checkout); staff capture it when the money arrives.
Saved payment methods
client.customer.paymentSetupSessions.create({ payment_method_id }, { token: jwt }) → confirm with the provider SDK using external_client_secret → .complete(id, { external_data: {} }) creates a CreditCard/PaymentSource. Requires a signed-in customer.
Building a custom payment method
# 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:)is the single settlement path shared by the confirm call and the webhook — it locks the owner, finds or creates the payment, and confirms it; it's safe to race.- Session transitions (
process,complete,fail,cancel,expire) are non-raising compare-and-swap writes returningfalsewhen they lose a race; guards arecan_process?,can_complete?,can_fail?… - For setup sessions:
setup_session_supported?,payment_setup_session_class,create_payment_setup_session(customer:, external_data:),complete_payment_setup_session(setup_session:, params:). - Legacy card gateways subclass
Spree::Gateway(ActiveMerchant-styleauthorize/purchase/capture/void/crediton a provider).
Extend behaviour without subclassing via workflow hooks: 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 … })). See spree-workflows.
Refunds
Refunds are Admin-only (no Store API route) and run through Spree::Refunds::Create (Spree.refund_create_workflow): the refund row commits first, then the gateway is credited; a clean gateway failure removes the uncredited row.
await admin.orders.refunds.create('or_xxx', { payment_id: 'py_xxx', amount: '25.00', refund_reason_id: 'rr_xxx' })
The param is refund_reason_id (older @spree/admin-sdk releases also accepted reason_id, which the API silently drops — the refund then falls back to the store's first refund reason).
Spree.refund_create_workflow.call(payment: payment, amount: 25, reason: reason, refunder: admin_user)
# amount nil → full creditable balance (payment.credit_allowed)
Hooks: refunds.create.validate, .before_refund, .after_refund. Publishes payment.refunded; the order moves to partially_refunded / refunded. Returns-driven refunds (to the original payment or store credit) go through Spree::Returns::Refund — see spree-returns.
Store credits and gift cards
Store credit (credit_) |
Gift card (gc_) |
|
|---|---|---|
| Belongs to | A customer (and store) | A store; optional customer |
| Issued by | Admin (admin.customers.storeCredits.create('cust_xxx', { amount, currency, memo })) or return/exchange workflows (originator) |
Admin (admin.giftCards.create, bulk via admin.giftCardBatches.create) |
| At checkout | client.carts.storeCredits.apply(cartId, amount?) / .remove — signed-in customers |
client.carts.giftCards.apply(cartId, code) / .remove — guests too; separate from discount_codes |
| Expiry | Never; spent oldest-first (Spree::StoreCredit.oldest_first) |
Per card expires_at |
| Statuses | — | active, partially_redeemed, redeemed, canceled (+ expired by date) |
Both pay via Spree::PaymentMethod::StoreCredit and reduce the amount due, not the order total. Both are applied before the customer picks a payment method, never chosen as one: the store-credit method is excluded from cart.payment_methods, and POST /carts/:id/payments with it answers payment_method_unavailable. Collect whatever amount_due is left with a real method. A cart uses a gift card or store credit, never both (each apply refuses while the other is on the cart). A gift card's payment covers min(spendable balance, total) and is resized on every totals recalculation as delivery, discounts or tax move; it's released when nothing is left to pay. Item changes still unapply both (see spree-checkout). Workflows: 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. Cancelling a gift card is refused once it has been spent against. A gift card's code is a bearer credential: Admin API responses show it in full only to callers holding read_gift_cards (masked to the last four characters elsewhere, e.g. on orders), the webhook delivery log stores it as [REDACTED], and company members see it masked on colleagues' orders. Setting customer_id on a gift card through the Admin API needs read_customers too (403 required_permission otherwise). Spree::StoreCreditCategory / StoreCreditType are deprecated shells.
Events
payment.completed, payment.paid, payment.captured, payment.voided, payment.refunded, order.paid (order fully settled — also per child order on split marketplace checkouts), payment_session.{processing,completed,failed,canceled,expired}, payment_setup_session.*, gift_card.*. There is no gateway log table — for transaction forensics use payment.gateway_dashboard_payment_url or the PaymentSession's external_data. See spree-events-webhooks.
Troubleshooting
- Payment stuck in
processing: the claim happened but the gateway call/crash didn't finish.failedandprocessingare claimable, so re-running the workflow (Spree.payment_process_workflow.call(payment:)) retries on the same row — check the provider dashboard first. - Charged but no order: the webhook should complete it — verify the webhook URL/secret at the provider and that jobs are running (Solid Queue /
/jobs). Checkpayment_session.statusandcart.completed_at. - Webhook 401:
parse_webhook_eventraisedWebhookSignatureError— wrong secret, or you parsed a re-serialized body instead ofraw_body. - Direct payment rejected with
payment_session_required: that method needs the session flow. - Amount mismatch on session: session created before delivery/coupon changed the total — update the session amount or create a new one.
Where to read further
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(alsoadyen.md,paypal.md,razorpay.md)- Source:
Spree::PaymentMethod,Spree::Payment,Spree::PaymentSession,Spree::Payments::*,Spree::Refunds::Createinspree_core;spree/providers/stripefor a full gateway - Related skills:
spree-checkout,spree-workflows,spree-returns,spree-events-webhooks,spree-security