Agent Skill · Spree Commerce

spree-shipping-fulfillment

Use when the user is working with Spree's shipping system — shipments, shipping methods, shipping rates, stock locations, the shipment state machine, splitter logic, returns. Common phrasings include "shipping method", "calculate shipping rate", "stock location", "shipment stuck in pending", "order fulfillment", "ship from", "shipping zone", "shipping category", "returns", "reimbursement". Provides the shipping graph, the state machine, and customization hooks.

Provider: Spree Commerce Path in repo: skills/spree-shipping-fulfillment/SKILL.md

Skill body

Spree Shipping

The shipping system answers two questions: where is this order going (the shipping method) and where is it shipping from (the stock location). Together they produce one or more Shipments — each Shipment represents a package leaving a specific StockLocation via a specific ShippingMethod.

The shipping graph

Order
  └── Shipment (one per fulfillment package — may be many per order)
        ├── StockLocation        — where it ships from
        ├── ShippingRate × n     — offered rates from configured methods
        │    └── ShippingMethod  — UPS Ground, USPS Priority, etc.
        └── InventoryUnit × n    — per LineItem, each carrying a quantity (may split, e.g. partial backorder)

ShippingMethod
  ├── ShippingCategory × n       — which categories of items this method handles
  ├── Calculator                 — how much (per-order, per-item, weight-based)
  └── Zone × n                   — where this method applies

Variant
  └── ShippingCategory           — heavy items, fragile items, digital, etc.

Each line item’s variant has a ShippingCategory. A ShippingMethod’s eligibility for a shipment depends on whether the variant’s category is in the method’s allowed categories AND the destination is in the method’s Zone.

Shipment state machine

pending ──ready──→ ready ──ship──→ shipped
   │    ←─pend──     │                ↑
   │                 │                │
   └────cancel───→ canceled ──ship────┘
                      │
                      └──resume──→ ready (or pending if not yet ready)

(cancel is allowed from both pending and ready; resume returns a canceled shipment to ready — or pending when the order isn’t ready — and a canceled shipment can be shipped directly via ship.)

State What it means
pending Created but not yet ready to ship (waiting on payment, allocation, etc.)
ready Inventory allocated, ready for the warehouse to pick
shipped Picked up by carrier; tracking number set
canceled Order was canceled; the shipment doesn’t go out

Transitions: ready, pend (back to pending), ship, cancel, resume. The ship transition fires the shipment.shipped event (subscribers see it) and updates order.shipment_state to shipped or partial based on the order’s other shipments. The cancel and resume transitions fire shipment.canceled and shipment.resumed respectively.

How shipping rates get calculated

When an order enters the delivery checkout state (after address):

For each Shipment in order:
  package = shipment.to_package
  Spree::Stock::Estimator.new(order).shipping_rates(package)
    ↓
    For each ShippingMethod where:
      - method.shipping_categories.include?(variant.shipping_category)
      - method.zones.include?(zone_for(order.ship_address))
    ↓
    Run method.calculator.compute(package)
    ↓
    ShippingRate(shipping_method: method, cost: amount, selected: best)

The cheapest rate per Shipment is selected: true by default. The customer can pick a different one in the checkout UI.

Built-in calculators

Calculator Computes
Spree::Calculator::Shipping::FlatRate Same flat rate; optional min/max weight and item-total bounds (returns nil → method unavailable outside them)
Spree::Calculator::Shipping::FlatPercentItemTotal % of order item total
Spree::Calculator::Shipping::PerItem Rate × number of items
Spree::Calculator::Shipping::FlexiRate Tiered by item count
Spree::Calculator::Shipping::PriceSack Tiered by order total (e.g. under $50 = $10, over $50 = free)
Spree::Calculator::Shipping::DigitalDelivery Configurable amount (default 0); only available when every item in the package is digital

Custom shipping calculators subclass Spree::ShippingCalculator and implement compute_package(package) (optionally available?(package)). The package is a Spree::Stock::Package with contents, total weight, and item total.

StockLocation

Stock is tracked per-Variant per-StockLocation via Spree::StockItem. A store has at least one StockLocation; multi-warehouse stores have many.

warehouse = Spree::StockLocation.create!(
  name: 'East Coast Warehouse',
  address1: '...',
  city: '...',
  state_id: Spree::State.find_by(name: 'New York').id,
  country_id: Spree::Country.find_by(iso: 'US').id,
  propagate_all_variants: true,  # auto-create StockItem for every Variant
  active: true,
  backorderable_default: false,
  default: false                 # only one StockLocation can be the default
)

variant.stock_items.where(stock_location: warehouse).first.count_on_hand
variant.total_on_hand   # summed across all locations

StockMovement

Stock changes are recorded as Spree::StockMovement entries — an audit log:

stock_item = warehouse.stock_item_or_create(variant)
stock_item.stock_movements.create!(
  quantity: 10,                  # positive = received, negative = sold/lost
  originator: purchase_order     # polymorphic — what caused the movement
)
# or the higher-level helpers (these wrap the same StockMovement creation):
warehouse.restock(variant, 10, purchase_order)
warehouse.unstock(variant, 2, shipment)

Don’t update count_on_hand directly; create a StockMovement and let the model recompute the count.

How Shipments split across StockLocations

When an order is split into shipments, Spree groups InventoryUnits by where they can ship from:

Order has 3 items: [A from East, B from East, C from West]
  ↓
