diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb index 1ced93f..43e919d 100755 --- a/app/controllers/pages_controller.rb +++ b/app/controllers/pages_controller.rb @@ -29,6 +29,11 @@ class PagesController < ApplicationController .distinct .limit(5) + # Draft tickets that can be retried + @draft_tickets = current_user.tickets.includes(: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) @tomorrow_events = Event.published.where("DATE(start_time) = ?", Date.current + 1).order(start_time: :asc) diff --git a/app/controllers/tickets_controller.rb b/app/controllers/tickets_controller.rb index 51d5698..d006a3c 100644 --- a/app/controllers/tickets_controller.rb +++ b/app/controllers/tickets_controller.rb @@ -106,6 +106,36 @@ class TicketsController < ApplicationController @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 @@ -113,10 +143,16 @@ class TicketsController < ApplicationController @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}" @@ -138,9 +174,6 @@ class TicketsController < ApplicationController return end - # Stripe is now initialized at application startup, no need to initialize here - Rails.logger.debug "Payment success - Using globally initialized Stripe" - begin stripe_session = Stripe::Checkout::Session.retrieve(session_id) @@ -148,7 +181,7 @@ class TicketsController < ApplicationController # 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 @@ -158,14 +191,14 @@ class TicketsController < ApplicationController @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 @@ -192,16 +225,51 @@ class TicketsController < ApplicationController # Handle payment failure/cancellation def payment_cancel - redirect_to dashboard_path, alert: "Le paiement a été annulé" + # 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 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é" + # Allow users to retry payment for failed/cancelled payments + 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? + redirect_to event_path(@event.slug, @event), + alert: "Aucun billet disponible pour un nouveau paiement" + return + 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 private def set_event diff --git a/app/jobs/cleanup_expired_drafts_job.rb b/app/jobs/cleanup_expired_drafts_job.rb new file mode 100644 index 0000000..c7d5a29 --- /dev/null +++ b/app/jobs/cleanup_expired_drafts_job.rb @@ -0,0 +1,15 @@ +class CleanupExpiredDraftsJob < ApplicationJob + queue_as :default + + def perform + expired_count = 0 + + Ticket.expired_drafts.find_each do |ticket| + Rails.logger.info "Expiring draft ticket #{ticket.id} for user #{ticket.user_id}" + ticket.expire_if_overdue! + expired_count += 1 + end + + Rails.logger.info "Expired #{expired_count} draft tickets" if expired_count > 0 + end +end \ No newline at end of file diff --git a/app/models/ticket.rb b/app/models/ticket.rb index e30171f..86e41c1 100755 --- a/app/models/ticket.rb +++ b/app/models/ticket.rb @@ -1,4 +1,8 @@ class Ticket < ApplicationRecord + # === Constants === + DRAFT_EXPIRY_TIME = 30.minutes + MAX_PAYMENT_ATTEMPTS = 3 + # === Associations === belongs_to :user belongs_to :ticket_type @@ -12,9 +16,17 @@ class Ticket < ApplicationRecord 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 @@ -26,6 +38,38 @@ class Ticket < ApplicationRecord price_cents / 100.0 end + # Check if ticket can be retried for payment + def can_retry_payment? + draft? && payment_attempts < MAX_PAYMENT_ATTEMPTS && !expired? + end + + # Check if ticket is expired + def expired? + expires_at.present? && expires_at < Time.current + 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? + + expires_at <= 5.minutes.from_now + end + private def set_price_from_ticket_type @@ -41,4 +85,14 @@ class Ticket < ApplicationRecord break unless Ticket.exists?(qr_code: qr_code) 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" + end end diff --git a/app/views/components/_header.html.erb b/app/views/components/_header.html.erb index d30df79..4ff2997 100755 --- a/app/views/components/_header.html.erb +++ b/app/views/components/_header.html.erb @@ -29,18 +29,18 @@
<% else %> <%= link_to t("header.login"), new_user_session_path, - class: "bg-black text-gray-100 hover:text-purple-200 px-3 py-2 rounded-md text-sm font-medium transition-colors duration-200" %> + class: "text-gray-100 hover:text-purple-200 px-3 py-2 rounded-md text-sm font-medium transition-colors duration-200" %> <%= link_to t("header.register"), new_user_registration_path, class: "bg-purple-600 text-white font-medium py-2 px-4 rounded-lg hover:bg-purple-700 transition-colors duration-200" %> <% end %> @@ -63,9 +63,9 @@Vous avez des billets qui nécessitent un paiement
++ + <%= event.start_time.strftime("%d %B %Y à %H:%M") %> +
+Vos billets vont expirer dans quelques minutes. Veuillez procéder rapidement au paiement pour éviter leur suppression automatique.
++ <% remaining_attempts = 3 - current_attempt %> + <% if remaining_attempts > 0 %> + Il vous reste <%= remaining_attempts %> tentative<%= 's' if remaining_attempts > 1 %> après celle-ci. + <% else %> + Ceci est votre dernière tentative de paiement. + <% end %> +
+