diff --git a/app/controllers/orders_controller.rb b/app/controllers/orders_controller.rb
index 7ce04a8..9456dd9 100644
--- a/app/controllers/orders_controller.rb
+++ b/app/controllers/orders_controller.rb
@@ -126,6 +126,15 @@ class OrdersController < ApplicationController
@total_amount = @order.total_amount_cents
@expiring_soon = @order.expiring_soon?
+ # For free orders, automatically mark as paid and redirect to success
+ if @order.free?
+ @order.mark_as_paid!
+ session.delete(:pending_cart)
+ session.delete(:ticket_names)
+ session.delete(:draft_order_id)
+ return redirect_to order_path(@order), notice: "Vos billets gratuits ont été confirmés !"
+ end
+
# Create Stripe checkout session if Stripe is configured
if Rails.application.config.stripe[:secret_key].present?
begin
diff --git a/app/controllers/promoter/events_controller.rb b/app/controllers/promoter/events_controller.rb
index 31e2ada..a45c7c0 100644
--- a/app/controllers/promoter/events_controller.rb
+++ b/app/controllers/promoter/events_controller.rb
@@ -5,7 +5,7 @@
class Promoter::EventsController < ApplicationController
before_action :authenticate_user!
before_action :ensure_can_manage_events!
- before_action :set_event, only: [ :show, :edit, :update, :destroy, :publish, :unpublish, :cancel, :mark_sold_out, :duplicate ]
+ before_action :set_event, only: [ :show, :edit, :update, :destroy, :publish, :unpublish, :cancel, :mark_sold_out, :mark_available, :duplicate ]
# Display all events for the current promoter
def index
@@ -93,6 +93,16 @@ class Promoter::EventsController < ApplicationController
end
end
+ # Mark event as available again
+ def mark_available
+ if @event.sold_out?
+ @event.update(state: :published)
+ redirect_to promoter_event_path(@event), notice: "Event marqué comme disponible!"
+ else
+ redirect_to promoter_event_path(@event), alert: "Cet event ne peut pas être marqué comme disponible."
+ end
+ end
+
# Duplicate an event and all its ticket types
def duplicate
clone_ticket_types = params[:clone_ticket_types] == "true"
diff --git a/app/helpers/tickets_helper.rb b/app/helpers/tickets_helper.rb
index 4722254..c661310 100644
--- a/app/helpers/tickets_helper.rb
+++ b/app/helpers/tickets_helper.rb
@@ -1,2 +1,9 @@
module TicketsHelper
+ def format_ticket_price(price_cents)
+ if price_cents == 0
+ "Gratuit"
+ else
+ number_to_currency(price_cents / 100.0, unit: "€")
+ end
+ end
end
diff --git a/app/mailers/ticket_mailer.rb b/app/mailers/ticket_mailer.rb
index 79a066c..8164572 100755
--- a/app/mailers/ticket_mailer.rb
+++ b/app/mailers/ticket_mailer.rb
@@ -1,4 +1,6 @@
class TicketMailer < ApplicationMailer
+ helper :tickets
+
def purchase_confirmation_order(order)
@order = order
@user = order.user
diff --git a/app/models/event.rb b/app/models/event.rb
index 674770c..2fa5daa 100755
--- a/app/models/event.rb
+++ b/app/models/event.rb
@@ -55,7 +55,6 @@ class Event < ApplicationRecord
# Scope for published events ordered by start time
scope :upcoming, -> { published.where("start_time >= ?", Time.current).order(start_time: :asc) }
-
# === Instance Methods ===
# Check if coordinates were successfully geocoded or are fallback coordinates
diff --git a/app/models/order.rb b/app/models/order.rb
index 666a6a3..3c84bc7 100644
--- a/app/models/order.rb
+++ b/app/models/order.rb
@@ -116,6 +116,11 @@ class Order < ApplicationRecord
promoter_payout_cents / 100.0
end
+ # Check if order contains only free tickets
+ def free?
+ total_amount_cents == 0
+ end
+
# Create Stripe invoice for accounting records
#
# This method creates a post-payment invoice in Stripe for accounting purposes
diff --git a/app/models/ticket.rb b/app/models/ticket.rb
index 51b0ae1..94b2508 100755
--- a/app/models/ticket.rb
+++ b/app/models/ticket.rb
@@ -9,7 +9,7 @@ class Ticket < ApplicationRecord
validates :qr_code, presence: true, uniqueness: true
validates :order_id, presence: true
validates :ticket_type_id, presence: true
- validates :price_cents, presence: true, numericality: { greater_than: 0 }
+ validates :price_cents, presence: true, numericality: { greater_than_or_equal_to: 0 }
validates :status, presence: true, inclusion: { in: %w[draft active used expired refunded] }
validates :first_name, presence: true
validates :last_name, presence: true
diff --git a/app/models/ticket_type.rb b/app/models/ticket_type.rb
index f06cfd5..1274758 100755
--- a/app/models/ticket_type.rb
+++ b/app/models/ticket_type.rb
@@ -6,7 +6,7 @@ class TicketType < ApplicationRecord
# Validations
validates :name, presence: true, length: { minimum: 3, maximum: 50 }
validates :description, presence: true, length: { minimum: 10, maximum: 500 }
- validates :price_cents, presence: true, numericality: { greater_than: 0 }
+ validates :price_cents, presence: true, numericality: { greater_than_or_equal_to: 0 }
validates :quantity, presence: true, numericality: { only_integer: true, greater_than: 0 }
validates :sale_start_at, presence: true
validates :sale_end_at, presence: true
@@ -48,6 +48,10 @@ class TicketType < ApplicationRecord
[ quantity - tickets.count, 0 ].max
end
+ def free?
+ price_cents == 0
+ end
+
def sales_status
return :draft if sale_start_at.nil? || sale_end_at.nil?
return :expired if sale_end_at < Time.current
diff --git a/app/views/components/_ticket_card.html.erb b/app/views/components/_ticket_card.html.erb
index 11fe4d5..81c9056 100755
--- a/app/views/components/_ticket_card.html.erb
+++ b/app/views/components/_ticket_card.html.erb
@@ -7,7 +7,7 @@
">
- <%= number_to_currency(price_cents / 100.0, unit: "€") %>
+ <%= format_ticket_price(price_cents) %>
diff --git a/app/views/events/show.html.erb b/app/views/events/show.html.erb
index 02f8dd1..00ccc34 100755
--- a/app/views/events/show.html.erb
+++ b/app/views/events/show.html.erb
@@ -135,11 +135,9 @@
controller: "ticket-selection",
ticket_selection_target: "form",
ticket_selection_event_slug_value: @event.slug,
- ticket_selection_event_id_value: @event.id,
- ticket_selection_order_new_url_value: event_order_new_path(@event.slug, @event.id),
- ticket_selection_store_cart_url_value: api_v1_store_cart_path,
- ticket_selection_order_new_url_value: event_order_new_path(@event.slug, @event.id),
- ticket_selection_store_cart_url_value: api_v1_store_cart_path
+ ticket_selection_event_id_value: @event.id,
+ ticket_selection_order_new_url_value: event_order_new_path(@event.slug, @event.id),
+ ticket_selection_store_cart_url_value: api_v1_store_cart_path
} do |form| %>
diff --git a/app/views/promoter/events/show.html.erb b/app/views/promoter/events/show.html.erb
index fe95cb4..ce0b4a1 100644
--- a/app/views/promoter/events/show.html.erb
+++ b/app/views/promoter/events/show.html.erb
@@ -132,10 +132,14 @@
-
+
Événement complet
Tous les billets pour cet événement ont été vendus.
+ <%= button_to mark_available_promoter_event_path(@event), method: :patch, class: "ml-4 inline-flex items-center px-3 py-1 bg-white border border-blue-300 text-blue-700 text-sm font-medium rounded-lg hover:bg-blue-50 transition-colors duration-200" do %>
+
+ Marquer comme disponible
+ <% end %>
<% end %>
@@ -273,10 +277,19 @@
Gérer les types de billets
<% end %>
- <%= 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", disabled: !@event.published? do %>
-
- Marquer comme complet
+
+ <% 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." },
diff --git a/app/views/promoter/ticket_types/edit.html.erb b/app/views/promoter/ticket_types/edit.html.erb
index 0f7e836..0ba89f4 100644
--- a/app/views/promoter/ticket_types/edit.html.erb
+++ b/app/views/promoter/ticket_types/edit.html.erb
@@ -81,7 +81,7 @@
<%= form.number_field :price_euros,
step: 0.01,
- min: 0.01,
+ min: 0,
class: "w-full px-4 py-2 pl-8 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent",
data: { "ticket-type-form-target": "price", action: "input->ticket-type-form#updateTotal" } %>
€
@@ -91,6 +91,8 @@
Modifier le prix n'affectera pas les billets déjà vendus
+ <% else %>
+
Prix unitaire du billet (0€ pour un billet gratuit)
<% end %>
@@ -163,8 +165,10 @@
- Événement: <%= @event.start_time.strftime("%d/%m/%Y à %H:%M") %>
- Les ventes doivent se terminer avant le début de l'événement.
+ Début d'événement : <%= @event.start_time.strftime("%d/%m/%Y à %H:%M") %>
+ <% unless @event.allow_booking_during_event? %>
+ Les ventes doivent se terminer avant le début de l'événement.
+ <% end %>
@@ -208,12 +212,6 @@
<%= link_to promoter_event_ticket_type_path(@event, @ticket_type), class: "text-gray-500 hover:text-gray-700 transition-colors" do %>
Annuler
<% end %>
- <% if @ticket_type.tickets.any? %>
-
-
- <%= pluralize(@ticket_type.tickets.count, 'billet') %> déjà vendu(s)
-
- <% end %>
diff --git a/app/views/promoter/ticket_types/new.html.erb b/app/views/promoter/ticket_types/new.html.erb
index dd57e59..3701c35 100644
--- a/app/views/promoter/ticket_types/new.html.erb
+++ b/app/views/promoter/ticket_types/new.html.erb
@@ -42,7 +42,7 @@
Informations générales
-
+
<%= form.label :name, "Nom du type de billet", class: "block text-sm font-medium text-gray-700 mb-2" %>
@@ -61,25 +61,25 @@
Prix et quantité
-
+
<%= form.label :price_euros, "Prix (€)", class: "block text-sm font-medium text-gray-700 mb-2" %>
- <%= form.number_field :price_euros,
- step: 0.01,
- min: 0.01,
+ <%= form.number_field :price_euros,
+ step: 0.01,
+ min: 0,
class: "w-full px-4 py-2 pl-8 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent",
data: { "ticket-type-form-target": "price", action: "input->ticket-type-form#updateTotal" } %>
€
-
Prix unitaire du billet
+
Prix unitaire du billet (0€ pour un billet gratuit)
-
+
<%= form.label :quantity, "Quantité disponible", class: "block text-sm font-medium text-gray-700 mb-2" %>
- <%= form.number_field :quantity,
- min: 1,
+ <%= form.number_field :quantity,
+ min: 1,
class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent",
data: { "ticket-type-form-target": "quantity", action: "input->ticket-type-form#updateTotal" } %>
Nombre total de billets de ce type
@@ -100,18 +100,18 @@
Période de vente
-
+
<%= form.label :sale_start_at, "Début des ventes", class: "block text-sm font-medium text-gray-700 mb-2" %>
- <%= form.datetime_local_field :sale_start_at,
+ <%= form.datetime_local_field :sale_start_at,
value: @ticket_type.sale_start_at&.strftime("%Y-%m-%dT%H:%M"),
class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" %>
-
+
<%= form.label :sale_end_at, "Fin des ventes", class: "block text-sm font-medium text-gray-700 mb-2" %>
- <%= form.datetime_local_field :sale_end_at,
+ <%= form.datetime_local_field :sale_end_at,
value: @ticket_type.sale_end_at&.strftime("%Y-%m-%dT%H:%M"),
class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" %>
Les ventes s'arrêtent automatiquement à cette date
@@ -123,8 +123,11 @@
- Événement: <%= @event.start_time.strftime("%d/%m/%Y à %H:%M") %>
- Les ventes doivent se terminer avant le début de l'événement.
+ Début d'événement : <%= @event.start_time.strftime("%d/%m/%Y à %H:%M") %>
+
+ <% unless @event.allow_booking_during_event? %>
+ Les ventes doivent se terminer avant le début de l'événement.
+ <% end %>
@@ -134,13 +137,13 @@
Conditions d'accès
-
+
<%= form.label :minimum_age, "Âge minimum", class: "block text-sm font-medium text-gray-700 mb-2" %>
- <%= form.number_field :minimum_age,
- min: 0,
- max: 120,
+ <%= form.number_field :minimum_age,
+ min: 0,
+ max: 120,
class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent",
placeholder: "Laisser vide si aucune restriction" %>
Âge minimum requis (optionnel)
@@ -167,11 +170,11 @@
Annuler
<% end %>
-
+
<%= form.submit "Créer le type de billet", class: "inline-flex items-center px-6 py-3 bg-purple-600 text-white font-medium rounded-lg hover:bg-purple-700 transition-colors duration-200" %>
<% end %>
-
\ No newline at end of file
+
diff --git a/app/views/promoter/ticket_types/show.html.erb b/app/views/promoter/ticket_types/show.html.erb
index fea82b3..a5d1fe1 100644
--- a/app/views/promoter/ticket_types/show.html.erb
+++ b/app/views/promoter/ticket_types/show.html.erb
@@ -138,7 +138,7 @@
- <%= number_to_currency(ticket.price_cents / 100.0, unit: "€") %>
+ <%= format_ticket_price(ticket.price_cents) %>
<%= ticket.created_at.strftime("%d/%m/%Y") %>
@@ -164,7 +164,7 @@
- <%= number_to_currency(@ticket_type.price_euros, unit: "€") %>
+ <%= format_ticket_price(@ticket_type.price_cents) %>
Prix unitaire
diff --git a/app/views/ticket_mailer/purchase_confirmation.html.erb b/app/views/ticket_mailer/purchase_confirmation.html.erb
index fbb52e2..7f276a6 100755
--- a/app/views/ticket_mailer/purchase_confirmation.html.erb
+++ b/app/views/ticket_mailer/purchase_confirmation.html.erb
@@ -57,7 +57,7 @@
📱 Voir le détail et le code QR
-
<%= number_to_currency(ticket.price_cents / 100.0, unit: "€") %>
+
<%= format_ticket_price(ticket.price_cents) %>
@@ -83,7 +83,7 @@
Prix
-
<%= number_to_currency(@ticket.price_cents / 100.0, unit: "€") %>
+
<%= format_ticket_price(@ticket.price_cents) %>
diff --git a/app/views/ticket_mailer/purchase_confirmation.text.erb b/app/views/ticket_mailer/purchase_confirmation.text.erb
index b4d9c78..3329cb9 100755
--- a/app/views/ticket_mailer/purchase_confirmation.text.erb
+++ b/app/views/ticket_mailer/purchase_confirmation.text.erb
@@ -13,11 +13,11 @@ DÉTAILS DE VOTRE COMMANDE
Événement : <%= @event.name %>
Date & heure : <%= @event.start_time.strftime("%d %B %Y à %H:%M") %>
Nombre de billets : <%= @tickets.count %>
-Total : <%= number_to_currency(@order.total_amount_euros, unit: "€") %>
+Total : <%= @order.free? ? "Gratuit" : number_to_currency(@order.total_amount_euros, unit: "€") %>
BILLETS INCLUS :
<% @tickets.each_with_index do |ticket, index| %>
-- Billet #<%= index + 1 %> : <%= ticket.ticket_type.name %> - <%= number_to_currency(ticket.price_cents / 100.0, unit: "€") %>
+- Billet #<%= index + 1 %> : <%= ticket.ticket_type.name %> - <%= format_ticket_price(ticket.price_cents) %>
<% end %>
Vos billets sont attachés à cet email en format PDF. Présentez-les à l'entrée de l'événement pour y accéder.
@@ -32,7 +32,7 @@ DÉTAILS DE VOTRE BILLET
Événement : <%= @event.name %>
Type de billet : <%= @ticket.ticket_type.name %>
Date & heure : <%= @event.start_time.strftime("%d %B %Y à %H:%M") %>
-Prix : <%= number_to_currency(@ticket.price_cents / 100.0, unit: "€") %>
+Prix : <%= format_ticket_price(@ticket.price_cents) %>
Votre billet est attaché à cet email en format PDF. Présentez-le à l'entrée de l'événement pour y accéder.
diff --git a/app/views/tickets/show.html.erb b/app/views/tickets/show.html.erb
index 94da8ac..cba7c3f 100644
--- a/app/views/tickets/show.html.erb
+++ b/app/views/tickets/show.html.erb
@@ -80,7 +80,7 @@
Prix
- <%= number_to_currency(@ticket.price_euros, unit: "€", separator: ",", delimiter: " ", format: "%n %u") %>
+ <%= format_ticket_price(@ticket.price_cents) %>
diff --git a/config/routes.rb b/config/routes.rb
index d4bf3be..ce1b6b6 100755
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -81,6 +81,7 @@ Rails.application.routes.draw do
patch :unpublish
patch :cancel
patch :mark_sold_out
+ patch :mark_available
post :duplicate
end
diff --git a/test/controllers/orders_controller_test.rb b/test/controllers/orders_controller_test.rb
index aaa6a3c..1cb416d 100644
--- a/test/controllers/orders_controller_test.rb
+++ b/test/controllers/orders_controller_test.rb
@@ -125,7 +125,7 @@ class OrdersControllerTest < ActionDispatch::IntegrationTest
assert_equal "draft", new_order.status
assert_equal @user, new_order.user
assert_equal @event, new_order.event
- assert_equal @ticket_type.price_cents + 100, new_order.total_amount_cents # includes 1€ service fee
+ assert_equal @ticket_type.price_cents, new_order.total_amount_cents # Service fee deducted from promoter payout, not added to customer
assert_redirected_to checkout_order_path(new_order)
assert_equal new_order.id, session[:draft_order_id]
diff --git a/test/models/order_test.rb b/test/models/order_test.rb
index 6db2124..0a67b6b 100644
--- a/test/models/order_test.rb
+++ b/test/models/order_test.rb
@@ -603,4 +603,22 @@ class OrderTest < ActiveSupport::TestCase
result = order.stripe_invoice_pdf_url
assert_nil result
end
+
+ test "free? should return true for zero amount orders" do
+ free_order = Order.create!(
+ user: @user, event: @event, total_amount_cents: 0,
+ status: "draft", payment_attempts: 0
+ )
+
+ assert free_order.free?
+ end
+
+ test "free? should return false for non-zero amount orders" do
+ paid_order = Order.create!(
+ user: @user, event: @event, total_amount_cents: 1000,
+ status: "draft", payment_attempts: 0
+ )
+
+ assert_not paid_order.free?
+ end
end
diff --git a/test/models/ticket_type_test.rb b/test/models/ticket_type_test.rb
index 3aea6b0..0b3caf3 100755
--- a/test/models/ticket_type_test.rb
+++ b/test/models/ticket_type_test.rb
@@ -244,4 +244,38 @@ class TicketTypeTest < ActiveSupport::TestCase
)
assert_not ticket_type.save
end
+
+ test "should allow free tickets with zero price" do
+ user = User.create!(
+ email: "test@example.com",
+ password: "password123",
+ password_confirmation: "password123"
+ )
+
+ event = Event.create!(
+ name: "Test Event",
+ slug: "test-event",
+ description: "Valid description for the event that is long enough",
+ latitude: 48.8566,
+ longitude: 2.3522,
+ venue_name: "Test Venue",
+ venue_address: "123 Test Street",
+ user: user
+ )
+
+ ticket_type = TicketType.new(
+ name: "Free Ticket",
+ description: "Valid description for the free ticket type",
+ price_cents: 0,
+ quantity: 50,
+ sale_start_at: Time.current,
+ sale_end_at: Time.current + 1.day,
+ event: event
+ )
+
+ assert ticket_type.save
+ assert ticket_type.free?
+ assert_equal 0, ticket_type.price_cents
+ assert_equal 0.0, ticket_type.price_euros
+ end
end
diff --git a/test/services/stripe_invoice_service_test.rb b/test/services/stripe_invoice_service_test.rb
index 60e914e..ebbd7d2 100644
--- a/test/services/stripe_invoice_service_test.rb
+++ b/test/services/stripe_invoice_service_test.rb
@@ -210,25 +210,12 @@ class StripeInvoiceServiceTest < ActiveSupport::TestCase
}
}
- expected_service_fee_line_item = {
- customer: "cus_test123",
- invoice: "in_test123",
- amount: 100,
- currency: "eur",
- description: "Frais de service - Frais de traitement de la commande",
- metadata: {
- item_type: "service_fee",
- amount_cents: 100
- }
- }
-
mock_invoice = mock("invoice")
mock_invoice.stubs(:id).returns("in_test123")
mock_invoice.stubs(:finalize_invoice).returns(mock_invoice)
mock_invoice.expects(:pay)
Stripe::Invoice.expects(:create).returns(mock_invoice)
- Stripe::InvoiceItem.expects(:create).with(expected_ticket_line_item)
- Stripe::InvoiceItem.expects(:create).with(expected_service_fee_line_item)
+ Stripe::InvoiceItem.expects(:create).with(expected_ticket_line_item) # Only for tickets, no service fee
result = @service.create_post_payment_invoice
assert_not_nil result