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 @@ + + <% if @draft_tickets.any? %> +
+
+ +
+

+ + + + Billets en attente de paiement +

+

Vous avez des billets qui nécessitent un paiement

+
+ +
+
+
+ <% @draft_tickets.group_by(&:event).each do |event, tickets| %> +
+
+
+

<%= event.name %>

+

+ + + + <%= event.start_time.strftime("%d %B %Y à %H:%M") %> +

+
+ + <%= tickets.count %> billet<%= 's' if tickets.count > 1 %> + +
+ +
+ <% tickets.each do |ticket| %> +
+
+ <%= ticket.ticket_type.name %> + - <%= ticket.first_name %> <%= ticket.last_name %> +
+
+ Expire <%= time_ago_in_words(ticket.expires_at) %> + <%= number_to_currency(ticket.price_euros, unit: "€") %> +
+
+ <% end %> +
+ +
+
+ <% max_attempts = tickets.map(&:payment_attempts).max %> + Tentatives: <%= max_attempts %>/3 + <% if tickets.any?(&:expiring_soon?) %> + ⚠️ Expire bientôt + <% end %> +
+ + <%= form_tag ticket_retry_payment_path(event.slug, event.id), method: :post do %> + <%= hidden_field_tag :ticket_ids, tickets.map(&:id).join(',') %> + <%= submit_tag "Reprendre le paiement", + 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 %> +
+
+
+ <% end %> +
diff --git a/app/views/tickets/checkout.html.erb b/app/views/tickets/checkout.html.erb index 3044083..5a094ce 100644 --- a/app/views/tickets/checkout.html.erb +++ b/app/views/tickets/checkout.html.erb @@ -31,6 +31,45 @@
+ + <% if @expiring_soon %> +
+
+ + + +
+

Attention - Billets bientôt expirés

+

Vos billets vont expirer dans quelques minutes. Veuillez procéder rapidement au paiement pour éviter leur suppression automatique.

+
+
+
+ <% end %> + + + <% max_attempts = @tickets.map(&:payment_attempts).max %> + <% if max_attempts >= 0 %> + <% current_attempt = max_attempts + 1 %> +
+
+ + + +
+

Tentative de paiement <%= current_attempt %>/3

+

+ <% 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 %> +

+
+
+
+ <% end %> +
diff --git a/config/initializers/ticket_cleanup_scheduler.rb b/config/initializers/ticket_cleanup_scheduler.rb new file mode 100644 index 0000000..ba1e8d7 --- /dev/null +++ b/config/initializers/ticket_cleanup_scheduler.rb @@ -0,0 +1,23 @@ +# Schedule regular cleanup of expired draft tickets +# +# This will run every 10 minutes to clean up expired draft tickets +# If you're using a more sophisticated scheduler like sidekiq or whenever, +# you can move this logic there. + +Rails.application.config.after_initialize do + # Only run in production and development, not in test + unless Rails.env.test? + # Schedule the cleanup job to run every 10 minutes + Thread.new do + loop do + begin + CleanupExpiredDraftsJob.perform_later + rescue => e + Rails.logger.error "Failed to schedule expired drafts cleanup: #{e.message}" + end + + sleep 10.minutes + end + end + end +end \ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index e313d3c..b73975e 100755 --- a/config/routes.rb +++ b/config/routes.rb @@ -42,6 +42,7 @@ Rails.application.routes.draw do 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" diff --git a/db/migrate/20250823171354_create_tickets.rb b/db/migrate/20250823171354_create_tickets.rb index 206f65f..80d9918 100755 --- a/db/migrate/20250823171354_create_tickets.rb +++ b/db/migrate/20250823171354_create_tickets.rb @@ -9,6 +9,14 @@ class CreateTickets < ActiveRecord::Migration[8.0] t.string :first_name t.string :last_name + # Implemented to "temporize" tickets + # If a ticket is not paid in time, it is removed from the database + # + 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 @@ -22,5 +30,9 @@ class CreateTickets < ActiveRecord::Migration[8.0] # 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 diff --git a/db/schema.rb b/db/schema.rb old mode 100755 new mode 100644 index 34acfd8..1b19dc7 --- a/db/schema.rb +++ b/db/schema.rb @@ -40,7 +40,6 @@ ActiveRecord::Schema[8.0].define(version: 2025_08_23_171354) do t.integer "quantity" t.datetime "sale_start_at" t.datetime "sale_end_at" - t.boolean "requires_id" t.integer "minimum_age" t.bigint "event_id", null: false t.datetime "created_at", null: false @@ -53,9 +52,13 @@ ActiveRecord::Schema[8.0].define(version: 2025_08_23_171354) do create_table "tickets", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t| t.string "qr_code" t.integer "price_cents" - t.string "status", default: "active" + t.string "status", default: "draft" t.string "first_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.datetime "created_at", null: false diff --git a/lib/tasks/tickets.rake b/lib/tasks/tickets.rake new file mode 100644 index 0000000..14bef56 --- /dev/null +++ b/lib/tasks/tickets.rake @@ -0,0 +1,22 @@ +namespace :tickets do + desc "Clean up expired draft tickets" + task cleanup_expired_drafts: :environment do + puts "Starting cleanup of expired draft tickets..." + CleanupExpiredDraftsJob.perform_now + puts "Cleanup completed." + end + + desc "Show stats about draft tickets" + task stats: :environment do + total_drafts = Ticket.draft.count + expired_drafts = Ticket.expired_drafts.count + retryable_drafts = Ticket.can_retry_payment.count + + puts "=== Draft Ticket Statistics ===" + puts "Total draft tickets: #{total_drafts}" + puts "Expired draft tickets: #{expired_drafts}" + puts "Retryable draft tickets: #{retryable_drafts}" + puts "Max payment attempts: #{Ticket::MAX_PAYMENT_ATTEMPTS}" + puts "Draft expiry time: #{Ticket::DRAFT_EXPIRY_TIME}" + end +end \ No newline at end of file