Back to Spree

Delivery Zones (Zone → DeliveryZone)

docs/plans/6.0-delivery-zones.md

5.6.19.8 KB
Original Source

Delivery Zones (Zone → DeliveryZone)

Status: Draft Target: Spree 6.0 Depends on: 6.0-tax-provider.md Phase 5 (tax decoupled from Zone — prerequisite for dropping the model), 6.0-fulfillment-and-delivery.md Phase 1 (spree_delivery_method_zones born pointing at DeliveryZone). Coordinates with: 6.0-delivery-rate-provider.md (the Estimator's zone check swaps to DeliveryZone matching; provider interface unaffected). Author: Damian + Claude Last updated: 2026-07-27

Summary

Replace the generic Spree::Zone with Spree::DeliveryZone, adding postal-code-range members, and drop Zone/ZoneMember entirely by the end of 6.0. Reaffirmed 2026-07-27: 6.0 is the one breaking-change window — this does not wait for 6.1.

An earlier revision descoped this because the Zone removal surface (~183 non-spec references) had no owner: the tax plan covered only TaxRate FKs, the delivery-rate plan covered only shipping. This plan is that owner. Tax-side decoupling stays in 6.0-tax-provider.md (TaxRate country/state FKs, tax readers rewritten onto TaxRate.for_address); everything else Zone touches is inventoried and assigned here.

Postal-code zones unlock what Zone structurally cannot express: "free local delivery to postcodes 10001–10099", "express in central Warsaw only", "no islands surcharge zone" — table-stakes for European local-delivery and courier setups, and a gap against carrier-level zone support in hosted platforms.

Key Decisions (do not deviate without discussion)

  • Spree::DeliveryZone + Spree::DeliveryZoneMember, prefix ids dz_ / dzm_. Member is typed by member_type: country (FK), state (FK), or postal_code (country_id FK + postal_code_from/postal_code_to range, or a single postal_code_prefix). A zone mixes member types freely.
  • Postal matching is prefix-or-range, always country-scoped (resolved 2026-07-27). Numeric-format countries (US, DE, PL, FR…) support lexicographic ranges on normalized codes (strip spaces/dashes, upcase); ranges are gated by a numeric-postal country allowlist — validation rejects range members for countries outside it. Non-numeric formats (UK, CA, NL, IE) get prefix lists only (SW1, EC, M5V — how merchants actually think there; UK outward codes and CA FSAs are inherently prefix-shaped). No per-country matcher strategies and no glob wildcards at launch: DeliveryZoneMember#match?(address) is one Ruby method with normalization in one place (Spree::Address#normalized_zipcode), so per-country matchers can be layered in later with zero schema change if real demand appears.
  • Empty zone list on a DeliveryMethod = worldwide (unchanged semantics from today's zone-less methods).
  • Spree::Zone and Spree::ZoneMember are dropped in 6.0 — tables, models, Country#zones/State#zones, admin CRUD, factories. Nothing may keep a Zone reference past this plan's final phase.
  • Spree::Current.zone / Pricing::Context lose the zone dimension. The browsing tax context becomes market/country-based (Market#default_country + TaxRate.for_address), per 6.0-tax-provider.md. Spree::Current.tax_country (ISO) replaces Spree::Current.zone as the request-context attribute; Pricing::Context keys on country, not zone.
  • Zone.global factory pattern is retired. The factories.rb monkey-patch and shipping_method_factory's zones { [Spree::Zone.global] } are replaced by zone-less methods (worldwide by default) — most specs don't care about geography; the few that do build an explicit :delivery_zone.

Design Details

ruby
class Spree::DeliveryZone < Spree.base_class
  has_prefix_id :dz
  include Spree::Metadata

  has_many :members, class_name: 'Spree::DeliveryZoneMember', dependent: :destroy
  has_many :delivery_method_zones, class_name: 'Spree::DeliveryMethodZone', dependent: :destroy
  has_many :delivery_methods, through: :delivery_method_zones

  validates :name, presence: true, uniqueness: { scope: spree_base_uniqueness_scope }

  def include?(address)
    members.any? { |m| m.match?(address) }
  end
end

class Spree::DeliveryZoneMember < Spree.base_class
  has_prefix_id :dzm

  belongs_to :delivery_zone, class_name: 'Spree::DeliveryZone', inverse_of: :members
  belongs_to :country, class_name: 'Spree::Country', optional: true
  belongs_to :state, class_name: 'Spree::State', optional: true

  validates :member_type, presence: true  # 'country' | 'state' | 'postal_code'
  # postal_code members: country required + exactly one of prefix / from+to range

  def match?(address)
    case member_type
    when 'country'     then address.country_id == country_id
    when 'state'       then address.state_id == state_id
    when 'postal_code' then address.country_id == country_id && postal_match?(address.normalized_zipcode)
    end
  end
end

DeliveryMethod#include?(address) iterates delivery_zones instead of zones; the check stays where it is (inside Estimator#shipping_methods filtering, per the delivery-rate plan — no seam moves).

Zone consumer inventory and ownership

Verified 2026-07-27 (~183 non-spec references: core/app 109, core/lib 37, admin/app 37):

SurfaceOwner
TaxRate zone FK, TaxRate.match, tax display readers (Order#tax_zone, Spree::Current.zone/default_tax_zone, Market#tax_zone, VatPriceCalculation taxation options, Stock::Estimator#taxation_options_for/first_tax_rate_for)6.0-tax-provider.md Phase 5 (rewritten onto TaxRate.for_address / Spree::Current.tax_country)
ShippingMethod has_many :zones, #include?, spree_shipping_method_zonesThis plan (join re-pointed to DeliveryZone; born correct if fulfillment Phase 1 lands first)
Spree::Pricing::Context zone dimension, Spree::Current zone attribute + resetsThis plan (country-based, with tax-provider)
Admin zones_controller + views, @available_zones in shipping-method form, nav/table registry entriesThis plan (replaced by DeliveryZones CRUD + picker)
Permission sets (default_customer, configuration_management)This plan
Country#zones / State#zones / ZoneMemberThis plan (removed)
Store#countries_with_shipping_coverage (Zone.joins(:shipping_methods))This plan (rewritten on DeliveryZone members)
CountryToTimezone serviceThis plan — verify actual Zone dependency at implementation; rewrite or leave if zone-free
Factories: Zone.global monkey-patch, zone_factory, zone_member_factory, shipping_method_factory, order_walkthrough, market_factoryThis plan (worldwide-by-default methods; explicit :delivery_zone factory)
Seeds referencing zonesThis plan

Migration Path

Phase A — model + tables (after fulfillment Phase 1)

spree_delivery_zones, spree_delivery_zone_members (typed members, country-scoped postal columns). spree_delivery_method_zones is born (or re-pointed) with delivery_zone_id.

Phase B — data migration (rake spree:migrate_zones_to_delivery_zones)

For each Zone referenced by a shipping/delivery method: create a DeliveryZone with the same name/description and copy members (country→country, state→state). Zones referenced only by TaxRate are handled by the tax plan's task; zones referenced by neither are logged and skipped. Idempotent, batched.

Phase C — consumers

Admin API v3 delivery-zone CRUD (+ nested on delivery method) with admin-sdk resource and a React dashboard page under delivery settings (zone list + typed-member editor incl. postal ranges/prefixes; the legacy Rails zones_controller + views are deleted with spree/admin at 6.0, not rebuilt). Estimator/DeliveryMethod matching, Spree::Current.tax_country + Pricing::Context country key (jointly with tax-provider), Store#countries_with_shipping_coverage, permission sets, factories + test support, seeds, type pipeline regeneration.

Phase D — drop (end of 6.0)

Drop spree_zones + spree_zone_members, remove Zone/ZoneMember models, Country#zones/State#zones, zone factories and the Zone.global monkey-patch. Grep-clean: zero Spree::Zone references outside the migration task.

Constraints on Current Work

  • No new Spree::Zone references anywhere. New geographic eligibility goes through DeliveryZone (shipping) or TaxRate.for_address (tax).
  • Don't build UI against the zones admin — it's being replaced.
  • Address normalization goes through Address#normalized_zipcode — don't scatter zipcode munging.

Resolved Questions

  1. Non-numeric postal formats: prefix lists only at launch (2026-07-27). Ranges are allowlist-gated to numeric-format countries; UK/CA/NL/IE merchants configure prefixes. Per-country matcher strategies were considered and deferred — matching is a single Ruby method, so they remain a non-breaking internal upgrade.
  2. Zipcode-sensitive rates: recalculation-on-write, no new machinery (2026-07-27). Carts::Update already rebuilds delivery proposals on address change (6.0-cart-order-split.md recalculation-on-write); the one-page auto-save checkout PATCHes the address on zipcode blur and re-renders the delivery options returned in the same response. No dedicated non-persisting estimate endpoint, and zone definitions are never exposed to the client for pre-filtering. One addition: the cart-page mini-estimator (Cart::EstimateShippingRates and its successor) gains an optional zipcode param so pre-address estimates are postal-aware too.

Open Questions

None at this time.

References

  • Descope reversal: decisions.md 2026-07-27 (amended — back in 6.0 per user decision)
  • Consumer inventory source: delivery-rate-provider verification review 2026-07-27
  • Related plans: 6.0-tax-provider.md, 6.0-fulfillment-and-delivery.md, 6.0-delivery-rate-provider.md
  • Current model: spree/core/app/models/spree/zone.rb, spree/core/app/models/spree/zone_member.rb