docs/plans/5.7-channel-markets.md
Status: Draft
Target: Spree 5.7
Depends on: 6.0-channels-catalogs-b2b.md (Channel, shipped 5.5), 5.6-store-channel-context-and-key-binding.md (channel binding + /store/channel), Markets (existing)
Author: Damian + Claude
Last updated: 2026-07-23
Let a Channel be limited to a subset of the store's Markets. Today Channel (where you sell) and Market (where your customers are) are fully orthogonal request axes — Spree::Current.channel comes from key binding / X-Spree-Channel, Spree::Current.market from x-spree-country — and nothing can express "the wholesale channel only serves EU markets" or "the POS channel is domestic-only". This plan adds an optional allowlist: a join table between Channel and Market, empty meaning "all markets" (today's behavior). The industry validates the link from both directions: Vendure and Saleor merge channel and market into one primitive (per-channel currencies/zones), Shopify bridges its separate primitives with channel markets (a market conditioned on a sales channel) and since May 2026 routes per-market Shopify Payments entities through it; Medusa alone leaves the axes unlinked. Spree keeps its two first-class models and adds the constraint — which also composes with 5.7-payment-method-rules.md (MarketRule on a channel-restricted market set) into "wholesale channel → EU markets → invoice payment".
spree_channel_markets), not a preference array. Unlike rule config, this is a first-class admin-managed relationship that needs eager loading (channel.markets), serializer expansion, and Ransack — the same reasoning that made ProductPublication a table. has_many :markets, through: :channel_markets.Channel#default_market = the store's default market when allowed, else the first allowed market by position. Spree::Current.market's fallback becomes channel-aware./api/v3/store/markets (and nested countries) return only the current channel's allowed markets. No markets embedding on the /store/channel serializer — the filtered reference endpoint already answers "which markets does this storefront serve" without creating a second source, and the no-enumeration rule (never leak other channels' config) stays intact.resolve_market_from_currency only considers allowed markets. Guards against clients forcing an off-channel market_id through the API.# app/models/spree/channel_market.rb
module Spree
class ChannelMarket < Spree.base_class
belongs_to :channel, class_name: 'Spree::Channel', touch: true
belongs_to :market, class_name: 'Spree::Market'
validates :channel, :market, presence: true
validates :market_id, uniqueness: { scope: [:channel_id, *spree_base_uniqueness_scope] }
validate :market_belongs_to_channel_store
end
end
Table: spree_channel_markets — channel_id (null: false), market_id (null: false), timestamps, unique index on (channel_id, market_id).
# Spree::Channel additions
has_many :channel_markets, class_name: 'Spree::ChannelMarket', dependent: :destroy
has_many :markets, through: :channel_markets, class_name: 'Spree::Market'
# @return [ActiveRecord::Relation<Spree::Market>] markets this channel serves;
# an empty allowlist means every market in the store.
def allowed_markets
channel_markets.exists? ? markets.order(:position) : store.markets.order(:position)
end
# @param market [Spree::Market]
# @return [Boolean]
def serves_market?(market)
return false if market.nil?
!channel_markets.exists? || markets.exists?(market.id)
end
# @return [Spree::Market, nil] the store default when served, else first allowed
def default_market
store_default = store.default_market
return store_default if serves_market?(store_default)
allowed_markets.first
end
Spree::Market gains the inverse (has_many :channel_markets, dependent: :destroy + has_many :channels, through:) so market deletion cleans allowlist rows.
Two touch points, both already centralized:
Spree::Api::V3::LocaleAndCurrency#set_market_from_country — after resolving store.market_for_country(country), discard the market unless current_channel.serves_market?(market) (visitor falls through to the channel default, same as an unmatched country today).Spree::Current#market fallback — becomes channel-aware: super || channel&.default_market || store&.default_market (the channel branch only diverges when an allowlist exists).Derived effect: since Market drives currency/locale/tax-zone fallbacks, a restricted channel automatically constrains its sellable currency set to its allowed markets' currencies — converging on Vendure's availableCurrencyCodes / Saleor's per-channel currency without a second mechanism.
Spree::Order#ensure_market_presence resolves through channel&.default_market when the channel restricts markets.market must be served by the order's channel when both are set (skipped when either is nil — jobs/imports without channel context keep working).resolve_market_from_currency (currency-change hook) picks from channel&.allowed_markets || store.markets.Store::MarketsController (+ Markets::CountriesController) scope through current_channel.allowed_markets instead of current_store.markets. These endpoints are guest-accessible reference data (allow_guest_storefront_access!) — filtering them is the entire public contract; nothing else changes shape.GET /store/channel (from 5.6-store-channel-context-and-key-binding.md) is unchanged.market_ids: [] (prefixed mkt_… IDs, normalized + store-scope-validated on write; empty array clears the allowlist → all markets).market_ids (prefixed) and many :markets via expand=markets.@spree/admin-sdk: market_ids on the channel resource types; typelizer regeneration.@spree/sdk (Store): no changes — clients keep consuming /store/markets, which is now channel-scoped transparently.Additive only:
spree_channel_markets. No backfill — every channel starts unrestricted.The 5.6 wholesale seed channel stays unrestricted by default; the sample-data story ("wholesale serves EU markets only") is optional polish once 5.7-payment-method-rules.md lands its seed changes.
channel.allowed_markets / Channel#serves_market? when a channel context exists — don't reach for store.markets directly in request-path code.5.6-store-channel-context-and-key-binding.md extends to this relationship.spree_channels (e.g. a default_market_id) — Channel#default_market is derived; a persisted override is a future decision.6.0-delivery-rate-provider.md) rather than live here.default_market_id override on Channel, à la Shopify's per-market primary) — deferred until a merchant needs a default other than "store default, else first by position".docs/plans/5.7-payment-method-rules.md (MarketRule composes with the allowlist)docs/plans/6.0-channels-catalogs-b2b.md, docs/plans/5.6-store-channel-context-and-key-binding.mdspree/core/app/models/spree/market.rb; resolution: spree/api/app/controllers/concerns/spree/api/v3/locale_and_currency.rb (set_market_from_country), spree/core/app/models/spree/current.rbspree/core/app/models/spree/order.rb (ensure_market_presence, resolve_market_from_currency)docs/plans/decisions.md 2026-07-23