docs/plans/5.7-payment-method-rules.md
Status: Draft
Target: Spree 5.7
Depends on: 6.0-channels-catalogs-b2b.md (Channel, shipped 5.5), 5.6-6.0-single-store-promotions-payment-methods.md (single-store PaymentMethod, 5.6), Markets (existing), 5.7-channel-markets.md (composes, not required)
Author: Damian + Claude
Last updated: 2026-07-23
Add conditional availability rules to Spree::PaymentMethod: STI rule records (Spree::PaymentMethodRule) attached to a payment method that gate whether it is offered for a given order — by channel, market, order total, or customer group. This closes the gap surfaced by merchant asks ("wholesale channel gets net payment while DTC gets Stripe", "orders over 5k qualify for installments", "market-bound payment methods") and confirmed by the 2026-07-23 competitive review: Vendure gates payment methods per channel + a PaymentMethodEligibilityChecker (their canonical example is an order-total minimum), Medusa scopes payment providers per Region, Saleor resolves payment apps per channel, and Shopify covers the same ground with Payment Customization Functions (cart total, market, B2B company inputs) plus, since May 2026, per-market business entities on Shopify Payments (Plus-gated).
This supersedes half of a key decision in 5.6-6.0-single-store-promotions-payment-methods.md ("promotions and payment methods have no distribution concept, so the FK is the whole story"): the single-store FK stays correct, but payment methods do have an eligibility dimension within a store. Rules are that dimension — mirroring how promotions already solve the identical problem (Spree::Promotion::Rules::Channel/Market/CustomerGroup/ItemTotal), rather than adding scoping columns or join tables to PaymentMethod.
store.payment_methods.active.available_on_front_end.select { |pm| pm.available_for_order?(order) } — every active payment method appears on every channel. A store running a B2C channel and a gated wholesale channel (seeded by default since 5.6) cannot offer invoice/manual payment to wholesale buyers without also showing it at B2C checkout.available_for_order? override — code, not configuration, and invisible to admins.Spree::PromotionRule, don't invent a framework. STI base Spree::PaymentMethodRule on a new spree_payment_method_rules table, subclasses under app/models/spree/payment_method_rules/, preference-schema-driven config (same preference … parse_on_set: normalize_id_preference(…) idiom as promotion/price rules), registry-validated type. This is the fourth instance of an established house pattern (PromotionRule, PriceRule, OrderRoutingRule) — deviating from it needs a reason.match_policy — "any" composition is a future addition if a real merchant needs it (none of the surveyed platforms offer OR at this layer).ChannelRule with no channels selected passes) — matching the PriceRule/PromotionRule convention so admin half-configured rules fail open per-rule, not per-method.PaymentMethod#eligible_for_order?(order) = existing available_for_order?(order) gateway hook && all active rules pass. Order#collect_frontend_payment_methods switches to it. Because Spree::Payments::Create (and via it the Store API payments + payment-sessions controllers) already resolves the payment method through collect_frontend_payment_methods, listing and creation are enforced server-side by the same change — a storefront cannot attach an ineligible method by ID.collect_backend_payment_methods and the Spree::Payment model validation keep checking only available_for_order? + available_for_store?. An admin taking a phone order can use any active method — matching Shopify, where Payment Customization Functions run at checkout only. Rules are a storefront checkout concept.order.channel_id, order.market_id, order.total, order.user), never Spree::Current — webhook/job/admin evaluation must not depend on request state.belongs_to :payment_method, store delegated (payment_method.store). No store_id column (unlike OrderRoutingRule, which needs it for its own scoping); ID-preference normalization scopes lookups to the owning store (rule.payment_method.store.channels, .markets, .customer_groups) so cross-store IDs can't sneak in.ChannelRule, MarketRule, OrderTotalRule (min/max), CustomerGroupRule. CompanyRule/CompanyLocationRule wait for the 6.1 Company tree. No Zone rule — Market is the geographic axis going forward (6.0-tax-provider.md drops Zone).Spree.payment_method_rules (array of class names, defaulted in the engine, extensible by plugins) — same shape as Spree.order_routing.rules; type_must_be_registered validation on the base class.Check) + a ChannelRule on the wholesale channel. First-class net terms (due dates, credit limits, deposits) belongs on Company/CompanyLocation per the Shopify model and is deferred to the post-6.1 B2B plan.display_on is orthogonal and untouched. Rules filter within the storefront-visible set; the 5.5-6.0-display-on-to-boolean.md migration proceeds independently.# app/models/spree/payment_method_rule.rb
module Spree
class PaymentMethodRule < Spree.base_class
has_prefix_id :pmrule
belongs_to :payment_method, class_name: 'Spree::PaymentMethod',
inverse_of: :payment_method_rules, touch: true
delegate :store, to: :payment_method
attribute :active, :boolean, default: true
validates :type, :payment_method, presence: true
# One instance of each rule kind per payment method — duplicates would
# either be a no-op or contradict themselves under AND semantics.
validates :type, uniqueness: { scope: [:payment_method_id, *spree_base_uniqueness_scope] }
validate :type_must_be_registered
scope :active, -> { where(active: true) }
# @param order [Spree::Order]
# @return [Boolean] whether the payment method may be offered for this order
def eligible?(order)
raise NotImplementedError, "#{self.class.name} must implement #eligible?"
end
def self.human_name
Spree.t("payment_method_rule_types.#{api_type}.name", default: api_type.titleize)
end
def self.human_description
Spree.t("payment_method_rule_types.#{api_type}.description", default: '')
end
def self.registered_subclasses
Array(Spree.payment_method_rules)
end
end
end
Table: spree_payment_method_rules — type (string, null: false), payment_method_id (reference, null: false), active (boolean, null: false), preferences (text), timestamps. Index on payment_method_id; unique index on (payment_method_id, type).
# app/models/spree/payment_method_rules/channel_rule.rb
module Spree
module PaymentMethodRules
class ChannelRule < Spree::PaymentMethodRule
preference :channel_ids, :array, default: [],
parse_on_set: normalize_id_preference(
klass: Spree::Channel,
scope: ->(rule) { rule.payment_method&.store&.channels || Spree::Channel.none }
)
def eligible?(order)
return true if preferred_channel_ids.empty?
preferred_channel_ids.map(&:to_s).include?(order.channel_id.to_s)
end
end
end
end
MarketRule is identical over market_ids / order.market_id. CustomerGroupRule matches when order.user belongs to any preferred group (guest orders fail a configured group rule — an invoice method restricted to "Wholesale" must never surface to guests). OrderTotalRule:
# app/models/spree/payment_method_rules/order_total_rule.rb
module Spree
module PaymentMethodRules
class OrderTotalRule < Spree::PaymentMethodRule
preference :min_total, :decimal, default: nil
preference :max_total, :decimal, default: nil
# Thresholds are interpreted in the order's currency (see Open Questions
# for multi-currency refinement).
def eligible?(order)
total = order.total
return false if preferred_min_total.present? && total < preferred_min_total
return false if preferred_max_total.present? && total > preferred_max_total
true
end
end
end
end
# Spree::PaymentMethod
has_many :payment_method_rules, class_name: 'Spree::PaymentMethodRule',
inverse_of: :payment_method, dependent: :destroy
# Storefront checkout eligibility: the gateway hook AND every active rule.
def eligible_for_order?(order)
available_for_order?(order) && payment_method_rules.active.all? { |rule| rule.eligible?(order) }
end
# Spree::Order#collect_frontend_payment_methods
store.payment_methods.active.available_on_front_end
.includes(:payment_method_rules)
.select { |pm| pm.eligible_for_order?(self) }
collect_backend_payment_methods, Spree::Payment validation, and available_for_store? are unchanged. The Store API cart payment-methods listing, Payments::Create, and payment-session creation all flow through collect_frontend_payment_methods already — no additional controller changes.
Mirrors the order-routing-rules surface (nested CRUD + type discovery):
GET /api/v3/admin/payment_methods/:payment_method_id/rules
POST /api/v3/admin/payment_methods/:payment_method_id/rules
PATCH /api/v3/admin/payment_method_rules/:id
DELETE /api/v3/admin/payment_method_rules/:id
GET /api/v3/admin/payment_method_rules/types # discovery: type, label, description, preference_schema
id, type, active, preferences (prefixed IDs in array prefs — the normalize_id_preference write path accepts them; the read path serializes back to prefixed), timestamps.read_payment_methods / write_payment_methods (rules are part of the payment-method aggregate — no new scope).many :payment_method_rules via expand=payment_method_rules.No new surface. The cart's payment-method listing is simply filtered; rules are never serialized to the storefront. (Same posture as channel gating: enforcement in the API, configuration invisible to clients.)
@spree/admin-sdk: client.paymentMethods.rules.{list,create,update,delete} + client.paymentMethodRules.types(); typelizer types.PreferencesForm composition as the channel sheet's routing-rules editor (add-rule picker fed by the types endpoint, active toggles). Translation keys under payment_method_rule_types.* (Ruby) and admin.paymentMethodRules.* (dashboard locales, all languages).Extend the 5.6 wholesale demo: sample data attaches a ChannelRule (wholesale channel) to the seeded manual/Check payment method and leaves Stripe/card methods unrestricted — a fresh install then demos differentiated checkout payment options per channel out of the box.
Additive only:
spree_payment_method_rules. No changes to existing tables, no backfill — zero-rule methods behave exactly as before.channel_id/market_id columns or join tables to Spree::PaymentMethod — channel/market confinement is a rule.collect_frontend_payment_methods (or eligible_for_order?), never by re-querying store.payment_methods and filtering manually — the single seam is what makes enforcement airtight.decisions.md 2026-07-15) must not bake in "one payment method per provider per store" assumptions beyond the existing admin types filter — the multi-credentials grooming (below) may relax it.available_for_order? overrides remain valid (StoreCredit's coverage check, custom gateways) — rules layer on top, they don't replace the hook.OrderTotalRule. v1 interprets min/max in the order's currency. Options for later: per-currency amounts (Vendure stores minor units in channel currency), or normalizing through the store default currency. Revisit when a multi-market merchant hits it.types "already installed" filter relaxed) vs. per-channel credential sets on one record (Saleor style). Also carries the legal-entity attribution question (payouts/compliance per entity). Do not implement under this plan.docs/plans/5.7-channel-markets.md (Channel→Markets allowlist; MarketRule + a restricted channel compose into "this channel's markets pay this way")docs/plans/5.6-6.0-single-store-promotions-payment-methods.md (Key Decisions — "No Channel/Publication layer")spree/core/app/models/spree/promotion_rule.rb, spree/core/app/models/spree/promotion/rules/channel.rb, spree/core/app/models/spree/price_rules/channel_rule.rb, spree/core/app/models/spree/order_routing_rule.rbspree/core/app/models/spree/order.rb (collect_frontend_payment_methods), spree/core/app/services/spree/payments/create.rbdocs/plans/decisions.md 2026-07-23 (this plan + multi-credentials grooming deferral), 2026-07-21 (dashboard-only rule management precedent)PaymentMethodEligibilityChecker (docs.vendure.io/reference/typescript-api/payment/payment-method-eligibility-checker) · Medusa Region↔PaymentProvider link (docs.medusajs.com/resources/commerce-modules/payment/links-to-other-modules) · Saleor per-channel payment apps (docs.saleor.io/developer/payments/payment-apps) · Shopify Payment Customization Functions (shopify.dev/docs/api/functions/latest/payment-customization) + multi-entity Shopify Payments (help.shopify.com/en/manual/payments/shopify-payments/onboarding/selling-with-multiple-entities)