mcpField
S
spree-fulfillment

spree-fulfillment

github.com/spreeDevelopment10104 installsUpdated 4w ago
Repository

About spree-fulfillment

spree-fulfillment is a Spree skill for understanding fulfillment records, shipping workflows, rates, providers, tracking, and stock routing. It is intended for developers extending Spree’s order delivery processes.

Key features

  1. 01Fulfillment model and status guidance
  2. 02Ship, split, cancel, and delivery workflows
  3. 03Delivery rates and provider extension points
  4. 04Carrier labels and tracking integration
  5. 05Stock location and item routing

Use cases

  1. 01Implement partial or manual order fulfillment
  2. 02Add a carrier or 3PL provider
  3. 03Process tracking updates and delivery webhooks

Install

Copy the command and run it in a terminal.

$npx skills add https://github.com/spree/agent-skills --skill spree-fulfillment

After installing, type/spree-fulfillmentin Claude Code to confirm it loaded.

Bundled filesreferences/provider-contracts.md

Details

From the project README

Spree Fulfillment

A fulfillment (Spree::Fulfillment, prefix ful_, number F…) is one batch of items going to the customer by one delivery method from one stock location — a parcel, a download, or an order waiting at a pickup counter. A cart/order gets one fulfillment per (stock location × delivery profile × splitter output); the customer picks a delivery rate per fulfillment.

Coming from 5.x (Shipment, ShippingMethod, StockItem, shipment state machine)? Constant aliases exist until 6.1, but see spree-upgrade-5-to-6 — don't write new code against the old names.

The model graph

Cart / Order
  └── Fulfillment (ful_)                 status: unfulfilled → fulfilled → delivered | canceled
        ├── stock_location (sloc_)        where it ships from
        ├── FulfillmentItem (fi_) × n     line_item + variant + quantity; status on_hand/backordered/shipped/returned
        ├── DeliveryRate (dr_) × n        the options offered; one has selected: true
        │     └── DeliveryMethod (dm_)    what the customer picks ("Standard", "Express", "Collect in store")
        ├── Delivery (dlv_) × n           each consignment/parcel: tracking_number, carrier, status
        ├── ShippingLabel (lbl_) × n      postage bought (purchased) or recorded (uploaded)
        └── TaxLine / Discount / Fee      delivery money rows (see spree-order-totals)

Product ──> DeliveryProfile (fp_)        how a product ships: DeliveryProfiles::Shipping | ::Digital
              ├── DeliveryOriginGroup (og_) × n   a named set of stock locations ("EU warehouse")
              ├── DeliveryZone (dz_) × n          where: DeliveryZoneMember (dzm_) country | state | postal_code
              └── DeliveryMethod × n              belongs_to profile + origin group, optional zone
                    ├── calculator                 how much (when priced internally)
                    ├── rate_provider              where the price comes from (default Internal)
                    ├── fulfillment_provider       how it's dispatched (Manual | Digital | Pickup | PickupPoint | carrier gem)
                    └── DeliveryMethodRule (dmrule_) × n   eligibility, AND-ed

PackageType (pkgtype_)                    box/envelope/carton/pallet/container — tare + dimensions for quotes

Things to internalize:

  • Every fulfillment has exactly one owner — cart_id during checkout, order_id after placement. Read fulfillment.owner, never assume .order.
  • Delivery type comes from the method's fulfillment provider class, not a string column: delivery_method.digital?, .pickup?, .pickup_point?, .requires_address?. The API exposes fulfillment_type (shipping | digital | pickup | pickup_point) on the fulfillment.
  • The delivery profile decides what a product can use. A digital profile only accepts digital providers; a shipping profile accepts everything else. Origin groups narrow which locations may source the product — the coordinator never packs a product from a location its profile doesn't cover.
  • cart.delivery_step_required? is line_items.any? && !digital? — an all-digital cart skips delivery. shipping_address_required? asks the selected methods (or the profiles, before a method is chosen) — a shipping profile offering only pickup needs no address.
  • Delivery zones are delivery-only. Tax zones are separate (spree-taxes).

Statuses

Spree::Fulfillment uses has_status :unfulfilled, :fulfilled, :delivered, :canceled — a string status column, no state machine. Statuses describe what the merchant did with the parcel and only move forward:

