Back to Spree

Channels, Catalogs & B2B Companies

docs/plans/6.1-channels-catalogs-b2b.md

5.6.051.3 KB
Original Source

Channels, Catalogs & B2B Companies

Status: Phase 1 shipped in 5.5 (Channel + new spree_product_publications table + single-store Product + channel-aware scopes + Admin API action endpoints + Store API back-compat + legacy admin Channels CRUD + Publishing card + dashboard SPA + Meilisearch channel filter); gated storefront access shipped in 5.6 (Spree::Channel::Gating + v3 Store API enforcement — see 5.6-store-channel-context-and-key-binding.md for the follow-on channel-context endpoint + key binding); Catalog, Company tree, and pricing integration deferred to 6.1 (the B2B release — see decisions.md 2026-06-16) Target: Spree 5.5 (Phase 1 ✓) → 5.6 (Gated Access ✓) → 6.1 (Catalog + Company)

Filename note: this plan keeps its 6.0- filename for stable cross-references (it's linked from CLAUDE.md and ~10 sibling plans), but the Phase 2 deliverable (Catalog + Company) now targets 6.1. Don't infer the target from the filename — the header and per-phase headings are authoritative. Phase 1 (5.5) and the 5.6 Gated Access phase are unaffected. Depends on: ProductType (6.0-product-types.md), PriceList system (existing) Author: Damian + Claude Last updated: 2026-07-01

Summary

Add three new concepts to Spree's multi-store architecture:

  • Channel — lightweight distribution surface within a Store (online, POS, Meta, wholesale portal). Controls where products are sold and how orders are attributed. Also owns the storefront access posture — whether anonymous visitors can see prices, browse at all, or check out as a guest.
  • Catalog — product assortment + optional pricing override. Controls what's available and at what price for a specific audience (B2B tier, VIP group, regional assortment).
  • Company — B2B customer hierarchy (Company → CompanyLocation → CompanyContact) for businesses with multiple buyers and offices.

StoreProduct is replaced by ProductPublication — the join between Product and Channel that says "this product is listed on this channel." The legacy spree_products_stores join table stays as compat surface for the spree_multi_store extension; core no longer reads or writes it.

Product is now belongs_to :store (single owner) in core. Multi-store catalog sharing — historically every Product belonged to many Stores via spree_products_stores — moves to the spree_multi_store extension. This matches Medusa/Saleor/Vendure: the catalog has one owner, distribution to multiple selling surfaces happens through channels.

Problem

  1. No product visibility control within a store. StoreProduct is binary — a product is in a store or it isn't. There's no way to say "this product is on the online store but not the POS" or "wholesalers see these 500 SKUs, retail sees 2000."

  2. No order attribution. Order.channel exists as a free-text varchar but isn't connected to any model. You can't query "all POS orders this month" reliably or configure channel-specific behavior.

  3. No B2B company hierarchy. CustomerGroup exists for user segmentation and pricing, but there's no way to model "Acme Corp has 3 offices, each with its own buyers, shipping addresses, and catalog assignments." B2B merchants need Company → Location → Contact.

  4. PriceList lacks a visibility counterpart. PriceList controls pricing, but there's no matching concept for product visibility. A wholesaler needs both different prices AND a different product assortment.

Current State

What existsWhat it doesGap
StoreConfig boundary — currency, tax, payments, fulfillmentHeavy, covers too much. No sub-store scoping.
MarketGeographic/currency region within a storeRead-only regions, no product visibility control
StoreProductProduct ↔ Store join (binary visibility)No channel/catalog dimension
CustomerGroupUser segmentation within a storeNo company hierarchy
PriceList + PriceRulesConditional pricing (customer group, market, zone, volume, user)No visibility counterpart
Order.channelFree-text varcharNot an FK, not a model

Architecture

Four concerns, four models:

Store (who you are — legal entity, config boundary)
  → Channels (where you sell — online, POS, Meta, wholesale)
     → ProductPublications (which products are listed on this channel)
  → Catalogs (what you sell at what price — assortment + optional PriceList)
     → CatalogProducts (which products are in this catalog)
     → PriceList (optional pricing override — existing model)
  → Markets (where your customers are — geography, currency — existing)
  → Companies (B2B — who your business customers are)
     → CompanyLocations (offices/branches with addresses)
     → CompanyContacts (buyers — linked to user accounts)

How they compose:

  • A Channel has a default Catalog (all listed products, base prices)
  • A Channel can have additional Catalogs assigned to CustomerGroups or CompanyLocations
  • A Market can have a default Catalog (regional assortment)
  • A CompanyLocation can have assigned Catalogs (B2B per-location assortment + pricing)

Product visibility resolution:

1. Request arrives with: store, channel, user (optional)
2. Base set: ProductPublications for this channel
3. If user belongs to a CompanyLocation with Catalogs:
   → Intersect with CatalogProducts from those Catalogs
4. Else if user belongs to a CustomerGroup with a Catalog:
   → Intersect with CatalogProducts from that Catalog
5. Else: show all channel listings (default catalog)

Price resolution (extends existing Pricing::Resolver):

1. Check Catalog's PriceList (if Catalog has one)
2. Check other applicable PriceLists (existing PriceRule system)
3. Fall back to base price

Key Decisions (do not deviate without discussion)

Channel

  • Lightweight model — not a config container. Store owns heavyweight config (currency, tax, payment methods, delivery methods). Channel is just a distribution surface.
  • Belongs to Store (SingleStoreResource). One store can have many channels.
  • Order.channel becomes a FK replacing the free-text varchar.
  • Each Channel can have its own PublishableApiKey — so a storefront app, a POS app, and a Meta integration each get their own key scoped to their channel.
  • Default channel created per store — "Online Store" is always present.
  • storefront_access (tiered enum) lives on Channel, nil falling back to a store-level default of public. Gating is a per-channel posture, not store-wide — one store can run a public B2C channel and a login_required wholesale channel side by side. Enforced in the v3 Store API, not the storefront.
  • Channel gains its own guest_checkout, nil → store fallback; the Store preference is kept as the fallback (not removed — non-breaking), and the resolved value is enforced server-side in the v3 Store API for the first time.

ProductPublication (replaces StoreProduct)

  • New spree_product_publications table. Joins Product to Channel, not Product to Store.
  • No store_id column — the publication's store is channel.store (delegated). Cross-store reporting joins through spree_channels.
  • Carries publication-specific datapublished_at, unpublished_at (scheduled publishing).
  • position was dropped from the original sketch — nothing in 5.5 reads it and acts_as_list added a MAX(position) query per insert for no gain. Clean additive migration later if per-channel ordering ever becomes a real requirement.
  • Store-level scoping is directSpree::Store has_many :products via store_id on Product. The historic store.products through: :store_products join is gone from core.

Catalog

  • Belongs to Store. A catalog can be shared across channels within the same store.
  • Has many CatalogProducts (visibility) + optional belongs_to PriceList (pricing).
  • Catalog without PriceList = assortment-only (use base prices). Catalog with PriceList = assortment + pricing.
  • Multiple Catalogs can apply — when a CompanyLocation has 2 Catalogs, product visibility is the UNION of both. Pricing uses the first matching Catalog's PriceList (priority by position).
  • Default Catalog per Channel — optional. If set, only products in the default catalog are visible on that channel. If not set, all ProductPublications are visible.

Company (B2B)

  • Company → CompanyLocation → CompanyContact hierarchy.
  • Company belongs to Store. Represents a business customer (Acme Corp).
  • CompanyLocation belongs to Company. Represents an office/branch. Has billing address, shipping address, tax exemption settings. Can have assigned Catalogs.
  • CompanyContact belongs to CompanyLocation. Links to a User (Spree.user_class). A user can be a contact at multiple locations.
  • CustomerGroup is NOT replaced. Companies and CustomerGroups serve different purposes: CustomerGroup is a flat segmentation tag (VIP, Wholesale). Company is a hierarchical B2B entity. A Company can be IN a CustomerGroup.

Design Details

Channel (shipped in 5.5)

See spree/core/app/models/spree/channel.rb. Final shape:

  • default boolean column + scope :default + partial unique index (store_id) WHERE default = TRUE on Postgres/SQLite (model-level uniqueness validation on MySQL). before_validation :promote_first_channel_to_default auto-promotes the first channel created on a store; after_save :demote_other_defaults enforces single-default invariant when an admin flips the flag.
  • No default_catalog_id — waits for Catalog in 6.0.
  • Includes routing-rules associations + preference :order_routing_strategy from 6.0-order-routing.md.
  • #add_products(product_ids, published_at:, unpublished_at:) bulk-UPSERTs publications. Idempotent: re-publishing an already-published product is a no-op for its window unless dates are explicitly passed (uses Arel.sql('updated_at = <table>."updated_at"') for the conflict clause to satisfy PG's DO UPDATE requirement without rewriting columns).
  • #remove_products(product_ids) mirrors add_products — destroys publications, touches the channel.
  • Touches every product via Product.where(id: product_ids).touch_all + enqueue_search_index after the upsert, since upsert_all bypasses belongs_to :product, touch: true.

ProductPublication (shipped in 5.5 — new table)

See spree/core/app/models/spree/product_publication.rb. Final shape:

  • New table spree_product_publications with columns product_id, channel_id, published_at, unpublished_at, created_at, updated_at. No store_id column — delegate :store, :store_id, to: :channel for in-memory access; cross-store reporting queries join through spree_channels.
  • Unique index on (product_id, channel_id). (product_id, channel_id, store_id) was abandoned along with the in-place reshape.
  • Spree::StoreProduct deleted. The class is gone in 5.5. Apps on 5.4 that referenced it directly must update to Spree::ProductPublication.
  • Legacy spree_products_stores table left untouched. Core no longer reads or writes it. The upcoming spree_multi_store extension uses it to restore the Product has_many :stores association for merchants who need shared catalogs across stores.

prefix :pp, validates (product, channel), validates unpublished_at_after_published_at, has a published scope.

Product (shipped in 5.5 — single store ownership)

See spree/core/app/models/spree/product.rb and spree/core/app/models/spree/product/channels.rb. Final shape:

  • belongs_to :store, optional: true with before_validation :assign_default_store (uses Spree::Current.storeSpree::Store.default fallback).
  • has_many :product_publications (autosave) + has_many :channels, -> { distinct }, through: :product_publications.
  • accepts_nested_attributes_for :product_publications for Rails nested-attribute form writes (legacy admin Publishing card).
  • Custom product_publications= setter for SPA's hash-array payload — full-set semantics (rows absent from the payload are destroyed). Coexists with the nested-attributes path on different keys.
  • Deprecated available_on= / discontinue_on= setters write through to every publication's published_at / unpublished_at. Readers prefer the current-channel publication's value with a ternary fallback (publication ? publication.send(attr) : super()) — || would mask nil from an always-live publication.
  • #stores / #stores= / #store_ids / #store_ids= bridges in Spree::Product::LegacyMultiStoreSupport (auto-included unless SpreeMultiStore defines), preserved as deprecation warnings for legacy callers.
  • set_default_publication callback removed. New products created via the Admin API are not auto-published — the caller supplies product_publications: [{ channel_id }]. The dashboard SPA's create form does auto-publish on the store's default channel (Shopify-style); the legacy admin form has a Publishing card with the same UX.

Store#default_channel via boolean column

ruby
# spree/core/app/models/concerns/spree/stores/channels.rb
has_one :default_channel, -> { default }, class_name: 'Spree::Channel'

def ensure_default_channel
  return if default_channel
  channels.create!(name: 'Online Store', code: Spree::Channel::DEFAULT_CODE)
end

The default scope on Channel resolves the right row by the boolean column — no string-code lookup, no fragile rename hazard.

Catalog

ruby
class Spree::Catalog < Spree.base_class
  has_prefix_id :cat

  include Spree::SingleStoreResource
  include Spree::Metafields
  include Spree::Metadata

  belongs_to :store, class_name: 'Spree::Store'
  belongs_to :price_list, class_name: 'Spree::PriceList', optional: true

  has_many :catalog_products, class_name: 'Spree::CatalogProduct', dependent: :destroy
  has_many :products, through: :catalog_products

  # Who sees this catalog
  has_many :catalog_assignments, class_name: 'Spree::CatalogAssignment', dependent: :destroy

  validates :name, presence: true

  acts_as_list scope: :store_id

  scope :active, -> { where(active: true) }

  attribute :active, :boolean, default: true
end

CatalogProduct

ruby
class Spree::CatalogProduct < Spree.base_class
  belongs_to :catalog, class_name: 'Spree::Catalog', touch: true
  belongs_to :product, class_name: 'Spree::Product'

  validates :catalog, :product, presence: true
  validates :product_id, uniqueness: { scope: :catalog_id }

  acts_as_list scope: :catalog_id
end

CatalogAssignment (who sees this catalog)

ruby
class Spree::CatalogAssignment < Spree.base_class
  belongs_to :catalog, class_name: 'Spree::Catalog'

  # Assignable: Channel, CustomerGroup, CompanyLocation, Market
  belongs_to :assignable, polymorphic: true

  validates :catalog, :assignable, presence: true
  validates :catalog_id, uniqueness: { scope: [:assignable_type, :assignable_id] }
end

Note: this is polymorphic, but the assignable set is small (4 types) and CatalogAssignment is never queried in hot paths — it's loaded once at session start and cached. The assignment direction is "who gets this catalog" not "what catalogs does this entity have" — so we query by catalog, not by assignable.

Company

ruby
class Spree::Company < Spree.base_class
  has_prefix_id :comp

  include Spree::SingleStoreResource
  include Spree::Metafields
  include Spree::Metadata

  belongs_to :store, class_name: 'Spree::Store'
  belongs_to :customer_group, class_name: 'Spree::CustomerGroup', optional: true

  has_many :company_locations, class_name: 'Spree::CompanyLocation', dependent: :destroy
  has_many :company_contacts, through: :company_locations

  validates :name, presence: true

  attribute :external_id, :string  # ERP/CRM reference
  attribute :tax_exempt, :boolean, default: false
end

CompanyLocation

ruby
class Spree::CompanyLocation < Spree.base_class
  has_prefix_id :cloc

  belongs_to :company, class_name: 'Spree::Company', inverse_of: :company_locations
  belongs_to :billing_address, class_name: 'Spree::Address', optional: true
  belongs_to :shipping_address, class_name: 'Spree::Address', optional: true

  has_many :company_contacts, class_name: 'Spree::CompanyContact', dependent: :destroy
  has_many :users, through: :company_contacts

  # Catalog assignments for this location
  has_many :catalog_assignments, as: :assignable, class_name: 'Spree::CatalogAssignment'
  has_many :catalogs, through: :catalog_assignments

  has_many :orders, class_name: 'Spree::Order', inverse_of: :company_location

  validates :name, presence: true

  attribute :tax_exempt, :boolean, default: false  # overrides company-level
  attribute :external_id, :string
end

CompanyContact

ruby
class Spree::CompanyContact < Spree.base_class
  has_prefix_id :cc

  belongs_to :company_location, class_name: 'Spree::CompanyLocation', inverse_of: :company_contacts
  belongs_to :user, class_name: Spree.user_class.to_s

  has_one :company, through: :company_location

  validates :user, :company_location, presence: true
  validates :user_id, uniqueness: { scope: :company_location_id }

  attribute :role, :string, default: 'buyer'  # buyer, admin, viewer
end

Order gains Channel and CompanyLocation

ruby
class Spree::Order < Spree.base_class
  belongs_to :channel, class_name: 'Spree::Channel', optional: true
  belongs_to :company_location, class_name: 'Spree::CompanyLocation', optional: true

  # Remove: string :channel column (replaced by FK)

  def b2b?
    company_location.present?
  end

  def company
    company_location&.company
  end
end

Store gains Channels (as-shipped in 5.5)

ruby
class Spree::Store < Spree.base_class
  has_many :channels, class_name: 'Spree::Channel', dependent: :destroy
  has_one :default_channel, -> { default }, class_name: 'Spree::Channel'

  # 6.0 additions
  has_many :catalogs, class_name: 'Spree::Catalog', dependent: :destroy
  has_many :companies, class_name: 'Spree::Company', dependent: :destroy

  # Direct ownership in 5.5 — products belong to a single store.
  has_many :products, class_name: 'Spree::Product', dependent: :nullify
  has_many :product_publications, through: :channels, source: :publications

  after_create :ensure_default_channel

  def ensure_default_channel
    return if default_channel
    channels.create!(name: 'Online Store', code: Spree::Channel::DEFAULT_CODE)
  end
end

Product visibility query

ruby
class Spree::Products::ForContext
  # Returns products visible to the current context
  def call(store:, channel:, user: nil)
    # Base: all active listings on this channel
    products = channel.products.active

    # If user has a company location with catalogs, filter to those
    if (location = user_company_location(user, store))
      catalog_ids = location.catalog_assignments.pluck(:catalog_id)
      if catalog_ids.any?
        catalog_product_ids = Spree::CatalogProduct.where(catalog_id: catalog_ids).select(:product_id)
        products = products.where(id: catalog_product_ids)
      end
    # If user belongs to a customer group with a catalog
    elsif (group_catalog_ids = user_group_catalog_ids(user, store)).any?
      catalog_product_ids = Spree::CatalogProduct.where(catalog_id: group_catalog_ids).select(:product_id)
      products = products.where(id: catalog_product_ids)
    end
    # Else: all channel listings (no catalog filtering)

    products
  end
end

Pricing integration

Existing Pricing::Context gains catalog awareness:

ruby
# In Spree::Pricing::Resolver
def resolve(variant, context)
  # 1. Check catalog's PriceList (if context has a catalog with a PriceList)
  if context.catalog&.price_list
    price = find_price(variant, context.catalog.price_list, context.currency)
    return price if price&.amount
  end

  # 2. Check other applicable PriceLists (existing logic)
  # ... existing PriceRule matching ...

  # 3. Fall back to base price
  variant.price_in(context.currency)
end

Gated Storefront Access (5.6)

Channel owns the storefront's access posture — the gate that Medusa, Saleor, and Vendure all push to the storefront layer. Spree enforces it in the v3 Store API instead, so it can't be bypassed by calling the API directly. Two orthogonal controls, both resolved from the channel the request is bound to (X-Spree-Channel, falling back to store.default_channel).

Because it builds only on the Channel infrastructure shipped in 5.5 (model, preference DSL, X-Spree-Channel resolution, v3 Store API auth context) and needs no schema migration, this ships in 5.6 — ahead of the Catalog/Company work it will later compose with in 6.0.

storefront_access — what anonymous visitors may see (tiered enum)

ruby
# spree/core/app/models/spree/channel.rb
STOREFRONT_ACCESS = %w[public prices_hidden login_required].freeze

# Empty -> falls back to the Store-level preference (mirrors order_routing_strategy).
preference :storefront_access, :string, default: nil
validates :preferred_storefront_access, inclusion: { in: STOREFRONT_ACCESS }, allow_nil: true

# spree/core/app/models/spree/store.rb — the fallback default
preference :storefront_access, :string, default: 'public'
ValueProductsPricesUse case
publicvisible to anyonevisible to anyonetoday's behaviour, B2C
prices_hiddenbrowsable by anyonenull for guestsB2B2C teaser — "log in to see your price"
login_required401 for gueststrade-only portal — anonymous sees nothing
  • Resolution lives directly in the Spree::Channel::Gating concern (there is no separate HasStorefrontAccessPreference concern): channel.resolved_storefront_access / resolved_guest_checkout return the channel value, or the store value when the channel preference is nil. guest_checkout is declared nullable: true so a blank write stays nil (inherit) rather than coercing to false.
  • "Guest" = no authenticated customer — a request carrying only a publishable key (or a guest cart token) with no customer JWT. A logged-in customer is never gated.
  • Enforcement (v3 Store API):
    • prices_hidden → the gate injects a hide_prices serializer flag; BaseSerializer#price_for/price_in and the money_attributes helper (used by the product, cart, order, and line-item serializers) render every price/total field null for guest requests, so the cart can't leak what the catalog withholds. Logged-in customers (incl. B2B) serialize normally.
    • login_required → a before_action on both Store::BaseController and Store::ResourceController (the two parallel Store API branches) returns 401 for guest requests across all Store endpoints. Public endpoints — sign in / register, password reset, and pre-login reference data (countries, currencies, locales, markets, newsletter, tokenized digital downloads) — opt out via allow_guest_storefront_access!.
  • This is the explicit "deny anonymous" gate the visibility model lacked. In 5.6 it sits in front of channel-publication visibility (shipped 5.5): a login_required guest 401s before any product list is built, and prices_hidden nulls prices on whatever the channel exposes. When Catalogs land in 6.0 the same gate fronts Products::ForContext — a guest never reaches the "show all channel listings (default catalog)" fallback, and a logged-in B2B buyer sees their company-location catalog at its PriceList price.

guest_checkout — may an order be placed without an account (channel override, store fallback)

ruby
# spree/core/app/models/spree/channel.rb
# Empty -> falls back to the Store-level preference (same pattern as storefront_access).
preference :guest_checkout, :boolean, default: nil

# spree/core/app/models/spree/store.rb — retained as the fallback default (NOT removed)
preference :guest_checkout, :boolean, default: true
  • Channel overrides, Store falls back. A B2C channel can allow guest checkout while a wholesale channel on the same store forbids it — the store is the wrong granularity once channels exist, but its preference is kept as the fallback so existing accessors (store.prefer_guest_checkout) and single-channel stores keep working unchanged. Resolution: channel.resolved_guest_checkout returns the channel value, or the store value when the channel preference is nil.
  • First server-side enforcement. Today guest checkout is unenforced in the API — the legacy Spree::Config.allow_guest_checkout was "only used in the rails frontend, not implemented in API" and Store#prefer_guest_checkout only drove the legacy admin form. In 5.6 Spree::Order#guest_checkout_disallowed? (enforced in Spree::Carts::Complete so controller, payment-session, and webhook completion paths are all covered) requires an authenticated customer when the order's channel.resolved_guest_checkout is false.
  • prices_hidden implies no guest checkout. guest_checkout_disallowed? also returns true whenever the channel hides prices from guests (storefront_prices_hidden?), regardless of the guest_checkout flag — a buyer who cannot see prices cannot meaningfully place an order. This dissolves the otherwise contradictory "prices hidden but guests may buy" configuration in the domain layer (no cross-record validation needed); the admin forms carry a hint saying guests must sign in to check out when prices are hidden.

Migration

  • No schema migration. All three are model preferences stored in the existing serialized preferences column on spree_channels / spree_stores (same mechanism as order_routing_strategy).
  • No backfill required, non-breaking. Channels default to nil → store fallback for both storefront_access (→ public) and guest_checkout (→ the retained Store default), so existing behaviour is preserved until a merchant overrides a channel. The Store#prefer_guest_checkout accessor stays valid — it remains the fallback source.

Gating × Catalog composition

Channel gating (5.6) and the Catalog/Company layer (6.0) are orthogonal and run in sequence — the gate is the front door, the catalog is the personalized shelf behind it.

LayerScopeKeyed onWhere it runsDecides
Gate (storefront_access / guest_checkout)whole Channelauthenticated vs guestv3 Store API controller/serializercan this request see the channel / see prices / check out
Catalogper Company / CustomerGroupmembershipProducts::ForContext + Pricing::Resolverwhich products + which prices for this buyer

Ordering — the gate fires first. enforce_storefront_login_required! is a before_action, so on a login_required channel a guest gets 401 before Products::ForContext runs — no catalog work for gated guests. prices_hidden sets hide_prices in serializer_params, which nulls whatever price the catalog resolver produced, so a company-catalog price is still withheld from a gated guest.

Canonical B2B shape — one wholesale channel, many company catalogs:

  • Anonymous → gate 401s; the whole channel is sealed.
  • Company A buyer (logged in) → passes the gate → ForContext intersects channel publications with Company A's location catalogs → Catalog A assortment at Catalog A's PriceList.
  • Company B buyer → Catalog B assortment + pricing.
  • Logged-in user with no company/catalog → passes the gate → channel default catalog (resolution step 5).

prices_hidden + per-company PriceList = "log in to see your price." A guest browses the default catalog with prices nulled; a logged-in company buyer sees prices, and the catalog layer makes them that company's negotiated prices. The two layers cooperate: the gate withholds from anonymous, the catalog personalizes per company.

Key constraint — the gate is authenticated-vs-guest, not per-group. hide_prices? / login_required only ask "is a customer logged in"; any authenticated customer passes. Consequences:

  • The gate stops anonymous visitors, not logged-in-but-unapproved ones. There is no per-catalog or per-company storefront_access.
  • Restricting what an unapproved-but-logged-in user sees is the catalog's job. For an "approved companies only" portal, give the channel an empty/minimal default catalog so step-5 fallback users see nothing purchasable — rather than expecting the gate to enforce approval.
  • guest_checkout is channel-wide; fine for B2B since company buyers are always real users (order.user.present? holds).

Design rule: use separate Channels for different access postures (a public B2C channel + a login_required wholesale channel, each with its own publishable key); use Catalogs for per-company variation within a channel that shares one posture. Mirrors Shopify — B2B is a separate gated market, catalogs personalize assortment + pricing inside it.

Migration Path

Phase 1 — shipped in 5.5

Migrations (spree/core/db/migrate/):

  • 20260508204040_create_spree_channels.rb — Channel table (shipped 5.5 with order routing). Note: the in-migration ensure_default_market backfill that originally lived here was removed — default-channel seeding for existing stores moves to the spree:channels:create_defaults rake task.
  • 20260601000001_create_spree_product_publications.rbnew spree_product_publications table with (product_id, channel_id, published_at, unpublished_at, created_at, updated_at) + unique index on (product_id, channel_id).
  • 20260601000002_add_store_id_to_spree_products.rb — adds store_id (nullable), units_sold_count (default 0), revenue (default 0) to spree_products + (store_id, units_sold_count) index. Existing rows have store_id IS NULL until the backfill rake runs.
  • 20260602000001_add_default_to_spree_channels.rb — adds default boolean to spree_channels + partial unique index on (store_id) WHERE default = TRUE (Postgres/SQLite; MySQL relies on the model-level uniqueness validation).

The earlier additive-then-swap pattern (reshape spree_products_stores in place) was abandoned. It carried a denormalized store_id column on the publication table and a Spree::StoreProduct STI alias for back-compat. The simpler shape — separate table, single owner on Product — is what actually shipped.

Upgrade rake (spree:channels:upgrade chains):

  1. spree:channels:create_defaults — creates the default "Online Store" channel for every existing store (via Store#ensure_default_channel). Idempotent.
  2. spree:upgrade:populate_publications — for every product with store_id IS NULL, reads the legacy spree_products_stores rows (raw SELECT with parameterized binding), picks a "home" store (preferring the row whose store is default: true, otherwise earliest by created_at), sets spree_products.store_id, and creates a ProductPublication row on each attached store's default channel. Runs in a transaction per product. Idempotent.
  3. spree:channels:backfill_order_channel_ids — populates spree_orders.channel_id from the legacy spree_orders.channel string column.
  4. spree:channels:backfill_product_publication_dates — copies the legacy Product.available_on / Product.discontinue_on columns (read raw via product[:column] to bypass the channel-aware reader) into each publication's published_at / unpublished_at where currently NULL.

Deploy order: code → db:migraterake spree:channels:upgrade. No swap migration — there's nothing to swap.

Warning: Until spree:channels:upgrade runs, every product has store_id IS NULL and is invisible to Product.for_store(store). The admin product list, storefront catalog, and search indexer all return empty. The upgrade guide docs/developer/upgrades/5.4-to-5.5.mdx makes this prominent.

Model layer:

  • Spree::ProductPublication on the new spree_product_publications table — no store_id column, store/store_id delegated to channel.
  • Spree::Product belongs_to :store (single). for_store(store) scope is now where(store_id: store.id) (was a join through publications).
  • Spree::Product::Channels concern owns has_many :product_publications, has_many :channels through:, the deprecated available_on=/discontinue_on= cascade, and the SPA's product_publications= hash-array setter. No set_default_publication callback — explicit publishing only.
  • Spree::Product::LegacyMultiStoreSupport concern owns #stores / #stores= / #store_ids / #store_ids= deprecation bridges (auto-included unless SpreeMultiStore is defined; the spree_multi_store extension supersedes them).
  • Spree::Product.available / .active / .not_discontinued scopes are channel-aware via Spree::Current.channel; jobs/rake without channel context use the legacy column fallback path.
  • Spree::Channel#add_products / #remove_products are the canonical bulk publishing API (used by both bulk-action controllers and the legacy/SPA Publishing cards).
  • Spree::Current.channel + X-Spree-Channel header resolution in API v3 base controller. Falls back to current_store.default_channel.

Search provider:

  • Spree::SearchProvider::ProductPresenter indexes a per-product channel_ids: [...] array (computed via product.product_publications.joins(:channel).where(spree_channels: { store_id: store.id })).
  • Spree::SearchProvider::Meilisearch#system_filter_conditions adds channel_ids = '<current channel>'.
  • Spree::SearchIndexable#store_ids_for_indexing reads the record's own store_id (was hardcoded to Spree::Store.default.id in an earlier sketch — that broke multi-store indexing).

Admin API:

GET    /api/v3/admin/channels
POST   /api/v3/admin/channels
PATCH  /api/v3/admin/channels/:id
DELETE /api/v3/admin/channels/:id

POST   /api/v3/admin/channels/:id/add_products       # body: { product_ids: [...], published_at?, unpublished_at? }
POST   /api/v3/admin/channels/:id/remove_products    # body: { product_ids: [...] }

POST   /api/v3/admin/products/bulk_add_to_channels      # body: { ids: [...], channel_ids: [...] }
POST   /api/v3/admin/products/bulk_remove_from_channels # body: { ids: [...], channel_ids: [...] }

The originally-planned nested CRUD (GET/POST/PATCH/DELETE /admin/channels/:channel_id/product_publications/...) was dropped — the SPA never used it, no industry precedent (Shopify/Saleor/Vendure/Medusa all expose action endpoints, not nested CRUD), and the Spree::ProductPublication model is internal join-table noise that shouldn't be a top-level API surface.

  • Admin Product API dropped available_on, discontinue_on, make_active_at from params + serializer.
  • Admin Product serializer gains many :product_publications via expand=product_publications (with :channel preloaded to avoid N+1).
  • Bulk endpoints delegate to Spree::Channel#add_products / #remove_products (single upsert per channel, no per-product saves).
  • Spree::Channel gains a default: boolean serializer field so the dashboard can identify the default channel for auto-publish on product create.

Store API:

  • Spree::Api::V3::ProductSerializer#available_on resolves from the current channel's publication published_at, falling back to the legacy Product.available_on column. Shape unchanged — single-channel stores see zero behavior change.

Legacy admin:

  • /admin/channels CRUD (controller + table registration + sidebar nav under Settings + permitted attributes + ConfigurationManagement permission grant). The settings nav labels it "Sales channels".
  • Product edit page has a Publishing card (spree/admin/app/views/spree/admin/products/form/_publishing.html.erb) — channel checkboxes via Manage panel, per-publication date editor via <details> row. Writes through accepts_nested_attributes_for :product_publications. Mirrors the SPA's <PublishingCard>.
  • make_active_at / available_on / discontinue_on inputs removed from the legacy admin product form's Status card — date scheduling moves to the per-publication editor on the Publishing card.

Dashboard SPA:

  • <PublishingCard> at packages/dashboard/src/components/spree/products/publishing-card.tsx — full Shopify-style publishing UI with status badges (Live / Scheduled / Hidden / Not available, gated by product status), per-channel schedule editor, Manage sheet for attach/detach.
  • New-product form auto-publishes on the store's default channel (uses the Channel default boolean).
  • Settings → "Sales channels" page (packages/dashboard/src/routes/_authenticated/$storeId/settings/channels.tsx).
  • Bulk actions on product list: Add to sales channels… / Remove from sales channels….
  • useChannels hook query-keyed by storeId so switching stores within the staleTime window doesn't serve the previous store's channels.

Sample data:

  • bin/rake spree:load_sample_data seeds three channels (Online Store [default], Point of Sale, Wholesale) via db/sample_data/channels.rb and explicitly publishes every loaded product on the default channel via Loader#publish_sample_products.

Phase 1.5 — 5.6: Gated storefront access

No schema migration (preferences only — see the Gated Storefront Access design section). Deliverable on top of the shipped 5.5 channel infrastructure:

  • Spree::Channel gains preference :storefront_access (nil → store fallback) + preference :guest_checkout (nil → store fallback); Spree::Store gains preference :storefront_access, default: 'public' and retains its existing guest_checkout preference as the fallback.
  • Resolution methods (resolved_storefront_access / resolved_guest_checkout) live on the Spree::Channel::Gating concern; guest_checkout uses nullable: true (the :boolean preference branch now honors nullable:) so "inherit" is expressible.
  • v3 Store API enforcement: hide_prices serializer flag + money_attributes price-nulling for prices_hidden; before_action 401 guard on Store::BaseController + Store::ResourceController for login_required (public endpoints opt out via allow_guest_storefront_access!); guest-checkout guard in the Spree::Carts::Complete service (so controller, payment-session, and webhook completion paths are all covered), with the controller also returning a 401 early.
  • Non-breaking, no backfill — channels default to store fallback, preserving today's behaviour.

Phase 2 — 6.1: Catalog + Company tree + pricing integration

ruby
class CreateCatalogsCompanies < ActiveRecord::Migration[7.2]
  def change
    add_reference :spree_channels, :default_catalog

    create_table :spree_catalogs do |t|
      t.references :store, null: false
      t.references :price_list
      t.string :name, null: false
      t.boolean :active, null: false, default: true
      t.integer :position
      if t.respond_to?(:jsonb)
        t.jsonb :metadata
      else
        t.json :metadata
      end
      t.timestamps
    end

    create_table :spree_catalog_products do |t|
      t.references :catalog, null: false
      t.references :product, null: false
      t.integer :position
      t.timestamps
    end
    add_index :spree_catalog_products, [:catalog_id, :product_id], unique: true

    create_table :spree_catalog_assignments do |t|
      t.references :catalog, null: false
      t.string :assignable_type, null: false
      t.bigint :assignable_id, null: false
      t.timestamps
    end
    add_index :spree_catalog_assignments, [:catalog_id, :assignable_type, :assignable_id],
              unique: true, name: 'idx_catalog_assignments_unique'

    create_table :spree_companies do |t|
      t.references :store, null: false
      t.references :customer_group
      t.string :name, null: false
      t.string :external_id
      t.boolean :tax_exempt, null: false, default: false
      if t.respond_to?(:jsonb)
        t.jsonb :metadata
      else
        t.json :metadata
      end
      t.timestamps
    end
    add_index :spree_companies, [:store_id, :external_id], unique: true,
              where: 'external_id IS NOT NULL', name: 'idx_companies_external_id'

    create_table :spree_company_locations do |t|
      t.references :company, null: false
      t.references :billing_address
      t.references :shipping_address
      t.string :name, null: false
      t.string :external_id
      t.boolean :tax_exempt, null: false, default: false
      if t.respond_to?(:jsonb)
        t.jsonb :metadata
      else
        t.json :metadata
      end
      t.timestamps
    end

    create_table :spree_company_contacts do |t|
      t.references :company_location, null: false
      t.references :user, null: false
      t.string :role, null: false, default: 'buyer'
      t.timestamps
    end
    add_index :spree_company_contacts, [:company_location_id, :user_id], unique: true

    add_reference :spree_orders, :company_location
  end
end

Phase 2 — 6.1 Admin API additions

# Catalogs
GET    /api/v3/admin/catalogs
POST   /api/v3/admin/catalogs
PATCH  /api/v3/admin/catalogs/:id
DELETE /api/v3/admin/catalogs/:id
POST   /api/v3/admin/catalogs/:id/products
DELETE /api/v3/admin/catalogs/:id/products/:product_id
POST   /api/v3/admin/catalogs/:id/assign

# Companies
GET    /api/v3/admin/companies
POST   /api/v3/admin/companies
PATCH  /api/v3/admin/companies/:id
DELETE /api/v3/admin/companies/:id

POST   /api/v3/admin/companies/:company_id/locations
PATCH  /api/v3/admin/company_locations/:id
DELETE /api/v3/admin/company_locations/:id

POST   /api/v3/admin/company_locations/:location_id/contacts
DELETE /api/v3/admin/company_contacts/:id

Phase 3 — 6.0 cleanup

Most of the planned 6.0 cleanup already happened in 5.5 (cleaner data model from the start). Remaining items:

  • Drop Spree::Product.available_on, Spree::Product.discontinue_on, Spree::Product.make_active_at columns and the deprecated setters in Spree::Product::Channels.
  • Drop Spree::Product::LegacyMultiStoreSupport (multi-store catalogs live in the spree_multi_store extension from 5.5 onward).
  • Drop Spree::Product.for_store's legacy column-fallback branch (Spree::Product.available / .not_discontinued always use publications once Spree::Current.channel is unconditionally set).
  • Make spree_products.store_id NOT NULL (it's nullable in 5.5 to accommodate the post-migrate / pre-backfill window).
  • Drop spree_orders.channel string column (5.5+ already ignores it via ignored_columns).
  • Drop the channel-aware-scope legacy fallback branch in Spree::Product.available / .not_discontinued (always uses publications).

Already done in 5.5 (don't redo in 6.0):

  • Drop Spree::StoreProduct STI subclass — deleted in 5.5.
  • Drop store_id column from the publications table — new spree_product_publications table never had it.
  • Rename spree_products_storesspree_product_publications — new table created; legacy spree_products_stores left for spree_multi_store.

Constraints on Current Work

  • Spree::StoreProduct is gone. Use Spree::ProductPublication directly. Don't reintroduce the STI alias or any reference to spree/core/app/models/spree/store_product.rb.
  • Spree::ProductPublication has no store_id column. It's delegated to channel.store. Don't write code that calls ProductPublication.where(store_id: …) — use a join: Publication.joins(:channel).where(spree_channels: { store_id: … }).
  • Product belongs_to :store (single). Don't write new code assuming product.stores is a real ActiveRecord association — the deprecation bridge returns Array(store) and won't chain. Multi-store catalogs live in spree_multi_store.
  • Channel#default is a boolean column. Use Spree::Channel.default.where(store: …) or store.default_channel. Don't look up by code: 'online' — merchants can rename it.
  • Channel-aware scopes assume Spree::Current.channel is set. API requests set it via the X-Spree-Channel header concern; jobs / rake tasks fall back to the legacy column path. Don't add new code that reads Spree::Current.channel without expecting either branch.
  • Spree::Channel#add_products is idempotent and preserves existing publication windows when called without published_at / unpublished_at kwargs. Don't change this — bulk-publish ergonomics depend on it.
  • 6.0 adds default_catalog_id to spree_channels (additive — won't conflict with the 5.5 schema).
  • PriceList stays as-is. Catalog optionally references a PriceList — don't duplicate pricing logic.
  • storefront_access is resolved per request from the bound channel (X-Spree-Channelstore.default_channel), with nil → store fallback. Use channel.resolved_storefront_access for enforcement — don't read channel.preferred_storefront_access directly (it ignores the store fallback).
  • guest_checkout resolves channel-first, store-fallback. The Store preference stays valid as the fallback (not removed); new enforcement code should resolve via order.channel.resolved_guest_checkout rather than reading store.prefer_guest_checkout directly.
  • Gating is enforced in the v3 Store API, not the storefront. prices_hidden / login_required must hold for direct API calls — don't push the check into a storefront-only layer.
  • The gate is authenticated-vs-guest, not per-group. Per-company assortment and pricing is the Catalog layer's job (Products::ForContext), which runs after the gate — don't add per-company/per-catalog logic to storefront_access or hide_prices?. See "Gating × Catalog composition".

Resolved Questions

  1. Default channel behavior. Channel resolution via X-Spree-Channel request header (accepts channel code, e.g. X-Spree-Channel: online). If header is absent or doesn't match an active channel, falls back to store.default_channel (Channel.default scope on the default boolean column). Shipped in 5.5.

  2. How is the default channel identified? Via a default boolean column on Spree::Channel, not by code. Partial unique index on Postgres/SQLite, model-level uniqueness validation on MySQL. The first channel created on a store auto-promotes; flipping the flag on a later channel auto-demotes the previous default. Looking up by code: 'online' would have been fragile (merchants can rename the default channel).

  3. Auto-publish on product create. Admin API: no auto-publish — the caller supplies product_publications: [{ channel_id }]. Dashboard SPA's create form: yes, auto-publishes on Channel#default == true (Shopify-style). Sample data: explicitly publishes via Loader#publish_sample_products. The set_default_publication model callback was removed — moving the decision to the layer that knows the merchant's intent.

  4. Multi-store catalogs. Core Spree 5.5+ models Product as belongs_to :store (single owner). Merchants who need shared catalogs across stores install the spree_multi_store extension, which restores has_many :stores, through: :spree_products_stores on top of the legacy join table. Matches the Medusa/Saleor/Vendure pattern (channels are the multi-brand primitive, stores are the tenant root).

  5. Cross-store onboarding via Channel#add_products. Allowed: if a multi-store admin's API key has update permission on a product owned by sibling store A, calling POST /admin/channels/:id/add_products (where the channel belongs to store B) co-publishes the product onto store B's channel. The product's owning store_id doesn't change. This is the path by which the spree_multi_store extension's "publish across stores" UX threads through core.

  6. Catalog auto-sync. Manual assignment only for 6.0, with bulk product assignment via Admin API (POST /catalogs/:id/products/bulk). Auto-sync using CollectionRule-style patterns is a future enhancement.

  7. CompanyContact roles and B2B approval workflows. Out of scope for this plan. The 6.1 deliverable is the data model: Company, CompanyLocation, CompanyContact with a simple role string column. Role-based permissions, approval workflows, purchase limits, and invoice management are deferred to a dedicated B2B plan after 6.1.

  8. Gated storefront access model. A single tiered storefront_access enum on Channel (public / prices_hidden / login_required), nil → store fallback — rather than independent booleans (which permit redundant/contradictory combinations) or a prices-only boolean (which can't express a full login wall). Enforced in the v3 Store API: prices_hidden nulls price fields for guests, login_required 401s catalog reads for guests. This is the explicit "deny anonymous" gate the visibility resolver lacked. Targeted for 5.6 — it builds only on the 5.5 channel infrastructure and needs no schema change, so it ships ahead of the 6.0 Catalog/Company work.

  9. Where guest checkout lives. Channel gains a guest_checkout preference (nil → store fallback), so B2C and wholesale channels on one store can differ; the Spree::Store preference is retained as the fallback for old accessors (not removed — non-breaking). The v3 API enforces the resolved value server-side for the first time (it was previously a legacy-frontend-only preference).

Open Questions

None at this time.

References

  • Channel model: spree/core/app/models/spree/channel.rb
  • ProductPublication model: spree/core/app/models/spree/product_publication.rb
  • Product channels concern: spree/core/app/models/spree/product/channels.rb
  • Legacy multi-store bridge: spree/core/app/models/spree/product/legacy_multi_store_support.rb
  • Store channels concern: spree/core/app/models/concerns/spree/stores/channels.rb
  • Upgrade rake: spree/core/lib/tasks/channels.rake (spree:channels:upgrade)
  • Population rake: spree/core/lib/tasks/publications.rake (spree:upgrade:populate_publications)
  • User-facing concept doc: docs/developer/core-concepts/channels.mdx
  • Upgrade guide: docs/developer/upgrades/5.4-to-5.5.mdx
  • SPA Publishing card: packages/dashboard/src/components/spree/products/publishing-card.tsx
  • Legacy admin Publishing card: spree/admin/app/views/spree/admin/products/form/_publishing.html.erb
  • Related plan: 6.0-product-types.md (ProductType defines product schema)
  • Related plan: 6.0-replace-taxons-with-categories.md (Collections for merchandising)
  • Related plan: 6.0-order-routing.md (Channel-aware order routing strategies)
  • Storefront access + resolution concern: spree/core/app/models/spree/channel/gating.rb (Spree::Channel::Gating)
  • Nullable-boolean preference support: spree/core/lib/spree/core/preferences/preferable.rb (:boolean branch honors nullable:)
  • v3 Store API gate: spree/api/app/controllers/concerns/spree/api/v3/storefront_gating.rb; guest-checkout guard: Spree::Order#guest_checkout_disallowed? enforced in spree/core/app/services/spree/carts/complete.rb
  • Price nulling: Spree::Api::V3::BaseSerializer.money_attributes (cart/order/line-item) + price_for/price_in
  • Pattern reference: spree/core/app/models/spree/order_routing/has_strategy_preference.rb (sibling channel preference → store fallback)
  • B2B use-case docs to reconcile: docs/use-case/b2b/b2b-capabilities.mdx, docs/use-case/b2b/b2b-buyer-capabilities.mdx (assert gated storefronts as a shipped Community-Edition capability)