docs/plans/6.0-delivery-zones.md
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
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.
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.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.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.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).
Verified 2026-07-27 (~183 non-spec references: core/app 109, core/lib 37, admin/app 37):
| Surface | Owner |
|---|---|
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_zones | This plan (join re-pointed to DeliveryZone; born correct if fulfillment Phase 1 lands first) |
Spree::Pricing::Context zone dimension, Spree::Current zone attribute + resets | This plan (country-based, with tax-provider) |
Admin zones_controller + views, @available_zones in shipping-method form, nav/table registry entries | This plan (replaced by DeliveryZones CRUD + picker) |
Permission sets (default_customer, configuration_management) | This plan |
Country#zones / State#zones / ZoneMember | This plan (removed) |
Store#countries_with_shipping_coverage (Zone.joins(:shipping_methods)) | This plan (rewritten on DeliveryZone members) |
CountryToTimezone service | This 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_factory | This plan (worldwide-by-default methods; explicit :delivery_zone factory) |
| Seeds referencing zones | This plan |
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.
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.
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.
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.
Spree::Zone references anywhere. New geographic eligibility goes through DeliveryZone (shipping) or TaxRate.for_address (tax).Address#normalized_zipcode — don't scatter zipcode munging.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.None at this time.
6.0-tax-provider.md, 6.0-fulfillment-and-delivery.md, 6.0-delivery-rate-provider.mdspree/core/app/models/spree/zone.rb, spree/core/app/models/spree/zone_member.rb