StatusMeaning
unfulfilledStill with you
fulfilledHanded over (carrier, pickup counter, or download made available); fulfilled_at set
deliveredCustomer has it; delivered_at set (from the carrier's scan, not webhook arrival)
canceledWon't be sent. Final — there is no resume

Payment/backorder readiness is not a status. It is checked by Spree::Fulfillments::Fulfill at the moment you ship, and returns a reason (order_not_paid, backordered_units, insufficient_stock_on_hand, order_draft). Staff pass force: true to ship against an unpaid invoice or past a backorder (never past a draft order). can_fulfill? / can_cancel? are phrased negatively ("not already gone out"), so custom pre-handover statuses stay fulfillable.

Carrier truth lives on each Spree::Delivery (Spree::Delivery::STATUSES: pending pre_transit in_transit out_for_delivery available_for_pickup delivered return_to_sender failure unknown) and never moves the fulfillment backwards. The fulfillment becomes delivered when every consignment has arrived. tracking / tracking_url on the fulfillment summarize the first delivery.

The order rolls this up into order.fulfillment_status (written only by Spree::Orders::UpdateStatuses): unfulfilled, partial, fulfilled, delivered, backorder, canceled. Don't write it yourself.

Transitions are workflows

Never update!(status: 'fulfilled'). Call the workflow through its DI key so apps can swap it:

ActionWorkflow (Spree.<key>)Hooks
Ship (whole or partial)fulfillment_fulfill_workflow → Spree::Fulfillments::Fulfillvalidate, after_fulfill
Confirm receiptfulfillment_mark_delivered_workflow → Fulfillments::MarkDeliveredvalidate, after_mark_delivered
Cancelfulfillment_cancel_workflow → Fulfillments::Cancelvalidate, after_cancel
Manual/3PL fulfillment on a placed orderfulfillment_create_workflow → Fulfillments::Createvalidate, get_provider_data, after_create
Edit (tracking, rate, location)fulfillment_update_workflow → Fulfillments::Update
Buy a label ahead of dispatchfulfillment_purchase_label_workflow → Fulfillments::PurchaseLabel
Carrier tracking updatedelivery_update_tracking_workflow → Deliveries::UpdateTrackingvalidate, after_update_tracking
result = Spree.fulfillment_fulfill_workflow.call(
  fulfillment: fulfillment,
  items: [{ line_item: line_item, quantity: 1 }], # omit to ship everything
  tracking: '1Z999AA10123456784',                # becomes the primary Spree::Delivery
  notify_customer: true,
  force: false
)
result.value # the fulfillment that actually shipped — for a partial ship, the NEW split-off one

What Fulfill does, in order: validate hook → guards → split off requested units (via Fulfillments::Create, cost 0) → record tracking → buy a label if the provider generates_labels? and none is active (failure is reported, parcel still ships) → provider.create_fulfillment → unstock allocated units (shipped movements) → mark items shipped + fulfillment fulfilled + publish fulfillment.fulfilled → capture payments whose method charges on dispatch → roll up order statuses.

Manual fulfillments (mirroring a 3PL or courier back into Spree): POST /api/v3/admin/orders/:order_id/fulfillments with stock_location_id, items: [{ item_id: 'li_…', quantity }], optional tracking, delivery_method_id, cost, status, metadata. Units move out of their current fulfillments (restock/unstock when locations differ); emptied sources are destroyed and their cost carries over. Quirk: the only accepted status value is the legacy 'shipped' (registers it already fulfilled); anything else fails invalid_status.

Splitting without shipping: PATCH …/fulfillments/:id/split takes variant_id, quantity, optional stock_location_id (moves units to a new fulfillment, possibly at another location) and returns every fulfillment on the order.

Gating and custom stages

Gate handover with a validate hook — handlers receive the workflow and call reject!:

# config/initializers/spree.rb
Spree.hooks.register('fulfillments.fulfill.validate') do |flow|
  work_order = MyApp::WorkOrder.find_by(fulfillment_id: flow.fulfillment.id)
  flow.reject!('Still in production') unless work_order&.completed?
end

Hook keys are <workflow key>.<hook> where the workflow key is the class name minus Spree::, underscored and dotted (Spree::Fulfillments::Fulfill → fulfillments.fulfill). Unknown keys fail boot when eager loading.

Add a real status only when staff filter by it, reports group by it, or webhooks should fire on it:

# config/initializers/spree.rb — to_prepare so it survives dev reloads
Rails.application.config.to_prepare do
  Spree::Fulfillment.add_status('in_production', after: 'unfulfilled')
end

That gives in_production?, .in_production and a valid value. Moving into it is your own Spree::Workflow (see spree-workflows); core fulfill/cancel keep working on it. Statuses are additive only — never remove a core one.

Rates: calculators vs rate providers

Every DeliveryMethod has a rate_provider (class name string, validated against Spree.delivery_rate_providers) and a calculator:

NeedUse
Arithmetic on what's in the parcel (flat, per item, % of item total, price sack, flexi, free for digital)A calculator — built-ins under Spree::Calculator::Shipping::*
A price that comes from outside Spree (carrier API, aggregator, contract rate card service)A rate provider — Spree::DeliveryRateProvider::Base subclass
"Quoted after review" (pallets, containers)Spree::DeliveryRateProvider::Freight — returns an unpriced rate carrying the freight summary
"Only offer this when …" (order value, weight, volume, company buyer, channel, excluded products)A DeliveryMethodRule on the method, not calculator thresholds (the calculator minimum_*/maximum_* preferences are deprecated)

Default provider is Spree::DeliveryRateProvider::Internal (prices via the calculator). Amount-based calculators carry a currency preference — a calculator whose currency doesn't match the order contributes nothing (no conversion). Create one method or calculator per currency you sell in. Rate-provider estimates in another currency are dropped by Spree::Stock::Estimator for the same reason.

Custom calculator:

class MyApp::Calculator::DimensionalWeight < Spree::ShippingCalculator
  preference :rate_per_kg, :decimal, default: 0
  preference :currency, :string, default: -> { Spree::Store.default&.default_currency }

  def self.description = 'Dimensional weight'

  def supports_currency?(currency) = currency.casecmp?(preferred_currency)

  def compute_package(package)
    package.weight * preferred_rate_per_kg # nil hides the method
  end
end

# config/initializers/spree.rb — core ASSIGNS this list in after_initialize, so append there
Rails.application.config.after_initialize do
  Spree.calculators.shipping_methods << MyApp::Calculator::DimensionalWeight
end

Custom carrier rate provider (full contract, service catalog and testing in references/provider-contracts.md):

module SpreeAcme
  class DeliveryRateProvider < Spree::DeliveryRateProvider::Base
    def self.integration_class = 'SpreeAcme::Integration' # hidden/refused until connected
    def self.requires_address? = true                        # it quotes real shipments

    def estimates(package)
      integration.client.rates(from: package.stock_location, to: package.order.ship_address, weight: package.weight).map do |q|
        Spree::DeliveryRateProvider::Estimate.new(
          cost: q.amount, currency: q.currency, carrier: q.carrier,
          service_level: q.service, estimated_delivery_date: q.eta, metadata: { quote_id: q.id }
        )
      end
    rescue SpreeAcme::Error => e
      Rails.error.report(e, source: 'acme.rating')
      [] # never raise into checkout — an empty array hides the method
    end
  end
end

Rails.application.config.after_initialize do
  Spree.integrations << 'SpreeAcme::Integration'
  Spree.delivery_rate_providers << SpreeAcme::DeliveryRateProvider
end

cost is pre-tax; Spree applies tax afterwards. The reference implementation is the spree_easypost gem (SpreeEasyPost::DeliveryRateProvider + SpreeEasyPost::FulfillmentProvider + SpreeEasyPost::Integration).

Fulfillment providers (dispatch, labels, 3PL/WMS)

delivery_method.fulfillment_provider (default Spree::FulfillmentProvider::Manual; also Digital, Pickup, PickupPoint) owns the outbound mechanics. A WMS/3PL/carrier integration subclasses Spree::FulfillmentProvider::Base and implements create_fulfillment(fulfillment) (return { tracking_number:, tracking_url: } optionally) and cancel_fulfillment(fulfillment); label-generating carriers also override self.generates_labels?, purchase_label(owner) (owner is a Fulfillment or a Return), refund_label(label), and optionally tracking_url(delivery), documents(owner), can_fulfill?(fulfillment). Register in after_initialize: Spree.fulfillment_providers << MyProvider (core assigns that list too).

Carrier tracking webhook: Spree owns POST /api/v3/webhooks/fulfillments/:integration_id (prefixed integration ID, no API key). The integration is looked up by ID alone and the request runs in the integration's own store (Spree::Current.store = integration.store), so one URL per integration works for every store. Your Spree::Integration subclass implements parse_webhook_event(raw_post, headers): verify the signature (raise Spree::Integration::WebhookSignatureError → 401, also when no secret is configured), return { tracking_code:, tracking_status:, estimated_delivery_at:, delivered_at:, details: } or nil to ignore (→ 200). The controller finds the Spree::Delivery in the integration's store by tracking number and runs Deliveries::UpdateTracking; a delivered status confirms receipt on the fulfillment. Map unknown carrier statuses to 'unknown'.

Order routing and stock splitters

Two layers decide how items become fulfillments:

  1. Which stock locations — order routing. Spree::OrderRouting::Strategy::Rules (default) walks the channel's Spree::OrderRoutingRule rows (PreferredLocation, MinimizeSplits, DefaultLocation; lower rank wins, nil abstains). Custom rule: subclass Spree::OrderRoutingRule, implement rank(order, locations) returning one LocationRanking per location, register via Spree.order_routing.rules << MyRule, create a row per channel. Custom strategy: subclass Spree::OrderRouting::Strategy::Base, implement for_allocation (returns Spree::Stock::Packages) plus for_sale(fulfillment:), for_release, for_cancellation; register via Spree.order_routing.strategies << MyStrategy, select with store.preferred_order_routing_strategy / channel.preferred_order_routing_strategy (unregistered values fall back to Rules).
  2. How one location's allocation is broken into packages — splitters. Default chain Spree.stock_splitters = [Splitter::DeliveryProfile, Splitter::Backordered]; Splitter::Weight (class-level threshold, default 150) is opt-in. Custom: subclass Spree::Stock::Splitter::Base, implement split(packages) and always end with return_next(packages).

Know where routing actually applies. Storefront checkout builds fulfillments with Cart#rebuild_fulfillments!, which calls Spree::Stock::Coordinator directly — it respects delivery-profile origin groups, the channel's served locations and the splitter chain, but does not consult the routing strategy or routing rules. Carts::Complete copies the cart's fulfillments onto the order. The routing strategy runs in Order#rebuild_fulfillments! — admin draft-order create/update (Spree::Orders::BuildFulfillments, a no-op once the order is placed) and OrderInventory changes on placed orders. Editing a placed order's items never rebuilds its fulfillments — Spree::OrderInventory adjusts them in place, so the allocations made at placement stay on rows that cancel/ship can still release. The strategy's for_sale/for_release/for_cancellation are part of the contract but core does not call them today (Rules implements them as no-ops) — do post-allocation side effects in fulfillment workflow hooks or fulfillment.* subscribers instead. Claim/exchange replacements also use the Coordinator. To change which warehouse storefront carts ship from, use delivery profiles/origin groups, channel stock locations, or a decorator on the Coordinator — not a routing rule alone.

# Splitters are assigned by core in after_initialize — append there, not in to_prepare
Rails.application.config.after_initialize do
  Spree.stock_splitters << MyApp::Stock::Splitter::Refrigerated
end

Pickup, digital, freight

  • Pickup: methods with FulfillmentProvider::Pickup serve pickup-enabled stock locations (GET /api/v3/store/delivery_methods/:id/pickup_locations); PickupPoint asks a pickup point provider (…/pickup_points?latitude=&longitude=). Pickup orders are closed out with mark-delivered.
  • Digital: assign the product the store's digital delivery profile. The Digital fulfillment provider auto-fulfills on placement and grants Spree::DigitalLinks (event digital_link.downloaded). Deliver something other than an uploaded file via a Spree::DigitalAssetProvider::Base subclass (deliver(digital_link, expires_in:)), registered in Spree.digital_asset_providers.
  • Freight: methods on the Freight rate provider bounded by volume_rule / company_rule; rates come back unpriced: true, display "Quoted after review", sort after priced rates, and checkout still completes.

API surface

  • Store: PATCH /api/v3/store/carts/:cart_id/fulfillments/:id with selected_delivery_rate_id; rates are on cart.fulfillments[].delivery_rates.
  • The choice survives item changes. Every recalculation rebuilds the proposals (rebuild_fulfillments!), and the rebuild carries each stock location's selected rate over (same carrier service, else same method, else the estimator's default) and prices it for the new contents. Only a destination change (Address::DESTINATION_FIELDS: address lines, city, state, postal code, country) resets to the default rate; a phone or name edit keeps it. A placed order's delivery isn't re-priced by a rebuild.
  • Admin — permission keys: read_/write_fulfillments for the fulfillments endpoints (nested labels/deliveries sit under *_orders), *_delivery_methods for methods/rules, *_package_types, *_settings for zones and profiles. Endpoints: …/orders/:order_id/fulfillments (index/show/create/update) + member fulfill (items: [{ item_id, quantity }], tracking, tracking_carrier, notify_customer, force), mark_delivered, cancel, split; nested labels (create/destroy, download, refund) and deliveries (CRUD, mark_delivered). Setup: delivery_methods (+ calculators, rate_providers, fulfillment_providers, nested rules), delivery_method_rules/types, delivery_zones, delivery_profiles (+ kinds, nested origin_groups), package_types, tracking_carriers.
  • Admin SDK: adminClient.orders.fulfillments.{list,fulfill,markDelivered,cancel,split}, .labels.*, .deliveries.*.