Order Routing (`order.order_routing_strategy.for_allocation`, default `Spree::OrderRouting::Strategy::Rules`) ranks eligible StockLocations via the channel's routing rules, packs each location (`Spree::Stock::Packer` + the `Spree.stock_splitters` chain), and `Spree::Stock::Prioritizer` assigns each inventory unit to the first ranked package with on-hand stock
  ↓
Creates 2 Shipments:
  - Shipment 1: items A + B from East Warehouse
  - Shipment 2: item C from West Warehouse

Since Spree 5.5, which stock locations fulfill an order is decided by Order Routing, not the splitters. Each Channel has an ordered list of Spree::OrderRoutingRule rows (STI subclasses: Spree::OrderRouting::Rules::PreferredLocation, MinimizeSplits, DefaultLocation — seeded in that priority order) that rank the candidate stock locations. The routing strategy is configurable via store.preferred_order_routing_strategy (default Spree::OrderRouting::Strategy::Rules), overridable per channel; Spree::OrderRouting::Strategy::Legacy restores the pre-5.5 Spree::Stock::Coordinator behavior and is dropped in 6.0. Splitters then break each chosen location’s allocation into packages within that location.

Each Shipment gets its own ShippingRate calculation (different origin = different rates). The customer pays each shipment’s selected rate.

For location-preference logic — distance-based, prefer-closest-warehouse, minimize splits — write a custom order routing rule or strategy, see node_modules/@spree/docs/dist/developer/how-to/custom-order-routing.mdx. For breaking one location’s allocation into more packages (refrigerated, hazmat, gift wrap), write a custom splitter — see node_modules/@spree/docs/dist/developer/how-to/custom-stock-splitter.mdx.

Returns + Reverse Logistics

A customer wants to return an item:

ReturnAuthorization (admin-created, lists which InventoryUnits)
  ↓
Customer ships item back
  ↓
CustomerReturn (admin-received, links InventoryUnits to receipt)
  ↓
Reimbursement (calculates the refund amount from the return items' amounts — admins can adjust per-item amounts before reimbursing)
  ↓
Refund (to original payment) OR StoreCredit

Admin creates a ReturnAuthorization listing the InventoryUnits the customer is returning. When the items come back, an admin records a CustomerReturn to mark the units received. The Reimbursement calculates the refund amount from the return items’ amounts, and produces either a Refund (back to the original payment method) or a StoreCredit.

Customizing shipping

Custom calculator

# app/models/spree/calculator/shipping/weight_based.rb
module Spree
  class Calculator::Shipping::WeightBased < Spree::ShippingCalculator
    preference :rate_per_kg, :decimal, default: 5.00

    def compute_package(package)
      package.weight * preferred_rate_per_kg
    end
  end
end

# Register so it shows in the admin shipping method UI (config/initializers/spree.rb)
Rails.application.config.after_initialize do
  Spree.calculators.shipping_methods << Spree::Calculator::Shipping::WeightBased
end

Hooking into shipment events

For external warehouse integration, subscribe to shipment.shipped:

class ShipmentShippedSubscriber < Spree::Subscriber
  subscribes_to 'shipment.shipped'

  def call(event)
    shipment = Spree::Shipment.find_by_prefix_id(event.payload['id'])
    return unless shipment

    ExternalWarehouseAPI.notify_dispatched(
      tracking: shipment.tracking,
      shipping_method: shipment.shipping_method.name,
      order_number: shipment.order.number
    )
  end
end

See the spree-events-webhooks skill for the events system.

Custom shipping rate ranking

By default, the cheapest rate is selected. To prefer carrier reliability, decorate Spree::Stock::Estimator:

module Spree::Stock::EstimatorDecorator
  # which rate is pre-selected
  def choose_default_shipping_rate(rates)
    preferred = rates.find { |r| r.shipping_method.name == 'UPS Ground' }
    (preferred || rates.min_by(&:cost))&.selected = true
  end

  # display order
  def sort_shipping_rates(rates)
    rates.sort_by { |r| [r.shipping_method.name == 'UPS Ground' ? 0 : 1, r.cost] }
  end

  Spree::Stock::Estimator.prepend self
end

Note: sort_shipping_rates only affects display order; choose_default_shipping_rate (which runs first, picking the cheapest by default) decides which rate gets selected: true.

Common shipping problems

“Shipment stuck in pending

Walk this list:

  1. Payment not complete? Shipment doesn’t move to ready until the order is paid. order.payment_state == 'paid'.
  2. Inventory not allocated? shipment.inventory_units.all? { |iu| iu.on_hand? } — if any are backordered, it’s waiting on stock.
  3. determine_state returning pending? That’s the explicit blocker; check what state the Shipment thinks the order is in via shipment.determine_state(shipment.order).

“No shipping rates appear at checkout”

“Order ships from the wrong warehouse”

Order Routing decides the location: the default Spree::OrderRouting::Strategy::Rules walks the channel’s routing rules (preferred_location → minimize_splits → default_location baseline). Adjust the channel’s Spree::OrderRoutingRule rows, or for closest-warehouse-wins write a custom routing rule (implementing #rank(order, locations)) or strategy — see node_modules/@spree/docs/dist/developer/how-to/custom-order-routing.mdx.

“Shipping rate doesn’t update when cart changes”

The rates are cached per Shipment after first calculation. When the cart changes (line item added/removed), the Shipment is destroyed and recreated, so rates do recompute at the next call to Spree::Stock::Estimator. If you’re displaying rates in a Turbo Frame, make sure to re-render on cart updates.

Where to read further