From a69ddb401222c742c680f76e9424b04c41e4ca9d Mon Sep 17 00:00:00 2001 From: kbe Date: Sun, 28 Sep 2025 20:20:22 +0200 Subject: [PATCH 1/8] feat: Add promotion code functionality to ticket orders --- BACKLOG.md | 2 + app/controllers/orders_controller.rb | 14 ++++ app/models/order.rb | 2 + app/models/order_promotion_code.rb | 26 +++++++ app/models/promotion_code.rb | 23 +++++++ app/views/orders/checkout.html.erb | 54 +++++++++------ .../20250928180837_create_promotion_codes.rb | 16 +++++ ...0928181311_create_order_promotion_codes.rb | 10 +++ .../orders_controller_promotion_test.rb | 66 ++++++++++++++++++ test/models/promotion_code_test.rb | 67 +++++++++++++++++++ 10 files changed, 258 insertions(+), 22 deletions(-) create mode 100644 app/models/order_promotion_code.rb create mode 100644 app/models/promotion_code.rb create mode 100644 db/migrate/20250928180837_create_promotion_codes.rb create mode 100644 db/migrate/20250928181311_create_order_promotion_codes.rb create mode 100644 test/controllers/orders_controller_promotion_test.rb create mode 100644 test/models/promotion_code_test.rb diff --git a/BACKLOG.md b/BACKLOG.md index abe8eab..1cf6e4e 100755 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -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 diff --git a/app/controllers/orders_controller.rb b/app/controllers/orders_controller.rb index 9456dd9..f6a6e29 100644 --- a/app/controllers/orders_controller.rb +++ b/app/controllers/orders_controller.rb @@ -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! diff --git a/app/models/order.rb b/app/models/order.rb index 3c84bc7..bc7ff56 100644 --- a/app/models/order.rb +++ b/app/models/order.rb @@ -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 diff --git a/app/models/order_promotion_code.rb b/app/models/order_promotion_code.rb new file mode 100644 index 0000000..9effdee --- /dev/null +++ b/app/models/order_promotion_code.rb @@ -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 diff --git a/app/models/promotion_code.rb b/app/models/promotion_code.rb new file mode 100644 index 0000000..4eb56fc --- /dev/null +++ b/app/models/promotion_code.rb @@ -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 diff --git a/app/views/orders/checkout.html.erb b/app/views/orders/checkout.html.erb index 73fd9c8..bdedda7 100644 --- a/app/views/orders/checkout.html.erb +++ b/app/views/orders/checkout.html.erb @@ -118,6 +118,16 @@

ProcΓ©dez au paiement pour finaliser votre commande