Events

fulfillment.created/updated/deleted, fulfillment.fulfilled (metadata notify_customer), fulfillment.delivered, fulfillment.canceled; delivery.created/updated/deleted; shipping_label.purchased/.refunded (+ lifecycle); order-level order.fulfilled, order.delivered. There is no fulfillment.resumed. Use subscribers for WMS sync and emails (spree-events-webhooks).

Troubleshooting

No delivery rates at checkout (cart shows a delivery_unavailable warning, fulfillment pruned):

  • The product's delivery profile has no method whose zone (or origin group) covers the address / source location.
  • A DeliveryMethodRule excludes the package (item total, weight, volume, company, channel).
  • Calculator currency ≠ cart currency, or the calculator returned nil.
  • The rate provider's integration isn't connected, it raised (check error reports), or it returned only foreign-currency estimates.
  • Console: Spree::Stock::Coordinator.new(cart).packages.map { |p| [p.stock_location.name, p.delivery_rates.map(&:name)] }, or Spree::Stock::Estimator.new(cart).delivery_rates(package, Spree::DeliveryMethod::BACKOFFICE) to include admin-only methods.

Fulfill returns 422: read the error — unpaid (force or capture first), backordered units, not enough count_on_hand at the location (correct stock, or force), draft order, or your own validate hook.

