docs/plans/6.0-channels-catalogs-b2b.md
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); Catalog, Company tree, and pricing integration land in 6.0
Target: Spree 5.5 (Phase 1 ✓) → 6.0 (Catalog + Company)
Depends on: ProductType (6.0-product-types.md), PriceList system (existing)
Author: Damian + Claude
Last updated: 2026-06-02
Add three new concepts to Spree's multi-store architecture:
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.
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."
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.
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.
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.
| What exists | What it does | Gap |
|---|---|---|
Store | Config boundary — currency, tax, payments, fulfillment | Heavy, covers too much. No sub-store scoping. |
Market | Geographic/currency region within a store | Read-only regions, no product visibility control |
StoreProduct | Product ↔ Store join (binary visibility) | No channel/catalog dimension |
CustomerGroup | User segmentation within a store | No company hierarchy |
PriceList + PriceRules | Conditional pricing (customer group, market, zone, volume, user) | No visibility counterpart |
Order.channel | Free-text varchar | Not an FK, not a model |
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:
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
Order.channel becomes a FK replacing the free-text varchar.spree_product_publications table. Joins Product to Channel, not Product to Store.store_id column — the publication's store is channel.store (delegated). Cross-store reporting joins through spree_channels.published_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.Spree::Store has_many :products via store_id on Product. The historic store.products through: :store_products join is gone from core.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.default_catalog_id — waits for Catalog in 6.0.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.Product.where(id: product_ids).touch_all + enqueue_search_index after the upsert, since upsert_all bypasses belongs_to :product, touch: true.See spree/core/app/models/spree/product_publication.rb. Final shape:
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.(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.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.
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.store → Spree::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).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.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.# 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.
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
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
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.
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
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
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
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
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
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
Existing Pricing::Context gains catalog awareness:
# 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
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.rb — new 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):
spree:channels:create_defaults — creates the default "Online Store" channel for every existing store (via Store#ensure_default_channel). Idempotent.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.spree:channels:backfill_order_channel_ids — populates spree_orders.channel_id from the legacy spree_orders.channel string column.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:migrate → rake spree:channels:upgrade. No swap migration — there's nothing to swap.
Warning: Until
spree:channels:upgraderuns, every product hasstore_id IS NULLand is invisible toProduct.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.
available_on, discontinue_on, make_active_at from params + serializer.many :product_publications via expand=product_publications (with :channel preloaded to avoid N+1).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".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.default boolean).packages/dashboard/src/routes/_authenticated/$storeId/settings/channels.tsx).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.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
# 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
Most of the planned 6.0 cleanup already happened in 5.5 (cleaner data model from the start). Remaining items:
Spree::Product.available_on, Spree::Product.discontinue_on, Spree::Product.make_active_at columns and the deprecated setters in Spree::Product::Channels.Spree::Product::LegacyMultiStoreSupport (multi-store catalogs live in the spree_multi_store extension from 5.5 onward).Spree::Product.for_store's legacy column-fallback branch (Spree::Product.available / .not_discontinued always use publications once Spree::Current.channel is unconditionally set).spree_products.store_id NOT NULL (it's nullable in 5.5 to accommodate the post-migrate / pre-backfill window).spree_orders.channel string column (5.5+ already ignores it via ignored_columns).Spree::Product.available / .not_discontinued (always uses publications).Already done in 5.5 (don't redo in 6.0):
Spree::StoreProduct STI subclassstore_id column from the publications tablespree_product_publications table never had it.spree_products_stores → spree_product_publicationsspree_products_stores left for spree_multi_store.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.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.default_catalog_id to spree_channels (additive — won't conflict with the 5.5 schema).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.
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).
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.
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).
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.
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.
CompanyContact roles and B2B approval workflows. Out of scope for this plan. The 6.0 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 post-6.0.
None at this time.
spree/core/app/models/spree/channel.rbspree/core/app/models/spree/product_publication.rbspree/core/app/models/spree/product/channels.rbspree/core/app/models/spree/product/legacy_multi_store_support.rbspree/core/app/models/concerns/spree/stores/channels.rbspree/core/lib/tasks/channels.rake (spree:channels:upgrade)spree/core/lib/tasks/publications.rake (spree:upgrade:populate_publications)docs/developer/core-concepts/channels.mdxdocs/developer/upgrades/5.4-to-5.5.mdxpackages/dashboard/src/components/spree/products/publishing-card.tsxspree/admin/app/views/spree/admin/products/form/_publishing.html.erb6.0-product-types.md (ProductType defines product schema)6.0-replace-taxons-with-categories.md (Collections for merchandising)6.0-order-routing.md (Channel-aware order routing strategies)