Back to Spree

Cart/Order Split

docs/plans/6.0-cart-order-split.md

5.6.133.0 KB
Original Source

Cart/Order Split

Status: Design finalized (expanded 2026-07-27 from the original stub), implementation not started Target: Spree 6.0 Depends on: API v3 conventions. Gates (they depend on this plan): 6.0-split-adjustments.md (typed-line owner), 6.0-fulfillment-and-delivery.md (fulfillment/rate owner), 6.0-multi-vendor-marketplace.md Phase 3 (split fires inside Carts::Complete), 6.0-normalize-state-to-status.md (Order.state removed here). This is the most upstream unstarted 6.0 refactor. Author: Damian + Claude Last updated: 2026-07-27

Summary

Cart and Order become separate models. A Spree::Cart owns the shopping/checkout phase; completing checkout copies it into an immutable Spree::Order (cart.complete!Spree::Carts::Complete). The Order state machine is removed: checkout progression is answered by the (already shipped) Spree::Checkout::Requirements, and order-level payment/fulfillment statuses are derived-then-persisted — one recompute service writing indexed columns.

Industry (2026-07-27 review, primary sources): separate cart/order with copy-on-completion is the majority pattern — OSS platform A (Cart module → Order module, parallel tables, cart retained with completed_at), OSS platform B (Checkout → Order via checkoutComplete, idempotent on checkout_token), the hosted market leader (which deleted its intermediate Checkout API entirely in 2025 in favor of Cart → Order). OSS platform C is the lone single-entity dissenter, and its workarounds (active flag, orderPlacedAt, orderPlacedQuantity snapshots, Modifying states that re-open placed orders) are the argument for splitting.

What already exists (verified 2026-07-27)

The codebase is further along than the old stub implied — the split formalizes seams that are already drawn:

  • The v3 Store API is already cart-shaped. Spree::Carts::{Create,Update,UpsertItems,Complete} exist with explicit "In Spree 6 …" comments; routes are /store/carts/... + read-only /store/orders/:id. This plan rewrites Carts::Complete's internals — the public surface barely moves.
  • The API contract is step-shaped, not state-shaped. CartSerializer exposes only current_step, completed_steps, requirements, warnings — never raw state. OrderSerializer exposes payment_status/fulfillment_status/completed_at — no state, no steps.
  • Spree::Checkout::Requirements + Checkout::Registry already compute "what's missing to complete" with zero reads of order.state (default_requirements.rb:6-7 says the 6.0 intent verbatim). One fix needed: Registry#ordered_steps currently derives ordering from Order.checkout_step_names — that circular dependency gets cut (the registry owns its own step list).
  • The query layer already draws the boundary on completed_at. Order.complete/.incomplete (the dominant scopes) key on completed_at, not state; Store#carts/#checkouts are incomplete-scoped. Only four raw where(state:) sites exist against orders, all trivially re-expressible.
  • Order#status (draft/placed/canceled) already exists and is maintained by finalize!/after_cancel/after_resume — the lifecycle column survives; the checkout state column does not.
  • LineItem has half the owner pattern alreadybelongs_to :order with presence validation and a real order_id FK. Under the unified dual-FK owner design (Key Decision 2), Phase 1 only adds nullable cart_id and relaxes presence to exactly-one; order_id stays forever. The old constraint "use line_item.owner" was unimplementable until then; owner ships as a method.

Key Decisions (do not deviate without discussion)

1. Spree::Cart model; Order keeps its table

