Back to Spree

Fulfillment & Delivery Rework

docs/plans/6.0-fulfillment-and-delivery.md

5.6.129.7 KB
Original Source

Fulfillment & Delivery Rework

Status: Draft Target: Spree 6.0 Depends on: 6.0-product-types.md (fulfillment_types addendum 2026-07-27 — ProductType replaces ShippingCategory; hard blocker, Spree::ProductType does not exist yet), 6.0-cart-order-split.md (where fulfillments/rates live pre-completion), 6.0-order-routing.md (packages come from for_allocation), 6.0-normalize-state-to-status.md. Coordinates with: 6.0-delivery-rate-provider.md (owns store_id on DeliveryMethod; calculators stay per decisions.md 2026-07-27), 5.5-6.0-display-on-to-boolean.md (storefront_visible lands in the same migration wave), 6.0-multi-vendor-marketplace.md (vendor_id, lifecycle events, delivery_amount). Gates: 6.0-split-adjustments.md and 6.0-typed-stock-movements.md are born with fulfillment_idthis plan's Phase 1 must land before both. Author: Damian + Claude Last updated: 2026-07-27

Summary

Rename the shipping domain to delivery/fulfillment vocabulary, drop ShippingCategory (replaced by ProductType#fulfillment_types), and add a FulfillmentProvider strategy interface for extensible fulfillment types (shipping, pickup, pickup point, digital, local delivery, custom).

Three changes in one:

  1. Rename — Shipment → Fulfillment, ShippingMethod → DeliveryMethod, ShippingRate → DeliveryRate, InventoryUnit → FulfillmentItem
  2. Drop ShippingCategoryProductType#fulfillment_types + DeliveryMethod#fulfillment_type control delivery eligibility
  3. Add FulfillmentProvider — strategy interface for 3rd-party integrations and custom fulfillment types; replaces the broken ShipmentHandler name-constantize mechanism

Calculators are NOT touched by this plan. The 2026-07-27 decision (decisions.md) keeps ShippingCalculator + Calculator::Shipping::*; rate estimation is the delivery-rate-provider plan's domain. Zone replacement ships in 6.0 via 6.0-delivery-zones.md — this plan's Phase 1 births spree_delivery_method_zones pointing at DeliveryZone (born correct, never re-migrated).

Already shipped (verified 2026-07-27, HEAD 4e5b6fd)

This plan is being implemented ahead of itself — the following exists today and must NOT be redone:

  • API v3 speaks the new vocabulary over the old models: FulfillmentSerializer, DeliveryMethodSerializer, DeliveryRateSerializer (+ Admin variants), fulfillments routes and controllers (store carts + admin orders), dual old/new registrations in api/lib/spree/api/dependencies.rb:111-128, 191-213. FulfillmentSerializer already emits status (from state), fulfilled_at (from shipped_at), and a computed fulfillment_type (digital? ? 'digital' : 'shipping').
  • Prefix IDs claimed: ful_ (Shipment), dm_ (ShippingMethod), fi_ (InventoryUnit), dr_ (via Shipment#selected_delivery_rate_id, whose YARD comment cites this plan).
  • Order aliases: alias_attribute :fulfillment_status, :shipment_state and alias_attribute :delivery_total, :shipment_total (order.rb:211-213) — the Phase 1 column renames must remove these aliases in the same change.
  • Spree::Fulfillments::Create service (core/app/services/spree/fulfillments/create.rb), registered as Spree.fulfillment_create_service.
  • StockLocation pickup columns (shipped 2026-05-08): kind, pickup_enabled, pickup_stock_policy, pickup_ready_in_minutes, pickup_instructions + indexes. Do not re-add.
  • admin-sdk generated types are already clean (zero shipment references); packages/dashboard/src still has 8 files on old vocabulary.

Outstanding: model/table/column renames, the eligibility rework, FulfillmentProvider, lifecycle events, and the legacy-admin/emails surfaces.

Entity Rename Map

Current (5.x)6.0Prefix IDTable
Spree::ShipmentSpree::Fulfillmentful_ (shipped)spree_fulfillments
Spree::ShippingMethodSpree::DeliveryMethoddm_ (shipped)spree_delivery_methods
Spree::ShippingRateSpree::DeliveryRatedr_spree_delivery_rates
Spree::ShippingMethodZoneSpree::DeliveryMethodZonespree_delivery_method_zones (born joining Spree::DeliveryZone — see 6.0-delivery-zones.md)
Spree::InventoryUnitSpree::FulfillmentItemfi_ (shipped)spree_fulfillment_items
Spree::FulfilmentChanger (sic)Spree::FulfillmentChanger(PORO — fixes the single-L/double-L collision)
Spree::ShipmentHandlerremoved → FulfillmentProvider
Spree::ShippingCategoryDropped (Phase 6)spree_shipping_categories dropped in 6.1
Spree::ShippingMethodCategoryDroppedspree_shipping_method_categories dropped in 6.1
— (new)Spree::DeliveryMethodStockLocationspree_delivery_method_stock_locations

Fulfillment numbers: existing H-prefixed numbers are opaque identifiers and stay untouched (unique index on number); new fulfillments generate F-prefixed numbers. No data migration.

Key Decisions (do not deviate without discussion)

Naming

  • Fulfillment = the act of getting items to the customer; the record tracking it. Replaces Shipment.
  • DeliveryMethod = the option presented at checkout. Replaces ShippingMethod.
  • DeliveryRate = the price quote for a delivery method on a fulfillment. Replaces ShippingRate.
  • FulfillmentItem = an item within a fulfillment (variant + quantity — the quantity column and its validation already exist on InventoryUnit today). Replaces InventoryUnit.
  • FulfillmentProvider = strategy handling create/track/cancel mechanics per fulfillment type. New; replaces ShipmentHandler (whose name-constantize factory can never match — no subclass exists and the seeded method name "Digital Delivery" can't constantize).

ProductType replaces ShippingCategory — and Package#shipping_methods is the real thing being replaced

ShippingCategory's load-bearing job is not the model — it's Stock::Package#shipping_methods (package.rb:73-80), which intersects delivery methods through categories and is the sole input to Stock::Estimator#shipping_methods. Drop the category without replacing this and mixed carts get zero rates, tripping ensure_available_shipping_rates and blocking checkout (asserted by order_routing/strategy/parity_spec.rb:426-452).

Replacement semantics:

  • ProductType#fulfillment_types (JSON array, default ['shipping']; see the 2026-07-27 addendum in 6.0-product-types.md). Products without a ProductType default to ['shipping'] — the reconciliation between optional ProductType and today's effectively-mandatory ShippingCategory (Product presence-validates + auto-assigns one today, product.rb:133, 153, 945-951).
  • DeliveryMethod#fulfillment_type (single string) must be included in every package item's fulfillment_types for the method to be eligible.
  • Package#eligible_delivery_methods replaces Package#shipping_methods: intersect items' fulfillment types, then subtract per-product exclusions. Product.excluded_delivery_method_ids (JSON array column, jsonb-guarded) covers the rare restriction case ("fragile item can't ship express") without a dedicated model.
  • Stock::Splitter::ShippingCategoryStock::Splitter::FulfillmentType — packages stay homogeneous by fulfillment type, so mixed carts (physical + digital) still split automatically. Splitter::Digital folds into it.
  • The eligibility seam stays inside Stock::Estimator#shipping_methods (per the delivery-rate-provider plan, which owns filtering — including the marketplace vendor gate). This plan changes the inputs to that filter, not its location. No separate DeliveryMethods::Available service.

The digital discriminator moves off the calculator class

ShippingMethod#digital?, #requires_zone_check?, and the .digital scope currently test calculator.is_a?(DigitalDelivery) (shipping_method.rb:42, 57, 128; memoized via MEMOIZED_METHODS :17), and Product#digital? derives through a four-hop chain (product → category → methods → calculator class). All of these move to columns: DeliveryMethod#fulfillment_type == 'digital' and ProductType#digital?. The DigitalDelivery calculator stays (calculators are kept) but becomes a plain zero-cost calculator — its available? gating is superseded by fulfillment-type eligibility. Seeds::DigitalDelivery updates to set fulfillment_type: 'digital'.

Fulfillment types are open strings

shipping, pickup (merchant location), pickup_point (third-party point — InPost locker, DHL ServicePoint), digital, local_delivery built in; extensible via Spree.fulfillment_types << 'same_day_courier'. No inclusion validation.

FulfillmentProvider is a strategy (not a DB model)

ruby
class Spree::FulfillmentProvider::Base
  def can_fulfill?(fulfillment) = true
  def create_fulfillment(fulfillment) = raise(NotImplementedError)  # → { tracking_number:, tracking_url: }
  def cancel_fulfillment(fulfillment) = raise(NotImplementedError)
  def tracking_url(fulfillment) = nil
  def documents(fulfillment) = []
end

Built-ins: Manual (default — current admin-driven behavior), Digital, Pickup, PickupPoint. Third-party: SpreeEasyPost::FulfillmentProvider, SpreeShipStation::FulfillmentProvider. Stored as a string class name on DeliveryMethod, constantized at call time. Registered via Spree.fulfillment_providers.

Digital auto-fulfillment is new behavior, not a refactor. Today digital shipments sit at ready until an admin clicks Ship (Shipment#shippable? exists precisely for that button), and DigitalLinks are created by a checkout state-machine callback (checkout.rb:87-89 — nested inside if states[:payment], non-idempotent, one link per line item regardless of quantity). FulfillmentProvider::Digital takes over both: on order completion it fires fulfill! directly and generates DigitalLinks idempotently, per quantity. This also gives the multi-vendor ledger its "digital vendor order earns immediately after payment" path.

Lifecycle events are first-class (multi-vendor ledger depends on them)

Fulfillment gets publishes_lifecycle_events plus explicit transition events: fulfillment.ready, fulfillment.fulfilled, fulfillment.canceled, fulfillment.resumed. Order-level: when the last fulfillment reaches fulfilled, publish order.fulfilled — the signal 6.0-multi-vendor-marketplace.md's vendor_transfer.due subscriber keys on ("all the vendor order's fulfillments fulfilled"). Order#fully_fulfilled? is the matching queryable predicate.

Webhook contract break: today Shipment publishes shipment.* events and webhooks (Shipment::Webhooks, Shipment::CustomEvents). Event names rename with the model; 6.0 dual-emits shipment.shipped alongside fulfillment.fulfilled for one release with a deprecation note in webhook docs, dropped in 6.1.

Vendor axis (marketplace)

vendor_id (nullable) on DeliveryMethod — the column ships in the marketplace plan's schema wave; this plan's model carries belongs_to :vendor, optional: true, replacing the Enterprise VendorConcern decorator seam (shipping_method.rb:10-12). Semantics (owned by the delivery-rate plan's Estimator filter): vendor_id present → offered only on that vendor's packages; nil → marketplace-wide. Store vs vendor are different axes: store = tenant + credential scope (store_id, owned by the delivery-rate plan), vendor = seller within it.

DeliveryMethodStockLocation validates vendor consistency: when both the method and the stock location carry a vendor_id, they must match — otherwise one vendor's pickup counter could serve another vendor's items. Pickup stock checks are package-scoped, never order.variants. pickup_stock_policy: 'any' (ship-to-store transfers) is incompatible across vendor boundaries and is refused when the location is vendor-owned.

Owner is cart-or-order

Checkout — where fulfillments and delivery rates are created and selected — happens on the Cart under copy-on-completion semantics. Fulfillment and DeliveryRate follow the split-adjustments house pattern: nullable cart_id / order_id with an exactly-one validation, re-pointed at Spree::Carts::Complete (and again at vendor split). The exact shape is confirmed when 6.0-cart-order-split.md is fleshed out (Open Question 1) — but belongs_to :order hard-required is known-wrong and must not be implemented.

Fulfillment#delivery_amount (commission base)

delivery_amount = cost + discounts.sum(:amount) — the selected rate's cost net of fulfillment-attached Discount rows (negative, from 6.0-split-adjustments.md). This is the base 6.0-multi-vendor-marketplace.md uses for include_shipping commission (CommissionLine fulfillment-scoped). cost remains the denormalized column it is today.

Status semantics — corrected against the real machine

The rename is shippedfulfilled (column statestatus per the normalize plan), preserving today's guards and hooks, which the earlier draft dropped:

  • ready keeps its determine_state == 'ready' guard (the #2040 fix — no ready fulfillments on unpaid orders).
  • pend (ready → pending) stays — used when an order becomes unpayable again.
  • resume keeps both conditional targets (canceled → ready when determine_state says so, else canceled → pending).
  • ready_for_pickup is cancelable (cancel accepts it) and reachable from ready (not just pending).
  • All after_transition hooks survive: restock/unstock, StateChange audit rows, events + webhooks (renamed per above).
  • The three update_columns escape hatches that bypass the machine (update_amounts, Shipment#update!, ShipmentHandler#update_order_shipment_state) are rewritten to go through the machine or explicitly documented as freeze-safe.

Order rollup keeps backorder. The earlier draft omitted it; backorder short-circuits the whole computation and feeds Order#allow_cancel? and the not_shipped scope. Domain of Order#fulfillment_status: backorder, canceled, partial, pending, ready, fulfilled (renamed from shipped), plus nil. ready_for_pickup/picked_up roll up as ready/fulfilled. Computation semantics are otherwise unchanged (including the [canceled, X] collapse rules).

Pickup location selection flows through order routing

A customer's merchant-pickup choice writes Order#preferred_stock_location_id, so it flows through the existing PreferredLocation routing rule (6.0-order-routing.md) instead of bypassing routing with a direct stock_location_id write. Fulfillment#stock_location always comes from the routing strategy's for_allocation packages — never a bare detect.

Design Details

DeliveryMethod

ruby
class Spree::DeliveryMethod < Spree.base_class
  has_prefix_id :dm

  acts_as_paranoid
  include Spree::SingleStoreResource   # store_id: migration owned by 6.0-delivery-rate-provider.md
  include Spree::CalculatedAdjustments # calculators STAY — decisions.md 2026-07-27
  include Spree::Metafields
  include Spree::Metadata

  belongs_to :store, class_name: 'Spree::Store'
  belongs_to :vendor, class_name: 'Spree::Vendor', optional: true  # marketplace; nil = marketplace-wide
  belongs_to :tax_category, -> { with_deleted }, class_name: 'Spree::TaxCategory', optional: true

  has_many :delivery_rates, class_name: 'Spree::DeliveryRate', inverse_of: :delivery_method
  has_many :fulfillments, through: :delivery_rates
  has_many :delivery_method_zones, class_name: 'Spree::DeliveryMethodZone', dependent: :destroy
  has_many :delivery_zones, through: :delivery_method_zones, class_name: 'Spree::DeliveryZone'  # 6.0-delivery-zones.md
  has_many :delivery_method_stock_locations, class_name: 'Spree::DeliveryMethodStockLocation', dependent: :destroy
  has_many :pickup_locations, through: :delivery_method_stock_locations, source: :stock_location

  # storefront_visible boolean replaces Spree::DisplayOn (5.5-6.0-display-on-to-boolean.md,
  # converted in this plan's Phase 1 wave). Do NOT include Spree::DisplayOn.
  validates :name, :fulfillment_type, presence: true
  validates :estimated_transit_business_days_min, :estimated_transit_business_days_max,
            numericality: { greater_than_or_equal_to: 1 }, allow_nil: true

  attribute :fulfillment_type, :string, default: 'shipping'
  attribute :fulfillment_provider, :string, default: 'Spree::FulfillmentProvider::Manual'
  attribute :pickup_point_provider, :string  # only for pickup_point type
  # rate_provider string column comes from 6.0-delivery-rate-provider.md

  scope :by_fulfillment_type, ->(type) { where(fulfillment_type: type) }

  def digital? = fulfillment_type == 'digital'
  def pickup? = fulfillment_type == 'pickup'
  def pickup_point? = fulfillment_type == 'pickup_point'
  def requires_address? = !digital? && !pickup?

  def provider = @provider ||= fulfillment_provider.constantize.new
end

Fulfillment

ruby
class Spree::Fulfillment < Spree.base_class
  has_prefix_id :ful
  include Spree::Metadata

  publishes_lifecycle_events

  # Owner — exactly one (cart pre-completion, order after; re-pointed at Carts::Complete)
  belongs_to :order, class_name: 'Spree::Order', touch: true, optional: true
  belongs_to :cart, class_name: 'Spree::Cart', optional: true
  belongs_to :address, class_name: 'Spree::Address', optional: true      # nil for pickup/digital
  belongs_to :stock_location, -> { with_deleted }, class_name: 'Spree::StockLocation'

  attribute :pickup_point_data, :json   # frozen snapshot of the selected third-party point
  attribute :fulfillment_type, :string  # copied from selected delivery method

  has_many :fulfillment_items, class_name: 'Spree::FulfillmentItem', dependent: :delete_all
  has_many :delivery_rates, -> { order(:cost) }, class_name: 'Spree::DeliveryRate', dependent: :delete_all
  has_one :selected_delivery_rate, -> { where(selected: true) }, class_name: 'Spree::DeliveryRate'
  has_many :discounts, class_name: 'Spree::Discount', dependent: :destroy   # split-adjustments
  has_many :tax_lines, class_name: 'Spree::TaxLine', dependent: :destroy

  # status column (normalize plan). Guards + hooks preserved from today's machine — see Key Decisions.
  state_machine :status, initial: :pending do
    event(:ready)                  { transition from: :pending, to: :ready, if: ->(f) { f.determine_state(f.order) == 'ready' } }
    event(:pend)                   { transition from: :ready, to: :pending }
    event(:fulfill)                { transition from: %i[ready ready_for_pickup canceled], to: :fulfilled }
    event(:cancel)                 { transition from: %i[pending ready ready_for_pickup], to: :canceled }
    event(:resume)                 { transition from: :canceled, to: :ready, if: ->(f) { f.determine_state(f.order) == 'ready' }
                                     transition from: :canceled, to: :pending }
    event(:mark_ready_for_pickup)  { transition from: :ready, to: :ready_for_pickup }
  end
  # after_transition hooks: restock/unstock, StateChange audit, set_fulfilled_at,
  # fulfillment.* events + webhooks (dual-emit shipment.* for one release)

  def delivery_method = selected_delivery_rate&.delivery_method

  # Commission base for multi-vendor include_shipping (cost net of fulfillment discounts)
  def delivery_amount = cost + discounts.sum(:amount)
end

FulfillmentItem is today's InventoryUnit renamed — the quantity column, its greater_than: 0 validation, the split/extract machinery, and has_many :return_items (FK renamed inventory_unit_id → fulfillment_item_id) all carry over unchanged.

Store API flows (pickup)

Merchant pickup: GET /delivery_methods?fulfillment_type=pickupGET /delivery_methods/:id/pickup_locations (pickup-enabled StockLocations with package-scoped stock) → select with stock_location_id, which writes Order#preferred_stock_location_id (routing rule picks it up). Third-party points: GET /delivery_methods/:id/pickup_points?latitude=&longitude= (calls the method's PickupPointProvider#find_nearby; ephemeral value objects, never persisted) → select with pickup_point_external_id → validated via find_by_external_id, frozen into fulfillment.pickup_point_data. PickupPointProvider::Base + PickupPointOption struct as previously designed.

Migration Path

Phase 1 as previously drafted was one ~20-operation migration; per CLAUDE.md (data transformations never in migrations) and the verification findings it is now split. The earlier draft's migration is void — it re-added shipped columns, targeted the nonexistent spree_product_types, and migrated a spree_store_shipping_methods join table that has never existed (see decisions.md 2026-03-16 correction).

Phase 0 — gates

  • 6.0-product-types.md shipped (with the fulfillment_types addendum).
  • 6.0-cart-order-split.md fleshed out enough to confirm the owner shape (Open Question 1).
  • 6.0-delivery-rate-provider.md Phase 1 (store_id) — independent, can land before or after.
  • 6.0-typed-stock-movements.md confirmed born with fulfillment_id (dependency inverted from the earlier draft).

Phase 1a — renames (schema only)

  • Tables per the rename map; FK columns: delivery_rates.{shipment_id→fulfillment_id, shipping_method_id→delivery_method_id}, fulfillment_items.shipment_id→fulfillment_id, delivery_method_zones.shipping_method_id→delivery_method_id, return_items.inventory_unit_id→fulfillment_item_id (missed by the earlier draft), orders.{shipment_state→fulfillment_status, shipment_total→delivery_total} (removing the shipped aliases in the same change).
  • display_onstorefront_visible boolean on spree_delivery_methods, jointly with 5.5-6.0-display-on-to-boolean.md Phase 2 (answering its coordination request).
  • Rename affected indexes (spree_shipping_rates_join_index — keep its uniqueness, it's the one-rate-per-method invariant).

Phase 1b — new columns/tables (additive)

  • delivery_methods: fulfillment_type (null: false, default 'shipping', indexed), fulfillment_provider (null: false, default Manual), pickup_point_provider.
  • fulfillments: fulfillment_type, pickup_point_data (JSON, jsonb-guarded), cart_id (per owner decision).
  • spree_delivery_method_stock_locations join (unique on the pair).
  • products.excluded_delivery_method_ids (JSON, jsonb-guarded).
  • NOT here: StockLocation pickup columns (shipped), store_id (delivery-rate plan), vendor_id (marketplace wave).

Phase 2 — data rake tasks (spree:migrate_shipping_to_delivery)

  1. Polymorphic/stored strings (missed entirely by the earlier draft): spree_state_changes.stateful_type + name, spree_adjustments.adjustable_type, spree_stock_movements.originator_type'Spree::Shipment''Spree::Fulfillment'.
  2. fulfillment_type backfill: methods with a DigitalDelivery calculator → 'digital' + FulfillmentProvider::Digital; all others 'shipping'. Copy to existing fulfillments from their selected method.
  3. ShippingCategory → ProductType: digital categories → Digital ProductType (fulfillment_types: ['digital']) assigned to their products (the sanctioned one-time assignment per the product-types addendum); everything else rides the ['shipping'] default — no ProductType auto-created.
  4. display_on value conversion (both/front_end → visible), per the display-on plan.

Batched, idempotent, resumable — same guarantees as the split-adjustments migration.

Phase 3 — model renames + behavior

  • Model/file renames + one-release deprecation aliases (Spree::Shipment = Spree::Fulfillment etc.). ShippingCategory gets no alias — referencing it raises NameError (the earlier draft's = nil would produce confusing NoMethodErrors instead).
  • FulfilmentChangerFulfillmentChanger; ShipmentHandler removed in favor of providers.
  • Fix raw-SQL/table-name literals that survive renames silently until query time: reverse_chronological, with_free_shipping_promotion?, Package#shipping_categories SQL, determine_state string literals.
  • Package#eligible_delivery_methods + Splitter::FulfillmentType; digital discriminator moved to columns; Product#digital?/#fulfillment_types on ProductType; remove ShippingCategory validations/auto-assign from Product in this phase (the column drops in 6.1 — products must stay saveable in between).
  • Order rollup rename with backorder preserved; CanCanCan grants; permitted attributes; seeds (incl. Seeds::DigitalDelivery and the hardcoded 'Digital'/'Default' store category readers); factories (product_factory assigns a shipping category to every product — whole-suite impact).

Phase 4 — providers + events

  • FulfillmentProvider::{Base,Manual,Digital,Pickup,PickupPoint} wired into transitions; PickupPointProvider::Base + option struct; Spree.fulfillment_providers / Spree.fulfillment_types registries.
  • Lifecycle events + order.fulfilled; shipment.*fulfillment.* webhook migration with one-release dual-emit.
  • DigitalLink creation moves from the checkout callback into FulfillmentProvider::Digital (idempotent, per-quantity).

Phase 5 — remaining surfaces

  • API: switch FulfillmentSerializer#fulfillment_type from computed to column-backed; pickup endpoints (/pickup_locations, /pickup_points); storefront_visible exposure; regenerate the type pipeline. (Most serializer/route work already shipped — see "Already shipped".)
  • Emails gem (20 files — never mentioned in the earlier draft): shipment_mailer, subscriber, templates → fulfillment vocabulary.
  • Dashboard (8 files still on shipment vocabulary) + CSV import/export contract (shipping_category is a documented public column — importers get a mapping + deprecation window) + user docs. The legacy Rails admin's 26 shipping files are deleted with spree/admin at 6.0, not renamed — admin fulfillment management lives in the dashboard via Admin API/SDK.

Phase 6 — cleanup (6.1)

  • Drop deprecation aliases, spree_shipping_categories + spree_shipping_method_categories, products.shipping_category_id, dual-emitted shipment.* events, the dangling admin_shipping_category_serializer / v2 platform registrations.

Constraints on Current Work

  • Use "delivery method" / "fulfillment" in all new API v3 code. No new shipping_method / shipment references. New tables referencing shipments are born with fulfillment_id.
  • Don't add features to ShippingCategory or InventoryUnit — both are being replaced.
  • Don't include Spree::DisplayOn in new models (display-on plan constraint); use storefront_visible.
  • Don't build on ShipmentHandler — it's removed; fulfillment mechanics belong to providers.
  • Don't touch calculators here — kept per decisions.md 2026-07-27; rate estimation belongs to the delivery-rate-provider plan. Zone→DeliveryZone belongs to 6.0-delivery-zones.md (this plan only births the join table correctly).

Resolved Questions

  1. Fulfillment status naming + rollup. shippedfulfilled (fulfilled_at); Order column → fulfillment_status with domain backorder | canceled | partial | pending | ready | fulfilled (+ nil) — backorder stays (earlier draft wrongly dropped it). ready_for_pickup/picked_up roll up as ready/fulfilled. Guards, pend, conditional resume, and all transition hooks are preserved.
  2. Store scoping is NOT this plan's. store_id on DeliveryMethod is owned by 6.0-delivery-rate-provider.md Phase 1 (greenfield + derived backfill — there was never a join table; see decisions.md 2026-03-16 correction). The earlier draft's PaymentMethod claim is obsolete — that shipped in 5.6 and was amended 2026-07-23 (PaymentMethodRule).
  3. fulfillment_types as JSON column on ProductType (jsonb-guarded), owned by the 6.0-product-types.md addendum.
  4. Lifecycle events: fulfillment.{ready,fulfilled,canceled,resumed} + order.fulfilled; one-release dual-emit of legacy shipment.* webhooks.
  5. Digital auto-fulfillment is new behavior implemented by FulfillmentProvider::Digital (fires fulfill! + DigitalLinks on completion); today nothing auto-fulfills.
  6. delivery_amount = cost + fulfillment discounts — the multi-vendor include_shipping commission base.
  7. Pickup selection routes through Order#preferred_stock_location_id and the PreferredLocation routing rule; stock checks are package-scoped; pickup_stock_policy: 'any' refused for vendor-owned locations.
  8. Fulfillment numbers: existing H numbers stay; new records use F.

Open Questions

  1. Owner shaperesolved 2026-07-27 with the cart-order-split expansion: dual cart_id/order_id exactly-one is the unified pattern (LineItem included, no polymorphism); Phase 1b's cart_id is unblocked.
  2. Webhook dual-emit window — is one release enough for shipment.* subscribers, or does Enterprise need a longer bridge?
  3. ready_for_pickup in the Store API — expose as its own status or map onto ready with fulfillment_type disambiguating?

References

  • Current models: spree/core/app/models/spree/{shipment,shipping_method,shipping_category,inventory_unit}.rb
  • Real state machine + hooks: shipment.rb:73-110; rollup: order_updater.rb:140-178; number generator: shipment.rb:7
  • Eligibility gate being replaced: stock/package.rb:73-80 + stock/estimator.rb:61-71; parity spec: spec/models/spree/order_routing/strategy/parity_spec.rb:426-452
  • Shipped API surface: api/app/serializers/spree/api/v3/fulfillment_serializer.rb, api/lib/spree/api/dependencies.rb:111-128
  • Related plans: 6.0-product-types.md (fulfillment_types addendum), 6.0-delivery-rate-provider.md, 6.0-cart-order-split.md, 6.0-order-routing.md, 6.0-split-adjustments.md, 6.0-typed-stock-movements.md, 6.0-returns-exchanges-claims.md, 6.0-multi-vendor-marketplace.md, 5.5-6.0-display-on-to-boolean.md, decisions.md (2026-07-27 calculators/Zone; 2026-03-16 corrected store-scoping)