docs/plans/6.0-cart-order-split.md
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
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.
The codebase is further along than the old stub implied — the split formalizes seams that are already drawn:
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.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).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 already — belongs_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.Spree::Cart model; Order keeps its tableSpree::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.
cart_id/order_id, exactly-one) — NOT polymorphicSupersedes 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_id — order_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.
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.
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.cart.with_lock + completing_at guard against parallel attempts.credits_count inputs) guarded by persisted flags (OSS platform B's is_voucher_usage_increased) — counters are the classic double-count victim on retry.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.(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.
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.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.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.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.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.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).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.Spree::Cart::* services removed; Carts::* become the registered seamOf 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).
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.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.checkout_state_url(token, :payment) in the payment-link email → a cart checkout URL.Spree::Carts::Complete) — pipeline, edge cases, recoveryCompletion 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):
| Step | Guard |
|---|---|
| Order creation | unique orders.cart_id (DB constraint, not a lookup) |
| Payment | payment records checked before processing (skip when a valid completed payment covers the total — today's Carts::Complete guard, kept) |
| Inventory finalize | pending flag on units; unstock only pending units |
| Coupon/gift-card/promo counters | persisted per-cart flags, check-then-set |
| Digital links | find_or_create per line item per quantity (fixes today's non-idempotent one-per-line-item callback) |
| Events/email | fired 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:
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.recalculate_store_credit_payment in P3, store-credit payments voided last on compensation).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):
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.draft with no other valid payment — then compensate as Y3. Cart stays active for another attempt.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.recalculate_store_credit_payment behavior carries over); the external session covers the remainder; Y2 processes both, store credit last-voided on compensation.Carts::Update#try_advance — only an explicit Complete call reaches this pipeline.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.expected_total guard (P4) turns silent drift into an explicit client confirmation loop instead of charging an amount the customer never saw.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):
| Outcome | Response |
|---|---|
| Completed (including replay of an already-completed cart) | 200 { order } (or { order_group }) |
| Validation failed | 422 { errors: [{ step, field, code, message }] } — same shape as the requirements attribute |
| Payment declined / failed pre-capture | 422 { error: { code: 'payment_failed', … } } |
| Totals changed since client render | 409 { error: { code: 'cart_changed', current_total } } |
| Another completion in flight | 409 { error: { code: 'completion_in_progress' } } — client polls/retries; replay returns the order when done |
| Cart mutated while locked | 409 cart_locked (on the mutation endpoints, not on complete) |
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.
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).
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.
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.
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.).
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.
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.
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.)
line_item.owner, not line_item.order — owner is a method (order || cart); until Phase 1 lands it simply returns order, so the constraint is implementable today.checkout_flow steps — extend Checkout::Registry instead.order.state in new code — use completed_at/status predicates and the requirements API.state_changes rows where name: 'order' — they're going away; subscribe to lifecycle events.confirmation_required? self-reference eliminated — confirm-step existence computed from config + payment-method data only.state_changes dropped for lifecycle events; payment/fulfillment audits unaffected.awaiting_return/returned are not order statuses — returns lifecycle belongs to the returns plan.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).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.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.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.)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.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.tsfinancial_status:expired filter divergencespree/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.rb6.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