+
-
-
- <%= link_to root_path, class: "text-gray-700 hover:text-purple-600 transition-colors" do %>
-
- Accueil
- <% end %>
-
-
-
- <%= link_to events_path, class: "text-gray-700 hover:text-purple-600 transition-colors" do %>
- Événements
- <% end %>
-
-
-
- <%= link_to event_path(@order.event.slug, @order.event), class: "text-gray-700 hover:text-purple-600 transition-colors" do %>
- <%= @order.event.name %>
- <% end %>
-
-
-
- Commande #<%= @order.id %>
-
-
+ <%= render 'components/breadcrumb', crumbs: [
+ { name: 'Accueil', path: root_path },
+ { name: 'Événements', path: events_path },
+ { name: @order.event.name, path: event_path(@order.event.slug, @order.event) },
+ { name: "Commande ##{@order.id}", path: nil }
+ ] %>
@@ -77,8 +59,8 @@
-
-
Récapitulatif de votre commande
+
+
Récapitulatif de votre commande
<% @tickets.each do |ticket| %>
@@ -99,12 +81,46 @@
<% end %>
+
+ <% if @order.promotion_codes.any? %>
+
+ <% @order.promotion_codes.each do |promo_code| %>
+
+
+
+ Code: <%= promo_code.code %>
+
+ -<%= promo_code.discount_amount_euros %>€
+
+ <% end %>
+
+ <% end %>
+
-
+
+
+ Sous-total
+ <%= @order.subtotal_amount_euros %>€
+
+
+
+ <% if @order.discount_amount_cents > 0 %>
+
+ Réduction
+ -<%= @order.discount_amount_euros %>€
+
+ <% end %>
+
+
+
Total
- <%= @order.total_amount_euros %>€
+ <% if @order.total_amount_cents == 0 %>
+ GRATUIT
+ <% else %>
+ <%= @order.total_amount_euros %>€
+ <% end %>
TVA incluse
@@ -118,6 +134,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,16 +157,20 @@
-
+
- Payer <%= @order.total_amount_euros %>€
+ <% if @order.total_amount_cents == 0 %>
+ Confirmer la commande
+ <% else %>
+ Payer <%= @order.total_amount_euros %>€
+ <% end %>
@@ -194,16 +224,16 @@
try {
// Increment payment attempt counter
- const orderId = checkoutButton.dataset.orderId;
- const incrementUrl = checkoutButton.dataset.incrementUrl;
- console.log('Incrementing payment attempt for order:', orderId);
- const response = await fetch(incrementUrl, {
- method: 'PATCH',
- headers: {
- 'Content-Type': 'application/json',
- 'X-CSRF-Token': document.querySelector('[name=csrf-token]').content
- }
- });
+const orderId = checkoutButton.dataset.orderId;
+const incrementUrl = checkoutButton.dataset.incrementUrl;
+console.log('Incrementing payment attempt for order:', orderId);
+const response = await fetch(incrementUrl, {
+ method: 'PATCH',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-CSRF-Token': document.querySelector('[name=csrf-token]').content
+ }
+});
if (!response.ok) {
console.error('Payment attempt increment failed:', response.status, response.statusText);
@@ -224,11 +254,11 @@
`;
// Redirect to Stripe
- const sessionId = checkoutButton.dataset.sessionId;
- console.log('Redirecting to Stripe with session ID:', sessionId);
- const stripeResult = await stripe.redirectToCheckout({
- sessionId: sessionId
- });
+const sessionId = checkoutButton.dataset.sessionId;
+console.log('Redirecting to Stripe with session ID:', sessionId);
+const stripeResult = await stripe.redirectToCheckout({
+ sessionId: sessionId
+});
if (stripeResult.error) {
throw new Error(stripeResult.error.message);
@@ -241,7 +271,11 @@
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/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/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
new file mode 100644
index 0000000..2a50d69
--- /dev/null
+++ b/app/views/promoter/promotion_codes/edit.html.erb
@@ -0,0 +1,109 @@
+<% content_for(:title, "Modifier le code de réduction - #{@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: "Modifier #{@promotion_code.code}" }
+ ] %>
+
+
+
+
+ <%= link_to promoter_event_promotion_codes_path(@event), class: "text-gray-400 hover:text-gray-600 transition-colors" do %>
+
+ <% end %>
+
+
Modifier le code de réduction
+
+ <%= @promotion_code.code %> pour <%= link_to @event.name, promoter_event_path(@event), class: "text-purple-600 hover:text-purple-800" %>
+
+
+
+
+
+ <%= form_with(model: [@event, @promotion_code], url: promoter_event_promotion_code_path(@event, @promotion_code), method: :patch, local: true, class: "bg-white rounded-2xl border border-gray-200 p-6 sm:p-8") do |form| %>
+ <% if @promotion_code.errors.any? %>
+
+
+
+
+
+ <%= pluralize(@promotion_code.errors.count, "erreur") %> ont empêché ce code de réduction d'être sauvegardé :
+
+
+ <% @promotion_code.errors.full_messages.each do |message| %>
+ <%= message %>
+ <% end %>
+
+
+
+
+ <% end %>
+
+
+
+ <%= 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.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)
+
+
+
+
+ <%= form.label :expires_at, "Date d'expiration", class: "block text-sm font-medium text-gray-700 mb-2" %>
+ <%= form.datetime_local_field :expires_at, 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" %>
+
Laissez vide pour une durée illimitée
+
+
+
+ <%= form.label :usage_limit, "Limite d'utilisation", class: "block text-sm font-medium text-gray-700 mb-2" %>
+ <%= form.number_field :usage_limit, 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: 50", min: 1 %>
+
Laissez vide pour une utilisation illimitée
+
+
+
+
+
+ <%= form.check_box :active, class: "h-4 w-4 text-purple-600 focus:ring-purple-500 border-gray-300 rounded" %>
+ <%= form.label :active, "Code actif", class: "ml-3 block text-sm font-medium text-gray-900" %>
+
+
+ Les clients peuvent utiliser ce code de réduction
+
+
+
+
+
+
+
+
Statut actuel
+
+
Utilisations: <%= @promotion_code.uses_count %><%= " / #{@promotion_code.usage_limit}" if @promotion_code.usage_limit %>
+
Commandes associées: <%= @promotion_code.orders.count %>
+
+
+
+
+
+
+
+ <%= link_to promoter_event_promotion_codes_path(@event), class: "inline-flex items-center px-6 py-3 border border-gray-300 text-gray-700 font-medium rounded-lg hover:bg-gray-50 transition-colors duration-200" do %>
+
+ Annuler
+ <% end %>
+ <%= form.submit "Mettre à jour le code de réduction", class: "inline-flex items-center px-6 py-3 bg-gray-900 text-white font-medium rounded-lg hover:bg-gray-800 transition-colors duration-200" %>
+
+ <% end %>
+
+
\ No newline at end of file
diff --git a/app/views/promoter/promotion_codes/index.html.erb b/app/views/promoter/promotion_codes/index.html.erb
new file mode 100644
index 0000000..36dae47
--- /dev/null
+++ b/app/views/promoter/promotion_codes/index.html.erb
@@ -0,0 +1,175 @@
+<% content_for(:title, "Codes de réduction - #{@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' }
+ ] %>
+
+
+
+ <%= link_to promoter_event_path(@event), class: "text-gray-400 hover:text-gray-600 transition-colors" do %>
+
+ <% end %>
+
+
Codes de réduction
+
+ <%= link_to @event.name, promoter_event_path(@event), class: "text-purple-600 hover:text-purple-800" %>
+
+
+ <%= link_to new_promoter_event_promotion_code_path(@event), class: "inline-flex items-center px-6 py-3 bg-gray-900 text-white font-medium rounded-lg hover:bg-gray-800 transition-colors duration-200" do %>
+
+ Nouveau code
+ <% end %>
+
+
+
+ <% if @event.draft? %>
+
+
+
+
+ Cet événement est en brouillon. Les codes de réduction ne seront actifs qu'une fois l'événement publié.
+
+
+
+ <% end %>
+
+
+ <% if @promotion_codes.any? %>
+
+ <% @promotion_codes.each do |promotion_code| %>
+
+
+
+
+
+
+
+ <%= promotion_code.code %>
+
+
Réduction de <%= number_to_currency(promotion_code.discount_amount_cents / 100.0, unit: "€") %>
+
+
+
+
+ <% 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 %>
+
+
+
+
+
+
+
+ <%= number_to_currency(promotion_code.discount_amount_cents / 100.0, unit: "€") %>
+
+
Réduction
+
+
+
+
+ <% if promotion_code.usage_limit %>
+ <%= promotion_code.usage_limit - promotion_code.uses_count %>
+ <% else %>
+ ∞
+ <% end %>
+
+
Restants
+
+
+
+
+ <%= promotion_code.uses_count %>
+
+
Utilisés
+
+
+
+
+ <%= promotion_code.orders.count %>
+
+
Commandes
+
+
+
+
+
+ <% if promotion_code.expires_at %>
+
+
+ Expire le : <%= l(promotion_code.expires_at, format: :short) %>
+
+ <% else %>
+
+
+ Pas d'expiration
+
+ <% end %>
+
+
+ <% if promotion_code.user.first_name && promotion_code.user.last_name %>
+ Créé par : <%= promotion_code.user.first_name %> <%= promotion_code.user.last_name %>
+ <% else %>
+ Créé par : <%= promotion_code.user.email %>
+ <% end %>
+
+
+
+
+
+
+
+
+ <%= link_to edit_promoter_event_promotion_code_path(@event, promotion_code), class: "text-gray-400 hover:text-blue-600 transition-colors", title: "Modifier" do %>
+
+ <% 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: "text-gray-400 hover:text-red-600 transition-colors", title: "Supprimer" do %>
+
+ <% end %>
+ <% end %>
+
+
+
+ Créé il y a <%= time_ago_in_words(promotion_code.created_at) %>
+
+
+
+ <% end %>
+
+ <% else %>
+
+
+
+
+
Aucun code de réduction
+
Créez des codes de réduction pour offrir des remises spéciales à vos clients.
+ <%= link_to new_promoter_event_promotion_code_path(@event), class: "inline-flex items-center px-6 py-3 bg-gray-900 text-white font-medium rounded-lg hover:bg-gray-800 transition-colors duration-200" do %>
+
+ Créer mon premier code de réduction
+ <% end %>
+
+ <% end %>
+
diff --git a/app/views/promoter/promotion_codes/new.html.erb b/app/views/promoter/promotion_codes/new.html.erb
new file mode 100644
index 0000000..2b9453a
--- /dev/null
+++ b/app/views/promoter/promotion_codes/new.html.erb
@@ -0,0 +1,96 @@
+<% content_for(:title, "Nouveau code de réduction - #{@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: 'Nouveau code' }
+ ] %>
+
+
+
+
+ <%= link_to promoter_event_promotion_codes_path(@event), class: "text-gray-400 hover:text-gray-600 transition-colors" do %>
+
+ <% end %>
+
+
Nouveau code de réduction
+
+ Pour <%= link_to @event.name, promoter_event_path(@event), class: "text-purple-600 hover:text-purple-800" %>
+
+
+
+
+
+ <%= form_with(model: [@event, @promotion_code], url: promoter_event_promotion_codes_path(@event), local: true, class: "bg-white rounded-2xl border border-gray-200 p-6 sm:p-8") do |form| %>
+ <% if @promotion_code.errors.any? %>
+
+
+
+
+
+ <%= pluralize(@promotion_code.errors.count, "erreur") %> ont empêché ce code de réduction d'être sauvegardé :
+
+
+ <% @promotion_code.errors.full_messages.each do |message| %>
+ <%= message %>
+ <% end %>
+
+
+
+
+ <% end %>
+
+
+
+ <%= 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: BIENVENUE10, VIP20" %>
+
Ce code sera à appliquer par le client lors du paiement.
+
+
+
+ <%= 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
+
+
+
+
+ <%= form.label :expires_at, "Date d'expiration", class: "block text-sm font-medium text-gray-700 mb-2" %>
+ <%= form.datetime_local_field :expires_at, 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" %>
+
Laissez vide pour une durée illimitée
+
+
+
+ <%= form.label :usage_limit, "Limite d'utilisation", class: "block text-sm font-medium text-gray-700 mb-2" %>
+ <%= form.number_field :usage_limit, 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: 50", min: 1 %>
+
Laissez vide pour une utilisation illimitée
+
+
+
+
+
+ <%= form.check_box :active, class: "h-4 w-4 text-purple-600 focus:ring-purple-500 border-gray-300 rounded" %>
+ <%= form.label :active, "Code actif", class: "ml-3 block text-sm font-medium text-gray-900" %>
+
+
+ Les clients peuvent utiliser ce code de réduction
+
+
+
+
+
+ <%= link_to promoter_event_promotion_codes_path(@event), class: "inline-flex items-center px-6 py-3 border border-gray-300 text-gray-700 font-medium rounded-lg hover:bg-gray-50 transition-colors duration-200" do %>
+
+ Annuler
+ <% end %>
+ <%= form.submit "Créer le code de réduction", class: "inline-flex items-center px-6 py-3 bg-gray-900 text-white font-medium rounded-lg hover:bg-gray-800 transition-colors duration-200" %>
+
+ <% end %>
+
+
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
new file mode 100644
index 0000000..41ecafd
--- /dev/null
+++ b/db/migrate/20250928180837_create_promotion_codes.rb
@@ -0,0 +1,21 @@
+class CreatePromotionCodes < ActiveRecord::Migration[8.0]
+ def change
+ create_table :promotion_codes do |t|
+ t.string :code, null: false
+ 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
+
+ # 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
+ add_index :promotion_codes, :code, unique: true
+ end
+end
diff --git a/db/migrate/20250928181311_create_order_promotion_codes.rb b/db/migrate/20250928181311_create_order_promotion_codes.rb
new file mode 100644
index 0000000..e7d3d23
--- /dev/null
+++ b/db/migrate/20250928181311_create_order_promotion_codes.rb
@@ -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
diff --git a/db/schema.rb b/db/schema.rb
index 08ebc4a..ed5c678 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[8.0].define(version: 2025_09_11_063815) do
+ActiveRecord::Schema[8.0].define(version: 2025_09_28_181311) do
create_table "events", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t|
t.string "name", null: false
t.string "slug", null: false
@@ -25,23 +25,32 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_11_063815) do
t.decimal "longitude", precision: 10, scale: 6, null: false
t.boolean "featured", default: false, null: false
t.bigint "user_id", null: false
+ t.boolean "allow_booking_during_event", default: false, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
- t.boolean "allow_booking_during_event", default: false, null: false
t.index ["featured"], name: "index_events_on_featured"
t.index ["latitude", "longitude"], name: "index_events_on_latitude_and_longitude"
t.index ["state"], name: "index_events_on_state"
t.index ["user_id"], name: "index_events_on_user_id"
end
+ create_table "order_promotion_codes", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t|
+ t.bigint "order_id", null: false
+ t.bigint "promotion_code_id", null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["order_id"], name: "index_order_promotion_codes_on_order_id"
+ t.index ["promotion_code_id"], name: "index_order_promotion_codes_on_promotion_code_id"
+ 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"
@@ -51,6 +60,22 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_11_063815) do
t.index ["user_id"], name: "index_orders_on_user_id"
end
+ create_table "promotion_codes", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t|
+ t.string "code", null: false
+ t.integer "discount_amount_cents", default: 0, null: false
+ t.datetime "expires_at"
+ 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|
t.string "name"
t.text "description"
@@ -104,4 +129,9 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_11_063815) do
t.index ["email"], name: "index_users_on_email", unique: true
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
end
+
+ 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 c1d254f..09af41d 100755
--- a/db/seeds.rb
+++ b/db/seeds.rb
@@ -189,3 +189,117 @@ TicketType.find_or_create_by!(event: belle_epoque_event, name: "Paid Entry 10€
end
puts "Created 1 promoter, 1 draft event with ticket types"
+
+# Create additional events fetched from Bizouk
+konpa_event = Event.find_or_create_by!(name: "Konpa With Bev - Cours De Konpa Gouyad") do |e|
+ e.slug = "konpa-with-bev-cours-de-konpa-gouyad"
+ e.description = "Séance ouverte à tous, débutant ou initié, venez perfectionner votre Konpa avec la talentueuse Beverly."
+ e.venue_name = "Guest Live"
+ e.venue_address = "36 Rue Marcel Dassault, 93140 Bondy"
+ e.latitude = 48.9096
+ e.longitude = 2.4836
+ e.start_time = Time.parse("2025-10-03 19:00:00")
+ e.end_time = Time.parse("2025-10-03 23:00:00")
+ e.featured = false
+ e.image = "https://data.bizouk.com/cache1/events/images/10/79/61/081f38b583ac651f3a0930c5d8f13458_800_600_auto_97.png"
+ e.user = promoter
+ e.state = :published
+end
+
+caribbean_groove_event = Event.find_or_create_by!(name: "La Plus Grosse Soirée Caribbean Groove") do |e|
+ e.slug = "la-plus-grosse-soiree-caribbean-groove"
+ e.description = "La CARIBBEAN GROOVE de 23H00 à 5h00... DJ DON BREEZY Aux Platines HIPHOP , RnB, zouk ,kompa , Dancehall, Afro.beat"
+ e.venue_name = "LE TOUT LE MONDE EN PARLE"
+ e.venue_address = "4 RUE DU DEPART 75015 PARIS"
+ e.latitude = 48.8406
+ e.longitude = 2.2935
+ e.start_time = Time.parse("2025-10-03 23:00:00")
+ e.end_time = Time.parse("2025-10-04 05:00:00")
+ e.featured = false
+ e.image = "https://data.bizouk.com/cache1/events/images/10/83/15/fa5d43f0b1998f691181cfda8fe35213_800_600_auto_97.png"
+ e.user = promoter
+ e.state = :published
+end
+
+belle_epoque_october_event = Event.find_or_create_by!(name: "LA BELLE ÉPOQUE PAR SISLEY ÉVENTS - OCTOBRE") do |e|
+ e.slug = "la-belle-epoque-par-sisley-events-octobre"
+ e.description = "SAM 4 OCTOBRE LA BELLE ÉPOQUE de 18H à 2H sur le Rooftop LE PATIO LA Dernière de la Saison ÉVÈNEMENT EN PLEIN AIR Ambiance Rétro / old school : zouk , Ragga , kompa , Dancehall , hip hop , Groove , Rnb … Restauration disponible sur place : Accras ,Allocos , specialités asiatique , japonaise et une large carte de choix de Pizzas pour vous régaler ! ENTRÉE LIBRE POUR TOUS AV 21H TARIF D'ENTRÉE : 10€ SUR PLACE UNIQUEMENT Réservée aux + de 30 ans Suivez nous sur Instagram : Sisley Évents"
+ e.venue_name = "Le Patio"
+ e.venue_address = "38 Avenue Leon Gaumont, 93100 Montreuil"
+ e.latitude = 48.862336
+ e.longitude = 2.441218
+ e.start_time = Time.parse("2025-10-04 18:00:00")
+ e.end_time = Time.parse("2025-10-05 02:00:00")
+ e.featured = false
+ e.image = "https://data.bizouk.com/cache1/events/images/10/92/72/351e61b55603a4d142b43486216457c1_800_600_auto_97.jpg"
+ e.user = promoter
+ e.state = :published
+ e.allow_booking_during_event = true
+end
+
+# Create ticket types for the new events
+# Konpa event ticket types
+TicketType.find_or_create_by!(event: konpa_event, name: "General Admission") do |tt|
+ tt.description = "General admission ticket for Konpa With Bev"
+ tt.price_cents = 1500 # $15.00
+ tt.quantity = 50
+ tt.sale_start_at = Time.current
+ tt.sale_end_at = konpa_event.start_time - 1.hour
+ tt.minimum_age = 18
+end
+
+# Caribbean Groove event ticket types
+TicketType.find_or_create_by!(event: caribbean_groove_event, name: "General Admission") do |tt|
+ tt.description = "General admission ticket for Caribbean Groove"
+ tt.price_cents = 2000 # $20.00
+ tt.quantity = 100
+ tt.sale_start_at = Time.current
+ tt.sale_end_at = caribbean_groove_event.start_time - 1.hour
+ tt.minimum_age = 18
+end
+
+# Belle Époque October event ticket types
+TicketType.find_or_create_by!(event: belle_epoque_october_event, name: "Free Entry Before 9 PM") do |tt|
+ tt.description = "Free entry before 9 PM for La Belle Époque October"
+ tt.price_cents = 0
+ tt.quantity = 50
+ tt.sale_start_at = Time.current
+ tt.sale_end_at = belle_epoque_october_event.start_time + 3.hours
+ tt.minimum_age = 30
+ tt.requires_id = true
+end
+
+TicketType.find_or_create_by!(event: belle_epoque_october_event, name: "Entry 10€ After 9 PM") do |tt|
+ tt.description = "Entry ticket 10€ after 9 PM for La Belle Époque October"
+ tt.price_cents = 1000 # 10€
+ tt.quantity = 150
+ tt.sale_start_at = Time.current
+ tt.sale_end_at = belle_epoque_october_event.start_time + 8.hours
+ tt.minimum_age = 30
+ tt.requires_id = true
+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"
diff --git a/docker-compose.production.yml b/docker-compose.production.yml
new file mode 100644
index 0000000..a2bc1a1
--- /dev/null
+++ b/docker-compose.production.yml
@@ -0,0 +1,65 @@
+volumes:
+ mariadb_data:
+ gem_home:
+ node_modules_home:
+
+networks:
+ default:
+
+services:
+ mariadb:
+ image: mariadb:11.7.2-noble
+ env_file: .env
+ restart: unless-stopped
+ volumes:
+ - mariadb_data:/var/lib/mysql
+ #- ./mysql:/var/lib/mysql"
+ ports:
+ - "${FORWARD_DB_PORT:-3306}:3306"
+ environment:
+ MYSQL_ROOT_PASSWORD: "${DB_ROOT_PASSWORD:-root}"
+ # MYSQL_ROOT_HOST: "%"
+ MYSQL_PORT: "${DB_PORT:-3306}"
+ MYSQL_DATABASE: "${DB_DATABASE:-aperonight}"
+ MYSQL_USER: "${DB_USERNAME:-aperonight}"
+ MYSQL_PASSWORD: "${DB_PASSWORD:-aperonight}"
+ #MYSQL_ALLOW_EMPTY_PASSWORD: 1
+ networks:
+ - default
+ #command: mysqld --character-set-server=utf8mb4 --collation-server=utf8mb4_general_ci
+
+ rails:
+ build:
+ context: .
+ dockerfile: Dockerfile.production
+ env_file: .env
+ restart: unless-stopped
+ volumes:
+ - ./:/rails
+ - gem_home:/usr/local/bundle
+ - node_modules_home:/rails/node_modules
+ #- ./log:/var/log
+ #- ./log/supervisor:/var/log/supervisor
+ ports:
+ #- 80:80
+ - 3000:3000
+ #- 5000:5000
+ depends_on:
+ - mariadb
+ networks:
+ - default
+ #tty: true
+ #command: /opt/src/bin/dev
+
+ mailhog:
+ image: corpusops/mailhog:v1.0.1
+ restart: unless-stopped
+ # environment:
+ # - "mh_auth_file=/opt/mailhog/passwd.conf"
+ volumes:
+ - ./data/mailhog:/opt/mailhog
+ ports:
+ - 1025:1025 # smtp port 25
+ - 8025:8025 # web mail access
+ networks:
+ - default
diff --git a/test/controllers/orders_controller_promotion_test.rb b/test/controllers/orders_controller_promotion_test.rb
new file mode 100644
index 0000000..fe3c958
--- /dev/null
+++ b/test/controllers/orders_controller_promotion_test.rb
@@ -0,0 +1,95 @@
+require "test_helper"
+
+class OrdersControllerPromotionTest < ActionDispatch::IntegrationTest
+ include Devise::Test::IntegrationHelpers
+
+ # Setup test data
+ def setup
+ @user = users(: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: 500, # €5.00
+ expires_at: 1.month.from_now,
+ active: true,
+ user: @user,
+ event: @event
+ )
+
+ get checkout_order_path(@order), params: { promotion_code: "TESTDISCOUNT" }
+ assert_response :success
+ 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.now[:alert]
+ assert_equal "Code promotionnel invalide", flash.now[: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,
+ user: @user,
+ event: @event
+ )
+
+ get checkout_order_path(@order), params: { promotion_code: "EXPIREDCODE" }
+ assert_response :success
+ assert_not_nil flash.now[:alert]
+ assert_equal "Code promotionnel invalide", flash.now[: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,
+ user: @user,
+ event: @event
+ )
+
+ get checkout_order_path(@order), params: { promotion_code: "INACTIVECODE" }
+ assert_response :success
+ 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
new file mode 100644
index 0000000..416b6fe
--- /dev/null
+++ b/test/models/promotion_code_test.rb
@@ -0,0 +1,269 @@
+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,
+ user: @user,
+ event: @event
+ )
+
+ 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, 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, 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, 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
+ end
+
+ # Test expired scope
+ def test_expired_scope
+ 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
+ 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, 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