Back to Spree

Single-Store Promotions & Payment Methods

docs/plans/5.6-6.0-single-store-promotions-payment-methods.md

5.6.019.7 KB
Original Source

Single-Store Promotions & Payment Methods

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

Summary

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:

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

Key Decisions (do not deviate without discussion)

  • Single owner via FK. Both models gain a 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).
  • No 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.
  • Multi-store behaviour moves to 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.
  • Join tables survive 5.6, drop in 6.0. 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.
  • Swap 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.
  • Store is required — no nil store, no per-model auto-assignment. The concern's 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.
  • Read/write field symmetry preserved. Serializers and controllers continue to expose/accept the same fields; the only change is that store_ids (plural, array) is deprecated in favour of store_id (singular). The API v3 controller branch above is deleted.
  • Loud multi-store data-loss signalling. The backfill warns per-record at runtime (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.

Design Details

Current state

Both models include Spree::StoreScopedResource and declare:

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

ModelTableColumnsUnique index
Spree::StorePromotionspree_promotions_storespromotion_id, store_id, timestamps(promotion_id, store_id)
Spree::StorePaymentMethodspree_payment_methods_storespayment_method_id, store_id(payment_method_id, store_id)

Spree::StoreScopedResource provides:

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

Target state

Models

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:

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

Legacy multi-store bridges

Mirror Spree::Product::LegacyMultiStoreSupport exactly — deprecation warnings + Array(store) semantics, auto-included unless the spree_multi_store extension defines SpreeMultiStore:

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

Replace the through-associations:

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

Controllers & serializers

  • 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.
  • Admin promotions API (promotion_includes): drop :stores from the includes array.
  • Admin payment methods API: the 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).
  • Serializers: no field changes — neither the promotion nor payment-method serializers currently expose stores/store_ids, so nothing to remove. (If any admin serializer is later found to expose store_ids, replace with store_id prefixed-id.)
  • Webhook URL in 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.

Factories

promotion_factory and payment_method factories currently push into promotion.stores. Replace with store { Spree::Store.default || association(:store) } assigning the FK directly.

Migrations

Two migrations, following the Product pattern (20260601000002_add_store_id_to_spree_products.rb). No FK constraints, no defaults, nullable until backfill:

ruby
class AddStoreIdToSpreePromotions < ActiveRecord::Migration[7.2]
  def change
    add_reference :spree_promotions, :store, null: true, index: true
  end
end
ruby
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 NULL and is invisible to for_store. Backfill must run immediately after migrate, exactly as with products.

Migration Path

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

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

  1. Deploy code (models, concerns, migrations, controller/serializer edits).
  2. bundle exec rake db:migrate → adds nullable store_id columns. Promotions/payment methods now invisible to for_store until backfilled.
  3. 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 responsibilities

  • Define SpreeMultiStore so core skips the LegacyMultiStoreSupport includes.
  • Re-declare has_many :stores, through: :store_{promotions,payment_methods} on both models and the inverse on Store.
  • Keep StorePromotion / StorePaymentMethod models and the join tables alive.
  • Restore the original multi-store for_store (joins(:stores)) and default-store-assignment semantics.

This is the same contract the extension already fulfils for Product has_many :stores.

Constraints on Current Work

Apply these now, even before implementation, to avoid widening the surface we have to migrate:

  • Do not add new 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.
  • Do not add new callers of StoreScopedResource's through-based for_store for these two models; prefer current_store.<assoc>.
  • New API/serializer code must not expose store_ids for these resources — there is no public field today, keep it that way.
  • When touching the ResourceController store_ids= bridge, leave it until the migration lands — it is the documented seam.

Resolved Decisions

These were the original open questions, now settled (see Key Decisions / Migration Path for how they fold in):

  • Multi-store data-loss signalling → loud. Per-record 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.
  • Payment-method owner tiebreak → lowest store_id. Deterministic and re-runnable; the join table has no timestamps so "earliest created" isn't available.

Open Questions

  • None outstanding. Reopen here if implementation surfaces new ones.

References

  • docs/plans/6.0-channels-catalogs-b2b.md — reference migration (single-owner Product, LegacyMultiStoreSupport, spree_multi_store extension split).
  • 5.5 Product migration code:
    • 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)
  • Current multi-store code being replaced:
    • spree/core/app/models/spree/promotion.rb, spree/core/app/models/spree/store_promotion.rb
    • spree/core/app/models/spree/payment_method.rb, spree/core/app/models/spree/store_payment_method.rb
    • spree/core/app/models/concerns/spree/store_scoped_resource.rb
    • spree/api/app/controllers/spree/api/v3/resource_controller.rb (the store_ids= bridge to delete)