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 @@ +
+
+ + + +
+ +
+ + <% if @expiring_soon %> +
+
+ + + +
+

Attention - Commande bientôt expirée

+

Votre commande va expirer dans quelques minutes. Veuillez procéder rapidement au paiement pour éviter son expiration automatique.

+
+
+
+ <% end %> + + + <% if @order.payment_attempts > 0 %> +
+
+ + + +
+

Nouvelle tentative de paiement

+

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

+
+
+
+ <% end %> + +
+

Commande pour <%= @order.event.name %>

+
+
+ + + + <% if @order.expires_at %> + Expire dans <%= time_ago_in_words(@order.expires_at, include_seconds: true) %> + <% end %> +
+
+ + + + Commande #<%= @order.id %> +
+
+
+ + +
+

Récapitulatif de votre commande

+ + <% @tickets.each do |ticket| %> +
+
+

<%= ticket.ticket_type.name %>

+
+ + + + <%= ticket.first_name %> <%= ticket.last_name %> +
+
+
+
<%= ticket.price_euros %>€
+ <% if ticket.ticket_type.description.present? %> +
<%= truncate(ticket.ticket_type.description, length: 30) %>
+ <% end %> +
+
+ <% end %> +
+ + +
+
+ Total + <%= @order.total_amount_euros %>€ +
+

TVA incluse

+
+
+ + +
+
+

Paiement sécurisé

+

Procédez au paiement pour finaliser votre commande

+
+ + <% if @checkout_session.present? %> + +
+
+
+ + + +
+

Paiement 100% sécurisé

+

Vos données bancaires sont protégées par le cryptage SSL et traitées par Stripe, leader mondial du paiement en ligne.

+
+
+
+ + + +
+ + + + + Visa + + + + + + Mastercard + + + + + + Sécurisé par Stripe + +
+ + + +
+ <% else %> + +
+
+ + + +

Paiement temporairement indisponible

+

Le système de paiement n'est pas encore configuré. Veuillez contacter l'organisateur pour plus d'informations.

+
+
+ <% end %> + + +
+
+ <%= 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 %> +
+ + + + Retour à l'événement +
+ <% end %> +
+
+
+
+
+
\ No newline at end of file diff --git a/app/views/orders/payment_success.html.erb b/app/views/orders/payment_success.html.erb new file mode 100644 index 0000000..d42f5ca --- /dev/null +++ b/app/views/orders/payment_success.html.erb @@ -0,0 +1,191 @@ +
+
+ +
+
+ + + +
+

Paiement réussi !

+

+ Félicitations ! Votre commande a été traitée avec succès. Vous allez recevoir vos billets par email d'ici quelques minutes. +

+
+ +
+ +
+
+

Récapitulatif de la commande

+
+
+ + + + Commande #<%= @order.id %> +
+
+ + + + Payée +
+
+
+ + +
+

Événement

+
+

<%= @order.event.name %>

+
+ <% if @order.event.start_time %> +
+ + + + <%= l(@order.event.start_time, format: :long) %> +
+ <% end %> + <% if @order.event.venue_name.present? %> +
+ + + + + <%= @order.event.venue_name %> +
+ <% end %> +
+
+
+ + +
+

Vos billets

+ + <% @order.tickets.each do |ticket| %> +
+
+

<%= ticket.ticket_type.name %>

+
+ + + + <%= ticket.first_name %> <%= ticket.last_name %> +
+
+ + + + Actif +
+
+
+
<%= ticket.price_euros %>€
+
+
+ <% end %> +
+ + +
+
+ Total payé + <%= @order.total_amount_euros %>€ +
+
+
+ + +
+
+

Prochaines étapes

+

Que faire maintenant ?

+
+ +
+ +
+
+ 1 +
+
+

Vérifiez votre email

+

Nous avons envoyé vos billets à <%= current_user.email %>. Vérifiez aussi vos spams.

+
+
+ + +
+
+ 2 +
+
+

Téléchargez vos billets

+

Gardez vos billets sur votre téléphone ou imprimez-les.

+
+ <% @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 %> + + + + <%= ticket.first_name %> <%= ticket.last_name %> + <% end %> + <% end %> +
+
+
+ + +
+
+ 3 +
+
+

Le jour J

+

Présentez votre billet (QR code) à l'entrée. Arrivez un peu en avance !

+
+
+
+ + +
+

Besoin d'aide ?

+

Si vous avez des questions ou des problèmes avec votre commande, n'hésitez pas à nous contacter.

