2 Commits

Author SHA1 Message Date
580b24bbed Merge pull request 'develop' (#3) from develop into main
All checks were successful
Ruby on Rails Test / rails-test (push) Successful in 1m43s
Reviewed-on: #3
2025-09-16 14:34:28 +00:00
a8d3bc12ae Merge pull request 'feat/free-ticket' (#2) from feat/free-ticket into develop
All checks were successful
Ruby on Rails Test / rails-test (push) Successful in 1m56s
Reviewed-on: #2
2025-09-16 14:31:43 +00:00
19 changed files with 48 additions and 149 deletions

View File

@@ -126,15 +126,6 @@ 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, :mark_available, :duplicate ]
before_action :set_event, only: [ :show, :edit, :update, :destroy, :publish, :unpublish, :cancel, :mark_sold_out, :duplicate ]
# Display all events for the current promoter
def index
@@ -93,16 +93,6 @@ 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,9 +1,2 @@
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,6 +55,7 @@ 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,11 +116,6 @@ 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_or_equal_to: 0 }
validates :price_cents, presence: true, numericality: { greater_than: 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_or_equal_to: 0 }
validates :price_cents, presence: true, numericality: { greater_than: 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,10 +48,6 @@ 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 %>">
<%= format_ticket_price(price_cents) %>
<%= number_to_currency(price_cents / 100.0, unit: "€") %>
</p>
</div>
</div>

View File

@@ -132,14 +132,10 @@
<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 class="flex-1">
<div>
<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 %>
@@ -277,19 +273,10 @@
<i data-lucide="ticket" class="w-4 h-4 mr-2"></i>
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 %>
<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 %>
<%= 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 %>
<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,
min: 0.01,
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,8 +91,6 @@
<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>
@@ -165,10 +163,8 @@
<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>Début d'événement :</strong> <%= @event.start_time.strftime("%d/%m/%Y à %H:%M") %><br>
<% unless @event.allow_booking_during_event? %>
<strong>Événement:</strong> <%= @event.start_time.strftime("%d/%m/%Y à %H:%M") %><br>
Les ventes doivent se terminer avant le début de l'événement.
<% end %>
</p>
</div>
</div>
@@ -212,6 +208,12 @@
<%= 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,
min: 0.01,
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 (0€ pour un billet gratuit)</p>
<p class="mt-1 text-sm text-gray-500">Prix unitaire du billet</p>
</div>
<div>
@@ -123,11 +123,8 @@
<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>Début d'événement :</strong> <%= @event.start_time.strftime("%d/%m/%Y à %H:%M") %><br>
<% unless @event.allow_booking_during_event? %>
<strong>Événement:</strong> <%= @event.start_time.strftime("%d/%m/%Y à %H:%M") %><br>
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">
<%= format_ticket_price(ticket.price_cents) %>
<%= number_to_currency(ticket.price_cents / 100.0, unit: "€") %>
</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">
<%= format_ticket_price(@ticket_type.price_cents) %>
<%= number_to_currency(@ticket_type.price_euros, unit: "€") %>
</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;"><%= format_ticket_price(ticket.price_cents) %></p>
<p style="margin: 0; font-weight: bold; color: #212529;"><%= number_to_currency(ticket.price_cents / 100.0, unit: "€") %></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;"><%= format_ticket_price(@ticket.price_cents) %></p>
<p style="margin: 5px 0 0; font-weight: bold; color: #212529;"><%= number_to_currency(@ticket.price_cents / 100.0, unit: "€") %></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 : <%= @order.free? ? "Gratuit" : number_to_currency(@order.total_amount_euros, unit: "€") %>
Total : <%= number_to_currency(@order.total_amount_euros, unit: "€") %>
BILLETS INCLUS :
<% @tickets.each_with_index do |ticket, index| %>
- Billet #<%= index + 1 %> : <%= ticket.ticket_type.name %> - <%= format_ticket_price(ticket.price_cents) %>
- Billet #<%= index + 1 %> : <%= ticket.ticket_type.name %> - <%= number_to_currency(ticket.price_cents / 100.0, unit: "€") %>
<% 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 : <%= format_ticket_price(@ticket.price_cents) %>
Prix : <%= number_to_currency(@ticket.price_cents / 100.0, unit: "€") %>
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">
<%= format_ticket_price(@ticket.price_cents) %>
<%= number_to_currency(@ticket.price_euros, unit: "€", separator: ",", delimiter: " ", format: "%n %u") %>
</p>
</div>
</div>

View File

@@ -81,7 +81,6 @@ 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, new_order.total_amount_cents # Service fee deducted from promoter payout, not added to customer
assert_equal @ticket_type.price_cents + 100, new_order.total_amount_cents # includes 1€ service fee
assert_redirected_to checkout_order_path(new_order)
assert_equal new_order.id, session[:draft_order_id]

View File

@@ -603,22 +603,4 @@ 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,38 +244,4 @@ 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