diff --git a/app/controllers/promoter/promotion_codes_controller.rb b/app/controllers/promoter/promotion_codes_controller.rb index c168923..0682431 100644 --- a/app/controllers/promoter/promotion_codes_controller.rb +++ b/app/controllers/promoter/promotion_codes_controller.rb @@ -9,7 +9,7 @@ class Promoter::PromotionCodesController < ApplicationController @promotion_codes = @event.promotion_codes.includes(:user) end - + # GET /promoter/events/:event_id/promotion_codes/new # Show form to create a new promotion code def new diff --git a/app/models/order.rb b/app/models/order.rb index aea6182..8fea6d7 100644 --- a/app/models/order.rb +++ b/app/models/order.rb @@ -96,7 +96,7 @@ class Order < ApplicationRecord discount_total = promotion_codes.sum(:discount_amount_cents) # Ensure total doesn't go below zero - final_total = [ticket_total - discount_total, 0].max + final_total = [ ticket_total - discount_total, 0 ].max update!(total_amount_cents: final_total) end @@ -110,9 +110,9 @@ class Order < ApplicationRecord subtotal_amount_cents / 100.0 end - # Total discount amount from all promotion codes + # Total discount amount from all promotion codes (capped at subtotal) def discount_amount_cents - promotion_codes.sum(:discount_amount_cents) + [ promotion_codes.sum(:discount_amount_cents), subtotal_amount_cents ].min end # Discount amount in euros diff --git a/app/services/stripe_invoice_service.rb b/app/services/stripe_invoice_service.rb index 803993a..a2f7bd8 100644 --- a/app/services/stripe_invoice_service.rb +++ b/app/services/stripe_invoice_service.rb @@ -166,6 +166,23 @@ class StripeInvoiceService }) end + # Add promotion code discounts as negative line items + @order.promotion_codes.each do |promo_code| + Stripe::InvoiceItem.create({ + customer: customer.id, + invoice: invoice.id, + amount: -promo_code.discount_amount_cents, # Negative amount for discount + currency: "eur", + description: "Réduction promotionnelle (Code: #{promo_code.code})", + metadata: { + promotion_code_id: promo_code.id, + promotion_code: promo_code.code, + discount_amount_cents: promo_code.discount_amount_cents, + type: "promotion_discount" + } + }) + end + # No service fee on customer invoice; platform fee deducted from promoter payout end diff --git a/app/views/orders/invoice.html.erb b/app/views/orders/invoice.html.erb index dfb3f44..e79ddf1 100644 --- a/app/views/orders/invoice.html.erb +++ b/app/views/orders/invoice.html.erb @@ -121,13 +121,56 @@ <% end %> + - Total - <%= "%.2f" % @order.total_amount_euros %>€ + Sous-total + <%= "%.2f" % @order.subtotal_amount_euros %>€ + + + + <% if @order.promotion_codes.any? %> + <% @order.promotion_codes.each do |promo_code| %> + + + Réduction (Code: <%= promo_code.code %>) + + -<%= "%.2f" % promo_code.discount_amount_euros %>€ + + <% end %> + <% end %> + + + + Total + + <% if @order.total_amount_cents == 0 %> + GRATUIT + <% else %> + <%= "%.2f" % @order.total_amount_euros %>€ + <% end %> + + + + <% if @order.promotion_codes.any? %> +
+

+ + Codes promotionnels appliqués +

