Agent Skill · Spree Commerce

spree-payments

Use when the user is working with Spree's payment system — payment methods, gateways (Stripe, Adyen, PayPal), payment sessions, the payment state machine, refunds, store credits, gift cards. Common phrasings include "add payment gateway", "Stripe integration", "payment failed", "refund order", "store credit", "gift card", "payment state stuck", "configure PaymentMethod", "process payment manually". Provides the payment graph, the state machine, and the integration points.

Provider: Spree Commerce Path in repo: skills/spree-payments/SKILL.md

Skill body

Spree Payments

Commands below use the Spree CLI form (spree …, Docker). On a classic Rails app without the CLI (typical pre-5.4), use the native mapping in the spree-project skill — bin/rails / bundle exec rake from the app root, paths without the backend/ prefix.

Payments in Spree are layered:

PaymentMethod   — the configured way to pay (Stripe, Adyen, PayPal, store credit, …)
  ↓
Payment         — the actual charge against an Order via a PaymentMethod
  ↓
PaymentSource   — the customer's instrument (CreditCard, StoreCredit, Spree::PaymentSource for wallets/accounts)

(A PaymentSession is not a payment source — it links to the Payment via the gateway transaction id, response_code/external_id.)

A single Order can have multiple Payments (e.g. store credit or a gift card combined with a card payment), each with its own state. Creating a new non-store-credit payment auto-invalidates any other payment still in the checkout state (store credit payments are spared), so splitting an order across multiple cards at checkout isn’t supported.

Payment state machine

checkout  →  processing  →  pending  →  completed
        ↘              ↘            ↘
         invalid        failed       void
State What it means
checkout Payment created during cart phase; no money moved yet
processing Gateway call in flight
pending Authorized but not captured (auth/capture flow, e.g. credit card pre-auth)
completed Captured — money has actually moved
failed Gateway returned an error during processing
void Cancelled — usually before capture, but completed payments can also be voided (gateway permitting)
invalid Superseded — a newer payment was added to the order (old checkout payments are auto-invalidated), or the source is unsupported by the gateway

Transitions are events: started_processing, pend, complete, failure, void, invalidate. After-callbacks fire payment.completed / payment.voided events. See the spree-events-webhooks skill.

Payment methods

A PaymentMethod is configured in the admin (Settings → Payments). The model carries:

stripe = Spree::PaymentMethod.create!(
  name: 'Credit Card',
  type: 'SpreeStripe::Gateway',
  display_on: 'front_end',
  active: true,
  preferences: { publishable_key: ENV['STRIPE_PUBLISHABLE_KEY'], secret_key: ENV['STRIPE_SECRET_KEY'] }
)

Most production stores don’t create PaymentMethods in code — they’re created via the admin UI after installing the gem (e.g. spree_stripe).

Built-in payment method types

Class Source
Spree::PaymentMethod::StoreCredit spree_core — pays from Spree::StoreCredit balance
Spree::PaymentMethod::Check spree_core — back-office “manual” payment
SpreeStripe::Gateway spree_stripe gem
SpreeAdyen::Gateway spree_adyen gem
SpreePaypalCheckout::Gateway spree_paypal_checkout gem

Custom payment methods subclass Spree::PaymentMethod, register via Spree.payment_methods << MyGateway, and implement the Payment Session interface (payment_session_class, create_payment_session, update_payment_session, complete_payment_session, parse_webhook_event) — see docs/developer/how-to/custom-payment-method. Legacy card gateways subclass Spree::Gateway, which delegates authorize/purchase/capture/void/credit to an ActiveMerchant-style provider. Most stores use an existing extension instead of writing custom.

Payment sessions (5.4+) — the modern flow

Classic Spree payments expected the storefront to collect card data and POST it. That doesn’t work for hosted forms (Stripe Checkout) or drop-in widgets (Adyen). The 5.4+ Spree::PaymentSession model wraps the customer redirect / return flow.

Customer hits checkout
  ↓
Storefront creates a PaymentSession via the Store API
  ↓
API returns provider-specific session data (Stripe Checkout URL, Adyen drop-in payload, etc.)
  ↓
Customer interacts with provider UI
  ↓
