Back to Spree

Payment Method Rules

docs/plans/5.7-payment-method-rules.md

5.6.015.8 KB
Original Source

Payment Method Rules

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

Summary

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.

Problem

  1. No channel dimension. Checkout availability is 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.
  2. No market dimension. Payment methods that only make sense for certain geographies (local methods, region-specific providers) can't be bound to a Market, even though Markets already drive currency/locale/tax-zone per request.
  3. No conditional rules. "Installments only above 5k", "invoice only for the Wholesale customer group" have no home. The only seam is the per-gateway available_for_order? override — code, not configuration, and invisible to admins.
  4. Blocks the per-channel credentials story. Running two records of the same gateway with different API keys (groomed separately — see Open Questions) is only meaningful once each record can be confined to a channel or market.

Key Decisions (do not deviate without discussion)

  • Mirror 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.
  • No rules = available everywhere. A payment method with zero rules behaves exactly as today. No backfill, no behavior change on upgrade.
  • AND semantics. All active rules on a payment method must pass. No match_policy — "any" composition is a future addition if a real merchant needs it (none of the surveyed platforms offer OR at this layer).
  • Empty preference within a rule = unrestricted (ChannelRule with no channels selected passes) — matching the PriceRule/PromotionRule convention so admin half-configured rules fail open per-rule, not per-method.
  • One enforcement seam: 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.
  • Admin/backoffice bypasses rules. 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.
  • Rules evaluate the order's persisted context (order.channel_id, order.market_id, order.total, order.user), never Spree::Current — webhook/job/admin evaluation must not depend on request state.
  • Rules belong to the payment method onlybelongs_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.
  • Initial rule set: 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).
  • Registry: 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.
  • Management is dashboard-only (Admin API v3 + React dashboard). No legacy Rails admin UI, per the 2026-07-21 order-routing-rules decision — the legacy admin is in maintenance mode.
  • Net-terms is out of scope. The wholesale demo is served by an existing manual-style method (e.g. 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.

Design Details

Base class

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

Rules

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

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

Enforcement

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

Admin API v3

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
  • Serializer: 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.
  • Scope gating: read_payment_methods / write_payment_methods (rules are part of the payment-method aggregate — no new scope).
  • Admin payment-method serializer gains many :payment_method_rules via expand=payment_method_rules.

Store API

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

SDK & Dashboard

  • @spree/admin-sdk: client.paymentMethods.rules.{list,create,update,delete} + client.paymentMethodRules.types(); typelizer types.
  • Dashboard: rules editor inside the payment-method edit form — same schema-driven 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).

Seeds / sample data

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.

Migration Path

Additive only:

  1. Migration creates spree_payment_method_rules. No changes to existing tables, no backfill — zero-rule methods behave exactly as before.
  2. Ship models + enforcement + Admin API + dashboard together (the feature is invisible until a rule is created).
  3. Sample-data/seed changes last.

Constraints on Current Work

  • Don't add ad-hoc channel_id/market_id columns or join tables to Spree::PaymentMethod — channel/market confinement is a rule.
  • New checkout code must resolve payment methods through 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.
  • The Stripe-gateway pull-in (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.
  • Per-gateway available_for_order? overrides remain valid (StoreCredit's coverage check, custom gateways) — rules layer on top, they don't replace the hook.

Open Questions

  • Multi-currency thresholds on 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.
  • Per-channel/per-market provider credentials ("multiple Stripe accounts", Shopify's multi-entity model). Explicitly parked for grooming (2026-07-23): candidate shapes are separate PaymentMethod records per channel/market (Vendure/Medusa style — needs the admin 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.
  • Backend enforcement toggle. If merchants ask for rules to bind admin-created orders too, add an explicit store preference rather than changing the default.

References

  • Sibling plan: docs/plans/5.7-channel-markets.md (Channel→Markets allowlist; MarketRule + a restricted channel compose into "this channel's markets pay this way")
  • Superseded rationale: docs/plans/5.6-6.0-single-store-promotions-payment-methods.md (Key Decisions — "No Channel/Publication layer")
  • Pattern references: 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.rb
  • Enforcement seam: spree/core/app/models/spree/order.rb (collect_frontend_payment_methods), spree/core/app/services/spree/payments/create.rb
  • Decision log: docs/plans/decisions.md 2026-07-23 (this plan + multi-credentials grooming deferral), 2026-07-21 (dashboard-only rule management precedent)
  • Competitive research (2026-07-23 session): Vendure 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)