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

414 lines
11 KiB
Markdown

# 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
<!-- 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.