Ships from the wrong warehouse: storefront carts ignore routing rules (see above) — check the product's delivery profile origin groups, the channel's stock locations, and StockLevel presence at each location.

Tracking webhook does nothing: the endpoint answers 200 for unknown tracking codes and nil events by design; 401 means signature verification failed; the integration must be active in the current store.

Testing

Factories (spree_dev_tools / spree/testing_support): :fulfillment, :fulfillment_item, :delivery_method, :free_delivery_method, :digital_delivery_method, :pickup_delivery_method, :delivery_zone, :delivery_zone_with_country, :delivery_profile, :digital_delivery_profile, :delivery_rate, :delivery, :stock_package, :cart_ready_for_delivery, :completed_order_with_totals. Stub carrier clients — rate quoting runs on every checkout. Set Spree::Current.store in specs that build rates outside a request.

Where to read further

  • node_modules/@spree/docs/dist/developer/core-concepts/fulfillments.md, delivery-setup.md, freight.md, calculators.md
  • node_modules/@spree/docs/dist/developer/how-to/custom-delivery-rate-provider.md, custom-order-routing.md, custom-stock-splitter.md, sell-digital-products.md, custom-digital-asset-provider.md
  • node_modules/@spree/docs/dist/developer/providers/fulfillment.md; EasyPost setup: node_modules/@spree/docs/dist/integrations/shipping/easypost.md
  • Deeper contracts: references/provider-contracts.md
  • Related skills: spree-inventory (stock levels, reservations, movements), spree-returns, spree-workflows (hooks, custom workflows), spree-providers, spree-checkout, spree-order-totals.

Comments

Markdown supported