feat/promotion-code #5
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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!
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
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>
|
<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">
|
||||||
@@ -131,13 +141,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
id="checkout-button"
|
id="checkout-button"
|
||||||
data-order-id="<%= @order.id %>"
|
data-order-id="<%= @order.id %>"
|
||||||
data-increment-url="/api/v1/orders/<%= @order.id %>/increment_payment_attempt"
|
data-increment-url="/api/v1/orders/<%= @order.id %>/increment_payment_attempt"
|
||||||
data-session-id="<%= @checkout_session.id if @checkout_session.present? %>"
|
data-session-id="<%= @checkout_session.id if @checkout_session.present? %>"
|
||||||
class="w-full btn btn-primary py-4 px-6 rounded-xl transition-all duration-200 transform hover:scale-105 active:scale-95 shadow-lg hover:shadow-xl"
|
class="w-full btn btn-primary py-4 px-6 rounded-xl transition-all duration-200 transform hover:scale-105 active:scale-95 shadow-lg hover:shadow-xl"
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-center">
|
<div class="flex items-center justify-center">
|
||||||
<i data-lucide="credit-card" class="w-5 h-5 mr-2"></i>
|
<i data-lucide="credit-card" class="w-5 h-5 mr-2"></i>
|
||||||
Payer <%= @order.total_amount_euros %>€
|
Payer <%= @order.total_amount_euros %>€
|
||||||
@@ -194,16 +204,16 @@
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Increment payment attempt counter
|
// Increment payment attempt counter
|
||||||
const orderId = checkoutButton.dataset.orderId;
|
const orderId = checkoutButton.dataset.orderId;
|
||||||
const incrementUrl = checkoutButton.dataset.incrementUrl;
|
const incrementUrl = checkoutButton.dataset.incrementUrl;
|
||||||
console.log('Incrementing payment attempt for order:', orderId);
|
console.log('Incrementing payment attempt for order:', orderId);
|
||||||
const response = await fetch(incrementUrl, {
|
const response = await fetch(incrementUrl, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'X-CSRF-Token': document.querySelector('[name=csrf-token]').content
|
'X-CSRF-Token': document.querySelector('[name=csrf-token]').content
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
console.error('Payment attempt increment failed:', response.status, response.statusText);
|
console.error('Payment attempt increment failed:', response.status, response.statusText);
|
||||||
@@ -224,11 +234,11 @@
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
// Redirect to Stripe
|
// Redirect to Stripe
|
||||||
const sessionId = checkoutButton.dataset.sessionId;
|
const sessionId = checkoutButton.dataset.sessionId;
|
||||||
console.log('Redirecting to Stripe with session ID:', sessionId);
|
console.log('Redirecting to Stripe with session ID:', sessionId);
|
||||||
const stripeResult = await stripe.redirectToCheckout({
|
const stripeResult = await stripe.redirectToCheckout({
|
||||||
sessionId: sessionId
|
sessionId: sessionId
|
||||||
});
|
});
|
||||||
|
|
||||||
if (stripeResult.error) {
|
if (stripeResult.error) {
|
||||||
throw new Error(stripeResult.error.message);
|
throw new Error(stripeResult.error.message);
|
||||||
|
|||||||
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