+
+ <%= link_to "mailto:support@example.com", class: "inline-flex items-center text-sm text-purple-600 hover:text-purple-700" do %> + + + + Contactez le support + <% end %> +
+
+ + +
+
+ <%= 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 %> +
+ + + + Voir tous mes billets +
+ <% 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 %> +
+ + + + Découvrir d'autres événements +
+ <% end %> +
+
+
+
+
+
\ No newline at end of file diff --git a/app/views/pages/dashboard.html.erb b/app/views/pages/dashboard.html.erb index b476858..5f862d8 100755 --- a/app/views/pages/dashboard.html.erb +++ b/app/views/pages/dashboard.html.erb @@ -31,8 +31,8 @@ - - <% if @draft_tickets.any? %> + + <% if @draft_orders.any? %>
@@ -41,40 +41,39 @@ - Billets en attente de paiement + Commandes en attente de paiement -

Vous avez des billets qui nécessitent un paiement

+

Vous avez des commandes qui nécessitent un paiement

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

<%= event.name %>

+

<%= order.event.name %>

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

- <%= tickets.count %> billet<%= 's' if tickets.count > 1 %> + Commande #<%= order.id %>
- <% tickets.each do |ticket| %> + <% order.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: "€") %>
@@ -83,17 +82,17 @@
- <% max_attempts = tickets.map(&:payment_attempts).max %> - Tentatives: <%= max_attempts %>/3 - <% if tickets.any?(&:expiring_soon?) %> - ⚠️ Expire bientôt + Tentatives: <%= order.payment_attempts %>/3 + <% if order.expiring_soon? %> + ⚠️ Expire dans <%= time_ago_in_words(order.expires_at) %> + <% else %> + Expire dans <%= time_ago_in_words(order.expires_at) %> <% 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" %> + <%= link_to retry_payment_order_path(order), method: :post, + 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 %> + Reprendre le paiement (<%= order.total_amount_euros %>€) <% end %>
diff --git a/config/routes.rb b/config/routes.rb index 488f1f7..2b996cb 100755 --- a/config/routes.rb +++ b/config/routes.rb @@ -34,6 +34,35 @@ Rails.application.routes.draw do # === Pages === 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 === namespace :promoter do resources :events do @@ -43,7 +72,7 @@ Rails.application.routes.draw do patch :cancel patch :mark_sold_out end - + # Nested ticket types routes resources :ticket_types do member do @@ -53,22 +82,6 @@ Rails.application.routes.draw do 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 namespace :api do diff --git a/db/migrate/20250823170408_create_ticket_types.rb b/db/migrate/20250823170408_create_ticket_types.rb index b62b458..8eb782e 100755 --- a/db/migrate/20250823170408_create_ticket_types.rb +++ b/db/migrate/20250823170408_create_ticket_types.rb @@ -8,6 +8,8 @@ class CreateTicketTypes < ActiveRecord::Migration[8.0] t.datetime :sale_start_at t.datetime :sale_end_at t.integer :minimum_age + t.boolean :requires_id, default: false, null: false + t.references :event, null: false, foreign_key: false t.timestamps diff --git a/db/migrate/20250823170409_create_orders.rb b/db/migrate/20250823170409_create_orders.rb new file mode 100644 index 0000000..8e0f057 --- /dev/null +++ b/db/migrate/20250823170409_create_orders.rb @@ -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 \ No newline at end of file diff --git a/db/migrate/20250823171354_create_tickets.rb b/db/migrate/20250823171354_create_tickets.rb index 80d9918..a504f28 100755 --- a/db/migrate/20250823171354_create_tickets.rb +++ b/db/migrate/20250823171354_create_tickets.rb @@ -9,30 +9,13 @@ 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 + # Tickets belong to orders (orders handle payment logic) + t.references :order, null: false, foreign_key: true + t.references :ticket_type, null: false, foreign_key: true t.timestamps end 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 diff --git a/db/migrate/20250831184955_add_requires_id_to_ticket_types.rb b/db/migrate/20250831184955_add_requires_id_to_ticket_types.rb deleted file mode 100644 index f979708..0000000 --- a/db/migrate/20250831184955_add_requires_id_to_ticket_types.rb +++ /dev/null @@ -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 diff --git a/db/schema.rb b/db/schema.rb index 554acd5..b8e879e 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # 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| t.string "name", 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" 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| t.string "name" 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 "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 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 ["ticket_type_id"], name: "index_tickets_on_ticket_type_id" - t.index ["user_id"], name: "index_tickets_on_user_id" end 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 ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true end + + add_foreign_key "orders", "events" + add_foreign_key "orders", "users" + add_foreign_key "tickets", "orders" end diff --git a/test/jobs/expired_orders_cleanup_job_test.rb b/test/jobs/expired_orders_cleanup_job_test.rb new file mode 100644 index 0000000..1703da2 --- /dev/null +++ b/test/jobs/expired_orders_cleanup_job_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class ExpiredOrdersCleanupJobTest < ActiveJob::TestCase + # test "the truth" do + # assert true + # end +end