diff --git a/app/controllers/orders_controller.rb b/app/controllers/orders_controller.rb new file mode 100644 index 0000000..f9c6d41 --- /dev/null +++ b/app/controllers/orders_controller.rb @@ -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 \ No newline at end of file diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb index 43e919d..e879142 100755 --- a/app/controllers/pages_controller.rb +++ b/app/controllers/pages_controller.rb @@ -18,21 +18,24 @@ class PagesController < ApplicationController # Accessible only to authenticated users def dashboard # 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_tomorrow = Event.published.where("DATE(start_time) = ?", Date.current + 1).count @upcoming_events = Event.published.upcoming.count # User's booked events - @user_booked_events = Event.joins(ticket_types: :tickets) - .where(tickets: { user: current_user, status: "active" }) + @user_booked_events = Event.joins(ticket_types: { tickets: :order }) + .where(orders: { user: current_user }, tickets: { status: "active" }) .distinct .limit(5) - # Draft tickets that can be retried - @draft_tickets = current_user.tickets.includes(:ticket_type, :event) - .can_retry_payment - .order(:expires_at) + # Draft orders that can be retried + @draft_orders = current_user.orders.includes(tickets: [:ticket_type, :event]) + .can_retry_payment + .order(:expires_at) # Events sections @today_events = Event.published.where("DATE(start_time) = ?", Date.current).order(start_time: :asc) diff --git a/app/controllers/tickets_controller.rb b/app/controllers/tickets_controller.rb index d006a3c..2deee6a 100644 --- a/app/controllers/tickets_controller.rb +++ b/app/controllers/tickets_controller.rb @@ -15,7 +15,7 @@ class TicketsController < ApplicationController @cart_data = session[:pending_cart] || {} 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 end @@ -38,10 +38,10 @@ class TicketsController < ApplicationController end end - # Create a new ticket + # Create a new order with tickets # - # Here new tickets are created but still in draft state. - # When user is ready he can proceed to payment + # Here a new order is created with associated tickets in draft state. + # When user is ready they can proceed to payment via the order checkout def create @cart_data = session[:pending_cart] || {} @@ -51,225 +51,99 @@ class TicketsController < ApplicationController end @event = Event.includes(:ticket_types).find(params[:id]) - @tickets = [] - + success = false + ActiveRecord::Base.transaction do + @order = current_user.orders.create!(event: @event, status: "draft") + ticket_params[:tickets_attributes]&.each do |index, ticket_attrs| next if ticket_attrs[:first_name].blank? || ticket_attrs[:last_name].blank? ticket_type = @event.ticket_types.find(ticket_attrs[:ticket_type_id]) - - ticket = current_user.tickets.build( + + ticket = @order.tickets.build( ticket_type: ticket_type, first_name: ticket_attrs[:first_name], last_name: ticket_attrs[:last_name], status: "draft" ) - if ticket.save - @tickets << ticket - else + unless ticket.save flash[:alert] = "Erreur lors de la création des billets: #{ticket.errors.full_messages.join(', ')}" raise ActiveRecord::Rollback end end - if @tickets.present? - session[:draft_ticket_ids] = @tickets.map(&:id) - session.delete(:pending_cart) - redirect_to ticket_checkout_path(@event.slug, @event.id) + if @order.tickets.present? + @order.calculate_total! + success = true else flash[:alert] = "Aucun billet valide créé" - redirect_to ticket_new_path(@event.slug, @event.id) + raise ActiveRecord::Rollback 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 error_message = e.message.present? ? e.message : "Erreur inconnue" flash[:alert] = "Une erreur est survenue: #{error_message}" redirect_to ticket_new_path(params[:slug], params[:id]) end - # Display payment page - # - # 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 + # Redirect to order-based 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]) - draft_ticket_ids = session[:draft_ticket_ids] || [] - - 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 + redirect_to event_path(@event.slug, @event), alert: "Aucun billet en attente de paiement" end - # Handle successful payment + # Redirect to order-based payment success 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 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 + redirect_to order_payment_success_path(session_id: params[:session_id]) end - # Handle payment failure/cancellation + # Redirect to order-based payment cancel def payment_cancel - # Keep draft tickets for potential retry, just redirect back to checkout - 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 + redirect_to order_payment_cancel_path end - # Allow users to retry payment for failed/cancelled payments + # Redirect retry payment to order system def retry_payment @event = Event.includes(:ticket_types).find(params[:id]) - ticket_ids = params[:ticket_ids]&.split(',') || [] - - @tickets = current_user.tickets.where(id: ticket_ids) - .select(&:can_retry_payment?) - - if @tickets.empty? + + # Look for draft order for this event + order = current_user.orders.find_by(event: @event, status: "draft") + + if order&.can_retry_payment? + redirect_to retry_payment_order_path(order) + else redirect_to event_path(@event.slug, @event), - alert: "Aucun billet disponible pour un nouveau paiement" - return + alert: "Aucune commande disponible pour un nouveau paiement" end - - # Set session for checkout - session[:draft_ticket_ids] = @tickets.map(&:id) - redirect_to ticket_checkout_path(@event.slug, @event.id) end - def show - @ticket = current_user.tickets.includes(:ticket_type, :event).find(params[:ticket_id]) - @event = @ticket.event - rescue ActiveRecord::RecordNotFound - redirect_to dashboard_path, alert: "Billet non trouvé" - end + def show + @ticket = current_user.orders.joins(:tickets).find(params[:ticket_id]) + @event = @ticket.event + rescue ActiveRecord::RecordNotFound + redirect_to dashboard_path, alert: "Billet non trouvé" + end private def set_event diff --git a/app/jobs/expired_orders_cleanup_job.rb b/app/jobs/expired_orders_cleanup_job.rb new file mode 100644 index 0000000..1040cc6 --- /dev/null +++ b/app/jobs/expired_orders_cleanup_job.rb @@ -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 diff --git a/app/models/event.rb b/app/models/event.rb index ed13f58..f6fa08f 100755 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -17,6 +17,7 @@ class Event < ApplicationRecord belongs_to :user has_many :ticket_types has_many :tickets, through: :ticket_types + has_many :orders # Validations for Event attributes # Basic information diff --git a/app/models/order.rb b/app/models/order.rb new file mode 100644 index 0000000..9a6004c --- /dev/null +++ b/app/models/order.rb @@ -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 \ No newline at end of file diff --git a/app/models/ticket.rb b/app/models/ticket.rb index 86e41c1..3dd744a 100755 --- a/app/models/ticket.rb +++ b/app/models/ticket.rb @@ -1,32 +1,25 @@ class Ticket < ApplicationRecord - # === Constants === - DRAFT_EXPIRY_TIME = 30.minutes - MAX_PAYMENT_ATTEMPTS = 3 - # === Associations === - belongs_to :user + belongs_to :order belongs_to :ticket_type has_one :event, through: :ticket_type + has_one :user, through: :order # === Validations === validates :qr_code, presence: true, uniqueness: true - validates :user_id, presence: true + validates :order_id, presence: true validates :ticket_type_id, presence: true 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 - validates :payment_attempts, presence: true, numericality: { greater_than_or_equal_to: 0 } # === Scopes === scope :draft, -> { where(status: "draft") } 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 :generate_qr_code, on: :create - before_validation :set_draft_expiry, on: :create # Generate PDF ticket def to_pdf @@ -38,36 +31,22 @@ class Ticket < ApplicationRecord price_cents / 100.0 end - # Check if ticket can be retried for payment + # Delegate payment methods to order def can_retry_payment? - draft? && payment_attempts < MAX_PAYMENT_ATTEMPTS && !expired? + order.can_retry_payment? end - # Check if ticket is expired def expired? - expires_at.present? && expires_at < Time.current + order.expired? 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? - 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 private @@ -86,11 +65,6 @@ class Ticket < ApplicationRecord 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? status == "draft" diff --git a/app/models/user.rb b/app/models/user.rb index 8076d13..73925e1 100755 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -22,6 +22,7 @@ class User < ApplicationRecord # Relationships has_many :events, dependent: :destroy has_many :tickets, dependent: :destroy + has_many :orders, dependent: :destroy # Validations validates :last_name, length: { minimum: 3, maximum: 12, allow_blank: true } diff --git a/app/views/orders/checkout.html.erb b/app/views/orders/checkout.html.erb new file mode 100644 index 0000000..ec96e86 --- /dev/null +++ b/app/views/orders/checkout.html.erb @@ -0,0 +1,244 @@ +
Votre commande va expirer dans quelques minutes. Veuillez procéder rapidement au paiement pour éviter son expiration automatique.
++ Tentative <%= @order.payment_attempts + 1 %> sur <%= @order.class::MAX_PAYMENT_ATTEMPTS %>. + <% if @order.payment_attempts >= @order.class::MAX_PAYMENT_ATTEMPTS - 1 %> + Dernière tentative avant expiration ! + <% end %> +
+TVA incluse
+Procédez au paiement pour finaliser votre commande
+Vos données bancaires sont protégées par le cryptage SSL et traitées par Stripe, leader mondial du paiement en ligne.
+Le système de paiement n'est pas encore configuré. Veuillez contacter l'organisateur pour plus d'informations.
++ Félicitations ! Votre commande a été traitée avec succès. Vous allez recevoir vos billets par email d'ici quelques minutes. +
+Que faire maintenant ?
+Nous avons envoyé vos billets à <%= current_user.email %>. Vérifiez aussi vos spams.
+Gardez vos billets sur votre téléphone ou imprimez-les.
+Présentez votre billet (QR code) à l'entrée. Arrivez un peu en avance !
+Si vous avez des questions ou des problèmes avec votre commande, n'hésitez pas à nous contacter.
+Vous avez des billets qui nécessitent un paiement
+Vous avez des commandes qui nécessitent un paiement
- <%= event.start_time.strftime("%d %B %Y à %H:%M") %> + <%= order.event.start_time.strftime("%d %B %Y à %H:%M") %>