diff --git a/docs/order-system-implementation.md b/docs/order-system-implementation.md new file mode 100644 index 0000000..51dfe15 --- /dev/null +++ b/docs/order-system-implementation.md @@ -0,0 +1,414 @@ +# 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. \ No newline at end of file