| name | spree-shipping-fulfillment |
| description | Build and customize Spree's shipping and fulfillment — ShippingMethod, ShippingCategory, Zone/ZoneMember, ShippingRate, the Stock::Estimator service, StockLocation/StockItem/StockMovement, multi-shipment orders, ShippingCalculator classes (FlatRate, FlatPercentItemTotal, PerItem, FlexiRate), shipment state machine, returns (ReturnAuthorization → CustomerReturn → Reimbursement → Refund), and integrating carrier APIs (UPS, FedEx, ShipStation). Use when configuring shipping rules, building fulfillment integrations, or debugging shipping-rate calculations.
|
Spree Shipping & Fulfillment
Before writing code
Fetch live docs:
- Fetch https://spreecommerce.org/docs/developer/core-concepts/architecture (shipping section) for the current model graph.
- Inspect
Spree::Stock::Estimator and Spree::Stock::Splitter in the live spree gem source.
- Check the live
Spree::ShippingMethod source for column names and associations.
- For carrier integrations, search community gems (
spree-active-shipping, spree-ups, spree-fedex) — verify which are maintained for v5.
- Check release notes for any shipping-related changes (Markets in v5.4 affect shipping availability per region).
Conceptual Architecture
The Core Models
| Model | Purpose |
|---|
ShippingMethod | Named shipping option (UPS Ground, FedEx 2-Day) |
ShippingRate | Computed cost option offered to the user during checkout |
ShippingCategory | Categorize products by shipping needs (Hazmat, Frozen, Standard) |
Zone / ZoneMember | Geographic regions a method ships to |
Shipment | A subset of order items shipped together from one StockLocation |
InventoryUnit | One unit of a Variant in a Shipment |
StockLocation | Physical/logical warehouse |
StockItem | Count of a Variant in a StockLocation |
StockMovement | Append-only ledger of stock changes |
StockTransfer | Move stock between StockLocations |
Why Multiple Shipments per Order?
If an order's items span multiple StockLocations, Spree creates one Shipment per location. The Spree::Stock::Splitter allocates items by stock availability and configured policy (closest, fastest, cheapest, etc.).
ShippingMethod Availability
A ShippingMethod is offered for a Shipment if:
- The ShippingMethod's Zones include the destination
- The Variants' ShippingCategories overlap with the ShippingMethod's allowed categories
- The ShippingMethod is enabled for the current Store
- (v5.4+) The Market for the destination allows this ShippingMethod
Shipping Calculators
Polymorphic Calculator (same base class as promotion/tax calculators):
| Calculator | Math |
|---|
Spree::Calculator::Shipping::FlatRate | Fixed cost |
Spree::Calculator::Shipping::FlatPercentItemTotal | % of cart |
Spree::Calculator::Shipping::PerItem | Per-item flat |
Spree::Calculator::Shipping::FlexiRate | Tiered by total |
Spree::Calculator::Shipping::PriceSack | Free over threshold |
Spree::Calculator::Shipping::DigitalDelivery | Free for digital |
Carrier-API calculators (UPS, FedEx, USPS) come from community gems — they compute rates by calling the carrier's API live.
Stock::Estimator
Spree::Stock::Estimator.new(order).shipping_rates(package) returns the ShippingRate options for a given Package. Replaceable via Spree::Dependencies:
Spree::Dependencies.shipping_rate_estimator = MyCustomEstimator
This is the canonical extension point for custom shipping logic — preferable to decorating the model.
Shipment State Machine
pending → ready → shipped
↓
canceled
pending — awaiting payment / stock
ready — paid and stocked, ready to fulfill
shipped — shipped_at set, tracking number captured
canceled — canceled with order
Returns Flow
ReturnAuthorization (authorized | canceled)
└── ReturnItem[]
↓
CustomerReturn (when physically received)
└── ReturnItem[]
↓
Reimbursement (pending | reimbursed | errored)
└── Refund (against original Payment)
OR StoreCredit
Implementation Guidance
Setting Up Shipping for a New Store
us = Spree::Zone.create!(name: 'US')
us.zone_members.create!(zoneable: Spree::Country.find_by(iso: 'US'))
standard = Spree::ShippingCategory.find_or_create_by!(name: 'Standard')
method = Spree::ShippingMethod.create!(
name: 'Standard Shipping',
display_on: 'both',
shipping_categories: [standard],
zones: [us],
tax_category: Spree::TaxCategory.find_by(name: 'Shipping')
)
method.calculator = Spree::Calculator::Shipping::FlatRate.new(preferred_amount: 5.00)
method.save!
loc = Spree::StockLocation.create!(
name: 'Main Warehouse',
default: true,
active: true,
country: Spree::Country.find_by(iso: 'US')
)
Customizing Shipping Rate Selection
If you need custom logic (e.g., "always offer free shipping for Gold customers"):
Option A — Subscribe to events (preferred when reacting):
class FreeGoldShippingSubscriber < Spree::Subscriber
subscribes_to 'order.recalculate'
on 'order.recalculate', :apply_free_shipping
end
Option B — Swap the Estimator (preferred when modifying rate generation):
class MyEstimator < Spree::Stock::Estimator
def shipping_rates(package, frontend_only = true)
rates = super
rates.each { |r| r.cost = 0 } if package.order.user&.gold_tier?
rates
end
end
Spree::Dependencies.shipping_rate_estimator = MyEstimator
Adding a Carrier API Integration
Community gems for UPS/FedEx/USPS exist; verify v5 compatibility before adoption. The pattern:
- Add a
Spree::Calculator::Shipping::CarrierName calculator
- Calculator's
compute(package) calls the carrier's rate API
- Authenticate with carrier credentials stored in Rails credentials
- Cache rates for the cart's lifetime to avoid repeat API calls
Tracking Numbers and Webhooks
When the fulfillment provider ships:
shipment.update!(tracking: 'TRACKING123', shipped_at: Time.current)
shipment.ship!
Stock Management
stock_item.adjust_count_on_hand(10)
stock_item.reduce_count_on_hand(2)
Spree::StockTransfer.create!(
source_location: loc_a,
destination_location: loc_b,
stock_movements_attributes: [
{ quantity: 5, stock_item_id: source_item.id }
]
)
Backorders
If track_inventory is on and stock hits zero, Spree marks the InventoryUnit as backordered. The shipment goes into backorder state until stock returns.
To disable backorders globally:
Spree::Config[:allow_backorders] = false
Verify the exact preference name in current config.
Returns Workflow
ra = Spree::ReturnAuthorization.create!(
order: order,
return_items_attributes: items.map { |i| { inventory_unit_id: i.id } },
stock_location: order.shipments.first.stock_location
)
ra.authorize!
cr = Spree::CustomerReturn.create!(
return_items: ra.return_items,
stock_location: ra.stock_location
)
reimb = Spree::Reimbursement.create!(
customer_return: cr,
order: order,
return_items: cr.return_items
)
reimb.perform!
Common Pitfalls
- Missing ShippingCategory on a Product → no ShippingMethod can match → checkout fails at
delivery.
- Zone doesn't include the country → no ShippingMethod offered.
- Multiple StockLocations with overlapping inventory → unexpected multi-shipment splits. Configure
propagate_all_variants or pin to a primary location.
- Carrier API rate-limited during checkout → cache rates, fall back to flat rate on error.
- Forgetting to fire
shipped! when manually marking a shipment — no event, no email, no webhook.
- Stock movements without going through
adjust_count_on_hand — bypasses the ledger and breaks reconciliation.
Always verify model relationships and calculator names against the live source — shipping is one of the most decoupled subsystems and has community gems that vary in v5 readiness.