Back to Spree

Order Cancellation & Approval Models

docs/plans/5.5-6.0-order-cancellation-and-approval.md

5.5.014.7 KB
Original Source

Order Cancellation & Approval Models

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

Summary

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:

  • Cart/draft side (buyer organization) — buyers in a Spree::Company need internal sign-off before submitting an order. Lives on Cart (post-6.0-cart-order-split) — deferred to 6.0.
  • Order side (store owner) — merchants sometimes need to approve placed orders before fulfillment (high-value, fraud check, finance hold). Lives on Order. Ships in 5.5 with a single-approval flow; multi-level (manager / finance / admin) ships in 6.0.

Key Decisions (do not deviate without discussion)

  1. Two new models in 5.5: Spree::OrderCancellation and Spree::OrderApproval. Each is a separate table with FKs to Spree::Order, append-only history.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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' }.

  7. 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").

  8. 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.

  9. 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.

Design Details

Spree::OrderCancellation

spree_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:

ruby
has_many :cancellations, class_name: 'Spree::OrderCancellation', dependent: :destroy

Spree::OrderApproval

spree_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

Service updates (additive, backward-compatible)

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):

ruby
# 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):

  1. Create Spree::OrderCancellation record with provided params (reason: 'other' if omitted)
  2. Update Order denormalized columns (canceled_at, canceler_id) — existing behavior
  3. order.cancel! (existing state machine transition) — existing behavior
  4. If restock_items: true: create Spree::StockMovement rows
  5. If refund_payments: true: issue refunds up to refund_amount (defaults to order.payment_total)
  6. Publish order.canceled event with cancellation_id in payload

Spree::Orders::Approve (file: spree/core/app/services/spree/orders/approve.rb) similarly:

ruby
# 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:

  1. Create Spree::OrderApproval record (status: 'approved', decided_at: Time.current)
  2. Update Order denormalized columns (approved_at, approver_id) — existing behavior
  3. Publish order.approved event with approval_id in payload

Backward 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.

API endpoints

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

Serializer fields

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 Path

5.5

  1. Migration 1: create spree_order_cancellations and spree_order_approvals tables.

  2. Migration 2 (data): backfill one row per existing canceled_at/approved_at (rake task, not in migration per CLAUDE.md):

    • For each 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 }.
    • For each 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 }.
  3. Service updates: Spree::Orders::Cancel and Spree::Orders::Approve write both the new record AND the denormalized columns in one transaction.

  4. Model methods: Order#canceled_at, Order#canceler_id, Order#approved_at, Order#approver_id continue to read from columns (unchanged).

  5. API: ship the four new GET endpoints and the new POST/PATCH params.

6.0 (breaking change)

  1. Migration 3: drop canceled_at, canceler_id, approved_at, approver_id columns from spree_orders.

  2. Replace column accessors with method reads:

    ruby
    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
    
  3. Eager-loading: list endpoints preload(:cancellations, :approvals) to avoid N+1.

  4. API contract stays stablecanceled_at, canceler_id, approved_at, approver_id are still serialized fields. The data path is just methods instead of columns.

  5. 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.

Constraints on Current Work

  • Don't add new code reading 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).
  • New cancel/approve flows must go through the services, not direct column updates. Direct update_column(:canceled_at, ...) will be invalid in 6.0 (column won't exist).
  • Don't add B2B order-side approvals before 6.0 without considering the 6.0 multi-level design — adding single-level approvals now and migrating to multi-level later is OK.
  • Don't add cart-side (buyer org) approvals in 5.5 — wait for 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.

Open Questions

  1. 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.

  2. 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.

  3. 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.

  4. Bulk operations. Cancel-all and approve-all endpoints — defer per 6.0-admin-api.md open question on bulk envelope.

  5. 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.

  6. 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.

References

  • /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.mdSpree.admin_user_class polymorphism
  • Shopify OrderCancelReason — enum values reference
  • Medusa B2B Approval module — multi-level approval pattern