+ + <%= form_tag checkout_order_path(@order), method: :get, class: "mb-6" do %> +
+ <%= 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 %> +
+ <% end %> + <% if @checkout_session.present? %>
@@ -131,13 +141,13 @@
- @@ -251,7 +271,11 @@ const stripeResult = await stripe.redirectToCheckout({ button.innerHTML = `
- Payer <%= @order.total_amount_euros %>€ + <% if @order.total_amount_cents == 0 %> + Confirmer la commande + <% else %> + Payer <%= @order.total_amount_euros %>€ + <% end %>
`; alert('Erreur: ' + error.message); diff --git a/app/views/orders/payment_success.html.erb b/app/views/orders/payment_success.html.erb index 4bdb2f4..e0af3e2 100644 --- a/app/views/orders/payment_success.html.erb +++ b/app/views/orders/payment_success.html.erb @@ -123,13 +123,58 @@ <% end %> - -
+ + <% if @order.promotion_codes.any? %> +
+

+ + + + Codes promotionnels appliquΓ©s +

+ <% @order.promotion_codes.each do |promo_code| %> +
+
+ + + + + <%= promo_code.code %> + +
+ -<%= promo_code.discount_amount_euros %>€ +
+ <% end %> +
+ <% end %> + + +
+

DΓ©tail du paiement

-
+ +
+ Sous-total + <%= @order.subtotal_amount_euros %>€ +
+ + + <% if @order.discount_amount_cents > 0 %> +
+ RΓ©duction + -<%= @order.discount_amount_euros %>€ +
+ <% end %> + + +
Total payΓ© - - <%= @order.total_amount_euros %>€ + + <% if @order.total_amount_cents == 0 %> + GRATUIT + <% else %> + <%= @order.total_amount_euros %>€ + <% end %>
diff --git a/app/views/orders/show.html.erb b/app/views/orders/show.html.erb index ebaf2d3..1708cf5 100644 --- a/app/views/orders/show.html.erb +++ b/app/views/orders/show.html.erb @@ -94,14 +94,57 @@ <% end %>
- + + <% if @order.promotion_codes.any? %> +
+

+ + Codes promotionnels appliquΓ©s +

+ <% @order.promotion_codes.each do |promo_code| %> +
+
+ + + <%= promo_code.code %> + +
+ -<%= promo_code.discount_amount_euros %>€ +
+ <% end %> +
+ <% end %> + +
-
+

DΓ©tail du paiement

+
+ +
+ Sous-total + <%= @order.subtotal_amount_euros %>€ +
+ + + <% if @order.discount_amount_cents > 0 %> +
+ RΓ©duction + -<%= @order.discount_amount_euros %>€ +
+ <% end %> + + +
Total <%= @order.status == 'paid' || @order.status == 'completed' ? 'payΓ©' : 'Γ  payer' %> - <%= @order.total_amount_euros %>€ + <% if @order.total_amount_cents == 0 %> + GRATUIT + <% else %> + <%= @order.total_amount_euros %>€ + <% end %>
+
diff --git a/app/views/pages/dashboard.html.erb b/app/views/pages/dashboard.html.erb index 39e0612..b0942ed 100755 --- a/app/views/pages/dashboard.html.erb +++ b/app/views/pages/dashboard.html.erb @@ -7,11 +7,11 @@ { name: 'Tableau de bord', path: dashboard_path } ] %> - +
-

Mon tableau de bord

+

Mon tableau de bord promoteur

GΓ©rez vos commandes et accΓ©dez Γ  vos billets

@@ -76,7 +76,9 @@
+
+ <%= link_to promoter_events_path do %>

Brouillons

@@ -86,7 +88,9 @@
-
+ <% end %> +
+
@@ -273,6 +277,16 @@
<% end %> + +
+
+
+

Mon tableau de bord

+

AccΓ©dez Γ  vos billets et Γ©venements

+
+
+
+
diff --git a/app/views/promoter/events/show.html.erb b/app/views/promoter/events/show.html.erb index ce0b4a1..f1dc632 100644 --- a/app/views/promoter/events/show.html.erb +++ b/app/views/promoter/events/show.html.erb @@ -209,6 +209,42 @@
+ +
+

Actions rapides

+
+ <%= link_to promoter_event_ticket_types_path(@event), class: "w-full inline-flex items-center justify-center px-4 py-3 bg-purple-600 text-white font-medium text-sm rounded-lg hover:bg-purple-700 transition-colors duration-200" do %> + + GΓ©rer les types de billets + <% end %> + + <%= link_to promoter_event_promotion_codes_path(@event), class: "w-full inline-flex items-center justify-center px-4 py-3 bg-green-600 text-white font-medium text-sm rounded-lg hover:bg-green-700 transition-colors duration-200" do %> + + GΓ©rer les codes de rΓ©duction + <% end %> + + <% if @event.sold_out? %> + <%= button_to mark_available_promoter_event_path(@event), method: :patch, class: "w-full inline-flex items-center justify-center px-4 py-3 bg-blue-50 text-blue-700 font-medium text-sm rounded-lg hover:bg-blue-100 transition-colors duration-200" do %> + + Marquer comme disponible + <% end %> + <% elsif @event.published? %> + <%= button_to mark_sold_out_promoter_event_path(@event), method: :patch, class: "w-full inline-flex items-center justify-center px-4 py-3 bg-gray-50 text-gray-700 font-medium text-sm rounded-lg hover:bg-gray-100 transition-colors duration-200" do %> + + Marquer comme complet + <% end %> + <% end %> + +
+ <%= button_to promoter_event_path(@event), method: :delete, + data: { confirm: "Êtes-vous sûr de vouloir supprimer cet événement ? Cette action est irréversible." }, + class: "w-full inline-flex items-center justify-center px-4 py-3 text-red-600 font-medium text-sm rounded-lg hover:bg-red-50 transition-colors duration-200" do %> + + Supprimer l'événement + <% end %> +
+
+

Statistiques

@@ -269,36 +305,6 @@
- -
-

Actions rapides

-
- <%= link_to promoter_event_ticket_types_path(@event), class: "w-full inline-flex items-center justify-center px-4 py-3 bg-purple-600 text-white font-medium text-sm rounded-lg hover:bg-purple-700 transition-colors duration-200" do %> - - GΓ©rer les types de billets - <% end %> - - <% if @event.sold_out? %> - <%= button_to mark_available_promoter_event_path(@event), method: :patch, class: "w-full inline-flex items-center justify-center px-4 py-3 bg-blue-50 text-blue-700 font-medium text-sm rounded-lg hover:bg-blue-100 transition-colors duration-200" do %> - - Marquer comme disponible - <% end %> - <% elsif @event.published? %> - <%= button_to mark_sold_out_promoter_event_path(@event), method: :patch, class: "w-full inline-flex items-center justify-center px-4 py-3 bg-gray-50 text-gray-700 font-medium text-sm rounded-lg hover:bg-gray-100 transition-colors duration-200" do %> - - Marquer comme complet - <% end %> - <% end %> - -
- <%= button_to promoter_event_path(@event), method: :delete, - data: { confirm: "Êtes-vous sûr de vouloir supprimer cet événement ? Cette action est irréversible." }, - class: "w-full inline-flex items-center justify-center px-4 py-3 text-red-600 font-medium text-sm rounded-lg hover:bg-red-50 transition-colors duration-200" do %> - - Supprimer l'événement - <% end %> -
-
diff --git a/app/views/promoter/promotion_codes/edit.html.erb b/app/views/promoter/promotion_codes/edit.html.erb index cd90cdb..2a50d69 100644 --- a/app/views/promoter/promotion_codes/edit.html.erb +++ b/app/views/promoter/promotion_codes/edit.html.erb @@ -54,8 +54,8 @@
- <%= form.label :discount_amount_cents, "Montant de la rΓ©duction (en euros)", class: "block text-sm font-medium text-gray-700 mb-2" %> - <%= form.number_field :discount_amount_cents, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-colors", placeholder: "10", min: 0, step: "0.01", value: number_with_precision(@promotion_code.discount_amount_cents.to_f / 100, precision: 2) %> + <%= form.label :discount_amount_euros, "Montant de la rΓ©duction (en euros)", class: "block text-sm font-medium text-gray-700 mb-2" %> + <%= form.number_field :discount_amount_euros, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-colors", placeholder: "10", min: 0, step: "0.01", value: number_with_precision(@promotion_code.discount_amount_cents.to_f / 100, precision: 2) %>

Entrez le montant en euros (ex: 10, 5.50, 25)

diff --git a/app/views/promoter/promotion_codes/index.html.erb b/app/views/promoter/promotion_codes/index.html.erb index ff57f9f..36dae47 100644 --- a/app/views/promoter/promotion_codes/index.html.erb +++ b/app/views/promoter/promotion_codes/index.html.erb @@ -51,7 +51,7 @@

- <%= link_to promotion_code.code, promoter_event_promotion_code_path(@event, promotion_code), class: "hover:text-purple-600 transition-colors" %> + <%= promotion_code.code %>

RΓ©duction de <%= number_to_currency(promotion_code.discount_amount_cents / 100.0, unit: "€") %>

diff --git a/app/views/promoter/promotion_codes/new.html.erb b/app/views/promoter/promotion_codes/new.html.erb index 039086b..2b9453a 100644 --- a/app/views/promoter/promotion_codes/new.html.erb +++ b/app/views/promoter/promotion_codes/new.html.erb @@ -49,14 +49,14 @@
<%= form.label :code, "Code de rΓ©duction", class: "block text-sm font-medium text-gray-700 mb-2" %> - <%= form.text_field :code, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-colors", placeholder: "Ex: SUMMER2024, BIENVENUE10, etc." %> -

Ce code sera visible par les clients lors du paiement

+ <%= form.text_field :code, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-colors", placeholder: "Ex: BIENVENUE10, VIP20" %> +

Ce code sera Γ  appliquer par le client lors du paiement.

- <%= form.label :discount_amount_cents, "Montant de la rΓ©duction (en euros)", class: "block text-sm font-medium text-gray-700 mb-2" %> - <%= form.number_field :discount_amount_cents, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-colors", placeholder: "10", min: 0, step: "0.01", value: number_with_precision(@promotion_code.discount_amount_cents.to_f / 100, precision: 2) %> -

Entrez le montant en euros (ex: 10, 5.50, 25)

+ <%= form.label :discount_amount_euros, "Montant de la rΓ©duction (en euros)", class: "block text-sm font-medium text-gray-700 mb-2" %> + <%= form.number_field :discount_amount_euros, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-colors", placeholder: "10", min: 0, step: "0.01", value: number_with_precision(@promotion_code.discount_amount_cents.to_f / 100, precision: 2) %> +

Entrez le montant en euros

@@ -93,4 +93,4 @@
<% end %>
-
\ No newline at end of file + diff --git a/app/views/promoter/promotion_codes/show.html.erb b/app/views/promoter/promotion_codes/show.html.erb deleted file mode 100644 index 69f3789..0000000 --- a/app/views/promoter/promotion_codes/show.html.erb +++ /dev/null @@ -1,212 +0,0 @@ -<% content_for(:title, "Code de rΓ©duction #{@promotion_code.code} - #{@event.name}") %> - -
- - - <%= render 'components/breadcrumb', crumbs: [ - { name: 'Accueil', path: root_path }, - { name: 'Tableau de bord', path: dashboard_path }, - { name: 'Mes Γ©vΓ©nements', path: promoter_events_path }, - { name: @event.name, path: promoter_event_path(@event) }, - { name: 'Codes de rΓ©duction', path: promoter_event_promotion_codes_path(@event) }, - { name: @promotion_code.code } - ] %> - -
-
-
- <%= link_to promoter_event_promotion_codes_path(@event), class: "text-gray-400 hover:text-gray-600 transition-colors" do %> - - <% end %> -
-

DΓ©tails du code de rΓ©duction

-

- <%= @promotion_code.code %> pour <%= link_to @event.name, promoter_event_path(@event), class: "text-purple-600 hover:text-purple-800" %> -

-
-
-
- -
- -
- -
-

Informations du code

- -
-
-
Code
-
- <%= @promotion_code.code %> -
-
- -
-
Statut
-
- <% if @promotion_code.active? && (promotion_code.expires_at.nil? || promotion_code.expires_at > Time.current) %> - - - Actif - - <% elsif @promotion_code.expires_at && @promotion_code.expires_at <= Time.current %> - - - ExpirΓ© - - <% else %> - - - Inactif - - <% end %> -
-
- -
-
Montant de la rΓ©duction
-
- <%= number_to_currency(@promotion_code.discount_amount_cents / 100.0, unit: "€") %> -
-
- -
-
Γ‰vΓ©nement
-
- <%= link_to @event.name, promoter_event_path(@event), class: "text-purple-600 hover:text-purple-800" %> -
-
-
-
- - -
-

Statistiques d'utilisation

- -
-
-
- <%= @promotion_code.uses_count %> -
-
Utilisations
-
- -
-
- <% if @promotion_code.usage_limit %> - <%= @promotion_code.usage_limit - @promotion_code.uses_count %> - <% else %> - ∞ - <% end %> -
-
Restants
-
- -
-
- <%= @promotion_code.orders.count %> -
-
Commandes
-
- -
-
- <%= number_to_currency(@promotion_code.orders.sum(:total_amount_cents) / 100.0, unit: "€") %> -
-
Montant total
-
-
-
- - - <% if @promotion_code.orders.any? %> -
-

Commandes utilisant ce code

-
- <% @promotion_code.orders.includes(:user).order(created_at: :desc).limit(5).each do |order| %> -
-
-
Commande #<%= order.id %>
-
- <%= order.user.email %> β€’ <%= l(order.created_at, format: :short) %> -
-
-
-
<%= number_to_currency(order.total_amount_cents / 100.0, unit: "€") %>
-
<%= order.status %>
-
-
- <% end %> - <% if @promotion_code.orders.count > 5 %> -
- - Et <%= @promotion_code.orders.count - 5 %> autres commandes... - -
- <% end %> -
-
- <% end %> -
- - -
- -
-

Informations supplΓ©mentaires

-
-
- Créé par -

<%= @promotion_code.user.email %>

-
-
- Créé le -

<%= l(@promotion_code.created_at, format: :long) %>

-
-
- ModifiΓ© le -

<%= l(@promotion_code.updated_at, format: :long) %>

-
- <% if @promotion_code.expires_at %> -
- Date d'expiration -

<%= l(@promotion_code.expires_at, format: :long) %>

-
- <% else %> -
- Date d'expiration -

Jamais

-
- <% end %> -
-
- - -
-

Actions

-
- <%= link_to edit_promoter_event_promotion_code_path(@event, @promotion_code), class: "w-full inline-flex items-center justify-center px-4 py-3 bg-gray-900 text-white font-medium rounded-lg hover:bg-gray-800 transition-colors duration-200" do %> - - Modifier - <% end %> - - <% if @promotion_code.orders.empty? %> - <%= button_to promoter_event_promotion_code_path(@event, @promotion_code), method: :delete, - data: { confirm: "Êtes-vous sûr de vouloir supprimer ce code de réduction ?" }, - class: "w-full inline-flex items-center justify-center px-4 py-3 text-red-600 font-medium rounded-lg hover:bg-red-50 transition-colors duration-200" do %> - - Supprimer - <% end %> - <% end %> - - <%= link_to promoter_event_promotion_codes_path(@event), class: "w-full inline-flex items-center justify-center px-4 py-3 border border-gray-300 text-gray-700 font-medium rounded-lg hover:bg-gray-50 transition-colors duration-200" do %> - - Retour à la liste - <% end %> -
-
-
-
-
-
\ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index ce1b6b6..06f050d 100755 --- a/config/routes.rb +++ b/config/routes.rb @@ -91,6 +91,16 @@ Rails.application.routes.draw do post :duplicate end end + + # Nested promotion codes routes + resources :promotion_codes, except: [ :show ] + end + end + + # === Promotion Codes Routes === + resources :promotion_codes, only: [ :index ] do + member do + post :apply end end diff --git a/db/migrate/20250823170409_create_orders.rb b/db/migrate/20250823170409_create_orders.rb index 950ab23..45992fb 100644 --- a/db/migrate/20250823170409_create_orders.rb +++ b/db/migrate/20250823170409_create_orders.rb @@ -1,14 +1,15 @@ class CreateOrders < ActiveRecord::Migration[8.0] def change create_table :orders do |t| - t.references :user, null: false, foreign_key: false - t.references :event, null: false, foreign_key: false t.string :status, null: false, default: "draft" t.integer :total_amount_cents, null: false, default: 0 t.integer :payment_attempts, null: false, default: 0 t.timestamp :expires_at t.timestamp :last_payment_attempt_at + t.references :user, null: false, foreign_key: false + t.references :event, null: false, foreign_key: false + t.timestamps end diff --git a/db/migrate/20250928180837_create_promotion_codes.rb b/db/migrate/20250928180837_create_promotion_codes.rb index 95e25ec..41ecafd 100644 --- a/db/migrate/20250928180837_create_promotion_codes.rb +++ b/db/migrate/20250928180837_create_promotion_codes.rb @@ -7,8 +7,12 @@ class CreatePromotionCodes < ActiveRecord::Migration[8.0] 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 + + # Reference user(promoter) who has created the promotion code + t.references :user, null: false, foreign_key: true + t.references :event, null: false, foreign_key: true + + t.timestamps end # Unique index for code diff --git a/db/schema.rb b/db/schema.rb index c47b075..ed5c678 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -44,13 +44,13 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_28_181311) do end create_table "orders", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t| - t.bigint "user_id", null: false - t.bigint "event_id", null: false t.string "status", default: "draft", null: false t.integer "total_amount_cents", default: 0, null: false t.integer "payment_attempts", default: 0, null: false t.timestamp "expires_at" t.timestamp "last_payment_attempt_at" + t.bigint "user_id", null: false + t.bigint "event_id", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["event_id", "status"], name: "idx_orders_event_status" @@ -67,9 +67,13 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_28_181311) do t.boolean "active", default: true, null: false t.integer "usage_limit" t.integer "uses_count", default: 0, null: false + t.bigint "user_id", null: false + t.bigint "event_id", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["code"], name: "index_promotion_codes_on_code", unique: true + t.index ["event_id"], name: "index_promotion_codes_on_event_id" + t.index ["user_id"], name: "index_promotion_codes_on_user_id" end create_table "ticket_types", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t| @@ -128,4 +132,6 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_28_181311) do add_foreign_key "order_promotion_codes", "orders" add_foreign_key "order_promotion_codes", "promotion_codes" + add_foreign_key "promotion_codes", "events" + add_foreign_key "promotion_codes", "users" end diff --git a/db/seeds.rb b/db/seeds.rb index 65565e1..09af41d 100755 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -280,3 +280,26 @@ TicketType.find_or_create_by!(event: belle_epoque_october_event, name: "Entry 10 end puts "Created 3 additional events from Bizouk with ticket types" + +# Create promotion codes for events +# Promotion code for belle_epoque_event +PromotionCode.find_or_create_by!(code: "BELLE10") do |pc| + pc.discount_amount_cents = 1000 # 10€ discount + pc.expires_at = belle_epoque_event.start_time + 1.day + pc.active = true + pc.usage_limit = 20 + pc.user = promoter + pc.event = belle_epoque_october_event +end + +# Promotion code for belle_epoque_october_event +PromotionCode.find_or_create_by!(code: "OCTOBRE5") do |pc| + pc.discount_amount_cents = 500 # 5€ discount + pc.expires_at = belle_epoque_october_event.start_time + 1.day + pc.active = true + pc.usage_limit = 30 + pc.user = promoter + pc.event = belle_epoque_october_event +end + +puts "Created promotion codes for events" -- 2.49.1 From 635644b55ad82148660c15edf403b0cbb80234f6 Mon Sep 17 00:00:00 2001 From: kbe Date: Mon, 29 Sep 2025 20:33:54 +0200 Subject: [PATCH 8/8] feat(promotion-code): Complete promotion code integration and testing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add comprehensive promotion code methods to Order model - Implement Stripe invoice integration for promotion code discounts - Display promotion codes on invoice with proper discount breakdown - Fix and enhance all unit tests for promotion code functionality - Add discount calculation with capping to prevent negative totals - Ensure promotion codes work across entire order lifecycle πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../promoter/promotion_codes_controller.rb | 2 +- app/models/order.rb | 6 +- app/services/stripe_invoice_service.rb | 17 ++ app/views/orders/invoice.html.erb | 47 +++- .../orders_controller_promotion_test.rb | 57 +++-- test/models/order_test.rb | 237 ++++++++++++++++++ test/models/promotion_code_test.rb | 222 +++++++++++++++- 7 files changed, 558 insertions(+), 30 deletions(-) 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 -- 2.49.1