3 Commits

Author SHA1 Message Date
kbe
5279ebe1a4 feat(event available/sold out): Promoter can mark event as sold out or available
On the event page, promoter can choose to mark the event as "sold out" using the status field or as "published". Only published event can be marked as sold out if promoter thinks he cannot handle all the people available.
2025-09-16 17:15:09 +02:00
kbe
329ba89eaa chore: Better description details for ticket types 2025-09-16 17:00:04 +02:00
kbe
9c56b2e1e5 refactor: prepare for free ticket 2025-09-16 16:36:39 +02:00
19 changed files with 149 additions and 48 deletions

View File

@@ -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

View File

@@ -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"

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -7,7 +7,7 @@
</div>
<div class="text-right">
<p class="text-xl font-bold text-purple-700 <%= "text-gray-400" if sold_out %>">
<%= number_to_currency(price_cents / 100.0, unit: "€") %>
<%= format_ticket_price(price_cents) %>
</p>
</div>
</div>

View File

@@ -132,10 +132,14 @@
<div class="bg-blue-50 border border-blue-200 rounded-2xl p-4">
<div class="flex items-center">
<i data-lucide="users" class="w-5 h-5 text-blue-400 mr-3"></i>
<div>
<div class="flex-1">
<h3 class="text-sm font-medium text-blue-900">Événement complet</h3>
<p class="text-sm text-blue-700">Tous les billets pour cet événement ont été vendus.</p>
</div>
<%= 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 %>
<i data-lucide="refresh-ccw" class="w-4 h-4 mr-1"></i>
Marquer comme disponible
<% end %>
</div>
</div>
<% end %>
@@ -273,10 +277,19 @@
<i data-lucide="ticket" class="w-4 h-4 mr-2"></i>
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 %>
<% 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 %>
<i data-lucide="refresh-ccw" class="w-4 h-4 mr-2"></i>
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 %>
<i data-lucide="users" class="w-4 h-4 mr-2"></i>
Marquer comme complet
<% end %>
<% end %>
<hr class="border-gray-200">
<%= 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." },

View File

@@ -81,7 +81,7 @@
<div class="relative">
<%= 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" } %>
<div class="absolute left-3 top-2.5 text-gray-500">€</div>
@@ -91,6 +91,8 @@
<i data-lucide="alert-triangle" class="w-4 h-4 inline mr-1"></i>
Modifier le prix n'affectera pas les billets déjà vendus
</p>
<% else %>
<p class="mt-1 text-sm text-gray-500">Prix unitaire du billet (0€ pour un billet gratuit)</p>
<% end %>
</div>
@@ -163,8 +165,10 @@
<div class="flex">
<i data-lucide="info" class="w-5 h-5 text-blue-400 mt-0.5 mr-2"></i>
<p class="text-sm text-blue-800">
<strong>Événement:</strong> <%= @event.start_time.strftime("%d/%m/%Y à %H:%M") %><br>
<strong>Début d'événement :</strong> <%= @event.start_time.strftime("%d/%m/%Y à %H:%M") %><br>
<% unless @event.allow_booking_during_event? %>
Les ventes doivent se terminer avant le début de l'événement.
<% end %>
</p>
</div>
</div>
@@ -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? %>
<p class="text-sm text-yellow-600">
<i data-lucide="info" class="w-4 h-4 inline mr-1"></i>
<%= pluralize(@ticket_type.tickets.count, 'billet') %> déjà vendu(s)
</p>
<% end %>
</div>
<div class="flex items-center space-x-3">

View File

@@ -68,12 +68,12 @@
<div class="relative">
<%= 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" } %>
<div class="absolute left-3 top-2.5 text-gray-500">€</div>
</div>
<p class="mt-1 text-sm text-gray-500">Prix unitaire du billet</p>
<p class="mt-1 text-sm text-gray-500">Prix unitaire du billet (0€ pour un billet gratuit)</p>
</div>
<div>
@@ -123,8 +123,11 @@
<div class="flex">
<i data-lucide="info" class="w-5 h-5 text-blue-400 mt-0.5 mr-2"></i>
<p class="text-sm text-blue-800">
<strong>Événement:</strong> <%= @event.start_time.strftime("%d/%m/%Y à %H:%M") %><br>
<strong>Début d'événement :</strong> <%= @event.start_time.strftime("%d/%m/%Y à %H:%M") %><br>
<% unless @event.allow_booking_during_event? %>
Les ventes doivent se terminer avant le début de l'événement.
<% end %>
</p>
</div>
</div>

View File

@@ -138,7 +138,7 @@
</div>
<div class="text-right">
<p class="text-sm font-medium text-gray-900">
<%= number_to_currency(ticket.price_cents / 100.0, unit: "€") %>
<%= format_ticket_price(ticket.price_cents) %>
</p>
<p class="text-xs text-gray-500">
<%= ticket.created_at.strftime("%d/%m/%Y") %>
@@ -164,7 +164,7 @@
<div class="space-y-4">
<div class="text-center p-4 bg-purple-50 rounded-lg">
<div class="text-3xl font-bold text-purple-600">
<%= number_to_currency(@ticket_type.price_euros, unit: "€") %>
<%= format_ticket_price(@ticket_type.price_cents) %>
</div>
<div class="text-sm text-gray-500">Prix unitaire</div>
</div>

View File

@@ -57,7 +57,7 @@
<p style="margin: 5px 0 0;"><a href="<%= ticket_url(ticket) %>" style="color: #4c1d95; text-decoration: none; font-size: 14px;">📱 Voir le détail et le code QR</a></p>
</div>
<div style="text-align: right;">
<p style="margin: 0; font-weight: bold; color: #212529;"><%= number_to_currency(ticket.price_cents / 100.0, unit: "€") %></p>
<p style="margin: 0; font-weight: bold; color: #212529;"><%= format_ticket_price(ticket.price_cents) %></p>
</div>
</div>
</div>
@@ -83,7 +83,7 @@
</div>
<div style="text-align: right;">
<p style="margin: 0; color: #6c757d; font-size: 14px;">Prix</p>
<p style="margin: 5px 0 0; font-weight: bold; color: #212529;"><%= number_to_currency(@ticket.price_cents / 100.0, unit: "€") %></p>
<p style="margin: 5px 0 0; font-weight: bold; color: #212529;"><%= format_ticket_price(@ticket.price_cents) %></p>
</div>
</div>

View File

@@ -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.

View File

@@ -80,7 +80,7 @@
<div>
<label class="block text-sm font-medium text-gray-500 mb-1">Prix</label>
<p class="text-xl font-bold text-gray-900">
<%= number_to_currency(@ticket.price_euros, unit: "€", separator: ",", delimiter: " ", format: "%n %u") %>
<%= format_ticket_price(@ticket.price_cents) %>
</p>
</div>
</div>

View File

@@ -81,6 +81,7 @@ Rails.application.routes.draw do
patch :unpublish
patch :cancel
patch :mark_sold_out
patch :mark_available
post :duplicate
end

View File

@@ -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]

View File

@@ -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

View File

@@ -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