docs: Add how to implement an order system
This commit is contained in:
414
docs/order-system-implementation.md
Normal file
414
docs/order-system-implementation.md
Normal file
@@ -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
|
||||
<!-- 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.
|
||||
Reference in New Issue
Block a user