Files
aperonight/docs/order-system-implementation.md

11 KiB

Order System Implementation Guide

Overview

This guide outlines how to implement an Order system in your Rails ticketing application, replacing the current individual ticket-based approach with a more robust order-based system.

Current System Analysis

Your current system has:

  • Individual tickets directly associated with users
  • Payment attempts tracked per ticket
  • No grouping of related tickets
  • Complex checkout logic in controllers

Proposed Order System Architecture

Database Schema Changes

New orders table:

CREATE TABLE orders (
  id SERIAL PRIMARY KEY,
  user_id INTEGER NOT NULL REFERENCES users(id),
  event_id INTEGER NOT NULL REFERENCES events(id),
  status VARCHAR(255) NOT NULL DEFAULT 'draft',
  total_amount_cents INTEGER NOT NULL DEFAULT 0,
  payment_attempts INTEGER NOT NULL DEFAULT 0,
  expires_at TIMESTAMP,
  last_payment_attempt_at TIMESTAMP,
  created_at TIMESTAMP NOT NULL,
  updated_at TIMESTAMP NOT NULL
);

-- Indexes for performance
CREATE INDEX idx_orders_user_status ON orders(user_id, status);
CREATE INDEX idx_orders_event_status ON orders(event_id, status);
CREATE INDEX idx_orders_expires_at ON orders(expires_at);

Updated tickets table:

-- Add order_id column
ALTER TABLE tickets ADD COLUMN order_id INTEGER REFERENCES orders(id);

-- Update existing tickets (if any)
UPDATE tickets SET order_id = (SELECT id FROM orders WHERE user_id = tickets.user_id LIMIT 1);

-- Make order_id NOT NULL after data migration
ALTER TABLE tickets ALTER COLUMN order_id SET NOT NULL;

-- Remove user_id from tickets (optional, but recommended)
-- ALTER TABLE tickets DROP COLUMN user_id;

1. Create Order Model

File: app/models/order.rb

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

2. Update Ticket Model

File: app/models/ticket.rb

class Ticket < ApplicationRecord
  # === Constants ===
  DRAFT_EXPIRY_TIME = 30.minutes
  MAX_PAYMENT_ATTEMPTS = 3

  # === Associations ===
  belongs_to :order  # Changed from belongs_to :user
  belongs_to :ticket_type
  has_one :user, through: :order  # Access user through order
  has_one :event, through: :ticket_type

  # === Validations ===
  validates :qr_code, presence: true, uniqueness: true
  validates :order_id, presence: true  # Changed from user_id
  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
  # Removed payment_attempts validation (now handled by Order)

  # === Scopes ===
  scope :draft, -> { where(status: "draft") }
  scope :active, -> { where(status: "active") }
  # Removed payment-related scopes (now in Order)

  before_validation :set_price_from_ticket_type, on: :create
  before_validation :generate_qr_code, on: :create
  # Removed set_draft_expiry (now in Order)

  # === Instance Methods ===

  # Price in euros (formatted)
  def price_euros
    price_cents / 100.0
  end

  # Delegate payment methods to order
  def can_retry_payment?
    order.can_retry_payment?
  end

  def expired?
    order.expired?
  end

  def expiring_soon?
    order.expiring_soon?
  end

  # Mark ticket as expired if it's past expiry time
  def expire_if_overdue!
    return unless draft? && expired?
    update!(status: "expired")
  end

  # Generate PDF ticket
  def to_pdf
    TicketPdfGenerator.new(self).generate
  end

  private

  def set_price_from_ticket_type
    return unless ticket_type
    self.price_cents = ticket_type.price_cents
  end

  def generate_qr_code
    return if qr_code.present?
    loop do
      self.qr_code = SecureRandom.uuid
      break unless Ticket.exists?(qr_code: qr_code)
    end
  end

  def draft?
    status == "draft"
  end
end

3. Update Controllers

TicketsController Changes

File: app/controllers/tickets_controller.rb

Key changes needed:

  1. Create orders instead of individual tickets
  2. Update session management to track order_id instead of draft_ticket_ids
  3. Modify checkout logic to work with orders
  4. Update payment success/cancel handling

Main changes in create action:

# OLD: Create individual tickets
@event = Event.includes(:ticket_types).find(params[:id])
@cart_data = session[:pending_cart] || {}
# ... create individual tickets

# NEW: Create order with tickets
@event = Event.includes(:ticket_types).find(params[:id])
@cart_data = session[:pending_cart] || {}

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])
    @order.tickets.create!(
      ticket_type: ticket_type,
      first_name: ticket_attrs[:first_name],
      last_name: ticket_attrs[:last_name],
      status: "draft"
    )
  end

  if @order.tickets.present?
    @order.calculate_total!
    session[:draft_order_id] = @order.id
    redirect_to order_checkout_path(@order)
  else
    @order.destroy
    # ... handle error
  end
end

Updated checkout action:

def checkout
  @order = current_user.orders.includes(tickets: :ticket_type)
                       .find_by(id: params[:id], status: "draft")

  return redirect_to event_path(@order.event.slug, @order.event),
                     alert: "Commande introuvable" unless @order

  # 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é"
  end

  @tickets = @order.tickets
  @total_amount = @order.total_amount_cents
  @expiring_soon = @order.expiring_soon?

  # Create Stripe session if configured
  if Rails.application.config.stripe[:secret_key].present?
    begin
      @checkout_session = create_stripe_session
      @order.increment_payment_attempt!
    rescue => e
      # handle error
    end
  end
end

4. Update Routes

File: config/routes.rb

# Add order routes
resources :orders, only: [:show] do
  member do
    get :checkout
    post :retry_payment
  end
end

# Update existing ticket routes to work with orders
# ... existing routes

5. Update Views

Checkout View Changes

File: app/views/orders/checkout.html.erb

<!-- Display order summary -->
<h1>Commande pour <%= @order.event.name %></h1>

<div class="order-summary">
  <h3>Récapitulatif de votre commande</h3>
  <% @order.tickets.each do |ticket| %>
    <div class="ticket-item">
      <span><%= ticket.ticket_type.name %></span>
      <span><%= ticket.first_name %> <%= ticket.last_name %></span>
      <span><%= ticket.price_euros %>€</span>
    </div>
  <% end %>

  <div class="total">
    <strong>Total: <%= @order.total_amount_euros %>€</strong>
  </div>
</div>

<!-- Stripe payment button -->
<% if @checkout_session.present? %>
  <!-- Stripe checkout integration -->
<% end %>

6. Migration Strategy

  1. Create migration for orders table
  2. Add order_id to tickets table
  3. Create data migration to associate existing tickets with orders
  4. Update existing data
  5. Remove old payment_attempts from tickets table

7. Testing Strategy

  1. Unit Tests:

    • Order model validations
    • Order status transitions
    • Payment attempt logic
  2. Integration Tests:

    • Complete order flow
    • Stripe integration
    • Order expiry handling
  3. Edge Cases:

    • Order with no tickets
    • Expired orders
    • Payment failures
    • Concurrent order creation

Benefits of This Implementation

  1. Better Data Organization: Related tickets grouped logically
  2. Improved Performance: Single query for order with tickets
  3. Enhanced UX: Clear order summaries and history
  4. Better Analytics: Order-level metrics and reporting
  5. Scalability: Easier to add features like order management
  6. Payment Logic: Centralized payment attempt tracking

Migration Checklist

  • Create Order model
  • Create orders migration
  • Update Ticket model associations
  • Update TicketsController
  • Update routes
  • Update views
  • Run migrations
  • Test complete flow
  • Update documentation

This implementation provides a solid foundation for a scalable ticketing system with proper order management.