Back to Spree

Channels, Catalogs & B2B Companies

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

5.5.036.9 KB
Original Source

Channels, Catalogs & B2B Companies

Status: Phase 1 shipped in 5.5 (Channel + new spree_product_publications table + single-store Product + channel-aware scopes + Admin API action endpoints + Store API back-compat + legacy admin Channels CRUD + Publishing card + dashboard SPA + Meilisearch channel filter); 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

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 ProductPublication — the join between Product and Channel that says "this product is listed on this channel." The legacy spree_products_stores join table stays as compat surface for the spree_multi_store extension; core no longer reads or writes it.

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

Problem

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

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

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

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

Current State

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

Architecture

Four concerns, four models:

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

How they compose:

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

Product visibility resolution:

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

Price resolution (extends existing Pricing::Resolver):

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

Key Decisions (do not deviate without discussion)

Channel

  • Lightweight model — not a config container. Store owns heavyweight config (currency, tax, payment methods, delivery methods). Channel is just a distribution surface.
  • Belongs to Store (SingleStoreResource). One store can have many channels.
  • Order.channel becomes a FK replacing the free-text varchar.
  • Each Channel can have its own PublishableApiKey — so a storefront app, a POS app, and a Meta integration each get their own key scoped to their channel.
  • Default channel created per store — "Online Store" is always present.

ProductPublication (replaces StoreProduct)

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

Catalog

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

Company (B2B)

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

Design Details

Channel (shipped in 5.5)

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

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

ProductPublication (shipped in 5.5 — new table)

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

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

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

Product (shipped in 5.5 — single store ownership)

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

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

Store#default_channel via boolean column

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

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

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

Catalog

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

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

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

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

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

  validates :name, presence: true

  acts_as_list scope: :store_id

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

  attribute :active, :boolean, default: true
end

CatalogProduct

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

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

  acts_as_list scope: :catalog_id
end

CatalogAssignment (who sees this catalog)

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

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

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

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

Company

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

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

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

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

  validates :name, presence: true

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

CompanyLocation

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

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

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

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

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

  validates :name, presence: true

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

CompanyContact

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

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

  has_one :company, through: :company_location

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

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

Order gains Channel and CompanyLocation

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

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

  def b2b?
    company_location.present?
  end

  def company
    company_location&.company
  end
end

Store gains Channels (as-shipped in 5.5)

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

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

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

  after_create :ensure_default_channel

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

Product visibility query

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

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

    products
  end
end

Pricing integration

Existing Pricing::Context gains catalog awareness:

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

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

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

Migration Path

Phase 1 — shipped in 5.5

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

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

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

Upgrade rake (spree:channels:upgrade chains):

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

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

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

Model layer:

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

Search provider:

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

Admin API:

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

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

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

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

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

Store API:

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

Legacy admin:

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

Dashboard SPA:

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

Sample data:

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

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

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

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

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

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

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

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

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

    add_reference :spree_orders, :company_location
  end
end

Phase 2 — 6.0 Admin API additions

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

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

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

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

Phase 3 — 6.0 cleanup

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

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

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

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

Constraints on Current Work

  • Spree::StoreProduct is gone. Use Spree::ProductPublication directly. Don't reintroduce the STI alias or any reference to spree/core/app/models/spree/store_product.rb.
  • Spree::ProductPublication has no store_id column. It's delegated to channel.store. Don't write code that calls ProductPublication.where(store_id: …) — use a join: Publication.joins(:channel).where(spree_channels: { store_id: … }).
  • Product belongs_to :store (single). Don't write new code assuming product.stores is a real ActiveRecord association — the deprecation bridge returns Array(store) and won't chain. Multi-store catalogs live in spree_multi_store.
  • Channel#default is a boolean column. Use Spree::Channel.default.where(store: …) or store.default_channel. Don't look up by code: 'online' — merchants can rename it.
  • Channel-aware scopes assume Spree::Current.channel is set. API requests set it via the X-Spree-Channel header concern; jobs / rake tasks fall back to the legacy column path. Don't add new code that reads Spree::Current.channel without expecting either branch.
  • Spree::Channel#add_products is idempotent and preserves existing publication windows when called without published_at / unpublished_at kwargs. Don't change this — bulk-publish ergonomics depend on it.
  • 6.0 adds default_catalog_id to spree_channels (additive — won't conflict with the 5.5 schema).
  • PriceList stays as-is. Catalog optionally references a PriceList — don't duplicate pricing logic.

Resolved Questions

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

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

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

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

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

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

  7. CompanyContact roles and B2B approval workflows. Out of scope for this plan. The 6.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

  • Channel model: spree/core/app/models/spree/channel.rb
  • ProductPublication model: spree/core/app/models/spree/product_publication.rb
  • Product channels concern: spree/core/app/models/spree/product/channels.rb
  • Legacy multi-store bridge: spree/core/app/models/spree/product/legacy_multi_store_support.rb
  • Store channels concern: spree/core/app/models/concerns/spree/stores/channels.rb
  • Upgrade rake: spree/core/lib/tasks/channels.rake (spree:channels:upgrade)
  • Population rake: spree/core/lib/tasks/publications.rake (spree:upgrade:populate_publications)
  • User-facing concept doc: docs/developer/core-concepts/channels.mdx
  • Upgrade guide: docs/developer/upgrades/5.4-to-5.5.mdx
  • SPA Publishing card: packages/dashboard/src/components/spree/products/publishing-card.tsx
  • Legacy admin Publishing card: spree/admin/app/views/spree/admin/products/form/_publishing.html.erb
  • Related plan: 6.0-product-types.md (ProductType defines product schema)
  • Related plan: 6.0-replace-taxons-with-categories.md (Collections for merchandising)
  • Related plan: 6.0-order-routing.md (Channel-aware order routing strategies)