docs/plans/6.1-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); 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
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.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.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.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
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)# 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'
| Value | Products | Prices | Use case |
|---|---|---|---|
public | visible to anyone | visible to anyone | today's behaviour, B2C |
prices_hidden | browsable by anyone | null for guests | B2B2C teaser — "log in to see your price" |
login_required | 401 for guests | — | trade-only portal — anonymous sees nothing |
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.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!.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)# 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
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.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.preferences column on spree_channels / spree_stores (same mechanism as order_routing_strategy).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.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.
| Layer | Scope | Keyed on | Where it runs | Decides |
|---|---|---|---|---|
Gate (storefront_access / guest_checkout) | whole Channel | authenticated vs guest | v3 Store API controller/serializer | can this request see the channel / see prices / check out |
| Catalog | per Company / CustomerGroup | membership | Products::ForContext + Pricing::Resolver | which 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:
401s; the whole channel is sealed.ForContext intersects channel publications with Company A's location catalogs → Catalog A assortment at Catalog A's PriceList.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:
storefront_access.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.
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.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.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.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.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).storefront_access is resolved per request from the bound channel (X-Spree-Channel → store.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.prices_hidden / login_required must hold for direct API calls — don't push the check into a storefront-only layer.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".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.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.
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.
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).
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)spree/core/app/models/spree/channel/gating.rb (Spree::Channel::Gating)spree/core/lib/spree/core/preferences/preferable.rb (:boolean branch honors nullable:)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.rbSpree::Api::V3::BaseSerializer.money_attributes (cart/order/line-item) + price_for/price_inspree/core/app/models/spree/order_routing/has_strategy_preference.rb (sibling channel preference → store fallback)docs/use-case/b2b/b2b-capabilities.mdx, docs/use-case/b2b/b2b-buyer-capabilities.mdx (assert gated storefronts as a shipped Community-Edition capability)