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