feat/promotion-code #5
@@ -8,6 +8,7 @@
|
||||
|
||||
### Medium Priority
|
||||
|
||||
- [ ] feat: Promotion code on ticket
|
||||
- [ ] feat: Promoter system with event creation, ticket types creation and metrics display
|
||||
- [ ] feat: Multiple ticket types (early bird, VIP, general admission)
|
||||
- [ ] feat: Refund management system
|
||||
@@ -53,6 +54,7 @@
|
||||
|
||||
## 🚧 Doing
|
||||
|
||||
- [ ] feat: Promotion code on ticket
|
||||
- [ ] feat: Page to display all tickets for an event
|
||||
- [ ] feat: Add a link into notification email to order page that display all tickets
|
||||
|
||||
|
||||
@@ -126,6 +126,20 @@ class OrdersController < ApplicationController
|
||||
@total_amount = @order.total_amount_cents
|
||||
@expiring_soon = @order.expiring_soon?
|
||||
|
||||
# Handle promotion code application
|
||||
if params[:promotion_code].present?
|
||||
promotion_code = PromotionCode.valid.find_by(code: params[:promotion_code].upcase)
|
||||
if promotion_code
|
||||
# Apply the promotion code to the order
|
||||
@order.promotion_codes << promotion_code
|
||||
@order.calculate_total!
|
||||
@total_amount = @order.total_amount_cents
|
||||
flash.now[:notice] = "Code promotionnel appliqué: #{promotion_code.code}"
|
||||
else
|
||||
flash.now[:alert] = "Code promotionnel invalide"
|
||||
end
|
||||
end
|
||||
|
||||
# For free orders, automatically mark as paid and redirect to success
|
||||
if @order.free?
|
||||
@order.mark_as_paid!
|
||||
|
||||
@@ -7,6 +7,8 @@ class Order < ApplicationRecord
|
||||
belongs_to :user
|
||||
belongs_to :event
|
||||
has_many :tickets, dependent: :destroy
|
||||
has_many :order_promotion_codes, dependent: :destroy
|
||||
has_many :promotion_codes, through: :order_promotion_codes
|
||||
|
||||
# === Validations ===
|
||||
validates :user_id, presence: true
|
||||
|
||||
26
app/models/order_promotion_code.rb
Normal file
26
app/models/order_promotion_code.rb
Normal file
@@ -0,0 +1,26 @@
|
||||
class OrderPromotionCode < ApplicationRecord
|
||||
# Associations
|
||||
belongs_to :order
|
||||
belongs_to :promotion_code
|
||||
|
||||
# Validations
|
||||
validates :order, presence: true
|
||||
validates :promotion_code, presence: true
|
||||
|
||||
# Callbacks
|
||||
after_create :apply_discount
|
||||
after_create :increment_promotion_code_uses
|
||||
|
||||
private
|
||||
|
||||
def apply_discount
|
||||
# Apply the discount to the order
|
||||
discount_amount = promotion_code.discount_amount_cents
|
||||
order.update!(total_amount_cents: [ order.total_amount_cents - discount_amount, 0 ].max)
|
||||
end
|
||||
|
||||
def increment_promotion_code_uses
|
||||
# Increment the uses count on the promotion code
|
||||
promotion_code.increment!(:uses_count)
|
||||
end
|
||||
end
|
||||
23
app/models/promotion_code.rb
Normal file
23
app/models/promotion_code.rb
Normal file
@@ -0,0 +1,23 @@
|
||||
class PromotionCode < ApplicationRecord
|
||||
# Validations
|
||||
validates :code, presence: true, uniqueness: true
|
||||
validates :discount_amount_cents, numericality: { greater_than_or_equal_to: 0 }
|
||||
|
||||
# Scopes
|
||||
scope :active, -> { where(active: true) }
|
||||
scope :expired, -> { where("expires_at < ? OR active = ?", Time.current, false) }
|
||||
scope :valid, -> { active.where("expires_at > ? OR expires_at IS NULL", Time.current) }
|
||||
|
||||
# Callbacks
|
||||
before_create :increment_uses_count
|
||||
|
||||
# Associations
|
||||
has_many :order_promotion_codes
|
||||
has_many :orders, through: :order_promotion_codes
|
||||
|
||||
private
|
||||
|
||||
def increment_uses_count
|
||||
self.uses_count ||= 0
|
||||
end
|
||||
end
|
||||
@@ -118,6 +118,16 @@
|
||||
<p class="text-sm text-gray-600">Procédez au paiement pour finaliser votre commande</p>
|
||||
</div>
|
||||
|
||||
<!-- Promotion Code Section -->
|
||||
<%= form_tag checkout_order_path(@order), method: :get, class: "mb-6" do %>
|
||||
<div class="flex items-center bg-gray-50 border border-gray-200 rounded-lg p-3">
|
||||
<%= text_field_tag :promotion_code, params[:promotion_code], class: "flex-1 border-none bg-transparent focus:ring-0 text-sm", placeholder: "Code promotionnel (optionnel)" %>
|
||||
<%= button_tag type: "submit", class: "ml-2 btn btn-secondary py-2 px-4 text-sm" do %>
|
||||
Appliquer
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% if @checkout_session.present? %>
|
||||
<!-- Stripe Checkout -->
|
||||
<div class="space-y-6">
|
||||
|
||||
16
db/migrate/20250928180837_create_promotion_codes.rb
Normal file
16
db/migrate/20250928180837_create_promotion_codes.rb
Normal file
@@ -0,0 +1,16 @@
|
||||
class CreatePromotionCodes < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
create_table :promotion_codes do |t|
|
||||
t.string :code, null: false, unique: true
|
||||
t.integer :discount_amount_cents, null: false, default: 0
|
||||
t.datetime :expires_at
|
||||
t.boolean :active, default: true, null: false
|
||||
t.integer :usage_limit, default: nil
|
||||
t.integer :uses_count, default: 0, null: false
|
||||
t.datetime :created_at, null: false
|
||||
t.datetime :updated_at, null: false
|
||||
end
|
||||
|
||||
add_index :promotion_codes, :code, unique: true
|
||||
end
|
||||
end
|
||||
10
db/migrate/20250928181311_create_order_promotion_codes.rb
Normal file
10
db/migrate/20250928181311_create_order_promotion_codes.rb
Normal file
@@ -0,0 +1,10 @@
|
||||
class CreateOrderPromotionCodes < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
create_table :order_promotion_codes do |t|
|
||||
t.references :order, null: false, foreign_key: true
|
||||
t.references :promotion_code, null: false, foreign_key: true
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
||||
66
test/controllers/orders_controller_promotion_test.rb
Normal file
66
test/controllers/orders_controller_promotion_test.rb
Normal file
@@ -0,0 +1,66 @@
|
||||
require "test_helper"
|
||||
|
||||
class OrdersControllerPromotionTest < ActionDispatch::IntegrationTest
|
||||
include Devise::Test::IntegrationHelpers
|
||||
|
||||
# Setup test data
|
||||
def setup
|
||||
@user = users(:one)
|
||||
@event = events(:one)
|
||||
@order = orders(:one)
|
||||
sign_in @user
|
||||
end
|
||||
|
||||
# Test applying a valid promotion code
|
||||
def test_apply_valid_promotion_code
|
||||
promotion_code = PromotionCode.create(
|
||||
code: "TESTDISCOUNT",
|
||||
discount_amount_cents: 1000, # €10.00
|
||||
expires_at: 1.month.from_now,
|
||||
active: true
|
||||
)
|
||||
|
||||
get checkout_order_path(@order), params: { promotion_code: "TESTDISCOUNT" }
|
||||
assert_response :success
|
||||
assert_not_nil flash[:notice]
|
||||
assert_match /Code promotionnel appliqué: TESTDISCOUNT/, flash[:notice]
|
||||
end
|
||||
|
||||
# Test applying an invalid promotion code
|
||||
def test_apply_invalid_promotion_code
|
||||
get checkout_order_path(@order), params: { promotion_code: "INVALIDCODE" }
|
||||
assert_response :success
|
||||
assert_not_nil flash[:alert]
|
||||
assert_equal "Code promotionnel invalide", flash[:alert]
|
||||
end
|
||||
|
||||
# Test applying an expired promotion code
|
||||
def test_apply_expired_promotion_code
|
||||
promotion_code = PromotionCode.create(
|
||||
code: "EXPIREDCODE",
|
||||
discount_amount_cents: 1000,
|
||||
expires_at: 1.day.ago,
|
||||
active: true
|
||||
)
|
||||
|
||||
get checkout_order_path(@order), params: { promotion_code: "EXPIREDCODE" }
|
||||
assert_response :success
|
||||
assert_not_nil flash[:alert]
|
||||
assert_equal "Code promotionnel invalide", flash[:alert]
|
||||
end
|
||||
|
||||
# Test applying an inactive promotion code
|
||||
def test_apply_inactive_promotion_code
|
||||
promotion_code = PromotionCode.create(
|
||||
code: "INACTIVECODE",
|
||||
discount_amount_cents: 1000,
|
||||
expires_at: 1.month.from_now,
|
||||
active: false
|
||||
)
|
||||
|
||||
get checkout_order_path(@order), params: { promotion_code: "INACTIVECODE" }
|
||||
assert_response :success
|
||||
assert_not_nil flash[:alert]
|
||||
assert_equal "Code promotionnel invalide", flash[:alert]
|
||||
end
|
||||
end
|
||||
67
test/models/promotion_code_test.rb
Normal file
67
test/models/promotion_code_test.rb
Normal file
@@ -0,0 +1,67 @@
|
||||
require "test_helper"
|
||||
|
||||
class PromotionCodeTest < ActiveSupport::TestCase
|
||||
# Test valid promotion code creation
|
||||
def test_valid_promotion_code
|
||||
promotion_code = PromotionCode.create(
|
||||
code: "DISCOUNT10",
|
||||
discount_amount_cents: 1000, # €10.00
|
||||
expires_at: 1.month.from_now,
|
||||
active: true
|
||||
)
|
||||
|
||||
assert promotion_code.valid?
|
||||
assert_equal "DISCOUNT10", promotion_code.code
|
||||
assert_equal 1000, promotion_code.discount_amount_cents
|
||||
assert promotion_code.active?
|
||||
end
|
||||
|
||||
# Test validation for required fields
|
||||
def test_validation_for_required_fields
|
||||
promotion_code = PromotionCode.new
|
||||
refute promotion_code.valid?
|
||||
assert_not_nil promotion_code.errors[:code]
|
||||
end
|
||||
|
||||
# Test unique code validation
|
||||
def test_unique_code_validation
|
||||
PromotionCode.create(code: "UNIQUE123", discount_amount_cents: 500)
|
||||
duplicate_code = PromotionCode.new(code: "UNIQUE123", discount_amount_cents: 500)
|
||||
refute duplicate_code.valid?
|
||||
assert_not_nil duplicate_code.errors[:code]
|
||||
end
|
||||
|
||||
# Test discount amount validation
|
||||
def test_discount_amount_validation
|
||||
promotion_code = PromotionCode.new(code: "VALID123", discount_amount_cents: -100)
|
||||
refute promotion_code.valid?
|
||||
assert_not_nil promotion_code.errors[:discount_amount_cents]
|
||||
end
|
||||
|
||||
# Test active scope
|
||||
def test_active_scope
|
||||
active_code = PromotionCode.create(code: "ACTIVE123", discount_amount_cents: 500, active: true)
|
||||
inactive_code = PromotionCode.create(code: "INACTIVE123", discount_amount_cents: 500, active: false)
|
||||
|
||||
assert_includes PromotionCode.active, active_code
|
||||
refute_includes PromotionCode.active, inactive_code
|
||||
end
|
||||
|
||||
# Test expired scope
|
||||
def test_expired_scope
|
||||
expired_code = PromotionCode.create(code: "EXPIRED123", discount_amount_cents: 500, expires_at: 1.day.ago)
|
||||
future_code = PromotionCode.create(code: "FUTURE123", discount_amount_cents: 500, expires_at: 1.month.from_now)
|
||||
|
||||
assert_includes PromotionCode.expired, expired_code
|
||||
refute_includes PromotionCode.expired, future_code
|
||||
end
|
||||
|
||||
# Test valid scope
|
||||
def test_valid_scope
|
||||
valid_code = PromotionCode.create(code: "VALID123", discount_amount_cents: 500, active: true, expires_at: 1.month.from_now)
|
||||
invalid_code = PromotionCode.create(code: "INVALID123", discount_amount_cents: 500, active: false, expires_at: 1.day.ago)
|
||||
|
||||
assert_includes PromotionCode.valid, valid_code
|
||||
refute_includes PromotionCode.valid, invalid_code
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user