Spree Checkout
Checkout in Spree 6 is
data-driven, not a state machine. A
collects items, addresses, delivery and payment in any order; the cart reports what it still needs (
); and exactly one hard gate — the
workflow — turns it into an immutable
.
Coming from 5.x (
,
,
)? Those APIs are gone — see
.
Cart vs Order
| () | () |
|---|
| Phase | Shopping + checkout | Placed purchase (financial record) |
| Lifecycle marker | only — no status column | (//) + / |
| Mutable? | Yes, until completed; then read-only ( blocks every write) | Items/prices frozen; admin edits go through explicit services |
| Checkout introspection | , , , | None — it's already placed |
| Money | Rows regenerated on every recalculation | Rows frozen; totals only re-summed |
Records that live through both phases —
,
,
,
,
,
,
,
— carry
two nullable FKs (
+
, exactly one set). Always read the parent through
:
ruby
line_item.owner # => Spree::Cart during checkout, Spree::Order after
line_item.order # nil during checkout — don't assume it's present
Shared behavior (addresses, taxation, store credit, gift cards, payment processing, currency/market) lives in
concerns included by both models. Decorate the concern (or use a hook) rather than only
.
points back at the source cart;
is
unique — it's the completion idempotency key.
Requirements: the one thing checkout enforces
Every cart read computes
Spree::Checkout::Requirements.new(cart).call
→ an array of
{ step:, field:, code:, message: }
. Branch on
;
is translated prose;
is a grouping hint.
Built-in advisory requirements (reported on every cart read):
| | Raised when |
|---|
| | Cart is empty |
| | No email |
| | Something ships and there's no address |
| | A fulfillment has no delivery method selected |
| | Valid payments don't cover |
| | The buyer's company requires a PO number (staff-keyed carts exempt) |
| | Below the B2B order minimum |
Completion-only checks (
, run inside
because they load every line item):
,
,
(all
), and
guest_checkout_not_allowed
.
An empty array does not guarantee completion will succeed. Treat a failed completion as a normal path.
Steps are advisory
ruby
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
The Store API exposes these as
and
. Built-in steps come from
Spree::Checkout::Registry.base_steps
:
| Step | Present when |
|---|
| Always |
| cart.delivery_step_required?
(has items, not all-digital) |
| (total above zero) |
| cart.confirmation_required?
(a payment method asks, or store preference) |
| Always |
Nothing on the server refuses a write because of the step — clients may set email, address, delivery and payment in any order. A free digital order reports
→
; don't hardcode five steps in a storefront.
Customizing: Spree::Checkout::Registry
Configure in
server/config/initializers/spree.rb
. One declaration feeds
both the
array and the completion gate.
Registration timing: the Registry is plain class-level state loaded from
— core never resets it at boot (only
, meant for tests), so top-level initializer calls or a
Rails.application.config.after_initialize
block both work. Avoid
: it re-runs on every dev code reload and
/
append without de-duplicating, so each reload adds another copy of the requirement.
Add a requirement to an existing step
ruby
# 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:
)
- is always derived as for added requirements.
- is stored verbatim at boot — a call here resolves once, in the boot locale. For per-request translation use (its lambda runs per cart).
Spree::Checkout::Registry.remove_requirement(step:, field:)
removes one you (or an extension) registered.
PO numbers are already built in: set
on the company and the cart reports
(see
). Don't re-register it.
Add a whole step
ruby
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') }]
}
)
Unanchored steps (or anchors this cart doesn't have, like
on a free cart) land right before
.
Registry.remove_step(:loyalty)
undoes it.
Reorder or drop built-in steps
ruby
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]
Removing a step only removes the
label. Requirements filed under it still gate completion — dropping
does not let an unpaid cart complete.
Storing the data a requirement checks
has a
JSON column the Store API accepts on
PATCH /api/v3/store/carts/:id
(merged, not replaced). Carts do
not include
. At completion the cart's
is
deep-copied onto the order (and, for a checkout split between sellers, onto every child order and the
), so
order.metadata['vat_number']
is still there after placement. After that the two are independent — editing one never changes the other.
Treat those keys as customer input: shoppers write cart metadata through the Store API, so never keep anything your code later trusts there (fraud/approval flags, prices, entitlements) — use a staff-only field, custom field or your own column for that.
Completion:
ruby
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
Three phases with explicit transaction boundaries:
- Prepare: replay check and contract-price confirmation run first, outside the lock. Then, under : concurrent-completion guard → in-lock recalculation (the charged total is computed now, never trusted from earlier requests) → optional drift guard () →
Requirements#call(completion: true)
→ hooks → stamp → create a order copying line items, fulfillments + selected rates, TaxLine/Discount/Fee rows, promotions, address snapshots, tax identifier and PO document; re-point payments, payment sessions, reservations and coupon codes.
- Payment (, outside any transaction): process payments if not already covered.
- Finalize:
carts.complete.before_finalize
hooks → Spree.order_complete_workflow
(inventory, , statuses, event) → cart → coupon codes marked used → → carts.complete.after_finalize
hooks.
Guarantees you can rely on
- Idempotent. A double-click, retry, or a payment webhook racing the customer's return gets the same order back — the replay step returns , the unique index catches concurrent winners ( re-enters and replays), and a live claim (5-minute TTL) returns (API: ).
- Crash recovery. If the process dies after the order commit, the next attempt (or
Spree::Orders::FinalizeStaleDraftsJob
) re-runs Finalize instead of charging again.
- Pre-capture failure rolls back. A payment failure before any money is captured destroys the draft order, re-points payments/sessions/reservations/coupons back to the cart, and clears — the customer can fix and retry.
- Guest token carries over. The order gets , so a guest reads their order with the same .
- Totals are recomputed at the last moment, so a price or promotion that changed during review can't produce a wrong charge.
Workflow hooks around checkout
Register in the initializer; handlers are class-name strings (reload-safe) with
. See
for the full hook catalog.
| Key | Kind | Use for |
|---|
| validate | Purchase limits, eligibility — before the line item is built |
carts.upsert_items.validate
| validate | Same rule for quantity edits/removals/bulk (register both keys) |
carts.add_item.after_item_added
| lifecycle (in txn) | Write related records atomically |
| validate | Last-moment veto (fraud score, external credit check) — before any money moves |
carts.complete.before_finalize
| lifecycle | Runs after payment; copy data onto |
carts.complete.after_finalize
| lifecycle | After the order is placed |
| / | validate / lifecycle | Merge policy |
ruby
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')
Never reject in or . The card has already been charged; rejecting rolls back the database but the charge stands. Veto in
. And prefer a Registry requirement over a
hook for anything the customer can fix — requirements show up on every cart read, a hook only fails at the end.
Side effects (emails, ERP push) belong in an
event subscriber, not a hook (
).
Addresses
Spree::Purchase::Addresses
(shared by Cart and Order):
- Dedupe: / reuse an identical row from the customer's address book instead of creating duplicates; guests may only edit the row already in the slot.
- Promote to defaults: a signed-in customer's checkout address becomes their default ship/bill address (wallet/quick-checkout addresses excluded).
- Ownership guard: / only accept an address from the customer's own book (or their company's). Anything else silently resolves to — a guest can never select an address by ID.
- copies the shipping address onto billing (shipping is canonical). is deprecated.
- Address fields: , , , , , , .
- Signed-in customers get blank slots auto-filled from saved defaults.
Changing the address re-prices items, rebuilds delivery proposals and re-estimates tax (
cart.recalculate_for_address_change!
).
Payment sessions
Sessions are
scoped to the cart:
POST /api/v3/store/carts/:cart_id/payment_sessions
looks the cart up first, so a session id from another cart 404s. Create/update run under the cart row lock;
deliberately doesn't hold the lock across the gateway call —
PaymentSession#settle_payment!
serializes locally, so a confirm racing the gateway webhook records the capture once. Only
resolves an already-completed cart (the webhook may have finished checkout first); creating a session on a completed cart 404s. See
.
Store API / SDK checkout sequence
typescript
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: 'jane@example.com',
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
Every write returns the whole cart with fresh totals and
— never make a separate "recalculate" call. Discount codes, gift cards and store credit (
,
,
) can be applied any time before completion. Store credit is applied through its own endpoint and is never one of
. A cart takes a gift card or store credit, not both. A gift card's share follows the total when delivery, discounts or tax change.
Login: associate and merge
client.carts.associate(cartId, { token: jwt, spreeToken: cart.token })
(PATCH /carts/:id/associate
) claims a guest cart for the signed-in customer. It requires both the JWT and the cart token, only claims guest carts (or the caller's own), fills email and blank addresses from the customer's defaults.
- The API does not auto-merge with the customer's other open carts (
customer.carts.where(store: current_store)
lists them — rows). To fold two carts together, call Spree.cart_merge_workflow.call(cart: survivor, other_cart: guest_cart, customer:)
(or survivor.merge!(guest_cart)
) from your own endpoint/subscriber. A currency mismatch fails and keeps both carts. Policy (keep the larger cart, cap quantities) goes in a hook.
After completion
- The completed cart is read-only and 404s on every Store API cart endpoint (the cart scope is ). Read the order via
client.orders.get(order.id, {}, { spreeToken })
.
- is the exception: re-posting (with the cart id, or the returned order/group id) returns the existing order.
- Storefronts should drop the stored cart id/token once succeeds.
Troubleshooting: "why can't this cart complete?"
- Read (:
Spree::Checkout::Requirements.new(cart).call(completion: true)
). The completion-only set adds stock/discontinued/quantity-rule/guest-policy failures.
- / → another attempt holds (TTL 5 min). Retry; don't clear it by hand while a payment may be in flight.
- though the customer paid → a gift card or store credit was removed: any item change runs
Spree::Carts::Recalculate
, which unapplies gift cards and checkout store credit. Re-apply after editing items.
- → the cart no longer holds its single-use batch code (another cart took it over, or an order used it). The next cart read drops it with a warning; the shopper re-reviews the total (see ).
- with no rates → the address has no matching delivery zone; carries per item.
- A custom requirement never clears → your lambda reads data the storefront never writes (e.g. on a cart — use ).
- Rejected by a hook → the error message comes from / in some handler; lists registrations.
Testing
ruby
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! }
Common mistakes
- Looking for , , — gone. Steps are derived; requirements are the gate.
- Adding a model validation on for a checkout field — that blocks every partial write. Use a Registry requirement.
- Rejecting in — money already moved.
- Reading in code that runs during checkout — use .
- Hardcoding the step list in the storefront — render from / and route by .
- Writing to a completed cart (raises
ActiveRecord::ReadOnlyRecord
) — edit the order through the admin services instead.
Where to read further
node_modules/@spree/docs/dist/developer/core-concepts/carts.md
and
node_modules/@spree/docs/dist/developer/customization/checkout.md
— Registry
node_modules/@spree/docs/dist/developer/customization/workflows.md
— hooks
node_modules/@spree/docs/dist/developer/sdk/store/cart-checkout.md
- Related skills: , , , , , ,
- Source:
spree/core/app/workflows/spree/carts/complete.rb
, spree/core/lib/spree/checkout/