Skip to content

Commit f54d7ff

Browse files
authored
Merge pull request #6331 from ThuktenSingye/feature/coupon-code-case-sensitive
Coupon Code Case Sensitive
2 parents f08a1ba + 0723dd3 commit f54d7ff

File tree

13 files changed

+425
-4
lines changed

13 files changed

+425
-4
lines changed

promotions/README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,13 @@ SolidusPromotions.configure do |config|
7070
end
7171
```
7272

73+
### Coupon Code Normalization
74+
75+
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).
76+
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.
77+
78+
See the `coupon_code_normalizer_class` configuration option for implementation details.
79+
7380
## Installation
7481

7582
Add solidus_promotions to your Gemfile:
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# frozen_string_literal: true
2+
3+
module SolidusPromotions
4+
# Normalizes coupon codes before saving or looking up promotions.
5+
#
6+
# By default, this class strips whitespace and downcases the code
7+
# to ensure case-insensitive behavior. You can override this class
8+
# or provide a custom normalizer class to change behavior (e.g.,
9+
# case-sensitive codes) via:
10+
#
11+
# SolidusPromotions.configure do |config|
12+
# config.coupon_code_normalizer_class = YourCustomNormalizer
13+
# end
14+
#
15+
# @example Default usage
16+
# CouponCodeNormalizer.call(" SAVE20 ") # => "save20"
17+
#
18+
# @example Custom case-sensitive usage
19+
# class CaseSensitiveNormalizer
20+
# def self.call(value)
21+
# value&.strip
22+
# end
23+
# end
24+
#
25+
# SolidusPromotions.configure do |config|
26+
# config.coupon_code_normalizer_class = CaseSensitiveNormalizer
27+
# end
28+
class CouponCodeNormalizer
29+
# Normalizes the given coupon code.
30+
#
31+
# @param value [String, nil] the coupon code to normalize
32+
# @return [String, nil] the normalized coupon code
33+
def self.call(value)
34+
value&.strip&.downcase
35+
end
36+
end
37+
end

promotions/app/models/solidus_promotions/promotion.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,9 @@ class Promotion < Spree::Base
4646

4747
def self.with_coupon_code(val)
4848
joins(:codes).where(
49-
SolidusPromotions::PromotionCode.arel_table[:value].eq(val.downcase)
49+
SolidusPromotions::PromotionCode.arel_table[:value].eq(
50+
SolidusPromotions.config.coupon_code_normalizer_class.call(val)
51+
)
5052
).first
5153
end
5254

promotions/app/models/solidus_promotions/promotion_code.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ def promotion_not_apply_automatically
5050
private
5151

5252
def normalize_code
53-
self.value = value.downcase.strip
53+
self.value = SolidusPromotions.config.coupon_code_normalizer_class.call(value)
5454
end
5555
end
5656
end

promotions/app/models/solidus_promotions/promotion_handler/coupon.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ class Coupon
99
def initialize(order)
1010
@order = order
1111
@errors = []
12-
@coupon_code = order&.coupon_code&.downcase
12+
@coupon_code = SolidusPromotions.config.coupon_code_normalizer_class.call(order&.coupon_code)
1313
end
1414

1515
def apply

promotions/app/patches/models/solidus_promotions/order_patch.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,14 @@ def free_from_order_benefit?(line_item, _options)
3636
!line_item.managed_by_order_benefit
3737
end
3838

39+
def coupon_code=(code)
40+
@coupon_code = begin
41+
SolidusPromotions.config.coupon_code_normalizer_class.call(code)
42+
rescue StandardError
43+
nil
44+
end
45+
end
46+
3947
Spree::Order.singleton_class.prepend self::ClassMethods
4048
Spree::Order.prepend self
4149
end
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
class UpdatePromotionCodeValueCollation < ActiveRecord::Migration[7.0]
2+
def up
3+
return unless mysql?
4+
5+
collation = use_accent_sensitive_collation? ? 'utf8mb4_0900_as_cs' : 'utf8mb4_bin'
6+
change_column :solidus_promotions_promotion_codes, :value, :string,
7+
collation: collation
8+
end
9+
10+
def down
11+
return unless mysql?
12+
13+
change_column :solidus_promotions_promotion_codes, :value, :string,
14+
collation: 'utf8mb4_general_ci'
15+
end
16+
17+
private
18+
19+
def mysql?
20+
ActiveRecord::Base.connection.adapter_name.downcase.include?('mysql')
21+
end
22+
23+
def use_accent_sensitive_collation?
24+
!mariadb? && mysql_version >= 8.0
25+
end
26+
27+
def mariadb?
28+
version_string.include?('mariadb')
29+
end
30+
31+
def mysql_version
32+
version_string.to_f
33+
end
34+
35+
def version_string
36+
@version_string ||= ActiveRecord::Base.connection.select_value('SELECT VERSION()').downcase
37+
end
38+
end

promotions/lib/solidus_promotions/configuration.rb

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,15 @@ class Configuration < Spree::Preferences::Configuration
1010

1111
class_name_attribute :coupon_code_handler_class, default: "SolidusPromotions::PromotionHandler::Coupon"
1212

13+
# The class used to normalize coupon codes before saving or lookup.
14+
# By default, this normalizes codes to lowercase for case-insensitive matching.
15+
# You can customize this by creating your own normalizer class or by overriding
16+
# the existing SolidusPromotions::CouponCodeNormalizer class using a decorator.
17+
# @!attribute [rw] coupon_code_normalizer_class
18+
# @return [String] The class used to normalize coupon codes.
19+
# Defaults to "SolidusPromotions::CouponCodeNormalizer".
20+
class_name_attribute :coupon_code_normalizer_class, default: "SolidusPromotions::CouponCodeNormalizer"
21+
1322
class_name_attribute :promotion_finder_class, default: "SolidusPromotions::PromotionFinder"
1423

1524
# Allows providing a different promotion advertiser.
@@ -107,7 +116,6 @@ class Configuration < Spree::Preferences::Configuration
107116
preference :sync_order_promotions, :boolean, default: false
108117

109118
preference :use_new_admin, :boolean, default: false
110-
111119
def use_new_admin?
112120
SolidusSupport.admin_available? && preferred_use_new_admin
113121
end
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
# frozen_string_literal: true
2+
3+
require "rails_helper"
4+
5+
RSpec.describe SolidusPromotions::CouponCodeNormalizer do
6+
describe '.call' do
7+
context 'when case is insensitive' do
8+
it 'downcases the value' do
9+
expect(described_class.call('10FFF')).to eq('10fff')
10+
end
11+
12+
it "strips leading and trailing whitespace" do
13+
expect(described_class.call(" 10oFF ")).to eq("10off")
14+
end
15+
16+
it 'downcases mixed cases' do
17+
expect(described_class.call('10OfF')).to eq('10off')
18+
end
19+
20+
it 'handles already normalized values' do
21+
expect(described_class.call('10off')).to eq('10off')
22+
end
23+
24+
it 'returns nil with nil input' do
25+
expect(described_class.call(nil)).to be_nil
26+
end
27+
28+
it 'returns empty string with empty string input' do
29+
expect(described_class.call('')).to eq('')
30+
end
31+
32+
it 'returns empty string with whitespace only input' do
33+
expect(described_class.call(' ')).to eq('')
34+
end
35+
end
36+
37+
context 'when case is sensitive' do
38+
before do
39+
stub_const("CaseSensitiveNormalizer", Class.new do
40+
def self.call(value)
41+
value&.strip
42+
end
43+
end)
44+
45+
stub_spree_preferences(
46+
SolidusPromotions.configuration,
47+
coupon_code_normalizer_class: CaseSensitiveNormalizer
48+
)
49+
end
50+
51+
it 'preserves the original cases' do
52+
expect(CaseSensitiveNormalizer.call('10OFF')).to eq('10OFF')
53+
end
54+
55+
it 'does not downcase the value' do
56+
expect(CaseSensitiveNormalizer.call('10OFF')).not_to eq('10off')
57+
end
58+
59+
it 'strips leading and trailing whitespace' do
60+
expect(CaseSensitiveNormalizer.call(' 10OFF ')).to eq('10OFF')
61+
end
62+
63+
it 'preserves lower case' do
64+
expect(CaseSensitiveNormalizer.call('10off')).to eq('10off')
65+
end
66+
67+
it 'preserves mixed case' do
68+
expect(CaseSensitiveNormalizer.call('10OfF')).to eq('10OfF')
69+
end
70+
71+
it 'returns nil with nil input' do
72+
expect(CaseSensitiveNormalizer.call(nil)).to be_nil
73+
end
74+
75+
it 'returns empty string with empty string input' do
76+
expect(CaseSensitiveNormalizer.call('')).to eq('')
77+
end
78+
79+
it 'returns empty string with whitespace only input' do
80+
expect(CaseSensitiveNormalizer.call(' ')).to eq('')
81+
end
82+
end
83+
end
84+
end

promotions/spec/models/solidus_promotions/promotion_code_spec.rb

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,82 @@
6767
end
6868
end
6969

70+
context "callbacks when coupon case is sensitive" do
71+
before do
72+
stub_const("CaseSensitiveNormalizer", Class.new do
73+
def self.call(value)
74+
value&.strip
75+
end
76+
end)
77+
78+
stub_spree_preferences(
79+
SolidusPromotions.configuration,
80+
coupon_code_normalizer_class: CaseSensitiveNormalizer
81+
)
82+
end
83+
84+
subject { promotion_code.save }
85+
86+
let(:promotion) { create(:solidus_promotion, code: code) }
87+
88+
describe "#normalize_code" do
89+
before { subject }
90+
91+
context "when no other code with the same value exists" do
92+
let(:promotion_code) { promotion.codes.first }
93+
94+
context "with mixed case" do
95+
let(:code) { "NewCoDe" }
96+
97+
it "does not downcase the value" do
98+
expect(promotion_code.value).to eq("NewCoDe")
99+
end
100+
end
101+
102+
context "with extra spacing" do
103+
let(:code) { " new code " }
104+
105+
it "removes surrounding whitespace" do
106+
expect(promotion_code.value).to eq("new code")
107+
end
108+
end
109+
end
110+
111+
context "when another code with the same value but different case exists" do
112+
context "with mixed case" do
113+
let(:promotion_code) { promotion.codes.build(value: "NewCoDe") }
114+
115+
let(:code) { "newcode" }
116+
117+
it "saves the record successfully as case sensitive" do
118+
expect(promotion_code.valid?).to eq(true)
119+
end
120+
end
121+
122+
context "with extra spacing" do
123+
let(:promotion_code) { promotion.codes.build(value: "NewCoDe") }
124+
125+
let(:code) { " newcode " }
126+
127+
it "saves the record successfully as case sensitive" do
128+
expect(promotion_code.valid?).to eq(true)
129+
end
130+
end
131+
end
132+
133+
context "when another code with the same value and same case exists" do
134+
let(:promotion_code) { promotion.codes.build(value: "newcode") }
135+
136+
let(:code) { "newcode" }
137+
138+
it "does not save the record and marks it as invalid" do
139+
expect(promotion_code.valid?).to eq(false)
140+
expect(promotion_code.errors.messages[:value]).to contain_exactly("has already been taken")
141+
end
142+
end
143+
end
144+
end
145+
70146
describe "#usage_limit_exceeded?" do
71147
subject { code.usage_limit_exceeded? }
72148

0 commit comments

Comments
 (0)