develop #3
161
app/controllers/orders_controller.rb
Normal file
161
app/controllers/orders_controller.rb
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
# Handle order management and checkout process
|
||||||
|
#
|
||||||
|
# This controller manages the order lifecycle from checkout to payment completion
|
||||||
|
# Orders group multiple tickets together for better transaction management
|
||||||
|
class OrdersController < ApplicationController
|
||||||
|
before_action :authenticate_user!
|
||||||
|
before_action :set_order, only: [:show, :checkout, :retry_payment]
|
||||||
|
|
||||||
|
# Display order summary
|
||||||
|
def show
|
||||||
|
@tickets = @order.tickets.includes(:ticket_type)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Display payment page for an order
|
||||||
|
#
|
||||||
|
# Display a summary of all tickets in the order and permit user
|
||||||
|
# to proceed to payment via Stripe
|
||||||
|
def checkout
|
||||||
|
# Handle expired orders
|
||||||
|
if @order.expired?
|
||||||
|
@order.expire_if_overdue!
|
||||||
|
return redirect_to event_path(@order.event.slug, @order.event),
|
||||||
|
alert: "Votre commande a expiré. Veuillez recommencer."
|
||||||
|
end
|
||||||
|
|
||||||
|
@tickets = @order.tickets.includes(:ticket_type)
|
||||||
|
@total_amount = @order.total_amount_cents
|
||||||
|
@expiring_soon = @order.expiring_soon?
|
||||||
|
|
||||||
|
# Create Stripe checkout session if Stripe is configured
|
||||||
|
if Rails.application.config.stripe[:secret_key].present?
|
||||||
|
begin
|
||||||
|
@checkout_session = create_stripe_session
|
||||||
|
@order.increment_payment_attempt!
|
||||||
|
rescue => e
|
||||||
|
error_message = e.message.present? ? e.message : "Erreur Stripe inconnue"
|
||||||
|
Rails.logger.error "Stripe checkout session creation failed: #{error_message}"
|
||||||
|
flash[:alert] = "Erreur lors de la création de la session de paiement"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Allow users to retry payment for failed/cancelled payments
|
||||||
|
def retry_payment
|
||||||
|
unless @order.can_retry_payment?
|
||||||
|
redirect_to event_path(@order.event.slug, @order.event),
|
||||||
|
alert: "Cette commande ne peut plus être payée"
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
redirect_to order_checkout_path(@order)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Handle successful payment
|
||||||
|
def payment_success
|
||||||
|
session_id = params[:session_id]
|
||||||
|
|
||||||
|
# Check if Stripe is properly configured
|
||||||
|
stripe_configured = Rails.application.config.stripe[:secret_key].present?
|
||||||
|
Rails.logger.debug "Payment success - Stripe configured: #{stripe_configured}"
|
||||||
|
|
||||||
|
unless stripe_configured
|
||||||
|
redirect_to dashboard_path, alert: "Le système de paiement n'est pas correctement configuré. Veuillez contacter l'administrateur."
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
begin
|
||||||
|
stripe_session = Stripe::Checkout::Session.retrieve(session_id)
|
||||||
|
|
||||||
|
if stripe_session.payment_status == "paid"
|
||||||
|
# Get order_id from session metadata
|
||||||
|
order_id = stripe_session.metadata["order_id"]
|
||||||
|
|
||||||
|
unless order_id.present?
|
||||||
|
redirect_to dashboard_path, alert: "Informations de commande manquantes"
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Find and update the order
|
||||||
|
@order = current_user.orders.includes(tickets: :ticket_type).find(order_id)
|
||||||
|
@order.mark_as_paid!
|
||||||
|
|
||||||
|
# Send confirmation emails
|
||||||
|
@order.tickets.each do |ticket|
|
||||||
|
TicketMailer.purchase_confirmation(ticket).deliver_now
|
||||||
|
end
|
||||||
|
|
||||||
|
# Clear session data
|
||||||
|
session.delete(:pending_cart)
|
||||||
|
session.delete(:ticket_names)
|
||||||
|
session.delete(:draft_order_id)
|
||||||
|
|
||||||
|
render "payment_success"
|
||||||
|
else
|
||||||
|
redirect_to dashboard_path, alert: "Le paiement n'a pas été complété avec succès"
|
||||||
|
end
|
||||||
|
rescue Stripe::StripeError => e
|
||||||
|
error_message = e.message.present? ? e.message : "Erreur Stripe inconnue"
|
||||||
|
redirect_to dashboard_path, alert: "Erreur lors du traitement de votre confirmation de paiement : #{error_message}"
|
||||||
|
rescue => e
|
||||||
|
error_message = e.message.present? ? e.message : "Erreur inconnue"
|
||||||
|
Rails.logger.error "Payment success error: #{e.class} - #{error_message}"
|
||||||
|
redirect_to dashboard_path, alert: "Une erreur inattendue s'est produite : #{error_message}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Handle payment failure/cancellation
|
||||||
|
def payment_cancel
|
||||||
|
order_id = session[:draft_order_id]
|
||||||
|
|
||||||
|
if order_id.present?
|
||||||
|
order = current_user.orders.find_by(id: order_id, status: "draft")
|
||||||
|
|
||||||
|
if order&.can_retry_payment?
|
||||||
|
redirect_to order_checkout_path(order),
|
||||||
|
alert: "Le paiement a été annulé. Vous pouvez réessayer."
|
||||||
|
else
|
||||||
|
session.delete(:draft_order_id)
|
||||||
|
redirect_to dashboard_path, alert: "Le paiement a été annulé et votre commande a expiré."
|
||||||
|
end
|
||||||
|
else
|
||||||
|
redirect_to dashboard_path, alert: "Le paiement a été annulé"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_order
|
||||||
|
@order = current_user.orders.includes(:tickets, :event).find(params[:id])
|
||||||
|
rescue ActiveRecord::RecordNotFound
|
||||||
|
redirect_to dashboard_path, alert: "Commande non trouvée"
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_stripe_session
|
||||||
|
line_items = @order.tickets.map do |ticket|
|
||||||
|
{
|
||||||
|
price_data: {
|
||||||
|
currency: "eur",
|
||||||
|
product_data: {
|
||||||
|
name: "#{@order.event.name} - #{ticket.ticket_type.name}",
|
||||||
|
description: ticket.ticket_type.description
|
||||||
|
},
|
||||||
|
unit_amount: ticket.price_cents
|
||||||
|
},
|
||||||
|
quantity: 1
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
Stripe::Checkout::Session.create(
|
||||||
|
payment_method_types: ["card"],
|
||||||
|
line_items: line_items,
|
||||||
|
mode: "payment",
|
||||||
|
success_url: order_payment_success_url + "?session_id={CHECKOUT_SESSION_ID}",
|
||||||
|
cancel_url: order_payment_cancel_url,
|
||||||
|
metadata: {
|
||||||
|
order_id: @order.id,
|
||||||
|
user_id: current_user.id
|
||||||
|
}
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -18,21 +18,24 @@ class PagesController < ApplicationController
|
|||||||
# Accessible only to authenticated users
|
# Accessible only to authenticated users
|
||||||
def dashboard
|
def dashboard
|
||||||
# Metrics for dashboard cards
|
# Metrics for dashboard cards
|
||||||
@booked_events = current_user.tickets.joins(:ticket_type, :event).where(events: { state: :published }).count
|
@booked_events = current_user.orders.joins(tickets: { ticket_type: :event })
|
||||||
|
.where(events: { state: :published })
|
||||||
|
.where(orders: { status: ['paid', 'completed'] })
|
||||||
|
.sum('1')
|
||||||
@events_today = Event.published.where("DATE(start_time) = ?", Date.current).count
|
@events_today = Event.published.where("DATE(start_time) = ?", Date.current).count
|
||||||
@events_tomorrow = Event.published.where("DATE(start_time) = ?", Date.current + 1).count
|
@events_tomorrow = Event.published.where("DATE(start_time) = ?", Date.current + 1).count
|
||||||
@upcoming_events = Event.published.upcoming.count
|
@upcoming_events = Event.published.upcoming.count
|
||||||
|
|
||||||
# User's booked events
|
# User's booked events
|
||||||
@user_booked_events = Event.joins(ticket_types: :tickets)
|
@user_booked_events = Event.joins(ticket_types: { tickets: :order })
|
||||||
.where(tickets: { user: current_user, status: "active" })
|
.where(orders: { user: current_user }, tickets: { status: "active" })
|
||||||
.distinct
|
.distinct
|
||||||
.limit(5)
|
.limit(5)
|
||||||
|
|
||||||
# Draft tickets that can be retried
|
# Draft orders that can be retried
|
||||||
@draft_tickets = current_user.tickets.includes(:ticket_type, :event)
|
@draft_orders = current_user.orders.includes(tickets: [:ticket_type, :event])
|
||||||
.can_retry_payment
|
.can_retry_payment
|
||||||
.order(:expires_at)
|
.order(:expires_at)
|
||||||
|
|
||||||
# Events sections
|
# Events sections
|
||||||
@today_events = Event.published.where("DATE(start_time) = ?", Date.current).order(start_time: :asc)
|
@today_events = Event.published.where("DATE(start_time) = ?", Date.current).order(start_time: :asc)
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ class TicketsController < ApplicationController
|
|||||||
@cart_data = session[:pending_cart] || {}
|
@cart_data = session[:pending_cart] || {}
|
||||||
|
|
||||||
if @cart_data.empty?
|
if @cart_data.empty?
|
||||||
redirect_to event_path(@event.slug, @event), alert: "Veuillez sélectionner au moins un billet"
|
redirect_to event_path(@event.slug, @event), alert: "Veuillez d'abord sélectionner vos billets sur la page de l'événement"
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -38,10 +38,10 @@ class TicketsController < ApplicationController
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Create a new ticket
|
# Create a new order with tickets
|
||||||
#
|
#
|
||||||
# Here new tickets are created but still in draft state.
|
# Here a new order is created with associated tickets in draft state.
|
||||||
# When user is ready he can proceed to payment
|
# When user is ready they can proceed to payment via the order checkout
|
||||||
def create
|
def create
|
||||||
@cart_data = session[:pending_cart] || {}
|
@cart_data = session[:pending_cart] || {}
|
||||||
|
|
||||||
@@ -51,225 +51,99 @@ class TicketsController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
@event = Event.includes(:ticket_types).find(params[:id])
|
@event = Event.includes(:ticket_types).find(params[:id])
|
||||||
@tickets = []
|
success = false
|
||||||
|
|
||||||
ActiveRecord::Base.transaction do
|
ActiveRecord::Base.transaction do
|
||||||
|
@order = current_user.orders.create!(event: @event, status: "draft")
|
||||||
|
|
||||||
ticket_params[:tickets_attributes]&.each do |index, ticket_attrs|
|
ticket_params[:tickets_attributes]&.each do |index, ticket_attrs|
|
||||||
next if ticket_attrs[:first_name].blank? || ticket_attrs[:last_name].blank?
|
next if ticket_attrs[:first_name].blank? || ticket_attrs[:last_name].blank?
|
||||||
|
|
||||||
ticket_type = @event.ticket_types.find(ticket_attrs[:ticket_type_id])
|
ticket_type = @event.ticket_types.find(ticket_attrs[:ticket_type_id])
|
||||||
|
|
||||||
ticket = current_user.tickets.build(
|
ticket = @order.tickets.build(
|
||||||
ticket_type: ticket_type,
|
ticket_type: ticket_type,
|
||||||
first_name: ticket_attrs[:first_name],
|
first_name: ticket_attrs[:first_name],
|
||||||
last_name: ticket_attrs[:last_name],
|
last_name: ticket_attrs[:last_name],
|
||||||
status: "draft"
|
status: "draft"
|
||||||
)
|
)
|
||||||
|
|
||||||
if ticket.save
|
unless ticket.save
|
||||||
@tickets << ticket
|
|
||||||
else
|
|
||||||
flash[:alert] = "Erreur lors de la création des billets: #{ticket.errors.full_messages.join(', ')}"
|
flash[:alert] = "Erreur lors de la création des billets: #{ticket.errors.full_messages.join(', ')}"
|
||||||
raise ActiveRecord::Rollback
|
raise ActiveRecord::Rollback
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
if @tickets.present?
|
if @order.tickets.present?
|
||||||
session[:draft_ticket_ids] = @tickets.map(&:id)
|
@order.calculate_total!
|
||||||
session.delete(:pending_cart)
|
success = true
|
||||||
redirect_to ticket_checkout_path(@event.slug, @event.id)
|
|
||||||
else
|
else
|
||||||
flash[:alert] = "Aucun billet valide créé"
|
flash[:alert] = "Aucun billet valide créé"
|
||||||
redirect_to ticket_new_path(@event.slug, @event.id)
|
raise ActiveRecord::Rollback
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Handle redirects outside transaction
|
||||||
|
if success
|
||||||
|
session[:draft_order_id] = @order.id
|
||||||
|
session.delete(:pending_cart)
|
||||||
|
redirect_to order_checkout_path(@order)
|
||||||
|
else
|
||||||
|
redirect_to ticket_new_path(@event.slug, @event.id)
|
||||||
|
end
|
||||||
rescue => e
|
rescue => e
|
||||||
error_message = e.message.present? ? e.message : "Erreur inconnue"
|
error_message = e.message.present? ? e.message : "Erreur inconnue"
|
||||||
flash[:alert] = "Une erreur est survenue: #{error_message}"
|
flash[:alert] = "Une erreur est survenue: #{error_message}"
|
||||||
redirect_to ticket_new_path(params[:slug], params[:id])
|
redirect_to ticket_new_path(params[:slug], params[:id])
|
||||||
end
|
end
|
||||||
|
|
||||||
# Display payment page
|
# Redirect to order-based checkout
|
||||||
#
|
|
||||||
# Display a sumup of all tickets ordered by user and permit it
|
|
||||||
# to go to payment page.
|
|
||||||
# Here the user can pay for a ticket a bundle of tickets
|
|
||||||
def checkout
|
def checkout
|
||||||
|
# Check for draft order
|
||||||
|
if session[:draft_order_id].present?
|
||||||
|
order = current_user.orders.find_by(id: session[:draft_order_id], status: "draft")
|
||||||
|
if order.present?
|
||||||
|
redirect_to order_checkout_path(order)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# No order found
|
||||||
@event = Event.includes(:ticket_types).find(params[:id])
|
@event = Event.includes(:ticket_types).find(params[:id])
|
||||||
draft_ticket_ids = session[:draft_ticket_ids] || []
|
redirect_to event_path(@event.slug, @event), alert: "Aucun billet en attente de paiement"
|
||||||
|
|
||||||
if draft_ticket_ids.empty?
|
|
||||||
redirect_to event_path(@event.slug, @event), alert: "Aucun billet en attente de paiement"
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
@tickets = current_user.tickets.includes(:ticket_type)
|
|
||||||
.where(id: draft_ticket_ids, status: "draft")
|
|
||||||
|
|
||||||
# Check for expired tickets and clean them up
|
|
||||||
expired_tickets = @tickets.select(&:expired?)
|
|
||||||
if expired_tickets.any?
|
|
||||||
expired_tickets.each(&:expire_if_overdue!)
|
|
||||||
@tickets = @tickets.reject(&:expired?)
|
|
||||||
|
|
||||||
if @tickets.empty?
|
|
||||||
session.delete(:draft_ticket_ids)
|
|
||||||
redirect_to event_path(@event.slug, @event), alert: "Vos billets ont expiré. Veuillez recommencer votre commande."
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
flash[:notice] = "Certains billets ont expiré et ont été supprimés de votre commande."
|
|
||||||
end
|
|
||||||
|
|
||||||
# Check if tickets can still be retried
|
|
||||||
non_retryable_tickets = @tickets.reject(&:can_retry_payment?)
|
|
||||||
if non_retryable_tickets.any?
|
|
||||||
non_retryable_tickets.each(&:expire_if_overdue!)
|
|
||||||
@tickets = @tickets.select(&:can_retry_payment?)
|
|
||||||
|
|
||||||
if @tickets.empty?
|
|
||||||
session.delete(:draft_ticket_ids)
|
|
||||||
redirect_to event_path(@event.slug, @event), alert: "Nombre maximum de tentatives de paiement atteint. Veuillez recommencer votre commande."
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
flash[:notice] = "Certains billets ont atteint le nombre maximum de tentatives de paiement."
|
|
||||||
end
|
|
||||||
|
|
||||||
if @tickets.empty?
|
|
||||||
redirect_to event_path(@event.slug, @event), alert: "Billets non trouvés ou déjà traités"
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
@total_amount = @tickets.sum(&:price_cents)
|
|
||||||
|
|
||||||
# Check for expiring soon tickets
|
|
||||||
@expiring_soon = @tickets.any?(&:expiring_soon?)
|
|
||||||
|
|
||||||
# Create Stripe checkout session if Stripe is configured
|
|
||||||
if Rails.application.config.stripe[:secret_key].present?
|
|
||||||
begin
|
|
||||||
@checkout_session = create_stripe_session
|
|
||||||
|
|
||||||
# Only increment payment attempts after successfully creating the session
|
|
||||||
@tickets.each(&:increment_payment_attempt!)
|
|
||||||
rescue => e
|
|
||||||
error_message = e.message.present? ? e.message : "Erreur Stripe inconnue"
|
|
||||||
Rails.logger.error "Stripe checkout session creation failed: #{error_message}"
|
|
||||||
flash[:alert] = "Erreur lors de la création de la session de paiement"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# Handle successful payment
|
# Redirect to order-based payment success
|
||||||
def payment_success
|
def payment_success
|
||||||
session_id = params[:session_id]
|
redirect_to order_payment_success_path(session_id: params[:session_id])
|
||||||
|
|
||||||
# Check if Stripe is properly configured
|
|
||||||
stripe_configured = Rails.application.config.stripe[:secret_key].present?
|
|
||||||
Rails.logger.debug "Payment success - Stripe configured: #{stripe_configured}"
|
|
||||||
|
|
||||||
unless stripe_configured
|
|
||||||
redirect_to dashboard_path, alert: "Le système de paiement n'est pas correctement configuré. Veuillez contacter l'administrateur."
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
begin
|
|
||||||
stripe_session = Stripe::Checkout::Session.retrieve(session_id)
|
|
||||||
|
|
||||||
if stripe_session.payment_status == "paid"
|
|
||||||
# Get event_id and ticket_ids from session metadata
|
|
||||||
event_id = stripe_session.metadata["event_id"]
|
|
||||||
ticket_ids_data = stripe_session.metadata["ticket_ids"]
|
|
||||||
|
|
||||||
unless event_id.present? && ticket_ids_data.present?
|
|
||||||
redirect_to dashboard_path, alert: "Informations de commande manquantes"
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
# Update existing draft tickets to active
|
|
||||||
@event = Event.find(event_id)
|
|
||||||
ticket_ids = ticket_ids_data.split(",")
|
|
||||||
@tickets = current_user.tickets.where(id: ticket_ids, status: "draft")
|
|
||||||
|
|
||||||
if @tickets.empty?
|
|
||||||
redirect_to dashboard_path, alert: "Billets non trouvés"
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
@tickets.update_all(status: "active")
|
|
||||||
|
|
||||||
# Send confirmation emails
|
|
||||||
@tickets.each do |ticket|
|
|
||||||
TicketMailer.purchase_confirmation(ticket).deliver_now
|
|
||||||
end
|
|
||||||
|
|
||||||
# Clear session data
|
|
||||||
session.delete(:pending_cart)
|
|
||||||
session.delete(:ticket_names)
|
|
||||||
session.delete(:draft_ticket_ids)
|
|
||||||
|
|
||||||
render "payment_success"
|
|
||||||
else
|
|
||||||
redirect_to dashboard_path, alert: "Le paiement n'a pas été complété avec succès"
|
|
||||||
end
|
|
||||||
rescue Stripe::StripeError => e
|
|
||||||
error_message = e.message.present? ? e.message : "Erreur Stripe inconnue"
|
|
||||||
redirect_to dashboard_path, alert: "Erreur lors du traitement de votre confirmation de paiement : #{error_message}"
|
|
||||||
rescue => e
|
|
||||||
error_message = e.message.present? ? e.message : "Erreur inconnue"
|
|
||||||
Rails.logger.error "Payment success error: #{e.class} - #{error_message}"
|
|
||||||
redirect_to dashboard_path, alert: "Une erreur inattendue s'est produite : #{error_message}"
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# Handle payment failure/cancellation
|
# Redirect to order-based payment cancel
|
||||||
def payment_cancel
|
def payment_cancel
|
||||||
# Keep draft tickets for potential retry, just redirect back to checkout
|
redirect_to order_payment_cancel_path
|
||||||
draft_ticket_ids = session[:draft_ticket_ids] || []
|
|
||||||
|
|
||||||
if draft_ticket_ids.any?
|
|
||||||
tickets = current_user.tickets.where(id: draft_ticket_ids, status: "draft")
|
|
||||||
retryable_tickets = tickets.select(&:can_retry_payment?)
|
|
||||||
|
|
||||||
if retryable_tickets.any?
|
|
||||||
event = retryable_tickets.first.event
|
|
||||||
redirect_to ticket_checkout_path(event.slug, event.id),
|
|
||||||
alert: "Le paiement a été annulé. Vous pouvez réessayer."
|
|
||||||
else
|
|
||||||
session.delete(:draft_ticket_ids)
|
|
||||||
redirect_to dashboard_path, alert: "Le paiement a été annulé et vos billets ont expiré."
|
|
||||||
end
|
|
||||||
else
|
|
||||||
redirect_to dashboard_path, alert: "Le paiement a été annulé"
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# Allow users to retry payment for failed/cancelled payments
|
# Redirect retry payment to order system
|
||||||
def retry_payment
|
def retry_payment
|
||||||
@event = Event.includes(:ticket_types).find(params[:id])
|
@event = Event.includes(:ticket_types).find(params[:id])
|
||||||
ticket_ids = params[:ticket_ids]&.split(',') || []
|
|
||||||
|
# Look for draft order for this event
|
||||||
@tickets = current_user.tickets.where(id: ticket_ids)
|
order = current_user.orders.find_by(event: @event, status: "draft")
|
||||||
.select(&:can_retry_payment?)
|
|
||||||
|
if order&.can_retry_payment?
|
||||||
if @tickets.empty?
|
redirect_to retry_payment_order_path(order)
|
||||||
|
else
|
||||||
redirect_to event_path(@event.slug, @event),
|
redirect_to event_path(@event.slug, @event),
|
||||||
alert: "Aucun billet disponible pour un nouveau paiement"
|
alert: "Aucune commande disponible pour un nouveau paiement"
|
||||||
return
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# Set session for checkout
|
|
||||||
session[:draft_ticket_ids] = @tickets.map(&:id)
|
|
||||||
redirect_to ticket_checkout_path(@event.slug, @event.id)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def show
|
def show
|
||||||
@ticket = current_user.tickets.includes(:ticket_type, :event).find(params[:ticket_id])
|
@ticket = current_user.orders.joins(:tickets).find(params[:ticket_id])
|
||||||
@event = @ticket.event
|
@event = @ticket.event
|
||||||
rescue ActiveRecord::RecordNotFound
|
rescue ActiveRecord::RecordNotFound
|
||||||
redirect_to dashboard_path, alert: "Billet non trouvé"
|
redirect_to dashboard_path, alert: "Billet non trouvé"
|
||||||
end
|
end
|
||||||
private
|
private
|
||||||
|
|
||||||
def set_event
|
def set_event
|
||||||
|
|||||||
23
app/jobs/expired_orders_cleanup_job.rb
Normal file
23
app/jobs/expired_orders_cleanup_job.rb
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
class ExpiredOrdersCleanupJob < ApplicationJob
|
||||||
|
queue_as :default
|
||||||
|
|
||||||
|
def perform
|
||||||
|
# Find and expire all draft orders that have passed their expiry time
|
||||||
|
expired_orders = Order.expired_drafts
|
||||||
|
|
||||||
|
Rails.logger.info "Found #{expired_orders.count} expired orders to process"
|
||||||
|
|
||||||
|
expired_orders.find_each do |order|
|
||||||
|
begin
|
||||||
|
order.expire_if_overdue!
|
||||||
|
Rails.logger.info "Expired order ##{order.id} for user ##{order.user_id}"
|
||||||
|
rescue => e
|
||||||
|
Rails.logger.error "Failed to expire order ##{order.id}: #{e.message}"
|
||||||
|
# Continue processing other orders even if one fails
|
||||||
|
next
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
Rails.logger.info "Completed expired orders cleanup job"
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -17,6 +17,7 @@ class Event < ApplicationRecord
|
|||||||
belongs_to :user
|
belongs_to :user
|
||||||
has_many :ticket_types
|
has_many :ticket_types
|
||||||
has_many :tickets, through: :ticket_types
|
has_many :tickets, through: :ticket_types
|
||||||
|
has_many :orders
|
||||||
|
|
||||||
# Validations for Event attributes
|
# Validations for Event attributes
|
||||||
# Basic information
|
# Basic information
|
||||||
|
|||||||
93
app/models/order.rb
Normal file
93
app/models/order.rb
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
class Order < ApplicationRecord
|
||||||
|
# === Constants ===
|
||||||
|
DRAFT_EXPIRY_TIME = 30.minutes
|
||||||
|
MAX_PAYMENT_ATTEMPTS = 3
|
||||||
|
|
||||||
|
# === Associations ===
|
||||||
|
belongs_to :user
|
||||||
|
belongs_to :event
|
||||||
|
has_many :tickets, dependent: :destroy
|
||||||
|
|
||||||
|
# === Validations ===
|
||||||
|
validates :user_id, presence: true
|
||||||
|
validates :event_id, presence: true
|
||||||
|
validates :status, presence: true, inclusion: {
|
||||||
|
in: %w[draft pending_payment paid completed cancelled expired]
|
||||||
|
}
|
||||||
|
validates :total_amount_cents, presence: true,
|
||||||
|
numericality: { greater_than_or_equal_to: 0 }
|
||||||
|
validates :payment_attempts, presence: true,
|
||||||
|
numericality: { greater_than_or_equal_to: 0 }
|
||||||
|
|
||||||
|
# === Scopes ===
|
||||||
|
scope :draft, -> { where(status: "draft") }
|
||||||
|
scope :active, -> { where(status: %w[paid completed]) }
|
||||||
|
scope :expired_drafts, -> { draft.where("expires_at < ?", Time.current) }
|
||||||
|
scope :can_retry_payment, -> {
|
||||||
|
draft.where("payment_attempts < ? AND expires_at > ?",
|
||||||
|
MAX_PAYMENT_ATTEMPTS, Time.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
before_validation :set_expiry, on: :create
|
||||||
|
|
||||||
|
# === Instance Methods ===
|
||||||
|
|
||||||
|
# Total amount in euros (formatted)
|
||||||
|
def total_amount_euros
|
||||||
|
total_amount_cents / 100.0
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check if order can be retried for payment
|
||||||
|
def can_retry_payment?
|
||||||
|
draft? && payment_attempts < MAX_PAYMENT_ATTEMPTS && !expired?
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check if order is expired
|
||||||
|
def expired?
|
||||||
|
expires_at.present? && expires_at < Time.current
|
||||||
|
end
|
||||||
|
|
||||||
|
# Mark order as expired if it's past expiry time
|
||||||
|
def expire_if_overdue!
|
||||||
|
return unless draft? && expired?
|
||||||
|
update!(status: "expired")
|
||||||
|
end
|
||||||
|
|
||||||
|
# Increment payment attempt counter
|
||||||
|
def increment_payment_attempt!
|
||||||
|
update!(
|
||||||
|
payment_attempts: payment_attempts + 1,
|
||||||
|
last_payment_attempt_at: Time.current
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check if draft is about to expire (within 5 minutes)
|
||||||
|
def expiring_soon?
|
||||||
|
return false unless draft? && expires_at.present?
|
||||||
|
expires_at <= 5.minutes.from_now
|
||||||
|
end
|
||||||
|
|
||||||
|
# Mark order as paid and activate all tickets
|
||||||
|
def mark_as_paid!
|
||||||
|
transaction do
|
||||||
|
update!(status: "paid")
|
||||||
|
tickets.update_all(status: "active")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Calculate total from tickets
|
||||||
|
def calculate_total!
|
||||||
|
update!(total_amount_cents: tickets.sum(:price_cents))
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_expiry
|
||||||
|
return unless status == "draft"
|
||||||
|
self.expires_at = DRAFT_EXPIRY_TIME.from_now if expires_at.blank?
|
||||||
|
end
|
||||||
|
|
||||||
|
def draft?
|
||||||
|
status == "draft"
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,32 +1,25 @@
|
|||||||
class Ticket < ApplicationRecord
|
class Ticket < ApplicationRecord
|
||||||
# === Constants ===
|
|
||||||
DRAFT_EXPIRY_TIME = 30.minutes
|
|
||||||
MAX_PAYMENT_ATTEMPTS = 3
|
|
||||||
|
|
||||||
# === Associations ===
|
# === Associations ===
|
||||||
belongs_to :user
|
belongs_to :order
|
||||||
belongs_to :ticket_type
|
belongs_to :ticket_type
|
||||||
has_one :event, through: :ticket_type
|
has_one :event, through: :ticket_type
|
||||||
|
has_one :user, through: :order
|
||||||
|
|
||||||
# === Validations ===
|
# === Validations ===
|
||||||
validates :qr_code, presence: true, uniqueness: true
|
validates :qr_code, presence: true, uniqueness: true
|
||||||
validates :user_id, presence: true
|
validates :order_id, presence: true
|
||||||
validates :ticket_type_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: 0 }
|
||||||
validates :status, presence: true, inclusion: { in: %w[draft active used expired refunded] }
|
validates :status, presence: true, inclusion: { in: %w[draft active used expired refunded] }
|
||||||
validates :first_name, presence: true
|
validates :first_name, presence: true
|
||||||
validates :last_name, presence: true
|
validates :last_name, presence: true
|
||||||
validates :payment_attempts, presence: true, numericality: { greater_than_or_equal_to: 0 }
|
|
||||||
|
|
||||||
# === Scopes ===
|
# === Scopes ===
|
||||||
scope :draft, -> { where(status: "draft") }
|
scope :draft, -> { where(status: "draft") }
|
||||||
scope :active, -> { where(status: "active") }
|
scope :active, -> { where(status: "active") }
|
||||||
scope :expired_drafts, -> { draft.where("expires_at < ?", Time.current) }
|
|
||||||
scope :can_retry_payment, -> { draft.where("payment_attempts < ? AND expires_at > ?", MAX_PAYMENT_ATTEMPTS, Time.current) }
|
|
||||||
|
|
||||||
before_validation :set_price_from_ticket_type, on: :create
|
before_validation :set_price_from_ticket_type, on: :create
|
||||||
before_validation :generate_qr_code, on: :create
|
before_validation :generate_qr_code, on: :create
|
||||||
before_validation :set_draft_expiry, on: :create
|
|
||||||
|
|
||||||
# Generate PDF ticket
|
# Generate PDF ticket
|
||||||
def to_pdf
|
def to_pdf
|
||||||
@@ -38,36 +31,22 @@ class Ticket < ApplicationRecord
|
|||||||
price_cents / 100.0
|
price_cents / 100.0
|
||||||
end
|
end
|
||||||
|
|
||||||
# Check if ticket can be retried for payment
|
# Delegate payment methods to order
|
||||||
def can_retry_payment?
|
def can_retry_payment?
|
||||||
draft? && payment_attempts < MAX_PAYMENT_ATTEMPTS && !expired?
|
order.can_retry_payment?
|
||||||
end
|
end
|
||||||
|
|
||||||
# Check if ticket is expired
|
|
||||||
def expired?
|
def expired?
|
||||||
expires_at.present? && expires_at < Time.current
|
order.expired?
|
||||||
end
|
end
|
||||||
|
|
||||||
# Mark ticket as expired if it"s past expiry time
|
|
||||||
def expire_if_overdue!
|
|
||||||
return unless draft? && expired?
|
|
||||||
|
|
||||||
update!(status: "expired")
|
|
||||||
end
|
|
||||||
|
|
||||||
# Increment payment attempt counter
|
|
||||||
def increment_payment_attempt!
|
|
||||||
update!(
|
|
||||||
payment_attempts: payment_attempts + 1,
|
|
||||||
last_payment_attempt_at: Time.current
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Check if draft is about to expire (within 5 minutes)
|
|
||||||
def expiring_soon?
|
def expiring_soon?
|
||||||
return false unless draft? && expires_at.present?
|
order.expiring_soon?
|
||||||
|
end
|
||||||
|
|
||||||
expires_at <= 5.minutes.from_now
|
# Mark ticket as expired if it's past expiry time
|
||||||
|
def expire_if_overdue!
|
||||||
|
order.expire_if_overdue!
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
@@ -86,11 +65,6 @@ class Ticket < ApplicationRecord
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_draft_expiry
|
|
||||||
return unless status == "draft"
|
|
||||||
|
|
||||||
self.expires_at = DRAFT_EXPIRY_TIME.from_now if expires_at.blank?
|
|
||||||
end
|
|
||||||
|
|
||||||
def draft?
|
def draft?
|
||||||
status == "draft"
|
status == "draft"
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ class User < ApplicationRecord
|
|||||||
# Relationships
|
# Relationships
|
||||||
has_many :events, dependent: :destroy
|
has_many :events, dependent: :destroy
|
||||||
has_many :tickets, dependent: :destroy
|
has_many :tickets, dependent: :destroy
|
||||||
|
has_many :orders, dependent: :destroy
|
||||||
|
|
||||||
# Validations
|
# Validations
|
||||||
validates :last_name, length: { minimum: 3, maximum: 12, allow_blank: true }
|
validates :last_name, length: { minimum: 3, maximum: 12, allow_blank: true }
|
||||||
|
|||||||
244
app/views/orders/checkout.html.erb
Normal file
244
app/views/orders/checkout.html.erb
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
<div class="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100 py-8">
|
||||||
|
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<!-- Breadcrumb -->
|
||||||
|
<nav class="mb-8" aria-label="Breadcrumb">
|
||||||
|
<ol class="flex items-center space-x-2 text-sm">
|
||||||
|
<%= link_to root_path, class: "text-gray-500 hover:text-purple-600 transition-colors" do %>
|
||||||
|
<svg class="w-4 h-4 inline-block mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/>
|
||||||
|
</svg>
|
||||||
|
Accueil
|
||||||
|
<% end %>
|
||||||
|
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||||
|
</svg>
|
||||||
|
<%= link_to events_path, class: "text-gray-500 hover:text-purple-600 transition-colors" do %>
|
||||||
|
Événements
|
||||||
|
<% end %>
|
||||||
|
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||||
|
</svg>
|
||||||
|
<%= link_to event_path(@order.event.slug, @order.event), class: "text-gray-500 hover:text-purple-600 transition-colors" do %>
|
||||||
|
<%= @order.event.name %>
|
||||||
|
<% end %>
|
||||||
|
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||||
|
</svg>
|
||||||
|
<li class="font-medium text-gray-900" aria-current="page">Commande #<%= @order.id %></li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
|
<!-- Order Summary -->
|
||||||
|
<div class="bg-white rounded-2xl shadow-xl p-6 md:p-8 h-fit">
|
||||||
|
<!-- Warning for expiring order -->
|
||||||
|
<% if @expiring_soon %>
|
||||||
|
<div class="mb-6 bg-orange-50 border border-orange-200 rounded-lg p-4">
|
||||||
|
<div class="flex items-start">
|
||||||
|
<svg class="w-5 h-5 text-orange-600 mr-2 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<h3 class="font-medium text-orange-800 mb-1">Attention - Commande bientôt expirée</h3>
|
||||||
|
<p class="text-orange-700 text-sm">Votre commande va expirer dans quelques minutes. Veuillez procéder rapidement au paiement pour éviter son expiration automatique.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<!-- Payment attempts warning -->
|
||||||
|
<% if @order.payment_attempts > 0 %>
|
||||||
|
<div class="mb-6 bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||||
|
<div class="flex items-start">
|
||||||
|
<svg class="w-5 h-5 text-blue-600 mr-2 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<h3 class="font-medium text-blue-800 mb-1">Nouvelle tentative de paiement</h3>
|
||||||
|
<p class="text-blue-700 text-sm">
|
||||||
|
Tentative <%= @order.payment_attempts + 1 %> sur <%= @order.class::MAX_PAYMENT_ATTEMPTS %>.
|
||||||
|
<% if @order.payment_attempts >= @order.class::MAX_PAYMENT_ATTEMPTS - 1 %>
|
||||||
|
<strong>Dernière tentative avant expiration !</strong>
|
||||||
|
<% end %>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<div class="border-b border-gray-200 pb-6 mb-6">
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 mb-2">Commande pour <%= @order.event.name %></h1>
|
||||||
|
<div class="flex items-center text-sm text-gray-600 space-x-4">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||||
|
</svg>
|
||||||
|
<% if @order.expires_at %>
|
||||||
|
Expire dans <%= time_ago_in_words(@order.expires_at, include_seconds: true) %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||||
|
</svg>
|
||||||
|
Commande #<%= @order.id %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Order Items -->
|
||||||
|
<div class="space-y-4 mb-6">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 mb-4">Récapitulatif de votre commande</h3>
|
||||||
|
|
||||||
|
<% @tickets.each do |ticket| %>
|
||||||
|
<div class="flex items-center justify-between py-3 border-b border-gray-100 last:border-b-0">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<h4 class="text-sm font-medium text-gray-900 truncate"><%= ticket.ticket_type.name %></h4>
|
||||||
|
<div class="flex items-center text-xs text-gray-500 mt-1">
|
||||||
|
<svg class="w-3 h-3 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
|
||||||
|
</svg>
|
||||||
|
<%= ticket.first_name %> <%= ticket.last_name %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-right">
|
||||||
|
<div class="text-lg font-semibold text-gray-900"><%= ticket.price_euros %>€</div>
|
||||||
|
<% if ticket.ticket_type.description.present? %>
|
||||||
|
<div class="text-xs text-gray-500"><%= truncate(ticket.ticket_type.description, length: 30) %></div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Order Total -->
|
||||||
|
<div class="border-t border-gray-200 pt-6">
|
||||||
|
<div class="flex items-center justify-between text-lg">
|
||||||
|
<span class="font-medium text-gray-900">Total</span>
|
||||||
|
<span class="font-bold text-2xl text-purple-600"><%= @order.total_amount_euros %>€</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500 mt-2">TVA incluse</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Payment Section -->
|
||||||
|
<div class="bg-white rounded-2xl shadow-xl p-6 md:p-8 h-fit">
|
||||||
|
<div class="border-b border-gray-200 pb-6 mb-6">
|
||||||
|
<h2 class="text-xl font-bold text-gray-900 mb-2">Paiement sécurisé</h2>
|
||||||
|
<p class="text-sm text-gray-600">Procédez au paiement pour finaliser votre commande</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if @checkout_session.present? %>
|
||||||
|
<!-- Stripe Checkout -->
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="bg-gradient-to-r from-purple-50 to-pink-50 rounded-lg p-4 border border-purple-200">
|
||||||
|
<div class="flex items-start">
|
||||||
|
<svg class="w-5 h-5 text-purple-600 mr-2 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/>
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<h3 class="font-medium text-purple-800 mb-1">Paiement 100% sécurisé</h3>
|
||||||
|
<p class="text-purple-700 text-sm">Vos données bancaires sont protégées par le cryptage SSL et traitées par Stripe, leader mondial du paiement en ligne.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
id="checkout-button"
|
||||||
|
class="w-full bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700 text-white font-bold py-4 px-6 rounded-xl transition-all duration-200 transform hover:scale-105 active:scale-95 shadow-lg hover:shadow-xl"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-center">
|
||||||
|
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z"/>
|
||||||
|
</svg>
|
||||||
|
Payer <%= @order.total_amount_euros %>€
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-center space-x-4 text-xs text-gray-500">
|
||||||
|
<span class="flex items-center">
|
||||||
|
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z"/>
|
||||||
|
</svg>
|
||||||
|
Visa
|
||||||
|
</span>
|
||||||
|
<span class="flex items-center">
|
||||||
|
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z"/>
|
||||||
|
</svg>
|
||||||
|
Mastercard
|
||||||
|
</span>
|
||||||
|
<span class="flex items-center">
|
||||||
|
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/>
|
||||||
|
</svg>
|
||||||
|
Sécurisé par Stripe
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://js.stripe.com/v3/"></script>
|
||||||
|
<script>
|
||||||
|
const stripe = Stripe('<%= Rails.application.config.stripe[:publishable_key] %>');
|
||||||
|
|
||||||
|
document.getElementById('checkout-button').addEventListener('click', function() {
|
||||||
|
const button = this;
|
||||||
|
button.disabled = true;
|
||||||
|
button.innerHTML = `
|
||||||
|
<div class="flex items-center justify-center">
|
||||||
|
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
Redirection vers le paiement...
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
stripe.redirectToCheckout({
|
||||||
|
sessionId: '<%= @checkout_session.id %>'
|
||||||
|
}).then(function (result) {
|
||||||
|
if (result.error) {
|
||||||
|
button.disabled = false;
|
||||||
|
button.innerHTML = `
|
||||||
|
<div class="flex items-center justify-center">
|
||||||
|
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z"/>
|
||||||
|
</svg>
|
||||||
|
Payer <%= @order.total_amount_euros %>€
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
alert('Erreur: ' + result.error.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<!-- No Stripe Configuration -->
|
||||||
|
<div class="text-center py-8">
|
||||||
|
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-6">
|
||||||
|
<svg class="w-12 h-12 text-yellow-600 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||||
|
</svg>
|
||||||
|
<h3 class="font-semibold text-yellow-800 mb-2">Paiement temporairement indisponible</h3>
|
||||||
|
<p class="text-yellow-700 text-sm">Le système de paiement n'est pas encore configuré. Veuillez contacter l'organisateur pour plus d'informations.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<!-- Order Actions -->
|
||||||
|
<div class="border-t border-gray-200 pt-6 mt-6">
|
||||||
|
<div class="space-y-3">
|
||||||
|
<%= link_to event_path(@order.event.slug, @order.event), class: "block w-full text-center py-3 px-4 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition-colors" do %>
|
||||||
|
<div class="flex items-center justify-center">
|
||||||
|
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
|
||||||
|
</svg>
|
||||||
|
Retour à l'événement
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
191
app/views/orders/payment_success.html.erb
Normal file
191
app/views/orders/payment_success.html.erb
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
<div class="min-h-screen bg-gradient-to-br from-green-50 to-emerald-50 py-8">
|
||||||
|
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<!-- Success Header -->
|
||||||
|
<div class="text-center mb-12">
|
||||||
|
<div class="mx-auto w-20 h-20 bg-green-100 rounded-full flex items-center justify-center mb-6">
|
||||||
|
<svg class="w-10 h-10 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1 class="text-4xl font-bold text-gray-900 mb-4">Paiement réussi !</h1>
|
||||||
|
<p class="text-xl text-gray-600 max-w-2xl mx-auto">
|
||||||
|
Félicitations ! Votre commande a été traitée avec succès. Vous allez recevoir vos billets par email d'ici quelques minutes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
|
<!-- Order Summary -->
|
||||||
|
<div class="bg-white rounded-2xl shadow-xl p-6 md:p-8">
|
||||||
|
<div class="border-b border-gray-200 pb-6 mb-6">
|
||||||
|
<h2 class="text-2xl font-bold text-gray-900 mb-2">Récapitulatif de la commande</h2>
|
||||||
|
<div class="flex items-center text-sm text-gray-600 space-x-4">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||||
|
</svg>
|
||||||
|
Commande #<%= @order.id %>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<svg class="w-4 h-4 mr-1 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
|
||||||
|
</svg>
|
||||||
|
<span class="text-green-600 font-medium">Payée</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Event Information -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 mb-3">Événement</h3>
|
||||||
|
<div class="bg-purple-50 rounded-lg p-4 border border-purple-200">
|
||||||
|
<h4 class="font-semibold text-purple-900 text-lg"><%= @order.event.name %></h4>
|
||||||
|
<div class="mt-2 space-y-1 text-sm text-purple-700">
|
||||||
|
<% if @order.event.start_time %>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||||
|
</svg>
|
||||||
|
<%= l(@order.event.start_time, format: :long) %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<% if @order.event.venue_name.present? %>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"/>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||||
|
</svg>
|
||||||
|
<%= @order.event.venue_name %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tickets List -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 mb-4">Vos billets</h3>
|
||||||
|
|
||||||
|
<% @order.tickets.each do |ticket| %>
|
||||||
|
<div class="flex items-center justify-between py-3 border-b border-gray-100 last:border-b-0">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<h4 class="text-sm font-medium text-gray-900 truncate"><%= ticket.ticket_type.name %></h4>
|
||||||
|
<div class="flex items-center text-xs text-gray-500 mt-1">
|
||||||
|
<svg class="w-3 h-3 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
|
||||||
|
</svg>
|
||||||
|
<%= ticket.first_name %> <%= ticket.last_name %>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center text-xs text-green-600 mt-1">
|
||||||
|
<svg class="w-3 h-3 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
|
||||||
|
</svg>
|
||||||
|
Actif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-right">
|
||||||
|
<div class="text-lg font-semibold text-gray-900"><%= ticket.price_euros %>€</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Total -->
|
||||||
|
<div class="border-t border-gray-200 pt-6 mt-6">
|
||||||
|
<div class="flex items-center justify-between text-lg">
|
||||||
|
<span class="font-medium text-gray-900">Total payé</span>
|
||||||
|
<span class="font-bold text-2xl text-green-600"><%= @order.total_amount_euros %>€</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Next Steps -->
|
||||||
|
<div class="bg-white rounded-2xl shadow-xl p-6 md:p-8 h-fit">
|
||||||
|
<div class="border-b border-gray-200 pb-6 mb-6">
|
||||||
|
<h2 class="text-xl font-bold text-gray-900 mb-2">Prochaines étapes</h2>
|
||||||
|
<p class="text-sm text-gray-600">Que faire maintenant ?</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Email Confirmation -->
|
||||||
|
<div class="flex items-start">
|
||||||
|
<div class="flex-shrink-0 w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center">
|
||||||
|
<span class="text-blue-600 font-semibold text-sm">1</span>
|
||||||
|
</div>
|
||||||
|
<div class="ml-4">
|
||||||
|
<h3 class="font-semibold text-gray-900 mb-1">Vérifiez votre email</h3>
|
||||||
|
<p class="text-gray-600 text-sm">Nous avons envoyé vos billets à <strong><%= current_user.email %></strong>. Vérifiez aussi vos spams.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Download Tickets -->
|
||||||
|
<div class="flex items-start">
|
||||||
|
<div class="flex-shrink-0 w-8 h-8 bg-purple-100 rounded-full flex items-center justify-center">
|
||||||
|
<span class="text-purple-600 font-semibold text-sm">2</span>
|
||||||
|
</div>
|
||||||
|
<div class="ml-4">
|
||||||
|
<h3 class="font-semibold text-gray-900 mb-1">Téléchargez vos billets</h3>
|
||||||
|
<p class="text-gray-600 text-sm mb-3">Gardez vos billets sur votre téléphone ou imprimez-les.</p>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<% @order.tickets.each do |ticket| %>
|
||||||
|
<%= link_to download_ticket_path(ticket), class: "inline-flex items-center px-3 py-2 border border-purple-300 rounded-md text-sm font-medium text-purple-700 bg-purple-50 hover:bg-purple-100 transition-colors mr-2 mb-2" do %>
|
||||||
|
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||||
|
</svg>
|
||||||
|
<%= ticket.first_name %> <%= ticket.last_name %>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Event Day -->
|
||||||
|
<div class="flex items-start">
|
||||||
|
<div class="flex-shrink-0 w-8 h-8 bg-green-100 rounded-full flex items-center justify-center">
|
||||||
|
<span class="text-green-600 font-semibold text-sm">3</span>
|
||||||
|
</div>
|
||||||
|
<div class="ml-4">
|
||||||
|
<h3 class="font-semibold text-gray-900 mb-1">Le jour J</h3>
|
||||||
|
<p class="text-gray-600 text-sm">Présentez votre billet (QR code) à l'entrée. Arrivez un peu en avance !</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Contact Support -->
|
||||||
|
<div class="bg-gray-50 rounded-lg p-4 mt-8">
|
||||||
|
<h4 class="font-medium text-gray-900 mb-2">Besoin d'aide ?</h4>
|
||||||
|
<p class="text-gray-600 text-sm mb-3">Si vous avez des questions ou des problèmes avec votre commande, n'hésitez pas à nous contacter.</p>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<%= link_to "mailto:support@example.com", class: "inline-flex items-center text-sm text-purple-600 hover:text-purple-700" do %>
|
||||||
|
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 4.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
|
||||||
|
</svg>
|
||||||
|
Contactez le support
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="border-t border-gray-200 pt-6 mt-6">
|
||||||
|
<div class="space-y-3">
|
||||||
|
<%= link_to dashboard_path, class: "block w-full text-center py-3 px-4 bg-purple-600 hover:bg-purple-700 text-white font-medium rounded-lg transition-colors" do %>
|
||||||
|
<div class="flex items-center justify-center">
|
||||||
|
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/>
|
||||||
|
</svg>
|
||||||
|
Voir tous mes billets
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<%= link_to events_path, class: "block w-full text-center py-3 px-4 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition-colors" do %>
|
||||||
|
<div class="flex items-center justify-center">
|
||||||
|
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||||
|
</svg>
|
||||||
|
Découvrir d'autres événements
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -31,8 +31,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Draft tickets needing payment -->
|
<!-- Draft orders needing payment -->
|
||||||
<% if @draft_tickets.any? %>
|
<% if @draft_orders.any? %>
|
||||||
<div class="card hover-lift mb-8 border-orange-200 bg-orange-50">
|
<div class="card hover-lift mb-8 border-orange-200 bg-orange-50">
|
||||||
<div class="card-header bg-orange-100 rounded-lg">
|
<div class="card-header bg-orange-100 rounded-lg">
|
||||||
|
|
||||||
@@ -41,40 +41,39 @@
|
|||||||
<svg class="w-6 h-6 mr-2 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-6 h-6 mr-2 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||||
</svg>
|
</svg>
|
||||||
Billets en attente de paiement
|
Commandes en attente de paiement
|
||||||
</h2>
|
</h2>
|
||||||
<p class="text-orange-700 mt-1">Vous avez des billets qui nécessitent un paiement</p>
|
<p class="text-orange-700 mt-1">Vous avez des commandes qui nécessitent un paiement</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<% @draft_tickets.group_by(&:event).each do |event, tickets| %>
|
<% @draft_orders.each do |order| %>
|
||||||
<div class="bg-white rounded-lg p-4 border border-orange-200">
|
<div class="bg-white rounded-lg p-4 border border-orange-200">
|
||||||
<div class="flex items-start justify-between mb-3">
|
<div class="flex items-start justify-between mb-3">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="font-semibold text-gray-900"><%= event.name %></h3>
|
<h3 class="font-semibold text-gray-900"><%= order.event.name %></h3>
|
||||||
<p class="text-sm text-gray-600">
|
<p class="text-sm text-gray-600">
|
||||||
<svg class="w-4 h-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||||
</svg>
|
</svg>
|
||||||
<%= event.start_time.strftime("%d %B %Y à %H:%M") %>
|
<%= order.event.start_time.strftime("%d %B %Y à %H:%M") %>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-sm font-medium text-orange-600 bg-orange-100 px-2 py-1 rounded-full">
|
<span class="text-sm font-medium text-orange-600 bg-orange-100 px-2 py-1 rounded-full">
|
||||||
<%= tickets.count %> billet<%= 's' if tickets.count > 1 %>
|
Commande #<%= order.id %>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid gap-2 mb-4">
|
<div class="grid gap-2 mb-4">
|
||||||
<% tickets.each do |ticket| %>
|
<% order.tickets.each do |ticket| %>
|
||||||
<div class="flex items-center justify-between text-sm bg-gray-50 rounded p-2">
|
<div class="flex items-center justify-between text-sm bg-gray-50 rounded p-2">
|
||||||
<div>
|
<div>
|
||||||
<span class="font-medium"><%= ticket.ticket_type.name %></span>
|
<span class="font-medium"><%= ticket.ticket_type.name %></span>
|
||||||
<span class="text-gray-600">- <%= ticket.first_name %> <%= ticket.last_name %></span>
|
<span class="text-gray-600">- <%= ticket.first_name %> <%= ticket.last_name %></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<span class="text-gray-600">Expire <%= time_ago_in_words(ticket.expires_at) %></span>
|
|
||||||
<span class="font-medium text-gray-900"><%= number_to_currency(ticket.price_euros, unit: "€") %></span>
|
<span class="font-medium text-gray-900"><%= number_to_currency(ticket.price_euros, unit: "€") %></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -83,17 +82,17 @@
|
|||||||
|
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="text-sm text-gray-600">
|
<div class="text-sm text-gray-600">
|
||||||
<% max_attempts = tickets.map(&:payment_attempts).max %>
|
Tentatives: <%= order.payment_attempts %>/3
|
||||||
Tentatives: <%= max_attempts %>/3
|
<% if order.expiring_soon? %>
|
||||||
<% if tickets.any?(&:expiring_soon?) %>
|
<span class="text-orange-600 font-medium ml-2">⚠️ Expire dans <%= time_ago_in_words(order.expires_at) %></span>
|
||||||
<span class="text-orange-600 font-medium ml-2">⚠️ Expire bientôt</span>
|
<% else %>
|
||||||
|
<span class="text-gray-500 ml-2">Expire dans <%= time_ago_in_words(order.expires_at) %></span>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%= form_tag ticket_retry_payment_path(event.slug, event.id), method: :post do %>
|
<%= link_to retry_payment_order_path(order), method: :post,
|
||||||
<%= hidden_field_tag :ticket_ids, tickets.map(&:id).join(',') %>
|
class: "inline-flex items-center px-4 py-2 bg-orange-600 text-white text-sm font-medium rounded-lg hover:bg-orange-700 transition-colors duration-200" do %>
|
||||||
<%= submit_tag "Reprendre le paiement",
|
Reprendre le paiement (<%= order.total_amount_euros %>€)
|
||||||
class: "inline-flex items-center px-4 py-2 bg-orange-600 text-white text-sm font-medium rounded-lg hover:bg-orange-700 transition-colors duration-200" %>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -34,6 +34,35 @@ Rails.application.routes.draw do
|
|||||||
# === Pages ===
|
# === Pages ===
|
||||||
get "dashboard", to: "pages#dashboard", as: "dashboard"
|
get "dashboard", to: "pages#dashboard", as: "dashboard"
|
||||||
|
|
||||||
|
# === Events ===
|
||||||
|
get "events", to: "events#index", as: "events"
|
||||||
|
get "events/:slug.:id", to: "events#show", as: "event"
|
||||||
|
|
||||||
|
# === Orders ===
|
||||||
|
resources :orders, only: [:show] do
|
||||||
|
member do
|
||||||
|
get :checkout
|
||||||
|
post :retry_payment
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Order payment routes
|
||||||
|
get "orders/payments/success", to: "orders#payment_success", as: "order_payment_success"
|
||||||
|
get "orders/payments/cancel", to: "orders#payment_cancel", as: "order_payment_cancel"
|
||||||
|
|
||||||
|
# === Tickets ===
|
||||||
|
get "events/:slug.:id/tickets/new", to: "tickets#new", as: "ticket_new"
|
||||||
|
post "events/:slug.:id/tickets/create", to: "tickets#create", as: "ticket_create"
|
||||||
|
|
||||||
|
# Keep these for now but they redirect to order system
|
||||||
|
get "events/:slug.:id/tickets/checkout", to: "tickets#checkout", as: "ticket_checkout"
|
||||||
|
post "events/:slug.:id/tickets/retry", to: "tickets#retry_payment", as: "ticket_retry_payment"
|
||||||
|
get "payments/success", to: "tickets#payment_success", as: "payment_success"
|
||||||
|
get "payments/cancel", to: "tickets#payment_cancel", as: "payment_cancel"
|
||||||
|
|
||||||
|
# === Tickets ===
|
||||||
|
get "tickets/:ticket_id/download", to: "events#download_ticket", as: "download_ticket"
|
||||||
|
|
||||||
# === Promoter Routes ===
|
# === Promoter Routes ===
|
||||||
namespace :promoter do
|
namespace :promoter do
|
||||||
resources :events do
|
resources :events do
|
||||||
@@ -43,7 +72,7 @@ Rails.application.routes.draw do
|
|||||||
patch :cancel
|
patch :cancel
|
||||||
patch :mark_sold_out
|
patch :mark_sold_out
|
||||||
end
|
end
|
||||||
|
|
||||||
# Nested ticket types routes
|
# Nested ticket types routes
|
||||||
resources :ticket_types do
|
resources :ticket_types do
|
||||||
member do
|
member do
|
||||||
@@ -53,22 +82,6 @@ Rails.application.routes.draw do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# === Events ===
|
|
||||||
get "events", to: "events#index", as: "events"
|
|
||||||
get "events/:slug.:id", to: "events#show", as: "event"
|
|
||||||
|
|
||||||
# === Tickets ===
|
|
||||||
get "events/:slug.:id/tickets/new", to: "tickets#new", as: "ticket_new"
|
|
||||||
post "events/:slug.:id/tickets/create", to: "tickets#create", as: "ticket_create"
|
|
||||||
get "events/:slug.:id/tickets/checkout", to: "tickets#checkout", as: "ticket_checkout"
|
|
||||||
post "events/:slug.:id/tickets/retry", to: "tickets#retry_payment", as: "ticket_retry_payment"
|
|
||||||
|
|
||||||
# Payment routes
|
|
||||||
get "payments/success", to: "tickets#payment_success", as: "payment_success"
|
|
||||||
get "payments/cancel", to: "tickets#payment_cancel", as: "payment_cancel"
|
|
||||||
|
|
||||||
# === Tickets ===
|
|
||||||
get "tickets/:ticket_id/download", to: "events#download_ticket", as: "download_ticket"
|
|
||||||
|
|
||||||
# API routes versioning
|
# API routes versioning
|
||||||
namespace :api do
|
namespace :api do
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ class CreateTicketTypes < ActiveRecord::Migration[8.0]
|
|||||||
t.datetime :sale_start_at
|
t.datetime :sale_start_at
|
||||||
t.datetime :sale_end_at
|
t.datetime :sale_end_at
|
||||||
t.integer :minimum_age
|
t.integer :minimum_age
|
||||||
|
t.boolean :requires_id, default: false, null: false
|
||||||
|
|
||||||
t.references :event, null: false, foreign_key: false
|
t.references :event, null: false, foreign_key: false
|
||||||
|
|
||||||
t.timestamps
|
t.timestamps
|
||||||
|
|||||||
20
db/migrate/20250823170409_create_orders.rb
Normal file
20
db/migrate/20250823170409_create_orders.rb
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
class CreateOrders < ActiveRecord::Migration[8.0]
|
||||||
|
def change
|
||||||
|
create_table :orders do |t|
|
||||||
|
t.references :user, null: false, foreign_key: true
|
||||||
|
t.references :event, null: false, foreign_key: true
|
||||||
|
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.timestamps
|
||||||
|
end
|
||||||
|
|
||||||
|
# Indexes for performance
|
||||||
|
add_index :orders, [:user_id, :status], name: 'idx_orders_user_status'
|
||||||
|
add_index :orders, [:event_id, :status], name: 'idx_orders_event_status'
|
||||||
|
add_index :orders, :expires_at, name: 'idx_orders_expires_at'
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -9,30 +9,13 @@ class CreateTickets < ActiveRecord::Migration[8.0]
|
|||||||
t.string :first_name
|
t.string :first_name
|
||||||
t.string :last_name
|
t.string :last_name
|
||||||
|
|
||||||
# Implemented to "temporize" tickets
|
# Tickets belong to orders (orders handle payment logic)
|
||||||
# If a ticket is not paid in time, it is removed from the database
|
t.references :order, null: false, foreign_key: true
|
||||||
#
|
t.references :ticket_type, null: false, foreign_key: true
|
||||||
t.string :stripe_session_id
|
|
||||||
t.timestamp :expires_at
|
|
||||||
t.integer :payment_attempts, default: 0
|
|
||||||
t.timestamp :last_payment_attempt_at
|
|
||||||
|
|
||||||
t.references :user, null: true, foreign_key: false
|
|
||||||
t.references :ticket_type, null: false, foreign_key: false
|
|
||||||
|
|
||||||
t.timestamps
|
t.timestamps
|
||||||
end
|
end
|
||||||
|
|
||||||
add_index :tickets, :qr_code, unique: true
|
add_index :tickets, :qr_code, unique: true
|
||||||
add_index :tickets, :user_id unless index_exists?(:tickets, :user_id)
|
|
||||||
add_index :tickets, :ticket_type_id unless index_exists?(:tickets, :ticket_type_id)
|
|
||||||
|
|
||||||
# Add indexes for better performance
|
|
||||||
# add_index :tickets, :first_name unless index_exists?(:tickets, :first_name)
|
|
||||||
# add_index :tickets, :last_name unless index_exists?(:tickets, :last_name)
|
|
||||||
#
|
|
||||||
# add_index :tickets, :stripe_session_id, unique: true
|
|
||||||
# add_index :tickets, [ :status, :expires_at ]
|
|
||||||
# add_index :tickets, [ :user_id, :status ]
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
class AddRequiresIdToTicketTypes < ActiveRecord::Migration[8.0]
|
|
||||||
def change
|
|
||||||
add_column :ticket_types, :requires_id, :boolean, default: false, null: false
|
|
||||||
end
|
|
||||||
end
|
|
||||||
31
db/schema.rb
generated
31
db/schema.rb
generated
@@ -10,7 +10,7 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[8.0].define(version: 2025_08_31_184955) do
|
ActiveRecord::Schema[8.0].define(version: 2025_09_02_003043) do
|
||||||
create_table "events", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t|
|
create_table "events", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t|
|
||||||
t.string "name", null: false
|
t.string "name", null: false
|
||||||
t.string "slug", null: false
|
t.string "slug", null: false
|
||||||
@@ -33,6 +33,23 @@ ActiveRecord::Schema[8.0].define(version: 2025_08_31_184955) do
|
|||||||
t.index ["user_id"], name: "index_events_on_user_id"
|
t.index ["user_id"], name: "index_events_on_user_id"
|
||||||
end
|
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.integer "total_amount_cents", default: 0, null: false
|
||||||
|
t.string "status", default: "draft", null: false
|
||||||
|
t.integer "payment_attempts", default: 0, null: false
|
||||||
|
t.datetime "expires_at"
|
||||||
|
t.datetime "last_payment_attempt_at"
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.index ["event_id", "status"], name: "index_orders_on_event_id_and_status"
|
||||||
|
t.index ["event_id"], name: "index_orders_on_event_id"
|
||||||
|
t.index ["expires_at"], name: "index_orders_on_expires_at"
|
||||||
|
t.index ["user_id", "status"], name: "index_orders_on_user_id_and_status"
|
||||||
|
t.index ["user_id"], name: "index_orders_on_user_id"
|
||||||
|
end
|
||||||
|
|
||||||
create_table "ticket_types", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t|
|
create_table "ticket_types", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t|
|
||||||
t.string "name"
|
t.string "name"
|
||||||
t.text "description"
|
t.text "description"
|
||||||
@@ -56,17 +73,13 @@ ActiveRecord::Schema[8.0].define(version: 2025_08_31_184955) do
|
|||||||
t.string "status", default: "draft"
|
t.string "status", default: "draft"
|
||||||
t.string "first_name"
|
t.string "first_name"
|
||||||
t.string "last_name"
|
t.string "last_name"
|
||||||
t.string "stripe_session_id"
|
|
||||||
t.timestamp "expires_at"
|
|
||||||
t.integer "payment_attempts", default: 0
|
|
||||||
t.timestamp "last_payment_attempt_at"
|
|
||||||
t.bigint "user_id"
|
|
||||||
t.bigint "ticket_type_id", null: false
|
t.bigint "ticket_type_id", null: false
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
|
t.bigint "order_id", null: false
|
||||||
|
t.index ["order_id"], name: "index_tickets_on_order_id"
|
||||||
t.index ["qr_code"], name: "index_tickets_on_qr_code", unique: true
|
t.index ["qr_code"], name: "index_tickets_on_qr_code", unique: true
|
||||||
t.index ["ticket_type_id"], name: "index_tickets_on_ticket_type_id"
|
t.index ["ticket_type_id"], name: "index_tickets_on_ticket_type_id"
|
||||||
t.index ["user_id"], name: "index_tickets_on_user_id"
|
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "users", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t|
|
create_table "users", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t|
|
||||||
@@ -83,4 +96,8 @@ ActiveRecord::Schema[8.0].define(version: 2025_08_31_184955) do
|
|||||||
t.index ["email"], name: "index_users_on_email", unique: true
|
t.index ["email"], name: "index_users_on_email", unique: true
|
||||||
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
|
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
add_foreign_key "orders", "events"
|
||||||
|
add_foreign_key "orders", "users"
|
||||||
|
add_foreign_key "tickets", "orders"
|
||||||
end
|
end
|
||||||
|
|||||||
7
test/jobs/expired_orders_cleanup_job_test.rb
Normal file
7
test/jobs/expired_orders_cleanup_job_test.rb
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class ExpiredOrdersCleanupJobTest < ActiveJob::TestCase
|
||||||
|
# test "the truth" do
|
||||||
|
# assert true
|
||||||
|
# end
|
||||||
|
end
|
||||||
Reference in New Issue
Block a user