docs/plans/6.0-channels-catalogs-b2b.md
Status: Draft Target: Spree 6.0 Depends on: ProductType (6.0-product-types.md), PriceList system (existing) Author: Damian + Claude Last updated: 2026-03-16
Add three new concepts to Spree's multi-store architecture:
StoreProduct is replaced by ProductListing — the join between Product and Channel that says "this product is listed on this channel."
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)
→ ProductListings (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: ProductListings 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.StoreProduct — the table is renamed, the join now connects Product to Channel instead of Product to Store.listed_at, unlisted_at (scheduled publishing), position (sort order per channel).store.products goes through store.channels → product_listings → products. No direct Store → Product join needed.class Spree::Channel < Spree.base_class
has_prefix_id :ch
include Spree::SingleStoreResource
include Spree::Metafields
include Spree::Metadata
belongs_to :store, class_name: 'Spree::Store'
belongs_to :default_catalog, class_name: 'Spree::Catalog', optional: true
has_many :product_listings, class_name: 'Spree::ProductListing', dependent: :destroy
has_many :products, through: :product_listings
has_many :orders, class_name: 'Spree::Order', inverse_of: :channel
has_many :api_keys, class_name: 'Spree::ApiKey', as: :owner
validates :name, presence: true
validates :code, presence: true, uniqueness: { scope: :store_id }
scope :active, -> { where(active: true) }
attribute :active, :boolean, default: true
end
class Spree::ProductListing < Spree.base_class
has_prefix_id :pl
belongs_to :product, class_name: 'Spree::Product', touch: true
belongs_to :channel, class_name: 'Spree::Channel'
validates :product, :channel, presence: true
validates :product_id, uniqueness: { scope: :channel_id }
attribute :listed_at, :datetime
attribute :unlisted_at, :datetime
scope :active, -> {
where('listed_at IS NULL OR listed_at <= ?', Time.current)
.where('unlisted_at IS NULL OR unlisted_at > ?', Time.current)
}
# Carry over from StoreProduct
attribute :units_sold_count, :integer, default: 0
attribute :revenue, :decimal, default: 0
def active?
(listed_at.nil? || listed_at <= Time.current) &&
(unlisted_at.nil? || unlisted_at > Time.current)
end
end
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_many :catalogs, class_name: 'Spree::Catalog', dependent: :destroy
has_many :companies, class_name: 'Spree::Company', dependent: :destroy
# Product access now goes through channels
has_many :product_listings, through: :channels
has_many :products, through: :product_listings
# Remove: has_many :store_products (replaced by channels → product_listings)
def default_channel
channels.find_by(code: 'online') || channels.first
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
class CreateChannelsCatalogsCompanies < ActiveRecord::Migration[7.2]
def change
# Channels
create_table :spree_channels do |t|
t.references :store, null: false
t.references :default_catalog
t.string :name, null: false
t.string :code, null: false
t.boolean :active, null: false, default: true
t.jsonb :metadata
t.timestamps
end
add_index :spree_channels, [:store_id, :code], unique: true
# ProductListings (replaces StoreProducts)
create_table :spree_product_listings do |t|
t.references :product, null: false
t.references :channel, null: false
t.datetime :listed_at
t.datetime :unlisted_at
t.integer :position
t.integer :units_sold_count, null: false, default: 0
t.decimal :revenue, precision: 10, scale: 2, null: false, default: 0
t.timestamps
end
add_index :spree_product_listings, [:channel_id, :product_id], unique: true
# Catalogs
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
t.jsonb :metadata
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
# CatalogAssignments (who sees which catalog)
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'
# Companies
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
t.jsonb :metadata
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
t.jsonb :metadata
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
# Order: add channel_id and company_location_id
add_reference :spree_orders, :channel
add_reference :spree_orders, :company_location
end
end
# rake spree:migrate_store_products_to_listings
#
# 1. For each Store, create a default Channel (code: 'online', name: 'Online Store')
#
# 2. For each StoreProduct:
# → Create ProductListing (product_id, channel_id = store's default channel)
# → Copy units_sold_count and revenue
#
# 3. For each Order with a string channel value:
# → Find or create Channel with matching code
# → Set order.channel_id
#
# 4. Rename spree_store_products → spree_store_products_legacy
# Channels (Admin)
GET /api/v3/admin/channels
POST /api/v3/admin/channels
PATCH /api/v3/admin/channels/:id
DELETE /api/v3/admin/channels/:id
# Product Listings (Admin)
POST /api/v3/admin/channels/:channel_id/product_listings
DELETE /api/v3/admin/channels/:channel_id/product_listings/:id
POST /api/v3/admin/channels/:channel_id/product_listings/bulk # list/unlist many products
# Catalogs (Admin)
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 # add products to catalog
DELETE /api/v3/admin/catalogs/:id/products/:product_id
POST /api/v3/admin/catalogs/:id/assign # assign to channel/group/location
# Companies (Admin)
GET /api/v3/admin/companies
POST /api/v3/admin/companies
PATCH /api/v3/admin/companies/:id
DELETE /api/v3/admin/companies/:id
# Company Locations (Admin)
POST /api/v3/admin/companies/:company_id/locations
PATCH /api/v3/admin/company_locations/:id
DELETE /api/v3/admin/company_locations/:id
# Company Contacts (Admin)
POST /api/v3/admin/company_locations/:location_id/contacts
DELETE /api/v3/admin/company_contacts/:id
# Store API: context-aware product listing
GET /api/v3/store/products # automatically filtered by channel + catalog context
spree_store_products_legacy tableOrder.channel string column (replaced by FK)StoreProduct modelOrder.channel as a free-text field. It will become a FK.store.products (which will be rerouted through channels).API key scoping. Add optional channel_id to existing ApiKey model. One key can serve multiple channels (channel_id nil = all channels) or be scoped to one. No automatic key creation per channel.
Default channel behavior. Falls back to store's default channel when no channel context provided. Channel resolution via X-Spree-Channel request header (accepts channel code string, e.g., X-Spree-Channel: online or X-Spree-Channel: pos). If header is absent, use ApiKey's channel (if scoped) or store's default channel.
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/store.rbspree/core/app/models/spree/market.rbspree/core/app/models/spree/customer_group.rbspree/core/app/models/spree/price_list.rb, spree/core/app/models/spree/price_rule.rbspree/core/app/models/spree/store_product.rb6.0-product-types.md (ProductType defines product schema)6.0-replace-taxons-with-categories.md (Collections for merchandising)