Back to Spree

Channels, Catalogs & B2B Companies

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

5.4.222.0 KB
Original Source

Channels, Catalogs & B2B Companies

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

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.
  • 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 ProductListing — the join between Product and Channel that says "this product is listed on this channel."

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)
     → 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:

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

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.

ProductListing (replaces StoreProduct)

  • Replaces StoreProduct — the table is renamed, the join now connects Product to Channel instead of Product to Store.
  • Carries listing-specific datalisted_at, unlisted_at (scheduled publishing), position (sort order per channel).
  • Store-level scoping derived from Channelstore.products goes through store.channels → product_listings → products. No direct Store → Product join needed.

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 ProductListings 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

ruby
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

ProductListing (replaces StoreProduct)

ruby
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

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

ruby
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

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

Migration Path

Phase 1: Create new tables

ruby
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

Phase 2: Data migration (rake task)

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

Phase 3: Model migration

  • Create Channel, ProductListing, Catalog, CatalogProduct, CatalogAssignment
  • Create Company, CompanyLocation, CompanyContact
  • Update Store associations (channels, catalogs, companies)
  • Update Order (channel FK, company_location FK)
  • Update Product scoping (through channels → product_listings)
  • Update Pricing::Context and Pricing::Resolver for catalog awareness
  • Wire into API key system (channel-scoped publishable keys)

Phase 4: API

# 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

Phase 5: Cleanup (6.1)

  • Drop spree_store_products_legacy table
  • Remove Order.channel string column (replaced by FK)
  • Remove StoreProduct model

Constraints on Current Work

  • Don't add new features to StoreProduct. It's being replaced by ProductListing.
  • Don't use Order.channel as a free-text field. It will become a FK.
  • New store-scoped product queries should use store.products (which will be rerouted through channels).
  • PriceList stays as-is. Catalog optionally references a PriceList — don't duplicate pricing logic.

Resolved Questions

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

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

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

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

Open Questions

None at this time.

References

  • Current Store model: spree/core/app/models/spree/store.rb
  • Current Market model: spree/core/app/models/spree/market.rb
  • Current CustomerGroup: spree/core/app/models/spree/customer_group.rb
  • Current PriceList + PriceRules: spree/core/app/models/spree/price_list.rb, spree/core/app/models/spree/price_rule.rb
  • Current StoreProduct: spree/core/app/models/spree/store_product.rb
  • Related plan: 6.0-product-types.md (ProductType defines product schema)
  • Related plan: 6.0-replace-taxons-with-categories.md (Collections for merchandising)