docs/plans/5.6-6.0-single-store-promotions-payment-methods.md
Status: In Progress — 5.6 phase implemented (models, migrations, bridges, backfill, controllers); 6.0 cleanup pending
Target: Spree 5.6 (single-store migration: FK + bridges + backfill), 6.0 (cleanup: enforce null: false, drop join tables)
Depends on: 6.0-channels-catalogs-b2b.md (single-owner Product is the reference pattern), 5.4-6.0-custom-fields-rename.md (unrelated, but touches the same serializers)
Author: Damian (getvendo)
Last updated: 2026-06-28
Spree::Promotion and Spree::PaymentMethod are the last two core resources still modelled as multi-store via has_many :through join tables (spree_promotions_stores, spree_payment_methods_stores). Every other store-scoped resource — including Spree::Product as of 5.5 — now uses a direct belongs_to :store foreign key. This plan migrates Promotions and Payment Methods to single-store ownership, mirroring the Product migration shipped in 5.5 (6.0-channels-catalogs-b2b.md), and pushes the historic "shared across stores" behaviour into the spree_multi_store extension.
The intent is already flagged in the codebase. Spree::Api::V3::ResourceController carries this bridge:
# very ugly code we need to still support for promotion/payment_method until we migrate them into single store in spree 6.0
resource.store_ids = [current_store.id] if resource.respond_to?(:store_ids) && resource.store_ids.blank? && !resource.respond_to?(:store_id)
This plan removes the need for that branch.
store_id column and belongs_to :store. No new join model is introduced (unlike Product, which needed ProductPublication for channel distribution — promotions and payment methods have no equivalent distribution concept, so the FK is the whole story).Channel/Publication layer. Products needed publications because a catalog item is distributed to multiple channels. A promotion or payment method belongs to exactly one store; there is nothing to publish. Keep it simple — just the FK. Amended 2026-07-23: the single-owner FK stands, but "no distribution concept" is superseded for payment methods — per-channel/market/total/customer-group eligibility is layered on via Spree::PaymentMethodRule (see 5.7-payment-method-rules.md), not via scoping columns or join tables.spree_multi_store. The has_many :stores / store_ids accessors are preserved as deprecation bridges in core (returning Array(store)), and the real multi-store association is restored by the spree_multi_store extension on top of the untouched join tables. Same split as Product.spree_promotions_stores and spree_payment_methods_stores are left intact in the 5.6 migration so the extension can overlay the old relationship and so the backfill is re-runnable. The 6.0 cleanup migration drops them.store_id stays nullable in 5.6, null: false in 6.0. Matching the Product migration (which left store_id nullable in 5.5), the 5.6 columns are nullable so installs that haven't run the backfill don't fail validations. The 6.0 cleanup migration enforces null: false once the data is known-clean.StoreScopedResource for the existing SingleStoreResource concern. The old StoreScopedResource (many-to-many for_store via joins(:stores)) is deprecated and its include removed from both models. In its place they include Spree::SingleStoreResource — the concern already used by Order, GiftCard, Channel, etc. — which provides the FK-based for_store scope, a store can't be changed once set validation, and (since #14231) a global before_validation :ensure_store that assigns Spree::Current.store when store_id is blank. No bespoke per-model for_store. StoreScopedResource stays in the tree for one deprecation cycle for external code still including it.ensure_store fills the store from Spree::Current.store; each model then declares validates :store, presence: true (honoring the documented disable_store_presence_validation escape hatch) — the same per-model presence pattern every other single-store resource uses. Together: the current store is auto-assigned, and a record with no resolvable store fails validation rather than persisting nil. Every entry point also sets the store explicitly: the API ResourceController#build_resource, the legacy admin ResourceController#ensure_current_store, the factories, seeds, and sample data.store_ids (plural, array) is deprecated in favour of store_id (singular). The API v3 controller branch above is deleted.Spree::Deprecation) whenever a record has more than one store attachment, in addition to an aggregate count in the rake task summary, so merchants who relied on sharing know to install spree_multi_store.Both models include Spree::StoreScopedResource and declare:
# Spree::Promotion
has_many :store_promotions, class_name: 'Spree::StorePromotion'
has_many :stores, class_name: 'Spree::Store', through: :store_promotions
# Spree::PaymentMethod
has_many :store_payment_methods, class_name: 'Spree::StorePaymentMethod', inverse_of: :payment_method
has_many :stores, class_name: 'Spree::Store', through: :store_payment_methods
Join models / tables:
| Model | Table | Columns | Unique index |
|---|---|---|---|
Spree::StorePromotion | spree_promotions_stores | promotion_id, store_id, timestamps | (promotion_id, store_id) |
Spree::StorePaymentMethod | spree_payment_methods_stores | payment_method_id, store_id | (payment_method_id, store_id) |
Spree::StoreScopedResource provides:
scope :for_store, ->(store) { joins(:stores).where(Store.table_name => { id: store.id }) }
before_validation :set_default_store, if: :new_record?
# set_default_store pushes Spree::Store.default into the through-association when empty
Spree::Store declares the inverse: has_many :promotions, through: :store_promotions and has_many :payment_methods, through: :store_payment_methods.
PaymentMethod is STI (Spree::Gateway, Spree::PaymentMethod::Check, Spree::PaymentMethod::StoreCredit, external gateway gems). The store_id FK lives on the base spree_payment_methods table and is inherited by all subclasses — no STI complications.
Both models adopt the existing Spree::SingleStoreResource concern (already used by Order, GiftCard, Channel, Wishlist, etc.), which provides the for_store scope, the global ensure_store assignment, and a store can't be changed once set validation. Each model declares its own belongs_to :store plus a per-model store-presence validation:
# Spree::Promotion / Spree::PaymentMethod
include Spree::SingleStoreResource # for_store + ensure_store + immutability
belongs_to :store, class_name: 'Spree::Store'
validates :store, presence: true, unless: -> { Spree::Config[:disable_store_presence_validation] }
include Spree::LegacyMultiStoreSupport unless defined?(SpreeMultiStore)
# Spree::SingleStoreResource (in core, shared)
before_validation :ensure_store, unless: :store_id? # self.store ||= Spree::Current.store
validate :ensure_store_association_is_not_changed # store_id frozen once persisted
scope :for_store, ->(store) { where(store_id: store.id) }
Spree.base_class.belongs_to_required_by_default is false, so belongs_to :store does not validate presence on its own — the per-model validates :store, presence: true does (matching every other single-store resource, which each declare their own). ensure_store auto-assigns the current store; the presence validation then rejects any record left with no resolvable store. The disable_store_presence_validation preference is the documented escape hatch for data imports and the backfill window (existing rows are NULL until the rake task runs).
Mirror Spree::Product::LegacyMultiStoreSupport exactly — deprecation warnings + Array(store) semantics, auto-included unless the spree_multi_store extension defines SpreeMultiStore:
module Spree::Promotion::LegacyMultiStoreSupport
included do
def stores
Spree::Deprecation.warn(
'Spree::Promotion#stores is deprecated. Use Spree::Promotion#store instead. ' \
'Install spree_multi_store to keep multi-store promotions.'
)
store ? [store] : []
end
def store_ids
Spree::Deprecation.warn('...')
store_id ? [store_id] : []
end
def stores=(values)
Spree::Deprecation.warn('...')
self.store = Array(values).compact.first
end
def store_ids=(ids)
Spree::Deprecation.warn('...')
self.store_id = Array(ids).compact_blank.first
end
end
end
Identical concern for Spree::PaymentMethod::LegacyMultiStoreSupport. These bridges are what let the API controller's store_ids= branch keep working during deprecation — though we delete that branch and rely on store_id directly (see below).
Spree::Store associationsReplace the through-associations:
# before
has_many :store_promotions, class_name: 'Spree::StorePromotion'
has_many :promotions, through: :store_promotions, class_name: 'Spree::Promotion'
has_many :store_payment_methods, class_name: 'Spree::StorePaymentMethod'
has_many :payment_methods, through: :store_payment_methods, class_name: 'Spree::PaymentMethod'
# after
has_many :promotions, class_name: 'Spree::Promotion', dependent: :nullify
has_many :payment_methods, class_name: 'Spree::PaymentMethod', dependent: :nullify
dependent: :nullify (not :destroy) matches the existing Store has_many :products association and is required for correctness: with a direct has_many, clearing the collection (store.promotions = [], used in the test-support teardown) applies the dependent strategy to the removed records. :destroy would cascade into Promotion's before_destroy :not_used? guard and raise on any used promotion; :nullify just clears the FK, which is the intended "detach, don't delete business records" behaviour.
store.promotions / store.payment_methods keep working unchanged for all callers — Order#payment_methods, the promotion handlers (Coupon, Cart, FreeShipping, Page), and the admin/API controllers all read through these and need no changes.
Spree::Api::V3::ResourceController: delete the resource.store_ids = [current_store.id] bridge. The base build_resource already assigns resource.store = current_store when store_id is blank, which now satisfies the required-store validation for both models.promotion_includes): drop :stores from the includes array.joins(:store_payment_methods).where(spree_payment_methods_stores: …) query for installed class names becomes where(store_id: current_store.id).pluck(:type) or simply current_store.payment_methods.pluck(:type).stores/store_ids, so nothing to remove. (If any admin serializer is later found to expose store_ids, replace with store_id prefixed-id.)PaymentMethod (currently stores.first) becomes store.available_for_store? / available_for_order?PaymentMethod#available_for_store?(store) currently checks store_ids.include?(store.id). Rewrite as store_id == store.id.
promotion_factory and payment_method factories currently push into promotion.stores. Replace with store { Spree::Store.default || association(:store) } assigning the FK directly.
Two migrations, following the Product pattern (20260601000002_add_store_id_to_spree_products.rb). No FK constraints, no defaults, nullable until backfill:
class AddStoreIdToSpreePromotions < ActiveRecord::Migration[7.2]
def change
add_reference :spree_promotions, :store, null: true, index: true
end
end
class AddStoreIdToSpreePaymentMethods < ActiveRecord::Migration[7.2]
def change
add_reference :spree_payment_methods, :store, null: true, index: true
end
end
spree_promotions_stores and spree_payment_methods_stores are not touched.
Critical window: after migrating but before backfilling, every promotion/payment method has
store_id IS NULLand is invisible tofor_store. Backfill must run immediately after migrate, exactly as with products.
A single rake task spree:upgrade:populate_single_store_associations (in spree/core/lib/tasks/), idempotent, batched, mirroring spree:upgrade:populate_publications but simpler (no publication/channel step — just FK backfill):
task populate_single_store_associations: :environment do
shared = Hash.new(0) # owner-store summary, surfaced at the end
# Promotions: earliest store attachment wins as the owner
Spree::Promotion.where(store_id: nil).find_each do |promotion|
store_ids = Spree::StorePromotion.where(promotion_id: promotion.id)
.order(:created_at).pluck(:store_id)
next if store_ids.empty?
if store_ids.size > 1
shared[:promotions] += 1
Spree::Deprecation.warn(
"Promotion #{promotion.id} was shared across #{store_ids.size} stores; " \
"assigning to store #{store_ids.first}. Install spree_multi_store to keep sharing."
)
end
promotion.update_column(:store_id, store_ids.first)
end
# Payment methods: join table has no timestamps — pick lowest store_id deterministically
Spree::PaymentMethod.where(store_id: nil).find_each do |payment_method|
store_ids = Spree::StorePaymentMethod.where(payment_method_id: payment_method.id)
.order(:store_id).pluck(:store_id)
next if store_ids.empty?
if store_ids.size > 1
shared[:payment_methods] += 1
Spree::Deprecation.warn(
"PaymentMethod #{payment_method.id} was shared across #{store_ids.size} stores; " \
"assigning to store #{store_ids.first}. Install spree_multi_store to keep sharing."
)
end
payment_method.update_column(:store_id, store_ids.first)
end
if shared.values.sum.positive?
puts " #{shared[:promotions]} promotion(s) and #{shared[:payment_methods]} payment method(s) " \
"were shared across stores — only the owner store keeps them unless spree_multi_store is installed."
end
end
For merchants who genuinely shared one promotion/payment method across N stores, the earliest (promotions) / lowest store_id (payment methods — its join table has no timestamps) attachment becomes the owner and the rest of the rows survive in the join table. Each shared record triggers a per-record Spree::Deprecation warning during backfill, plus an aggregate count in the task summary, so the loss is impossible to miss. Installing spree_multi_store restores the full has_many :stores view; without it, those extra stores lose the shared record (also documented as a breaking change in the upgrade guide, with a remediation note to duplicate the record per store or install the extension).
Deployment order (identical shape to the channels upgrade):
bundle exec rake db:migrate → adds nullable store_id columns. Promotions/payment methods now invisible to for_store until backfilled.bundle exec rake spree:upgrade:populate_single_store_associations → backfills FKs. Resources visible again.6.0 cleanup (separate cycle). Once the spree_multi_store extension has shipped and the deprecation window has elapsed, a follow-up migration (a) sets store_id null: false and (b) drops the spree_promotions_stores / spree_payment_methods_stores join tables and removes the now-unused StorePromotion / StorePaymentMethod models, the LegacyMultiStoreSupport bridges, and the deprecated StoreScopedResource concern.
spree_multi_store extension responsibilitiesSpreeMultiStore so core skips the LegacyMultiStoreSupport includes.has_many :stores, through: :store_{promotions,payment_methods} on both models and the inverse on Store.StorePromotion / StorePaymentMethod models and the join tables alive.for_store (joins(:stores)) and default-store-assignment semantics.This is the same contract the extension already fulfils for Product has_many :stores.
Apply these now, even before implementation, to avoid widening the surface we have to migrate:
store_ids/stores usages for promotions or payment methods. Write code against store / store_id (singular) where possible, or go through current_store.promotions / current_store.payment_methods so it survives the change transparently.StoreScopedResource's through-based for_store for these two models; prefer current_store.<assoc>.store_ids for these resources — there is no public field today, keep it that way.ResourceController store_ids= bridge, leave it until the migration lands — it is the documented seam.These were the original open questions, now settled (see Key Decisions / Migration Path for how they fold in):
Spree::Deprecation warning at backfill time for any record with >1 store attachment, plus an aggregate count in the rake summary and an upgrade-guide note.null: false timing → deferred to 6.0. store_id stays nullable in 5.6 (matching the 5.5 Product migration); the 6.0 cleanup migration enforces not-null once data is clean.StoreScopedResource → deprecated, replaced by SingleStoreResource. The concern is marked deprecated and its include dropped from both models, which now include Spree::SingleStoreResource (FK for_store + ensure_store + immutability) and declare a per-model validates :store, presence: true. StoreScopedResource is removed entirely in the 6.0 cleanup.store_id. Deterministic and re-runnable; the join table has no timestamps so "earliest created" isn't available.docs/plans/6.0-channels-catalogs-b2b.md — reference migration (single-owner Product, LegacyMultiStoreSupport, spree_multi_store extension split).spree/core/app/models/spree/product/channels.rb (belongs_to :store, assign_default_store)spree/core/app/models/spree/product/legacy_multi_store_support.rb (deprecation bridge to mirror)spree/core/db/migrate/20260601000002_add_store_id_to_spree_products.rb (migration shape)spree/core/lib/tasks/publications.rake (backfill task shape)spree/core/app/models/spree/promotion.rb, spree/core/app/models/spree/store_promotion.rbspree/core/app/models/spree/payment_method.rb, spree/core/app/models/spree/store_payment_method.rbspree/core/app/models/concerns/spree/store_scoped_resource.rbspree/api/app/controllers/spree/api/v3/resource_controller.rb (the store_ids= bridge to delete)