feat: Add promotion code functionality to ticket orders

This commit is contained in:
kbe
2025-09-28 20:20:22 +02:00
parent e5ed1a34dd
commit a69ddb4012
10 changed files with 258 additions and 22 deletions

View File

@@ -8,6 +8,7 @@
### Medium Priority ### Medium Priority
- [ ] feat: Promotion code on ticket
- [ ] feat: Promoter system with event creation, ticket types creation and metrics display - [ ] feat: Promoter system with event creation, ticket types creation and metrics display
- [ ] feat: Multiple ticket types (early bird, VIP, general admission) - [ ] feat: Multiple ticket types (early bird, VIP, general admission)
- [ ] feat: Refund management system - [ ] feat: Refund management system
@@ -53,6 +54,7 @@
## 🚧 Doing ## 🚧 Doing
- [ ] feat: Promotion code on ticket
- [ ] feat: Page to display all tickets for an event - [ ] feat: Page to display all tickets for an event
- [ ] feat: Add a link into notification email to order page that display all tickets - [ ] feat: Add a link into notification email to order page that display all tickets

View File

@@ -126,6 +126,20 @@ class OrdersController < ApplicationController
@total_amount = @order.total_amount_cents @total_amount = @order.total_amount_cents
@expiring_soon = @order.expiring_soon? @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 # For free orders, automatically mark as paid and redirect to success
if @order.free? if @order.free?
@order.mark_as_paid! @order.mark_as_paid!

View File

@@ -7,6 +7,8 @@ class Order < ApplicationRecord
belongs_to :user belongs_to :user
belongs_to :event belongs_to :event
has_many :tickets, dependent: :destroy has_many :tickets, dependent: :destroy
has_many :order_promotion_codes, dependent: :destroy
has_many :promotion_codes, through: :order_promotion_codes
# === Validations === # === Validations ===
validates :user_id, presence: true validates :user_id, presence: true

View 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

View 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

View File

@@ -118,6 +118,16 @@
<p class="text-sm text-gray-600">Procédez au paiement pour finaliser votre commande</p> <p class="text-sm text-gray-600">Procédez au paiement pour finaliser votre commande</p>
</div> </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? %> <% if @checkout_session.present? %>
<!-- Stripe Checkout --> <!-- Stripe Checkout -->
<div class="space-y-6"> <div class="space-y-6">

View 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

View 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

View 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

View 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