Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions promotions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
@@ -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
4 changes: 3 additions & 1 deletion promotions/app/models/solidus_promotions/promotion.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
10 changes: 9 additions & 1 deletion promotions/lib/solidus_promotions/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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? }

Expand Down
Loading
Loading