AgentStack
SKILL verified MIT Self-run

Spree Shipping Fulfillment

skill-spree-agent-skills-spree-shipping-fulfillment · by spree

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". Provid…

No reviews yet
0 installs
4 views
0.0% view→install

Install

$ agentstack add skill-spree-agent-skills-spree-shipping-fulfillment

✓ scanned · ✓ verified — works with Claude Code, Cursor, and more.

Security review

✓ Passed

No issues found. Passed automated security review. · v0.1.0 How review works →

  • Prompt-injection patterns
  • Secret / credential exfiltration
  • Dangerous shell & filesystem operations
  • Untrusted network calls
  • Known-malicious package signatures

What it can access

  • Network access No
  • Filesystem access No
  • Shell / process execution No
  • Environment & secrets No
  • Dynamic code execution No

From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.

Are you the author of Spree Shipping Fulfillment? Claim this listing to set pricing, connect Stripe payouts, and keep 70% of every sale.
Sign up to claim

About

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

Manual fulfillments (3PL / external fulfillment)

To mirror an externally-managed fulfillment back into Spree — a 3PL or courier API shipped part of a completed order — use Spree::Fulfillments::Create (5.5+) instead of hand-building shipments. It bypasses order routing, moves the requested not-yet-shipped units out of their current shipments into a new one, and handles the stock bookkeeping (restock at source / unstock at target when locations differ):

result = Spree.fulfillment_create_service.call(
  order: order,                       # must be completed, not canceled
  stock_location: three_pl_location,
  items: [{ line_item: li, quantity: 1 }],   # nil = everything not yet shipped
  tracking: '1Z…',
  delivery_method: shipping_method,   # defaults to the drained source shipment's
  cost: '12.50',                      # explicit 3PL price; defaults to carrying over the source cost
  status: 'shipped'                   # register as already shipped ('shipped' is the only accepted value; omit for pending)
)

result.valid? # true if the fulfillment was created successfully

Note an explicit cost sticks only with status: 'shipped' — pending fulfillments get re-priced by the rate engine on the next recalculation.

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"

  • No ShippingMethod covers the address's Zone. Add a method for the country, OR add the country to an existing method's Zone.
  • No ShippingMethod covers the variant's ShippingCategory. Make sure each variant has a category and each method allows that category.
  • All methods' calculators return nil/zero erroneously. Inspect rates by calling Spree::Stock::Estimator.new(order).shipping_rates(package) for each package in order.shipments.map(&:to_package) in the console.

"Order ships from the wrong warehouse"

Order Routing decides the location: the default Spree::OrderRouting::Strategy::Rules walks the channel's routing rules (preferredlocation → minimizesplits → 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.md.

"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

  • Core concepts: node_modules/@spree/docs/dist/developer/core-concepts/shipments.md, inventory.md
  • Custom stock splitter: node_modules/@spree/docs/dist/developer/how-to/custom-stock-splitter.md
  • Custom order routing: node_modules/@spree/docs/dist/developer/how-to/custom-order-routing.md
  • Stock services: Spree::Stock::Estimator, Spree::Stock::Packer, Spree::Stock::Prioritizer, Spree::Stock::Splitter::Base (+ ShippingCategory/Backordered/Digital/Weight subclasses). Allocation goes through Spree::OrderRouting::Strategy::Rules by default; Spree::Stock::Coordinator is deprecated (slated for removal in 6.0) and survives only in the opt-in Legacy routing strategy, Spree::Exchange, and Spree::Cart::EstimateShippingRates.

Source & license

This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.

  • Author: spree
  • Source: spree/agent-skills
  • License: MIT
  • Homepage: https://spreecommerce.org/docs/developer/agentic/overview

Install and usage instructions live in the source repository linked above.

Reviews

No reviews yet — be the first.

Versions

  • v0.1.0 Imported from the upstream source.