diff --git a/promotions/README.md b/promotions/README.md index b76511553aa..ac84ff20df8 100644 --- a/promotions/README.md +++ b/promotions/README.md @@ -70,6 +70,13 @@ SolidusPromotions.configure do |config| end ``` +### Coupon Code Normalization + +Solidus Promotions provides a configurable coupon code normalizer that controls how coupon codes are processed before saving and lookup. By default, codes are case-insensitive (e.g., "SAVE20" and "save20" are treated as the same). +You can customize this behavior to support case-sensitive codes, remove special characters, apply formatting rules, or implement other normalization strategies based on your business requirements. + +See the `coupon_code_normalizer_class` configuration option for implementation details. + ## Installation Add solidus_promotions to your Gemfile: diff --git a/promotions/app/models/concerns/solidus_promotions/coupon_code_normalizer.rb b/promotions/app/models/concerns/solidus_promotions/coupon_code_normalizer.rb new file mode 100644 index 00000000000..066b1383505 --- /dev/null +++ b/promotions/app/models/concerns/solidus_promotions/coupon_code_normalizer.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module SolidusPromotions + # Normalizes coupon codes before saving or looking up promotions. + # + # By default, this class strips whitespace and downcases the code + # to ensure case-insensitive behavior. You can override this class + # or provide a custom normalizer class to change behavior (e.g., + # case-sensitive codes) via: + # + # SolidusPromotions.configure do |config| + # config.coupon_code_normalizer_class = YourCustomNormalizer + # end + # + # @example Default usage + # CouponCodeNormalizer.call(" SAVE20 ") # => "save20" + # + # @example Custom case-sensitive usage + # class CaseSensitiveNormalizer + # def self.call(value) + # value&.strip + # end + # end + # + # SolidusPromotions.configure do |config| + # config.coupon_code_normalizer_class = CaseSensitiveNormalizer + # end + class CouponCodeNormalizer + # Normalizes the given coupon code. + # + # @param value [String, nil] the coupon code to normalize + # @return [String, nil] the normalized coupon code + def self.call(value) + value&.strip&.downcase + end + end +end diff --git a/promotions/app/models/solidus_promotions/promotion.rb b/promotions/app/models/solidus_promotions/promotion.rb index 3cc716fb6f5..362c9271d61 100644 --- a/promotions/app/models/solidus_promotions/promotion.rb +++ b/promotions/app/models/solidus_promotions/promotion.rb @@ -46,7 +46,9 @@ class Promotion < Spree::Base def self.with_coupon_code(val) joins(:codes).where( - SolidusPromotions::PromotionCode.arel_table[:value].eq(val.downcase) + SolidusPromotions::PromotionCode.arel_table[:value].eq( + SolidusPromotions.config.coupon_code_normalizer_class.call(val) + ) ).first end diff --git a/promotions/app/models/solidus_promotions/promotion_code.rb b/promotions/app/models/solidus_promotions/promotion_code.rb index 37da59ee875..324fdf63f45 100644 --- a/promotions/app/models/solidus_promotions/promotion_code.rb +++ b/promotions/app/models/solidus_promotions/promotion_code.rb @@ -50,7 +50,7 @@ def promotion_not_apply_automatically private def normalize_code - self.value = value.downcase.strip + self.value = SolidusPromotions.config.coupon_code_normalizer_class.call(value) end end end diff --git a/promotions/app/models/solidus_promotions/promotion_handler/coupon.rb b/promotions/app/models/solidus_promotions/promotion_handler/coupon.rb index 99fe4918b66..098b6ae1b9f 100644 --- a/promotions/app/models/solidus_promotions/promotion_handler/coupon.rb +++ b/promotions/app/models/solidus_promotions/promotion_handler/coupon.rb @@ -9,7 +9,7 @@ class Coupon def initialize(order) @order = order @errors = [] - @coupon_code = order&.coupon_code&.downcase + @coupon_code = SolidusPromotions.config.coupon_code_normalizer_class.call(order&.coupon_code) end def apply diff --git a/promotions/app/patches/models/solidus_promotions/order_patch.rb b/promotions/app/patches/models/solidus_promotions/order_patch.rb index 75e79bfa36d..12bc32b4890 100644 --- a/promotions/app/patches/models/solidus_promotions/order_patch.rb +++ b/promotions/app/patches/models/solidus_promotions/order_patch.rb @@ -36,6 +36,14 @@ def free_from_order_benefit?(line_item, _options) !line_item.managed_by_order_benefit end + def coupon_code=(code) + @coupon_code = begin + SolidusPromotions.config.coupon_code_normalizer_class.call(code) + rescue StandardError + nil + end + end + Spree::Order.singleton_class.prepend self::ClassMethods Spree::Order.prepend self end diff --git a/promotions/db/migrate/20251104170913_update_promotion_code_value_collation.rb b/promotions/db/migrate/20251104170913_update_promotion_code_value_collation.rb new file mode 100644 index 00000000000..f76cafb4465 --- /dev/null +++ b/promotions/db/migrate/20251104170913_update_promotion_code_value_collation.rb @@ -0,0 +1,38 @@ +class UpdatePromotionCodeValueCollation < ActiveRecord::Migration[7.0] + def up + return unless mysql? + + collation = use_accent_sensitive_collation? ? 'utf8mb4_0900_as_cs' : 'utf8mb4_bin' + change_column :solidus_promotions_promotion_codes, :value, :string, + collation: collation + end + + def down + return unless mysql? + + change_column :solidus_promotions_promotion_codes, :value, :string, + collation: 'utf8mb4_general_ci' + end + + private + + def mysql? + ActiveRecord::Base.connection.adapter_name.downcase.include?('mysql') + end + + def use_accent_sensitive_collation? + !mariadb? && mysql_version >= 8.0 + end + + def mariadb? + version_string.include?('mariadb') + end + + def mysql_version + version_string.to_f + end + + def version_string + @version_string ||= ActiveRecord::Base.connection.select_value('SELECT VERSION()').downcase + end +end diff --git a/promotions/lib/solidus_promotions/configuration.rb b/promotions/lib/solidus_promotions/configuration.rb index 7cfc41f52fa..23601c36e3c 100644 --- a/promotions/lib/solidus_promotions/configuration.rb +++ b/promotions/lib/solidus_promotions/configuration.rb @@ -10,6 +10,15 @@ class Configuration < Spree::Preferences::Configuration class_name_attribute :coupon_code_handler_class, default: "SolidusPromotions::PromotionHandler::Coupon" + # The class used to normalize coupon codes before saving or lookup. + # By default, this normalizes codes to lowercase for case-insensitive matching. + # You can customize this by creating your own normalizer class or by overriding + # the existing SolidusPromotions::CouponCodeNormalizer class using a decorator. + # @!attribute [rw] coupon_code_normalizer_class + # @return [String] The class used to normalize coupon codes. + # Defaults to "SolidusPromotions::CouponCodeNormalizer". + class_name_attribute :coupon_code_normalizer_class, default: "SolidusPromotions::CouponCodeNormalizer" + class_name_attribute :promotion_finder_class, default: "SolidusPromotions::PromotionFinder" # Allows providing a different promotion advertiser. @@ -107,7 +116,6 @@ class Configuration < Spree::Preferences::Configuration preference :sync_order_promotions, :boolean, default: false preference :use_new_admin, :boolean, default: false - def use_new_admin? SolidusSupport.admin_available? && preferred_use_new_admin end diff --git a/promotions/spec/models/concerns/solidus_promotions/coupon_code_normalizer_spec.rb b/promotions/spec/models/concerns/solidus_promotions/coupon_code_normalizer_spec.rb new file mode 100644 index 00000000000..7309baec238 --- /dev/null +++ b/promotions/spec/models/concerns/solidus_promotions/coupon_code_normalizer_spec.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe SolidusPromotions::CouponCodeNormalizer do + describe '.call' do + context 'when case is insensitive' do + it 'downcases the value' do + expect(described_class.call('10FFF')).to eq('10fff') + end + + it "strips leading and trailing whitespace" do + expect(described_class.call(" 10oFF ")).to eq("10off") + end + + it 'downcases mixed cases' do + expect(described_class.call('10OfF')).to eq('10off') + end + + it 'handles already normalized values' do + expect(described_class.call('10off')).to eq('10off') + end + + it 'returns nil with nil input' do + expect(described_class.call(nil)).to be_nil + end + + it 'returns empty string with empty string input' do + expect(described_class.call('')).to eq('') + end + + it 'returns empty string with whitespace only input' do + expect(described_class.call(' ')).to eq('') + end + end + + context 'when case is sensitive' do + before do + stub_const("CaseSensitiveNormalizer", Class.new do + def self.call(value) + value&.strip + end + end) + + stub_spree_preferences( + SolidusPromotions.configuration, + coupon_code_normalizer_class: CaseSensitiveNormalizer + ) + end + + it 'preserves the original cases' do + expect(CaseSensitiveNormalizer.call('10OFF')).to eq('10OFF') + end + + it 'does not downcase the value' do + expect(CaseSensitiveNormalizer.call('10OFF')).not_to eq('10off') + end + + it 'strips leading and trailing whitespace' do + expect(CaseSensitiveNormalizer.call(' 10OFF ')).to eq('10OFF') + end + + it 'preserves lower case' do + expect(CaseSensitiveNormalizer.call('10off')).to eq('10off') + end + + it 'preserves mixed case' do + expect(CaseSensitiveNormalizer.call('10OfF')).to eq('10OfF') + end + + it 'returns nil with nil input' do + expect(CaseSensitiveNormalizer.call(nil)).to be_nil + end + + it 'returns empty string with empty string input' do + expect(CaseSensitiveNormalizer.call('')).to eq('') + end + + it 'returns empty string with whitespace only input' do + expect(CaseSensitiveNormalizer.call(' ')).to eq('') + end + end + end +end diff --git a/promotions/spec/models/solidus_promotions/promotion_code_spec.rb b/promotions/spec/models/solidus_promotions/promotion_code_spec.rb index 418bb9a9833..01a6f5bfe31 100644 --- a/promotions/spec/models/solidus_promotions/promotion_code_spec.rb +++ b/promotions/spec/models/solidus_promotions/promotion_code_spec.rb @@ -67,6 +67,82 @@ end end + context "callbacks when coupon case is sensitive" do + before do + stub_const("CaseSensitiveNormalizer", Class.new do + def self.call(value) + value&.strip + end + end) + + stub_spree_preferences( + SolidusPromotions.configuration, + coupon_code_normalizer_class: CaseSensitiveNormalizer + ) + end + + subject { promotion_code.save } + + let(:promotion) { create(:solidus_promotion, code: code) } + + describe "#normalize_code" do + before { subject } + + context "when no other code with the same value exists" do + let(:promotion_code) { promotion.codes.first } + + context "with mixed case" do + let(:code) { "NewCoDe" } + + it "does not downcase the value" do + expect(promotion_code.value).to eq("NewCoDe") + end + end + + context "with extra spacing" do + let(:code) { " new code " } + + it "removes surrounding whitespace" do + expect(promotion_code.value).to eq("new code") + end + end + end + + context "when another code with the same value but different case exists" do + context "with mixed case" do + let(:promotion_code) { promotion.codes.build(value: "NewCoDe") } + + let(:code) { "newcode" } + + it "saves the record successfully as case sensitive" do + expect(promotion_code.valid?).to eq(true) + end + end + + context "with extra spacing" do + let(:promotion_code) { promotion.codes.build(value: "NewCoDe") } + + let(:code) { " newcode " } + + it "saves the record successfully as case sensitive" do + expect(promotion_code.valid?).to eq(true) + end + end + end + + context "when another code with the same value and same case exists" do + let(:promotion_code) { promotion.codes.build(value: "newcode") } + + let(:code) { "newcode" } + + it "does not save the record and marks it as invalid" do + expect(promotion_code.valid?).to eq(false) + expect(promotion_code.errors.messages[:value]).to contain_exactly("has already been taken") + end + end + end + end + describe "#usage_limit_exceeded?" do subject { code.usage_limit_exceeded? } diff --git a/promotions/spec/models/solidus_promotions/promotion_handler/coupon_spec.rb b/promotions/spec/models/solidus_promotions/promotion_handler/coupon_spec.rb index 0f76beab29a..9ed59feda1d 100644 --- a/promotions/spec/models/solidus_promotions/promotion_handler/coupon_spec.rb +++ b/promotions/spec/models/solidus_promotions/promotion_handler/coupon_spec.rb @@ -187,6 +187,45 @@ def expect_adjustment_creation(adjustable:, promotion:) end end end + + context "with case sensitive coupon code" do + before do + stub_const("CaseSensitiveNormalizer", Class.new do + def self.call(value) + value&.strip + end + end) + + stub_spree_preferences( + SolidusPromotions.configuration, + coupon_code_normalizer_class: CaseSensitiveNormalizer + ) + end + + context "with exact case match" do + before { order.coupon_code = "10off" } + + it "successfully activates promo" do + expect(order.total).to eq(130) + subject.apply + expect(subject.success).to be_present + expect_order_connection(order: order, promotion: promotion, promotion_code: promotion_code) + expect(order.reload.total).to eq(100) + end + end + + context "with incorrect case" do + before { order.coupon_code = "10OFF" } + + it "fails to activate promo" do + expect(order.total).to eq(130) + subject.apply + expect(subject.success).to be_blank + expect(subject.error).to eq("The coupon code you entered doesn't exist. Please try again.") + expect(order.reload.total).to eq(130) + end + end + end end context "with a free-shipping adjustment benefit" do @@ -220,6 +259,50 @@ def expect_adjustment_creation(adjustable:, promotion:) expect(subject.error).to eq "The coupon code has already been applied to this order" end end + + context "with case sensitive coupon code" do + before do + stub_const("CaseSensitiveNormalizer", Class.new do + def self.call(value) + value&.strip + end + end) + + stub_spree_preferences( + SolidusPromotions.configuration, + coupon_code_normalizer_class: CaseSensitiveNormalizer + ) + end + + let(:order) { create(:order_with_line_items, line_items_count: 3) } + + context "with exact case match" do + before { order.coupon_code = "10off" } + + it "successfully activates promo" do + expect(order.total).to eq(130) + subject.apply + expect(subject.success).to be_present + + expect_order_connection(order: order, promotion: promotion, promotion_code: promotion_code) + order.shipments.each do |shipment| + expect_adjustment_creation(adjustable: shipment, promotion: promotion) + end + end + end + + context "with incorrect case" do + before { order.coupon_code = "10OFF" } + + it "fails to activate promo" do + expect(order.total).to eq(130) + subject.apply + expect(subject.success).to be_blank + expect(subject.error).to eq("The coupon code you entered doesn't exist. Please try again.") + expect(order.reload.total).to eq(130) + end + end + end end context "with a whole-order adjustment benefit" do diff --git a/promotions/spec/models/solidus_promotions/promotion_spec.rb b/promotions/spec/models/solidus_promotions/promotion_spec.rb index 67876927853..8b706915f97 100644 --- a/promotions/spec/models/solidus_promotions/promotion_spec.rb +++ b/promotions/spec/models/solidus_promotions/promotion_spec.rb @@ -147,6 +147,49 @@ end end + describe ".with_coupon_code" do + context "when coupon code is case-insensitive (default)" do + let!(:promotion) { create(:solidus_promotion, code: "10Off") } + + it "finds promotion with case-insensitive match" do + expect(described_class.with_coupon_code("10off")).to eq(promotion) + expect(described_class.with_coupon_code("10OFF")).to eq(promotion) + expect(described_class.with_coupon_code("10OfF")).to eq(promotion) + end + + it "normalizes the promotion code to lowercase on creation" do + expect(promotion.codes.first.value).to eq("10off") + end + end + + context "when coupon code is case-sensitive" do + before do + stub_const("CaseSensitiveNormalizer", Class.new do + def self.call(value) + value&.strip + end + end) + + stub_spree_preferences( + SolidusPromotions.configuration, + coupon_code_normalizer_class: CaseSensitiveNormalizer + ) + end + + let!(:promotion) { create(:solidus_promotion, code: "10Off") } + + it "requires exact case match" do + expect(described_class.with_coupon_code("10Off")).to eq(promotion) + expect(described_class.with_coupon_code("10OFF")).to be_nil + expect(described_class.with_coupon_code("10off")).to be_nil + end + + it "preserves the original case on creation" do + expect(promotion.codes.first.value).to eq("10Off") + end + end + end + describe ".active" do subject { described_class.active } diff --git a/promotions/spec/models/spree/order_spec.rb b/promotions/spec/models/spree/order_spec.rb index 7f879da74b7..f7e84ad4fa9 100644 --- a/promotions/spec/models/spree/order_spec.rb +++ b/promotions/spec/models/spree/order_spec.rb @@ -39,4 +39,39 @@ it { is_expected.to include("solidus_promotions", "solidus_order_promotions") } end + + describe "#coupon_code=" do + let(:order) { create(:order) } + let(:promotion) { create(:promotion, code: "10off") } + let(:coupon_code) { "10OFF" } + + subject { order.coupon_code = coupon_code } + + context "when coupon code is case-insensitive (default)" do + it "converts coupon codes to lowercase" do + subject + expect(order.coupon_code).to eq("10off") + end + end + + context "when coupon code is case-sensitive" do + before do + stub_const("CaseSensitiveNormalizer", Class.new do + def self.call(value) + value&.strip + end + end) + + stub_spree_preferences( + SolidusPromotions.configuration, + coupon_code_normalizer_class: CaseSensitiveNormalizer + ) + end + + it "preserves case in coupon codes" do + subject + expect(order.coupon_code).to eq("10OFF") + end + end + end end