# 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:** ```sql 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:** ```sql -- 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`** ```ruby 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`** ```ruby 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:** ```ruby # 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:** ```ruby 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`** ```ruby # 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`** ```erb

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

Récapitulatif de votre commande

<% @order.tickets.each do |ticket| %>
<%= ticket.ticket_type.name %> <%= ticket.first_name %> <%= ticket.last_name %> <%= ticket.price_euros %>€
<% end %>
Total: <%= @order.total_amount_euros %>€
<% if @checkout_session.present? %> <% 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.