Provider redirects back to storefront OR fires a webhook to backend
  ↓
PaymentSession.complete! → Payment created → storefront (or webhook handler) calls cart completion to finish the order

The session has events: payment_session.processing, payment_session.completed, payment_session.failed, payment_session.canceled, payment_session.expired. Sessions carry an optional expires_at set by the gateway extension from the provider’s own session expiry; expired sessions drop out of the active/not_expired scopes (and can be transitioned via the expire event, firing payment_session.expired), so abandoned sessions don’t leave dangling payments.

For most stores, you don’t interact with PaymentSession directly — the gateway extension (spree_stripe, spree_adyen) handles creation and completion. You just subscribe to the events if you need to react.

Adding a payment gateway

Stripe, Adyen and PayPal ship preinstalled in spree-starter projects (the backend create-spree-app scaffolds) — nothing to install; enable and configure them in the admin under Settings → Payment methods. For any other gateway gem:

spree eject                           # switch to the dev compose: bind-mounts backend/ so Gemfile changes take effect
spree bundle add spree_other_gateway  # installs into the bundle_cache volume — no image rebuild needed
spree rails g spree_other_gateway:install
spree migrate
spree dev                             # restart so the new gem loads (Ctrl+C the running one first)

Then configure credentials via the admin Payment Methods UI (or via ENV-fed initializer for repeatability).

Refunds + reimbursements

Payment (completed)
  ↓
Refund — partial or full credit back to the original payment source

Refunds carry a Spree::RefundReason (admin-managed: “duplicate charge”, “customer return”, etc.) and an amount. The Refund’s transaction_id links to the gateway’s refund record.

payment = order.payments.completed.first
refund = payment.refunds.create!(
  amount: 25.00,
  reason: Spree::RefundReason.find_by(name: 'Goodwill'),
  refunder: current_user
)

create! performs the gateway refund automatically (after_create callback) and writes transaction_id; it raises if the gateway call fails.

For partial refunds with return authorizations, the chain is:

Customer requests return → ReturnAuthorization → CustomerReturn → Reimbursement → Refund / StoreCredit

See the spree-shipping-fulfillment skill for the reverse-logistics chain.

Store credits

Spree::StoreCredit is built-in. Tracks balance per user per store per currency. Pays via Spree::PaymentMethod::StoreCredit.

user.store_credits.create!(
  store: current_store,
  currency: 'USD',
  amount: 50.00,
  category: Spree::StoreCreditCategory.find_by(name: 'Goodwill'),
  created_by: current_admin_user
)

Categories are admin-managed via the CRUD pages at /admin/store_credit_categories (no admin navigation link — reachable by direct URL only).

Gift cards

Spree::GiftCard is built-in (5.x). Each gift card has a redemption code and a remaining balance. Customers can apply at checkout; partial redemption is supported.

Events fired: gift_card.redeemed, gift_card.partially_redeemed. See the spree-events-webhooks skill.

gc = Spree::GiftCard.create!(
  store: current_store,
  amount: 100.00,
  currency: 'USD',
  code: SecureRandom.alphanumeric(16).upcase,  # optional — Spree generates if omitted
  expires_at: 1.year.from_now,                 # optional
  created_by: current_admin_user
)

Common payment problems

“Payment stuck in processing

The gateway call started but never finished. Either the gateway timed out (network), or the result-handling code crashed before transitioning. Check payment.log_entries (each Payment has a paper trail of gateway responses). Manually transition with payment.failure! after investigating.

“Payment completed but order didn’t transition”

The Payment is in completed but Order is still in payment or confirm. The order-state-machine should advance automatically; if it doesn’t, check order.payment_state and run Spree::OrderUpdater.new(order).update.

“Wrong amount captured”

By default, Spree captures the outstanding balance at checkout. If you ran an authorize earlier with a different amount (e.g. customer used a gift card after authorization), you need to void + re-authorize OR partial-capture (gateway-dependent).

“Webhook from Stripe but no PaymentSession found”

The webhook arrived before the storefront’s redirect-back, OR the PaymentSession TTL expired. Stripe’s webhook is the source of truth — always trust it over the redirect-back. The spree_stripe gem handles this; if you’re writing custom, idempotency keys are essential.

Where to read further