Back to Spree

Channel → Markets Allowlist

docs/plans/5.7-channel-markets.md

5.6.010.7 KB
Original Source

Channel → Markets Allowlist

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

Summary

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".

Key Decisions (do not deviate without discussion)

  • A join table (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.
  • Empty allowlist = channel serves all store markets. Matches the "empty = unrestricted" convention used by every rule preference. No backfill, zero behavior change on upgrade, and a single-market store never has to touch this.
  • Resolution degrades gracefully — no 422 for browsing. A visitor whose country resolves to a market the channel doesn't serve gets the channel's default market experience (currency/locale/prices), exactly like a visitor from a country with no market at all. Hard ship-to enforcement at checkout is a separate concern, out of scope here (see Open Questions).
  • 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.
  • The Store API markets reference endpoints are the public surface of the restriction. /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.
  • Order-level integrity: when both FKs are present, the order's market must be served by its channel — validated on market/channel assignment, and resolve_market_from_currency only considers allowed markets. Guards against clients forcing an off-channel market_id through the API.
  • Markets must belong to the channel's store (validation on the join model), mirroring every cross-store guard in the codebase.
  • Deleting a market removes it from allowlists; a channel left with an empty allowlist reverts to "all markets". Documented behavior + dashboard confirmation copy — not a deletion blocker (Market already blocks deleting the default/last market; further guards would make market cleanup miserable).
  • Management is dashboard-only (markets multi-select in the channel sheet + Admin API), consistent with the 2026-07-21 decision — no legacy Rails admin surface.

Design Details

Models

ruby
# 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_marketschannel_id (null: false), market_id (null: false), timestamps, unique index on (channel_id, market_id).

ruby
# 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.

Request resolution

Two touch points, both already centralized:

  1. 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).
  2. 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.

Order integrity

  • Spree::Order#ensure_market_presence resolves through channel&.default_market when the channel restricts markets.
  • Validation: 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 API

  • 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.

Admin API v3

  • Channel create/update accepts market_ids: [] (prefixed mkt_… IDs, normalized + store-scope-validated on write; empty array clears the allowlist → all markets).
  • Admin channel serializer exposes market_ids (prefixed) and many :markets via expand=markets.
  • No new endpoints — the allowlist is part of the channel aggregate, matching how publications are managed through channel action endpoints rather than join-table CRUD.

SDK & Dashboard

  • @spree/admin-sdk: market_ids on the channel resource types; typelizer regeneration.
  • Dashboard: a "Markets" multi-select (Combobox) in the channel edit sheet, defaulting to "All markets" when the allowlist is empty; confirmation copy when clearing the last entry ("this channel will serve all markets"). Translation keys in all dashboard locale files.
  • @spree/sdk (Store): no changes — clients keep consuming /store/markets, which is now channel-scoped transparently.

Migration Path

Additive only:

  1. Migration creates spree_channel_markets. No backfill — every channel starts unrestricted.
  2. Models + resolution changes ship together (behavior only diverges once an admin restricts a channel).
  3. Admin API + dashboard UI.

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.

Constraints on Current Work

  • New market-dependent features must resolve through channel.allowed_markets / Channel#serves_market? when a channel context exists — don't reach for store.markets directly in request-path code.
  • Don't expose other channels' market allowlists through any Store API surface — the no-enumeration rule from 5.6-store-channel-context-and-key-binding.md extends to this relationship.
  • Don't add market columns to spree_channels (e.g. a default_market_id) — Channel#default_market is derived; a persisted override is a future decision.

Open Questions

  • Hard checkout enforcement for unserved geographies (reject ship-to addresses outside the channel's markets) — likely wants to compose with delivery-zone work (6.0-delivery-rate-provider.md) rather than live here.
  • Per-channel market defaults (a persisted 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".

References

  • Sibling plan: docs/plans/5.7-payment-method-rules.md (MarketRule composes with the allowlist)
  • Channel infrastructure: docs/plans/6.0-channels-catalogs-b2b.md, docs/plans/5.6-store-channel-context-and-key-binding.md
  • Market model: spree/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.rb
  • Order market wiring: spree/core/app/models/spree/order.rb (ensure_market_presence, resolve_market_from_currency)
  • Decision log: docs/plans/decisions.md 2026-07-23
  • Competitive research (2026-07-23 session): Shopify channel markets (help.shopify.com/en/manual/online-sales-channels/channel-markets) + market types/precedence (help.shopify.com/en/manual/markets/getting-started/market-types) + multi-entity payments routed by market (help.shopify.com/en/manual/payments/shopify-payments/onboarding/selling-with-multiple-entities) · Vendure per-channel currencies/zones (docs.vendure.io/guides/core-concepts/channels/) · Saleor one-currency channels (docs.saleor.io/developer/channels/overview) · Medusa's unlinked axes as the cautionary contrast (docs.medusajs.com/resources/commerce-modules/region)