+
+ <% @order.promotion_codes.each do |promo_code| %> +
+ <%= promo_code.code %> + -<%= "%.2f" % promo_code.discount_amount_euros %>€ +
+ <% end %> +
+
+ <% end %> diff --git a/test/controllers/orders_controller_promotion_test.rb b/test/controllers/orders_controller_promotion_test.rb index 348f78c..fe3c958 100644 --- a/test/controllers/orders_controller_promotion_test.rb +++ b/test/controllers/orders_controller_promotion_test.rb @@ -6,32 +6,57 @@ class OrdersControllerPromotionTest < ActionDispatch::IntegrationTest # Setup test data def setup @user = users(:one) - @event = events(:one) - @order = orders(:one) + @event = events(:concert_event) + @order = orders(:draft_order) sign_in @user end # Test applying a valid promotion code def test_apply_valid_promotion_code + # Create ticket type and tickets for the order + ticket_type = TicketType.create!( + name: "Test Ticket Type", + description: "A valid description for the ticket type that is long enough", + price_cents: 2000, + quantity: 10, + sale_start_at: Time.current, + sale_end_at: Time.current + 1.day, + requires_id: false, + event: @event + ) + + Ticket.create!( + order: @order, + ticket_type: ticket_type, + status: "draft", + first_name: "John", + last_name: "Doe" + ) + + # Recalculate the order total + @order.calculate_total! + promotion_code = PromotionCode.create( code: "TESTDISCOUNT", - discount_amount_cents: 1000, # €10.00 + discount_amount_cents: 500, # €5.00 expires_at: 1.month.from_now, - active: true + active: true, + user: @user, + event: @event ) 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] + assert_not_nil flash.now[:notice] + assert_match /Code promotionnel appliqué: TESTDISCOUNT/, flash.now[: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] + assert_not_nil flash.now[:alert] + assert_equal "Code promotionnel invalide", flash.now[:alert] end # Test applying an expired promotion code @@ -40,13 +65,15 @@ class OrdersControllerPromotionTest < ActionDispatch::IntegrationTest code: "EXPIREDCODE", discount_amount_cents: 1000, expires_at: 1.day.ago, - active: true + active: true, + user: @user, + event: @event ) get checkout_order_path(@order), params: { promotion_code: "EXPIREDCODE" } assert_response :success - assert_not_nil flash[:alert] - assert_equal "Code promotionnel invalide", flash[:alert] + assert_not_nil flash.now[:alert] + assert_equal "Code promotionnel invalide", flash.now[:alert] end # Test applying an inactive promotion code @@ -55,12 +82,14 @@ class OrdersControllerPromotionTest < ActionDispatch::IntegrationTest code: "INACTIVECODE", discount_amount_cents: 1000, expires_at: 1.month.from_now, - active: false + active: false, + user: @user, + event: @event ) get checkout_order_path(@order), params: { promotion_code: "INACTIVECODE" } assert_response :success - assert_not_nil flash[:alert] - assert_equal "Code promotionnel invalide", flash[:alert] + assert_not_nil flash.now[:alert] + assert_equal "Code promotionnel invalide", flash.now[:alert] end end diff --git a/test/models/order_test.rb b/test/models/order_test.rb index 0a67b6b..283a89d 100644 --- a/test/models/order_test.rb +++ b/test/models/order_test.rb @@ -582,6 +582,243 @@ class OrderTest < ActiveSupport::TestCase assert_equal 95.0, order.promoter_payout_euros end + # === Promotion Code Tests === + + test "subtotal_amount_cents should calculate total without discounts" do + order = Order.create!( + user: @user, event: @event, total_amount_cents: 0, + status: "draft", payment_attempts: 0 + ) + + # Create ticket type and tickets + ticket_type = TicketType.create!( + name: "Test Ticket Type", + description: "A valid description for the ticket type that is long enough", + price_cents: 1500, + quantity: 10, + sale_start_at: Time.current, + sale_end_at: Time.current + 1.day, + requires_id: false, + event: @event + ) + + Ticket.create!( + order: order, + ticket_type: ticket_type, + status: "draft", + first_name: "John", + last_name: "Doe" + ) + + Ticket.create!( + order: order, + ticket_type: ticket_type, + status: "draft", + first_name: "Jane", + last_name: "Doe" + ) + + # Create promotion code + promotion_code = PromotionCode.create!( + code: "TESTCODE", + discount_amount_cents: 500, + user: @user, + event: @event + ) + + order.promotion_codes << promotion_code + order.calculate_total! + + assert_equal 3000, order.subtotal_amount_cents # 2 tickets * 1500 cents + assert_equal 2500, order.total_amount_cents # 3000 - 500 discount + end + + test "subtotal_amount_euros should convert subtotal cents to euros" do + order = Order.new(total_amount_cents: 2500) + def order.subtotal_amount_cents; 3000; end + assert_equal 30.0, order.subtotal_amount_euros + end + + test "discount_amount_cents should calculate total discount from promotion codes" do + order = Order.create!( + user: @user, event: @event, total_amount_cents: 0, + status: "draft", payment_attempts: 0 + ) + + # Create ticket type and tickets for subtotal + ticket_type = TicketType.create!( + name: "Test Ticket Type", + description: "A valid description for the ticket type that is long enough", + price_cents: 2000, + quantity: 10, + sale_start_at: Time.current, + sale_end_at: Time.current + 1.day, + requires_id: false, + event: @event + ) + + Ticket.create!( + order: order, + ticket_type: ticket_type, + status: "draft", + first_name: "John", + last_name: "Doe" + ) + + # Create multiple promotion codes + promo1 = PromotionCode.create!( + code: "PROMO1", + discount_amount_cents: 300, + user: @user, + event: @event + ) + + promo2 = PromotionCode.create!( + code: "PROMO2", + discount_amount_cents: 700, + user: @user, + event: @event + ) + + order.promotion_codes << [ promo1, promo2 ] + order.calculate_total! + + assert_equal 1000, order.discount_amount_cents # 300 + 700 (within 2000 subtotal) + end + + test "discount_amount_euros should convert discount cents to euros" do + order = Order.new(total_amount_cents: 2000) + def order.discount_amount_cents; 1000; end + assert_equal 10.0, order.discount_amount_euros + end + + test "calculate_total! should apply promotion code discounts" do + order = Order.create!( + user: @user, event: @event, total_amount_cents: 0, + status: "draft", payment_attempts: 0 + ) + + # Create ticket type and tickets + ticket_type = TicketType.create!( + name: "Test Ticket Type", + description: "A valid description for the ticket type that is long enough", + price_cents: 2000, + quantity: 10, + sale_start_at: Time.current, + sale_end_at: Time.current + 1.day, + requires_id: false, + event: @event + ) + + Ticket.create!( + order: order, + ticket_type: ticket_type, + status: "draft", + first_name: "John", + last_name: "Doe" + ) + + # Create promotion code + promotion_code = PromotionCode.create!( + code: "TESTCODE", + discount_amount_cents: 500, + user: @user, + event: @event + ) + + order.promotion_codes << promotion_code + order.calculate_total! + + assert_equal 2000, order.subtotal_amount_cents + assert_equal 500, order.discount_amount_cents + assert_equal 1500, order.total_amount_cents + end + + test "calculate_total! should handle zero total after promotion codes" do + order = Order.create!( + user: @user, event: @event, total_amount_cents: 0, + status: "draft", payment_attempts: 0 + ) + + # Create ticket type and tickets + ticket_type = TicketType.create!( + name: "Test Ticket Type", + description: "A valid description for the ticket type that is long enough", + price_cents: 500, + quantity: 10, + sale_start_at: Time.current, + sale_end_at: Time.current + 1.day, + requires_id: false, + event: @event + ) + + Ticket.create!( + order: order, + ticket_type: ticket_type, + status: "draft", + first_name: "John", + last_name: "Doe" + ) + + # Create promotion code that covers the entire amount + promotion_code = PromotionCode.create!( + code: "FULLDISCOUNT", + discount_amount_cents: 500, + user: @user, + event: @event + ) + + order.promotion_codes << promotion_code + order.calculate_total! + + assert_equal 500, order.subtotal_amount_cents + assert_equal 500, order.discount_amount_cents + assert_equal 0, order.total_amount_cents + assert order.free? + end + + test "calculate_total! should not allow negative totals with promotion codes" do + order = Order.create!( + user: @user, event: @event, total_amount_cents: 0, + status: "draft", payment_attempts: 0 + ) + + # Create ticket type and tickets + ticket_type = TicketType.create!( + name: "Test Ticket Type", + description: "A valid description for the ticket type that is long enough", + price_cents: 300, + quantity: 10, + sale_start_at: Time.current, + sale_end_at: Time.current + 1.day, + requires_id: false, + event: @event + ) + + Ticket.create!( + order: order, + ticket_type: ticket_type, + status: "draft", + first_name: "John", + last_name: "Doe" + ) + + # Create promotion code that exceeds the ticket amount + promotion_code = PromotionCode.create!( + code: "TOOMUCH", + discount_amount_cents: 1000, + user: @user, + event: @event + ) + + order.promotion_codes << promotion_code + order.calculate_total! + + assert_equal 300, order.subtotal_amount_cents + assert_equal 300, order.discount_amount_cents # Capped at subtotal + assert_equal 0, order.total_amount_cents + end + # === Stripe Integration Tests (Mock) === test "create_stripe_invoice! should return nil for non-paid orders" do diff --git a/test/models/promotion_code_test.rb b/test/models/promotion_code_test.rb index 09ca44a..416b6fe 100644 --- a/test/models/promotion_code_test.rb +++ b/test/models/promotion_code_test.rb @@ -1,13 +1,34 @@ require "test_helper" class PromotionCodeTest < ActiveSupport::TestCase + def setup + @user = User.create!( + email: "test@example.com", + password: "password123", + password_confirmation: "password123" + ) + + @event = Event.create!( + name: "Test Event", + slug: "test-event", + description: "A valid description for the test event that is long enough", + latitude: 48.8566, + longitude: 2.3522, + venue_name: "Test Venue", + venue_address: "123 Test Street", + user: @user + ) + end + # 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 + active: true, + user: @user, + event: @event ) assert promotion_code.valid? @@ -25,23 +46,23 @@ class PromotionCodeTest < ActiveSupport::TestCase # 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) + PromotionCode.create(code: "UNIQUE123", discount_amount_cents: 500, user: @user, event: @event) + duplicate_code = PromotionCode.new(code: "UNIQUE123", discount_amount_cents: 500, user: @user, event: @event) 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) + promotion_code = PromotionCode.new(code: "VALID123", discount_amount_cents: -100, user: @user, event: @event) 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) + active_code = PromotionCode.create(code: "ACTIVE123", discount_amount_cents: 500, active: true, user: @user, event: @event) + inactive_code = PromotionCode.create(code: "INACTIVE123", discount_amount_cents: 500, active: false, user: @user, event: @event) assert_includes PromotionCode.active, active_code refute_includes PromotionCode.active, inactive_code @@ -49,8 +70,8 @@ class PromotionCodeTest < ActiveSupport::TestCase # 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) + expired_code = PromotionCode.create(code: "EXPIRED123", discount_amount_cents: 500, expires_at: 1.day.ago, user: @user, event: @event) + future_code = PromotionCode.create(code: "FUTURE123", discount_amount_cents: 500, expires_at: 1.month.from_now, user: @user, event: @event) assert_includes PromotionCode.expired, expired_code refute_includes PromotionCode.expired, future_code @@ -58,10 +79,191 @@ class PromotionCodeTest < ActiveSupport::TestCase # 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) + valid_code = PromotionCode.create(code: "VALID123", discount_amount_cents: 500, active: true, expires_at: 1.month.from_now, user: @user, event: @event) + invalid_code = PromotionCode.create(code: "INVALID123", discount_amount_cents: 500, active: false, expires_at: 1.day.ago, user: @user, event: @event) assert_includes PromotionCode.valid, valid_code refute_includes PromotionCode.valid, invalid_code end + + # Test discount_amount_euros method + def test_discount_amount_euros_converts_cents_to_euros + promotion_code = PromotionCode.new(discount_amount_cents: 1000) + assert_equal 10.0, promotion_code.discount_amount_euros + + promotion_code = PromotionCode.new(discount_amount_cents: 550) + assert_equal 5.5, promotion_code.discount_amount_euros + end + + # Test active? method + def test_active_method + # Active and not expired + active_code = PromotionCode.create( + code: "ACTIVE1", + discount_amount_cents: 500, + active: true, + expires_at: 1.month.from_now, + user: @user, + event: @event + ) + assert active_code.active? + + # Active but expired + expired_active_code = PromotionCode.create( + code: "ACTIVE2", + discount_amount_cents: 500, + active: true, + expires_at: 1.day.ago, + user: @user, + event: @event + ) + assert_not expired_active_code.active? + + # Inactive but not expired + inactive_code = PromotionCode.create( + code: "INACTIVE1", + discount_amount_cents: 500, + active: false, + expires_at: 1.month.from_now, + user: @user, + event: @event + ) + assert_not inactive_code.active? + + # Active with no expiration + no_expiry_code = PromotionCode.create( + code: "NOEXPIRY", + discount_amount_cents: 500, + active: true, + expires_at: nil, + user: @user, + event: @event + ) + assert no_expiry_code.active? + end + + # Test expired? method + def test_expired_method + # Expired code + expired_code = PromotionCode.create( + code: "EXPIRED1", + discount_amount_cents: 500, + expires_at: 1.day.ago, + user: @user, + event: @event + ) + assert expired_code.expired? + + # Future code + future_code = PromotionCode.create( + code: "FUTURE1", + discount_amount_cents: 500, + expires_at: 1.month.from_now, + user: @user, + event: @event + ) + assert_not future_code.expired? + + # No expiration + no_expiry_code = PromotionCode.create( + code: "NOEXPIRY1", + discount_amount_cents: 500, + expires_at: nil, + user: @user, + event: @event + ) + assert_not no_expiry_code.expired? + end + + # Test can_be_used? method + def test_can_be_used_method + # Can be used: active, not expired, under usage limit + usable_code = PromotionCode.create( + code: "USABLE1", + discount_amount_cents: 500, + active: true, + expires_at: 1.month.from_now, + usage_limit: 10, + uses_count: 0, + user: @user, + event: @event + ) + assert usable_code.can_be_used? + + # Cannot be used: inactive + inactive_code = PromotionCode.create( + code: "INACTIVE2", + discount_amount_cents: 500, + active: false, + expires_at: 1.month.from_now, + usage_limit: 10, + uses_count: 0, + user: @user, + event: @event + ) + assert_not inactive_code.can_be_used? + + # Cannot be used: expired + expired_code = PromotionCode.create( + code: "EXPIRED2", + discount_amount_cents: 500, + active: true, + expires_at: 1.day.ago, + usage_limit: 10, + uses_count: 0, + user: @user, + event: @event + ) + assert_not expired_code.can_be_used? + + # Cannot be used: at usage limit + limit_reached_code = PromotionCode.create( + code: "LIMIT1", + discount_amount_cents: 500, + active: true, + expires_at: 1.month.from_now, + usage_limit: 5, + uses_count: 5, + user: @user, + event: @event + ) + assert_not limit_reached_code.can_be_used? + + # Can be used: no usage limit + no_limit_code = PromotionCode.create( + code: "NOLIMIT1", + discount_amount_cents: 500, + active: true, + expires_at: 1.month.from_now, + usage_limit: nil, + uses_count: 100, + user: @user, + event: @event + ) + assert no_limit_code.can_be_used? + end + + # Test increment_uses_count callback + def test_increment_uses_count_callback + promotion_code = PromotionCode.create( + code: "INCREMENT1", + discount_amount_cents: 500, + uses_count: 0, + user: @user, + event: @event + ) + + assert_equal 0, promotion_code.uses_count + + # The callback should only run on create, so we test the initial value + new_code = PromotionCode.create( + code: "INCREMENT2", + discount_amount_cents: 500, + uses_count: nil, + user: @user, + event: @event + ) + + assert_equal 0, new_code.uses_count + end end