Skip to content

Commit ff5b9d0

Browse files
committed
solidus_promotions: Add configurable coupon code normalization with case sensitivity
Introduce configurable coupon code normalization in Solidus Promotions, enabling customization of how coupon codes are normalized before saving, lookup, or application. By default, coupon codes are handled case-insensitively, but case-sensitive or other normalization strategies can be implemented by overriding or providing a custom normalizer Changes include: - Add `SolidusPromotions::CouponCodeNormalizer` to centralize normalization logic and support custom strategies - Update `PromotionCode#normalize_code`, `Promotion.with_coupon_code`, `PromotionHandler::Coupon`, and `Spree::Order` to delegate normalization to the configured normalizer - Add specs covering case-sensitive, case-insensitive, and custom behavior - Add migration to make promotion code values case-sensitive by default in MySQL/MariaDB Signed-off-by: Thukten Singye <thuktensingye2163@gmail.com>
1 parent 1742f04 commit ff5b9d0

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.
@@ -105,7 +114,6 @@ class Configuration < Spree::Preferences::Configuration
105114
preference :sync_order_promotions, :boolean, default: false
106115

107116
preference :use_new_admin, :boolean, default: false
108-
109117
def use_new_admin?
110118
SolidusSupport.admin_available? && preferred_use_new_admin
111119
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)