docs/plans/5.5-6.0-order-cancellation-and-approval.md
Status: 5.5 shipped (OrderCancellation + OrderApproval models + migrations); 6.0 cleanup (drop denormalized columns) pending
Target: Spree 5.5 (models + API ✓) and 6.0 (drop denormalized columns, B2B order-side approvals)
Depends on: 6.0-admin-api.md, 6.0-channels-catalogs-b2b.md, 6.0-cart-order-split.md
Author: Damian + Claude
Last updated: 2026-05-20
Replace the current denormalized canceled_at / canceler_id / approved_at / approver_id columns on Spree::Order with first-class Spree::OrderCancellation and Spree::OrderApproval models. Captures cancellation reasons, restock decisions, refund coupling, multi-level B2B approvals, and full history (cancel → resume → cancel-again preserves prior records).
5.5 ships the models and API alongside the existing columns (denormalized columns mirror the latest record for fast queries). 6.0 drops the denormalized columns entirely — clients and internal callers read the last record directly. This is a deliberate breaking change in 6.0 to eliminate the out-of-sync risk that denormalization always carries.
B2B approvals are scoped to two sides:
Spree::Company need internal sign-off before submitting an order. Lives on Cart (post-6.0-cart-order-split) — deferred to 6.0.Order. Ships in 5.5 with a single-approval flow; multi-level (manager / finance / admin) ships in 6.0.Two new models in 5.5: Spree::OrderCancellation and Spree::OrderApproval. Each is a separate table with FKs to Spree::Order, append-only history.
5.5 keeps denormalized columns (canceled_at, canceler_id, approved_at, approver_id) for backward compatibility and fast queries. They mirror the latest record in each respective collection.
6.0 drops the denormalized columns. Reads come from order.cancellations.order(:created_at).last and order.approvals.order(:created_at).last. This is a breaking change for any direct DB consumer or extension that reads these columns; the API surface (canceled_at, approver_id) stays stable but is now derived.
Order#status ('draft' / 'placed' / 'canceled') is the source of truth for "is this order canceled right now." The cancellation record is history; the status column is current state. Cancel: status: 'canceled' + new OrderCancellation row. Resume (5.5 only — see open questions): status: 'placed' + previous cancellation row preserved.
B2B cart-side approvals are out of scope for 5.5. They land in 6.0 alongside Spree::Company, Spree::CompanyContact, and the Cart model split. The cart-side approval is a different concept (buyer organization internal sign-off) than the merchant-side order approval shipping in 5.5.
Approval is opt-in per-store. No flow forces approvals; they're a hook for merchants who want them. Spree::Order#requires_approval? is a method merchants override (or a per-store config); when true, status: 'placed' does not auto-fulfill — fulfillments wait for an OrderApproval { status: 'approved' }.
Reasons follow Shopify's enum (string-stored): customer, declined, fraud, inventory, staff, other, expired. Stored as strings, not Rails enums (per CLAUDE.md "Use string columns instead of enums").
Cancellation drives downstream actions transactionally. When restock_items: true, the same transaction creates Spree::StockMovement rows. When refund_payments: true, refunds are issued up to refund_amount. Failures roll back the cancellation record.
Polymorphic actor. canceled_by and approver are polymorphic (Spree::AdminUser, Spree::User, Spree::CompanyContact in 6.0, or nil for system actions). Replaces the existing canceler_id / approver_id integer FKs that assumed Spree.admin_user_class.
Spree::OrderCancellationspree_order_cancellations
id string (prefixed: cncl_)
order_id string (FK → spree_orders)
reason string (NOT NULL) -- Shopify enum, stored as string
note text -- staff-facing note
restock_items boolean (default false)
refund_payments boolean (default false)
refund_amount decimal(10,2) -- nullable; used when refund_payments: true
notify_customer boolean (default false)
canceled_by_id string -- polymorphic
canceled_by_type string -- polymorphic
metadata jsonb (default {})
created_at datetime
updated_at datetime
index: order_id
index: canceled_by_id, canceled_by_type
index: created_at
Spree::Order association:
has_many :cancellations, class_name: 'Spree::OrderCancellation', dependent: :destroy
Spree::OrderApprovalspree_order_approvals
id string (prefixed: appr_)
order_id string (FK → spree_orders)
status string (NOT NULL) -- 'pending' | 'approved' | 'rejected'
level string -- nullable in 5.5; for 6.0 multi-level: 'manager' | 'finance' | 'admin'
note text
approver_id string -- polymorphic; nullable while status='pending'
approver_type string -- polymorphic
decided_at datetime -- nullable while status='pending'
metadata jsonb (default {})
created_at datetime
updated_at datetime
index: order_id, status
index: approver_id, approver_type
We reuse the existing Spree::Orders::Cancel and Spree::Orders::Approve services. The existing keyword arguments stay valid; the new ones are optional. Existing callers (storefront resume/cancel flows, admin engine, internal code) keep working without changes.
Spree::Orders::Cancel (file: spree/core/app/services/spree/orders/cancel.rb):
# Existing signature — STAYS VALID
Spree.order_cancel_service.call(
order: order,
canceler: current_admin, # legacy keyword preserved
canceled_at: Time.current
)
# New signature — accepts new keywords additively
Spree.order_cancel_service.call(
order: order,
canceler: current_admin, # still works
reason: 'inventory', # NEW (defaults to 'other' if omitted)
note: 'Out of stock', # NEW
restock_items: true, # NEW
refund_payments: true, # NEW
refund_amount: order.total, # NEW
notify_customer: false, # NEW
canceled_at: Time.current
)
Internal flow (additive, single transaction):
Spree::OrderCancellation record with provided params (reason: 'other' if omitted)canceled_at, canceler_id) — existing behaviororder.cancel! (existing state machine transition) — existing behaviorrestock_items: true: create Spree::StockMovement rowsrefund_payments: true: issue refunds up to refund_amount (defaults to order.payment_total)order.canceled event with cancellation_id in payloadSpree::Orders::Approve (file: spree/core/app/services/spree/orders/approve.rb) similarly:
# Existing signature — STAYS VALID
Spree.order_approve_service.call(order: order, approver: current_admin)
# New signature
Spree.order_approve_service.call(
order: order,
approver: current_admin, # still works
level: 'manager', # NEW — 6.0 multi-level; 5.5 stores it but ignores routing logic
note: 'Approved per phone call' # NEW
)
Internal flow:
Spree::OrderApproval record (status: 'approved', decided_at: Time.current)approved_at, approver_id) — existing behaviororder.approved event with approval_id in payloadBackward compat requirement: all existing specs covering Spree::Orders::Cancel/Approve must continue to pass without changes. The new behavior is purely additive — new records get written under the hood when these services run, regardless of whether the new keywords are provided.
PATCH /api/v3/admin/orders/:id/cancel — body accepts the new params. Returns the order with the new cancellation record nested if ?expand=cancellations.
PATCH /api/v3/admin/orders/:id/approve — body accepts level and note. Returns the order with the new approval record nested if ?expand=approvals.
New read-only endpoints (5.5):
GET /api/v3/admin/orders/:order_id/cancellations
GET /api/v3/admin/orders/:order_id/cancellations/:id
GET /api/v3/admin/orders/:order_id/approvals
GET /api/v3/admin/orders/:order_id/approvals/:id
OrderCancellationSerializer:
id, order_id, reason, note, restock_items, refund_payments, refund_amount,
notify_customer, canceled_by_id, canceled_by_type, metadata, created_at
OrderApprovalSerializer:
id, order_id, status, level, note, approver_id, approver_type,
decided_at, metadata, created_at
Existing Admin::OrderSerializer keeps canceled_at, canceler_id, approved_at, approver_id fields. In 5.5 they're column reads; in 6.0 they become method reads (order.canceled_at becomes order.cancellations.order(:created_at).last&.created_at). The API contract is unchanged.
Migration 1: create spree_order_cancellations and spree_order_approvals tables.
Migration 2 (data): backfill one row per existing canceled_at/approved_at (rake task, not in migration per CLAUDE.md):
Order WHERE canceled_at IS NOT NULL: create OrderCancellation { reason: 'other', canceled_by_id: canceler_id, canceled_by_type: Spree.admin_user_class.to_s, created_at: canceled_at }.Order WHERE approved_at IS NOT NULL: create OrderApproval { status: 'approved', approver_id: approver_id, approver_type: Spree.admin_user_class.to_s, decided_at: approved_at, created_at: approved_at }.Service updates: Spree::Orders::Cancel and Spree::Orders::Approve write both the new record AND the denormalized columns in one transaction.
Model methods: Order#canceled_at, Order#canceler_id, Order#approved_at, Order#approver_id continue to read from columns (unchanged).
API: ship the four new GET endpoints and the new POST/PATCH params.
Migration 3: drop canceled_at, canceler_id, approved_at, approver_id columns from spree_orders.
Replace column accessors with method reads:
def canceled_at
cancellations.order(:created_at).last&.created_at
end
def canceler
cancellations.order(:created_at).last&.canceled_by
end
def approved_at
latest_approval = approvals.where(status: 'approved').order(:decided_at).last
latest_approval&.decided_at
end
def approver
approvals.where(status: 'approved').order(:decided_at).last&.approver
end
Eager-loading: list endpoints preload(:cancellations, :approvals) to avoid N+1.
API contract stays stable — canceled_at, canceler_id, approved_at, approver_id are still serialized fields. The data path is just methods instead of columns.
Multi-level approvals: OrderApproval#level becomes meaningful. Spree::Order#fully_approved? checks all required levels per Spree::Store#required_approval_levels. Buyer-side cart approvals (Spree::Cart model post 6.0-cart-order-split.md) get a parallel Spree::CartApproval model — separate from OrderApproval.
Order#canceler_id or Order#approver_id as integers. They become methods returning prefixed IDs in 6.0. Use Order#canceler / Order#approver (the polymorphic association).update_column(:canceled_at, ...) will be invalid in 6.0 (column won't exist).6.0-cart-order-split.md and 6.0-channels-catalogs-b2b.md to land. Doing so prematurely will require rework once Cart and Company exist.Resume semantics in 5.5. Today Spree::Order#resume! flips state from canceled back to complete. With the new model, does resume create an "anti-cancellation" record, or just a status flip? Lean toward status flip for now; the cancellation row stays as historical record. In 6.0 resume may be deprecated entirely.
Cancellation by line item. Vendure supports partial cancellations (cancel specific lines, keep others). Current Spree doesn't. Defer to 6.0 alongside 6.0-returns-exchanges-claims.md — the line-item-cancel use case overlaps with returns/refunds.
Approval requirement signal. How does Order#requires_approval? decide? Per-store config (store.require_order_approval)? Per-order metadata flag? Per-customer-group rule? Defer to 6.0 channels/catalogs/B2B work.
Bulk operations. Cancel-all and approve-all endpoints — defer per 6.0-admin-api.md open question on bulk envelope.
OrderCancellation#refund_amount semantics. When refund_payments: true and refund_amount is omitted — refund the full order total, or only payments captured to date? Recommend: full payment_total (order.payment_total), matching what merchants actually expect.
Cancellation event payload. order.canceled event currently carries the order's prefixed ID. Should the new event payload carry the cancellation's prefixed ID too (so subscribers can read the reason without re-querying)? Lean yes — payload includes cancellation_id.
/Users/damian/spree/docs/plans/6.0-admin-api.md — Orders endpoint list (canc/appr stay under /admin/orders/:id/cancel + /approve)/Users/damian/spree/docs/plans/6.0-channels-catalogs-b2b.md — buyer-org approvals (cart-side, 6.0)/Users/damian/spree/docs/plans/6.0-cart-order-split.md — Cart model arrives in 6.0/Users/damian/spree/docs/plans/6.0-platform-auth.md — Spree.admin_user_class polymorphismOrderCancelReason — enum values reference