Spree::Cart (prefix cart_, spree_carts) owns the pre-completion phase: store/market/channel/currency/locale/customer binding (settling today's three inconsistent creation paths — current_order creates orders without market/channel/locale and would fail multi-market validation), email, ship/bill address references, accept_marketing, special instructions, its own has_secure_token, metadata, completed_at, completing_at (completion lock). Carts have no status column (OSS platform A: completed_at is the only lifecycle marker). Order keeps spree_orders; its state column is dropped, status stays.

2. One owner pattern everywhere: dual concrete FKs (cart_id/order_id, exactly-one) — NOT polymorphic

Supersedes the original stub's "polymorphic owner" (decision 2026-07-27). 6.0's design language is concrete FKs over polymorphic type strings (split-adjustments removes adjustable_type, typed-stock-movements removes originator_type) — LineItem doesn't get to reintroduce the pattern the release removes. So every cart-or-order-owned table uses the same shape: nullable cart_id + nullable order_id, exactly-one validation, #owner as a method (order || cart). That covers spree_line_items (which only gains cart_idorder_id already exists and stays permanently; the cheapest possible migration), the typed money lines (TaxLine/Discount/Fee — split-adjustments), and Fulfillment/DeliveryRate (fulfillment plan).

Provenance needs no second FK on the row. Order#cart_id (unique — the idempotency key) links every order to its cart; the retained cart keeps its own rows as the frozen at-checkout snapshot, so "which cart did this come from" is answered through the order, and post-placement additions are distinguishable (rows the order has that its cart never had). Both-columns-set was considered and rejected: order-side copies carrying cart_id would collide with the cart's own rows in every cart.line_items-style association.

LineItem's 22 internal + 14 external order reads move to owner (pricing context, tax zone, inventory, promo allocation, guest-token permission checks); order-owned items keep answering line_item.order natively.

3. Copy-on-completion — copy everything, share nothing

At completion the order receives copies: line items (with new IDs; typed money lines re-pointed per the split-adjustments copy contract), fulfillments + selected delivery rates, and addresses (never shared rows — OSS platform B copies addresses precisely so a later cart/profile edit can't corrupt order history). The cart is retained with completed_at + the order link (OSS platform A's choice, not the hosted leader's deletion) — retention is what makes idempotent replay natural and preserves abandoned-cart analytics. The guest token is carried onto the order so the confirmation page works with the credential the guest already holds.

4. Idempotent completion (the top gap in the old stub)

Every competitor treats this as load-bearing. Spree::Carts::Complete:

  • spree_orders.cart_id with a unique index — stronger than OSS platform B's filter().first() under true concurrency.
  • On entry: if an order exists for this cart, return it as success — a double-clicked Place Order or a retry after a lost response must get the order back, never an error (OSS platform B ships three separate recovery paths for exactly this).
  • cart.with_lock + completing_at guard against parallel attempts.
  • Order created before payment capture (OSS platform A, verbatim rationale: minimizes the window in which an authorized/captured payment must be rolled back); compensation voids on post-payment failure. Applies to external gateways; offline methods (Check, net terms, COD, manual) have no external window and complete in a single transaction.
  • Usage counters (coupon codes, gift cards, promotion credits_count inputs) guarded by persisted flags (OSS platform B's is_voucher_usage_increased) — counters are the classic double-count victim on retry.
  • The payment-webhook completion path stays (Payments::HandleWebhook → same service): with the unique index + lock, the two-writer race (synchronous complete vs. webhook) is safe by construction, not by the current 30-second job delay.

5. State machine removed — three replacements, not one

(a) Checkout progression → Requirements. current_step/completed_steps/requirements are computed by Checkout::Requirements against cart data. Checkout::Registry becomes the extension surface (register_step, add_requirement with before/after anchors), replacing the checkout_flow/insert_checkout_step class-attribute API, and owns its own ordered step list. A Spree::Carts::Validate service runs the full battery at completion (lines present + in stock + not discontinued, email, addresses, delivery selected, payment coverage, guest policy) returning structured per-field errors with stable codes — also exposed read-only so storefronts can render checkout readiness without attempting completion (OSS platform B's model; OSS platform C's five ArrangingPayment guards expressed as data).

(b) Transition-triggered recalculation → recalculation-on-write. Today tax/prices/shipment rebuilds fire only on state transitions (4 call sites) — an address changed in place at payment never re-runs tax. The Cart model recalculates explicitly on the writes that matter: address/market change → re-price, re-tax, rebuild delivery proposals (the destructive create_proposed_shipments behavior becomes an idempotent cart-side rebuild; the order's fulfillments are a frozen copy, never rebuilt); item changes → totals + reservations (the existing Release/Reserve/Extend dispatch in Carts::Update carries over). The stale memoized tax_zone hazard dies with the memoized order context.

(c) Statuses → derive-then-persist (OSS platform B's hybrid, explicitly not OSS platform A's pure derivation). OSS platform A derives payment/fulfillment status at read time and has open bugs to show for it (can't filter/sort admin order lists — #14095; status stuck after full return — #13882); the hosted market leader has the analogous financial_status:expired filter-index bug. Spree's admin is Ransack-heavy — pure derivation breaks order-list filtering on day one. So: payment_status and fulfillment_status are stored, indexed string columns (names per the normalize plan), recomputed by a single Spree::Orders::RecomputeStatuses service triggered from payment/fulfillment/refund/return event subscribers. One writer, many triggers. Rules that competitors learned the hard way: quantize money to currency precision before comparing; subtract granted/pending refunds from the total before comparing; include overcharged (OSS platform B) and voided (the hosted market leader) in the payment domain; define the returns interplay explicitly (where OSS platform A's derivation broke). The fulfillment_status domain (incl. backorder) is owned by 6.0-fulfillment-and-delivery.md.

6. Order lifecycle stays on Order, without a machine

  • status: draft / placed / canceled (existing column; partially_canceled becomes a derived predicate over child cancellations, not a status value).
  • completed?completed_at.present?; canceled? ≡ status. can_ship?, uneditable?, allow_cancel? re-expressed over status + completed_at + fulfillment/return data.
  • Cancel/resume/approve remain services (Orders::Cancel/Resume/Approve — approve is already machine-free) flipping status and running today's side effects (restock/void/refund branches preserved verbatim). awaiting_return/returned states are not carried over — that lifecycle belongs to 6.0-returns-exchanges-claims.md's first-class Return models.
  • The confirmation_required? self-reference (the || confirm? hack from #4117) dies with the machine: whether the confirm/review step exists is computed from data only (Spree::Config[:always_include_confirm_step] or any selected payment method requiring confirmation). A confirm-stage failure no longer "falls back" to an earlier state — there are no states; the client stays on review with the failure in requirements/errors.
  • Order state_changes audit rows are dropped in favor of lifecycle events (order.placed, order.canceled, order.resumed, order.approved — already published today). Payment/fulfillment machines keep their own StateChange rows.
  • Price-freeze guarantee (what OSS platform C's ArrangingPayment lock provided): completing_at rejects cart mutations while payment is in flight, and the completed Order is immutable per split-adjustments' order-level freeze. Auditable price-at-completion also serves EU Omnibus.

7. Cart hygiene — new, and newly necessary

  • Tiered expiry reaper (none exists today — verified): guest carts 30d, customer carts 90d, empty carts hours — configurable, measured on an indexed updated_at, batched. Carts with money attached (authorized/pending payment sessions or transactions) are never reaped (OSS platform B's exclusion — never garbage-collect a cart mid-authorization).
  • Cart merging becomes a swappable strategy (Spree::Dependencies.cart_merge_strategy; OSS platform C ships four strategies because there's no universally right answer). The default preserves current OrderMerger behavior minus its bugs: no silent dropping of different-currency items without surfacing it, no hard-destroy of the >10 cart overflow with only a log line.

8. Legacy Spree::Cart::* services removed; Carts::* become the registered seam

Of the 14 Spree::Cart::* services, 8 are dead weight (DI-registered, zero callers) and are deleted. The 6 with real callers re-home onto the Cart model/Carts::* namespace (AddItem/SetQuantity/RemoveLineItem → the carts items flow; Associate; Empty; RemoveOutOfStockItems; CompareLineItems → the merge strategy). Carts::{Create,Update,UpsertItems} get DI registration (today only carts_complete_service is registered and controllers reference the others as hard constants). controller_helpers/order.rb's current_order becomes current_cart built through Carts::Create (fixing its missing market/channel/locale binding).

9. Breaking surface, stated plainly

  • order.state, the checkout state machine, checkout_flow/insert_checkout_step/remove_checkout_step, next!/advance, and can_go_to_state? are removed. Extension authors migrate to Checkout::Registry + requirements.
  • Ransack: state leaves the whitelist (invisible-but-real surface: any admin table filter or API list query using state_eq). status, payment_status, fulfillment_status are filterable stored columns — the replacement is strictly more useful.
  • OrderLock's state_lock_version optimistic counter is renamed lock_version semantics-preserving (it never depended on the state machine, only on the column name).
  • order_walkthrough + order factories (public testing_support API) are rewritten cart-first.
  • The emails gem survives untouched except checkout_state_url(token, :payment) in the payment-link email → a cart checkout URL.

Design Details

Completion (Spree::Carts::Complete) — pipeline, edge cases, recovery

Completion is a three-phase pipeline with explicit transaction boundaries. External payment I/O never runs inside a DB transaction; everything after a successful charge is small, idempotent, and resumable.

PHASE P — PREPARE (one transaction)
  P1. Replay check: completion_result(cart) → return success(order|order_group)
  P2. cart.with_lock; reject if completing_at fresh (409 completion_in_progress);
      treat completing_at older than TTL (default 5 min) as stale → take over
  P3. In-lock recalculation (prices, promotions, tax estimate) — the totals about
      to be charged are computed HERE, not trusted from earlier requests
  P4. Drift guard: if client sent expected_total and it ≠ cart.total
      → 409 cart_changed { current_total } (client re-renders, confirms, retries)
  P5. Carts::Validate — full battery, structured errors (see API contract)
  P6. Final availability check against reservations (extend holds through completion)
  P7. Create Order with status: 'draft' + copies (line items owner re-pointed,
      typed money lines, fulfillments + selected rates, address COPIES,
      email/currency/locale/market/channel, guest token carried, number 'R…');
      write orders.cart_id (unique); stamp completing_at
      — invisible everywhere: storefront lists use placed_orders; the admin
        Drafts list is scoped to cart_id: nil (backoffice draft orders have no
        cart), so a mid-completion 'draft' order never surfaces there

PHASE Y — PAYMENT (transaction boundary depends on the method — see below)
  Y1. Skip when not payment_required? (zero total, or store credit + gift card
      fully cover — the covered-by-credit path must never auto-complete earlier
      than this point; see Carts::Update's try_advance note)
  Y2. Process payments against the DRAFT ORDER's totals, per method class:
      • OFFLINE (Check, on-account/terms, cash-on-delivery, bank transfer,
        manual/backoffice-recorded): no external I/O — the method returns a
        local success response and the payment lands `pending`/`completed`
        per auto_capture?. Runs INSIDE the FINALIZE transaction (no separate
        phase, nothing to compensate).
      • EXTERNAL GATEWAY (payment sessions, redirect/3DS, card capture):
        no transaction; compensation on failure.
  Y3. External failure → compensate: destroy draft order + copies, clear
      completing_at, return 422 payment_failed { code, message } — cart stays
      active, nothing external happened. (Only pre-capture failures compensate
      this way; post-capture failures never destroy the order — see FINALIZE.)

PHASE F — FINALIZE (one transaction; every step idempotent)
  F1. Inventory: finalize units (pending: false), unstock, release stock
      reservations (single home — fixes today's asymmetry where the admin
      completion path never releases reservations)
  F2. Usage counters behind persisted flags: coupon codes, gift card redeem,
      promotion credits (each checks-then-sets its flag — retry-safe)
  F3. Vendor split hook (multi-vendor Phase 3): partition → child orders +
      OrderGroup; completion_result becomes the group
  F4. order.status: 'draft' → 'placed'; cart.completed_at stamped;
      completing_at cleared; RecomputeStatuses
  F5. after_commit: publish order.placed / order.completed (notify_customer
      honored), digital fulfillment via FulfillmentProvider::Digital,
      user-account creation + newsletter subscribe (non-fatal side jobs)

Resumability is structural, not bookkept. The order's status: 'draft' + cart.completed_at: nil IS the "finalize pending" marker — no separate completion-state column. A retried Complete call resumes: replay check finds the draft order → skips PREPARE, re-verifies payment coverage (Y), re-runs FINALIZE (every step guards itself). Events fire only in after_commit of the transaction that flips draft → placed, so a rolled-back finalize never leaks an order.completed event or confirmation email.

Per-step idempotency (what makes retry safe):

StepGuard
Order creationunique orders.cart_id (DB constraint, not a lookup)
Paymentpayment records checked before processing (skip when a valid completed payment covers the total — today's Carts::Complete guard, kept)
Inventory finalizepending flag on units; unstock only pending units
Coupon/gift-card/promo counterspersisted per-cart flags, check-then-set
Digital linksfind_or_create per line item per quantity (fixes today's non-idempotent one-per-line-item callback)
Events/emailfired on the draft → placed flip only; confirmation_delivered? guard stays

Not every payment method is an external gateway. Offline methods — PaymentMethod::Check, B2B on-account/net-terms, cash on delivery, bank transfer, and anything recorded through the manual payments API — already flow through the same process_payments! seam but perform no external I/O: the method returns a local success response, and auto_capture? decides whether the payment lands pending (authorized, captured later by an admin when the invoice is settled) or completed. Consequences for this pipeline:

  • Offline payments collapse PAYMENT into FINALIZE. With no network call there is nothing to compensate and no partial-failure window, so the whole completion is a single transaction. The three-phase shape exists for external gateways; offline methods take the degenerate path.
  • payment_required? is not "must be paid now". A net-terms order completes with payment_total: 0 and a pending payment — that is a successfully completed, unpaid order, not a failure. Completion must gate on "a valid payment exists covering the total" (today's pending_payments.any? && unprocessed_payments.empty? guard), never on paid?. RecomputeStatuses reports payment_status: none/authorized; dunning and capture are post-completion admin/AR concerns.
  • Store credit and gift cards are offline by the same rule — already true today (recalculate_store_credit_payment in P3, store-credit payments voided last on compensation).
  • Mixed offline + external (deposit on card, remainder on terms): any external component forces the external path; the offline components are processed inside the same phase and voided alongside on compensation.
  • B2B/backoffice orders created through the Admin API bypass this pipeline's checkout ergonomics but must reuse the same service so idempotency, inventory finalization, and reservation release stay in one place — fixing today's asymmetry where Orders::Complete never releases reservations.
  • allow_checkout_on_gateway_error keeps its meaning for external methods only; it must not mask offline failures (which indicate a bug or a rule violation, not a flaky network).

Payment edge cases (external gateways):

  • Async success (3DS / redirect / webhook). Both writers call the same service: the customer's return-page POST /complete and Payments::HandleWebhook. Whoever runs first creates the order; the other replays it. The webhook keeps its order.with_lock + replay check, but safety comes from the unique index — the current 30-second job delay becomes a latency optimization, not a correctness mechanism.
  • Webhook says failed. Mark the payment session failed; do not compensate an order unless one exists in draft with no other valid payment — then compensate as Y3. Cart stays active for another attempt.
  • Payment captured, finalize keeps failing (e.g. DB partition). The order exists as draft with a successful payment. Never auto-refund on transient failure. A completion-recovery sweeper retries FINALIZE for stale draft orders with paid coverage; persistent failures alert operators. This is the precise scenario the order-before-payment ordering minimizes.
  • Partial store credit + external payment. Store-credit payments are (re)calculated in P3 (recalculate_store_credit_payment behavior carries over); the external session covers the remainder; Y2 processes both, store credit last-voided on compensation.
  • Zero-total carts (100% promo, gift card): Y is skipped entirely; F runs. The "never auto-complete during address updates" rule stays in Carts::Update#try_advance — only an explicit Complete call reaches this pipeline.
  • Late webhook after cart expiry. Cannot happen by construction: the reaper never deletes carts with authorized/pending payment sessions (money-safety exclusion, Key Decision 7).

Drift and mutation guards:

  • completing_at rejects cart mutations (items, addresses, discounts) with 409 cart_locked while payment is in flight — the price-freeze guarantee. TTL-stale locks are reclaimable so a crashed process never bricks a cart.
  • Recalculation happens inside the lock (P3): expired promotions drop out, price-list changes apply, tax re-estimates. The expected_total guard (P4) turns silent drift into an explicit client confirmation loop instead of charging an amount the customer never saw.
  • Stock: reservations are held/extended through completion; the P6 check catches reservation-less installs. Backorderable variants complete with backordered units (today's semantics — backorder is a fulfillment status, not a completion failure).
  • Optimistic concurrency: state_lock_version is renamed lock_version with identical increment-on-write semantics; a stale client gets 409 stale_cart on mutation, and Complete itself relies on the row lock instead.

Multi-vendor interaction. The idempotency lookup is completion_result(cart): exactly one of cart.order (single-partition — bare order, cart_id on it) or cart.order_group (multi-partition — cart_id on the group, child orders linked through it). The split runs inside FINALIZE (F3), i.e. after payment: a mid-split crash resumes like any finalize failure, and the replay returns the group once it exists.

API contract (POST /store/carts/:id/complete):

OutcomeResponse
Completed (including replay of an already-completed cart)200 { order } (or { order_group })
Validation failed422 { errors: [{ step, field, code, message }] } — same shape as the requirements attribute
Payment declined / failed pre-capture422 { error: { code: 'payment_failed', … } }
Totals changed since client render409 { error: { code: 'cart_changed', current_total } }
Another completion in flight409 { error: { code: 'completion_in_progress' } } — client polls/retries; replay returns the order when done
Cart mutated while locked409 cart_locked (on the mutation endpoints, not on complete)

Status recomputation

ruby
Spree::Orders::RecomputeStatuses.call(order:)   # the ONLY writer of both columns
# payment_status:  quantized(captured + charged) vs quantized(total − granted_refunds)
#                  → none/authorized/partially_paid/paid/partially_refunded/refunded/
#                    overcharged/voided
# fulfillment_status: aggregated from fulfillment records incl. backorder
#                     (domain owned by the fulfillment plan)

Triggered by subscribers on payment, refund, fulfillment, and return events — never inline from controllers.

Completion side-effect parity checklist

Everything today's finalize! + after_transition to: :complete chain does is accounted for in the flow above or explicitly relocated: adjustment freeze (→ split-adjustments order-level freeze), payment/shipment status updates (→ RecomputeStatuses), status: 'placed', completed_at, update hooks, risk check (considered_risky + order.updated), order.completed event, coupon-code use, gift-card redeem, newsletter subscribe, user-account creation, digital links (→ FulfillmentProvider::Digital, idempotent + per-quantity — fixing the current checkout-callback's non-idempotent one-per-line-item behavior), confirmation email (already event-driven via the emails subscriber; unchanged).

Migration Path

Phase 1 — schema (additive)

spree_carts; nullable cart_id on spree_line_items (order_id stays; presence validation relaxes to exactly-one); spree_orders.cart_id (unique); stored status columns per the normalize plan. spree_orders.state survives untouched until Phase 4.

Phase 2 — data (rake task)

Existing incomplete orders convert to Cart rows (they are today's carts): one Cart per incomplete order carrying token/binding/addresses; line items re-owned to the cart (cart_id set, order_id nulled); the hollow order row deleted. Incomplete orders mid-payment (valid payment sessions/authorizations) are converted last with their sessions re-linked. Completed orders are untouched except cart_id: nil. Batched, idempotent, resumable.

Phase 3 — model + services

Cart model + recalculation-on-write; Carts::Complete rewrite (flow above); Registry step-list ownership; legacy Cart::* deletion/re-homing; current_cart; merge strategy; reaper; predicates re-expressed (LINE_ITEM_REMOVABLE_STATES et al.).

Phase 4 — remove the machine

Delete Order::Checkout, the state column, checkout_flow API, order state_changes writes; Ransack whitelist swap; RecomputeStatuses + subscribers; the six out-of-band state writers (address services etc.) become cart-recalculation triggers.

Phase 5 — API/SDK/dashboard/docs

Contract is already step-shaped, so this is thin: carts/fulfillments_controller's cart.next loop → requirements-driven; serializers unchanged in shape; typelizer/Zod/OpenAPI regeneration; order_walkthrough/factories; extension-migration guide (checkout_flow → Registry); emails payment-link URL.

Phase 6 — cleanup (6.1)

Drop deprecation aliases and the migration task. (spree_line_items.order_id is NOT dropped — it's half of the permanent dual-FK owner pattern.)

Constraints on Current Work

  • Any new code touching line items uses line_item.owner, not line_item.orderowner is a method (order || cart); until Phase 1 lands it simply returns order, so the constraint is implementable today.
  • Don't add checkout states, transitions, or checkout_flow steps — extend Checkout::Registry instead.
  • Don't read or filter on order.state in new code — use completed_at/status predicates and the requirements API.
  • New money/fulfillment tables are born with the cart/order owner XOR (already honored by split-adjustments and fulfillment plans).
  • Don't build on state_changes rows where name: 'order' — they're going away; subscribe to lifecycle events.

Resolved Questions

  1. Order before payment (OSS platform A's ordering) — minimizes authorized-money rollback; compensation voids on post-payment failure.
  2. Cart retained, not deleted (OSS platform A over the hosted market leader) — idempotency, analytics, debugging.
  3. Statuses stored, not read-time-derived (OSS platform B over OSS platform A) — with the four hard-won derivation rules above.
  4. confirmation_required? self-reference eliminated — confirm-step existence computed from config + payment-method data only.
  5. Existing incomplete orders migrate to carts in Phase 2 — no dual-model serving window; the API façade means clients never notice.
  6. Guest token carried cart → order; permission sets check the same credential.
  7. Order state_changes dropped for lifecycle events; payment/fulfillment audits unaffected.
  8. awaiting_return/returned are not order statuses — returns lifecycle belongs to the returns plan.
  9. Completion resumability is structural: order.status: 'draft' + orders.cart_id present + cart.completed_at: nil is the "finalize pending" marker — no completion-state column. Mid-completion drafts are excluded from the admin Drafts list by cart_id: nil scoping (backoffice drafts have no cart).
  10. A completion-recovery sweeper retries FINALIZE for stale draft orders with paid coverage and alerts on persistent failure; transient failures never trigger auto-refund. Pre-capture payment failures compensate by destroying the draft order; post-capture failures never do.
  11. Offline payment methods are first-class, not a gateway special case. Check, B2B on-account/net-terms, COD, bank transfer, store credit, gift cards, and manual-API-recorded payments perform no external I/O, so PAYMENT collapses into the FINALIZE transaction with nothing to compensate. Completion gates on "a valid payment covers the total", not on paid? — a net-terms order completes unpaid with a pending payment and payment_status: none/authorized; capture and dunning are post-completion AR concerns. allow_checkout_on_gateway_error applies to external methods only.

Open Questions

  1. Extension migration window — does checkout_flow/insert_checkout_step get a hard removal in 6.0 or a one-release shim that translates registrations into Checkout::Registry entries? (Enterprise checkout customizations are the main consumers.)
  2. partially_canceled — today a quasi-state in the canceled scopes; proposed as a derived predicate. Confirm no report/export depends on it as a stored value.
  3. Reaper defaults — 30/90-day tiers are OSS platform B's; confirm against Spree hosting expectations before hardcoding defaults.

References

  • Codebase inventory + competitor research (2026-07-27): checkout-flow anatomy (order/checkout.rb:42-133 — 14 cart-phase / 8 completion / 11 post-completion callbacks), state-consumer census (public contract is step-shaped; scopes already on completed_at), OSS platform A completeCartWorkflow, OSS platform B checkout_complete.py (idempotency, is_voucher_usage_increased, expiry tiers), the hosted market leader Checkout-API shutdown, OSS platform C default-order-process.ts
  • Cautionary bugs: OSS platform A's repo#14095 (unfilterable derived status), #13882 (status stuck after return), the hosted market leader financial_status:expired filter divergence
  • Existing seams: spree/core/app/services/spree/carts/*.rb, spree/core/app/models/spree/checkout/{requirements,registry,default_requirements}.rb, api/app/serializers/spree/api/v3/cart_serializer.rb
  • Related plans: 6.0-split-adjustments.md, 6.0-fulfillment-and-delivery.md, 6.0-multi-vendor-marketplace.md, 6.0-returns-exchanges-claims.md, 6.0-normalize-state-to-status.md, 6.0-stock-reservations.md