5 Commits

Author SHA1 Message Date
kbe
be3d80e541 chore: prepare checkout handling with stripe 2025-08-28 19:11:40 +02:00
kbe
0b58768a24 docs: More about how to process the checkout 2025-08-28 19:11:23 +02:00
kbe
911e821948 feat(events): breadcrumb on page
- Add breadcrumb on ``/events`` page
2025-08-28 18:50:19 +02:00
kbe
2fd93dc3bf style(ticket): Improve mobile layout for ticket card component
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2025-08-28 18:46:25 +02:00
kbe
a3dce5c363 style(events): Improve event display layout and styling
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2025-08-28 18:45:48 +02:00
7 changed files with 694 additions and 60 deletions

View File

@@ -0,0 +1,90 @@
/* Events page specific styles */
.events-page {
background: linear-gradient(135deg, var(--color-neutral-50) 0%, var(--color-neutral-100) 100%);
min-height: 100vh;
}
.events-page .breadcrumb {
padding: var(--space-4) 0;
}
.events-page .event-card {
background: white;
border-radius: var(--radius-2xl);
overflow: hidden;
box-shadow: var(--shadow-lg);
transition: all var(--duration-slow) var(--ease-out);
border: 1px solid var(--color-neutral-200);
position: relative;
}
.events-page .event-card:hover {
transform: translateY(-8px) scale(1.02);
box-shadow: var(--shadow-2xl);
border-color: var(--color-primary-200);
}
.events-page .event-date-badge {
background: linear-gradient(135deg, var(--color-primary-100) 0%, var(--color-accent-100) 100%);
color: var(--color-primary-800);
font-weight: 700;
padding: var(--space-1) var(--space-3);
border-radius: var(--radius-full);
font-size: var(--text-xs);
}
.events-page .price-highlight {
color: var(--color-primary-600);
font-weight: 800;
}
.events-page .pagination {
margin-top: var(--space-12);
}
.events-page .pagination .page,
.events-page .pagination .next,
.events-page .pagination .last,
.events-page .pagination .prev,
.events-page .pagination .first {
padding: var(--space-2) var(--space-4);
border-radius: var(--radius-lg);
margin: 0 var(--space-1);
transition: all var(--duration-normal);
}
.events-page .pagination .page:hover,
.events-page .pagination .next:hover,
.events-page .pagination .last:hover,
.events-page .pagination .prev:hover,
.events-page .pagination .first:hover {
background: var(--color-primary-100);
color: var(--color-primary-700);
}
.events-page .pagination .current {
background: var(--color-primary-600);
color: white;
font-weight: 700;
}
.events-page .no-events-card {
background: white;
border-radius: var(--radius-2xl);
padding: var(--space-12);
box-shadow: var(--shadow-lg);
text-align: center;
max-width: 500px;
margin: 0 auto;
}
@media (max-width: 768px) {
.events-page .event-grid {
grid-template-columns: 1fr;
}
.events-page .no-events-card {
padding: var(--space-8);
}
}

View File

@@ -43,14 +43,14 @@ class EventsController < ApplicationController
# Create Stripe line item
line_items << {
price_data: {
currency: 'eur',
currency: "eur",
product_data: {
name: "#{@event.name} - #{ticket_type.name}",
description: ticket_type.description,
description: ticket_type.description
},
unit_amount: ticket_type.price_cents,
unit_amount: ticket_type.price_cents
},
quantity: quantity,
quantity: quantity
}
# Store for ticket creation
@@ -72,10 +72,10 @@ class EventsController < ApplicationController
begin
# Create Stripe Checkout Session
session = Stripe::Checkout::Session.create({
payment_method_types: ['card'],
payment_method_types: [ "card" ],
line_items: line_items,
mode: 'payment',
success_url: payment_success_url(event_id: @event.id, session_id: '{CHECKOUT_SESSION_ID}'),
mode: "payment",
success_url: payment_success_url(event_id: @event.id, session_id: "{CHECKOUT_SESSION_ID}"),
cancel_url: event_url(@event.slug, @event),
customer_email: current_user.email,
metadata: {
@@ -99,19 +99,19 @@ class EventsController < ApplicationController
begin
session = Stripe::Checkout::Session.retrieve(session_id)
if session.payment_status == 'paid'
if session.payment_status == "paid"
# Create tickets
@event = Event.find(event_id)
order_items = JSON.parse(session.metadata['order_items'])
order_items = JSON.parse(session.metadata["order_items"])
@tickets = []
order_items.each do |item|
ticket_type = TicketType.find(item['ticket_type_id'])
item['quantity'].times do
ticket_type = TicketType.find(item["ticket_type_id"])
item["quantity"].times do
ticket = Ticket.create!(
user: current_user,
ticket_type: ticket_type,
status: 'active'
status: "active"
)
@tickets << ticket
@@ -120,7 +120,7 @@ class EventsController < ApplicationController
end
end
render 'payment_success'
render "payment_success"
else
redirect_to event_path(@event.slug, @event), alert: "Le paiement n'a pas été complété avec succès"
end
@@ -140,8 +140,8 @@ class EventsController < ApplicationController
pdf = @ticket.to_pdf
send_data pdf,
filename: "ticket-#{@ticket.event.name.parameterize}-#{@ticket.qr_code[0..7]}.pdf",
type: 'application/pdf',
disposition: 'attachment'
type: "application/pdf",
disposition: "attachment"
end
end
end

View File

@@ -102,7 +102,12 @@ export default class extends Controller {
proceedToCheckout() {
if (Object.keys(this.cart).length === 0) {
alert('Veuillez sélectionner au moins un billet')
this.showNotification('Veuillez sélectionner au moins un billet', 'warning')
return
}
// Validate cart contents
if (!this.validateCartAvailability()) {
return
}
@@ -110,17 +115,13 @@ export default class extends Controller {
const isAuthenticated = document.body.dataset.userAuthenticated === "true"
if (!isAuthenticated) {
if (confirm('Vous devez être connecté pour acheter des billets. Souhaitez-vous vous connecter maintenant ?')) {
// Store cart in session storage
sessionStorage.setItem('pending_cart', JSON.stringify({
eventId: this.eventIdValue,
cart: this.cart
}))
window.location.href = '/auth/sign_in'
}
this.showLoginModal()
return
}
// Show loading state
this.setCheckoutLoading(true)
// Create form and submit to checkout
const form = document.createElement('form')
form.method = 'POST'
@@ -145,6 +146,141 @@ export default class extends Controller {
form.submit()
}
validateCartAvailability() {
// Check each ticket type availability before checkout
for (let ticketTypeId in this.cart) {
const input = this.quantityTargetFor(ticketTypeId)
if (input) {
const maxAvailable = parseInt(input.max)
const requested = this.cart[ticketTypeId].quantity
if (requested > maxAvailable) {
this.showNotification(`Seulement ${maxAvailable} billets disponibles pour ${this.cart[ticketTypeId].name}`, 'error')
// Adjust cart to maximum available
input.value = maxAvailable
this.updateCartItem(ticketTypeId, input)
return false
}
}
}
return true
}
showLoginModal() {
// Create and show modern login modal
const modal = document.createElement('div')
modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50'
modal.innerHTML = `
<div class="bg-white rounded-2xl p-8 max-w-md mx-4 shadow-2xl">
<div class="text-center">
<div class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-purple-100 mb-4">
<svg class="h-6 w-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</div>
<h3 class="text-lg font-medium text-gray-900 mb-2">Connexion requise</h3>
<p class="text-sm text-gray-500 mb-6">Vous devez être connecté pour acheter des billets. Votre panier sera conservé.</p>
<div class="flex flex-col sm:flex-row gap-3">
<button id="login-btn" class="flex-1 bg-purple-600 hover:bg-purple-700 text-white px-4 py-2 rounded-lg font-medium transition-colors">
Se connecter
</button>
<button id="cancel-login" class="flex-1 bg-gray-100 hover:bg-gray-200 text-gray-700 px-4 py-2 rounded-lg font-medium transition-colors">
Annuler
</button>
</div>
</div>
</div>
`
document.body.appendChild(modal)
// Handle login button
modal.querySelector('#login-btn').addEventListener('click', () => {
// Store cart in session storage
sessionStorage.setItem('pending_cart', JSON.stringify({
eventId: this.eventIdValue,
cart: this.cart
}))
window.location.href = '/auth/sign_in'
})
// Handle cancel button
modal.querySelector('#cancel-login').addEventListener('click', () => {
document.body.removeChild(modal)
})
// Handle backdrop click
modal.addEventListener('click', (e) => {
if (e.target === modal) {
document.body.removeChild(modal)
}
})
}
setCheckoutLoading(loading) {
const checkoutBtn = this.checkoutButtonTarget
if (loading) {
checkoutBtn.disabled = true
checkoutBtn.innerHTML = `
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Redirection...
`
} else {
checkoutBtn.disabled = false
checkoutBtn.innerHTML = `
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" />
</svg>
Procéder au paiement
`
}
}
showNotification(message, type = 'info') {
// Create toast notification
const toast = document.createElement('div')
const colors = {
success: 'bg-green-50 text-green-800 border-green-200',
error: 'bg-red-50 text-red-800 border-red-200',
warning: 'bg-yellow-50 text-yellow-800 border-yellow-200',
info: 'bg-blue-50 text-blue-800 border-blue-200'
}
toast.className = `fixed top-4 right-4 z-50 max-w-sm p-4 border rounded-lg shadow-lg ${colors[type]} transform transition-all duration-300 translate-x-full`
toast.innerHTML = `
<div class="flex items-center">
<div class="flex-1">
<p class="text-sm font-medium">${message}</p>
</div>
<button class="ml-3 text-sm font-medium opacity-70 hover:opacity-100" onclick="this.parentElement.parentElement.remove()">
×
</button>
</div>
`
document.body.appendChild(toast)
// Animate in
setTimeout(() => {
toast.classList.remove('translate-x-full')
}, 10)
// Auto remove after 5 seconds
setTimeout(() => {
if (document.body.contains(toast)) {
toast.classList.add('translate-x-full')
setTimeout(() => {
if (document.body.contains(toast)) {
document.body.removeChild(toast)
}
}, 300)
}
}, 5000)
}
checkForPendingCart() {
const pendingCart = sessionStorage.getItem('pending_cart')
if (pendingCart) {

View File

@@ -12,8 +12,8 @@
</div>
</div>
<div class="flex justify-between items-center mt-4">
<div>
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center mt-4 gap-3">
<div class="<%= 'order-2 sm:order-1' unless sold_out %>">
<% if sold_out %>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
<svg class="-ml-0.5 mr-1 h-2 w-2 text-red-400" fill="currentColor" viewBox="0 0 8 8">
@@ -32,7 +32,7 @@
</div>
<% unless sold_out %>
<div class="flex items-center space-x-2">
<div class="flex items-center space-x-2 order-1 sm:order-2">
<button type="button"
class="w-8 h-8 rounded-full bg-gray-100 hover:bg-gray-200 flex items-center justify-center transition-colors duration-200"
data-action="click->ticket-cart#decreaseQuantity"
@@ -59,7 +59,7 @@
</button>
</div>
<% else %>
<div class="text-sm text-gray-500 font-medium">
<div class="text-sm text-gray-500 font-medium order-1 sm:order-2">
<svg class="w-5 h-5 inline-block mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
</svg>

View File

@@ -1,23 +1,81 @@
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="flex justify-between items-center mb-8">
<div class="flex justify-between items-center my-8">
<h1 class="text-3xl font-bold text-gray-900">Événements à venir</h1>
<div class="text-sm text-gray-500">
<%= @events.total_count %> événements trouvés
<%= @events.total_count %>
événements trouvés
</div>
</div>
<!-- Breadcrumb -->
<nav class="mb-6" aria-label="Breadcrumb">
<ol class="flex items-center space-x-2 text-sm">
<%= link_to root_path, class: "text-gray-500 hover:text-purple-600 transition-colors" do %>
<svg
class="w-4 h-4 inline-block mr-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"
/>
</svg>
Accueil
<% end %>
<svg
class="w-4 h-4 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
<%= link_to events_path, class: "text-gray-500 hover:text-purple-600 transition-colors" do %>
Événements
<% end %>
</ol>
</nav>
<% if @events.any? %>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<% @events.each do |event| %>
<div class="bg-white rounded-xl shadow-md overflow-hidden hover:shadow-xl transition-all duration-300 transform hover:-translate-y-1">
<div
class="
bg-white rounded-xl shadow-md overflow-hidden hover:shadow-xl transition-all
duration-300 transform hover:-translate-y-1
"
>
<% if event.image.present? %>
<div class="h-48 overflow-hidden">
<%= image_tag event.image, class: "w-full h-full object-cover" %>
</div>
<% else %>
<div class="h-48 bg-gradient-to-r from-purple-500 to-indigo-600 flex items-center justify-center">
<svg class="w-16 h-16 text-white opacity-80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
<div
class="
h-48 bg-gradient-to-r from-purple-500 to-indigo-600 flex items-center
justify-center
"
>
<svg
class="w-16 h-16 text-white opacity-80"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
</div>
<% end %>
@@ -26,14 +84,24 @@
<div class="flex justify-between items-start mb-3">
<div>
<h2 class="text-xl font-bold text-gray-900 line-clamp-1"><%= event.name %></h2>
<p class="text-sm text-gray-500 mt-1 flex items-center">
<p class="text-xs text-gray-500 flex items-center mt-1">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
/>
</svg>
<%= event.user.email.split('@').first %>
<%= event.venue_name.truncate(20) %>
</p>
</div>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800">
<span
class="
inline-flex items-center my-2 px-2.5 py-2 rounded-full text-xs font-medium
bg-purple-100 text-purple-800
"
>
<%= event.start_time.strftime("%d/%m") %>
</span>
</div>
@@ -46,13 +114,8 @@
<div>
<% if event.ticket_types.any? %>
<p class="text-sm font-medium text-gray-900">
À partir de <%= format_price(event.ticket_types.minimum(:price_cents)) %>€
</p>
<p class="text-xs text-gray-500 flex items-center mt-1">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
</svg>
<%= event.venue_name.truncate(20) %>
À partir de
<%= format_price(event.ticket_types.minimum(:price_cents)) %>€
</p>
<% else %>
<p class="text-sm text-gray-500">Pas de billets disponibles</p>
@@ -62,7 +125,12 @@
<%= link_to event_path(event.slug, event), class: "inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-lg shadow-sm text-white bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-700 hover:to-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500 transition-all duration-200" do %>
Détails
<svg class="w-4 h-4 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
<% end %>
</div>
@@ -77,14 +145,32 @@
<% else %>
<div class="text-center py-16">
<div class="mx-auto max-w-md">
<div class="w-24 h-24 mx-auto bg-gradient-to-r from-purple-100 to-indigo-100 rounded-full flex items-center justify-center mb-6">
<svg class="w-12 h-12 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
<div
class="
w-24 h-24 mx-auto bg-gradient-to-r from-purple-100 to-indigo-100 rounded-full
flex items-center justify-center mb-6
"
>
<svg
class="w-12 h-12 text-purple-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
</div>
<h3 class="text-lg font-medium text-gray-900 mb-2">Aucun événement disponible</h3>
<p class="text-gray-500 mb-6">Il n'y a aucun événement à venir pour le moment.</p>
<%= link_to "Retour à l'accueil", root_path, class: "inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-700 hover:to-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500" %>
<%= link_to "Retour à l'accueil",
root_path,
class:
"inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-700 hover:to-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500" %>
</div>
</div>
<% end %>

View File

@@ -151,14 +151,14 @@
<div class="border-t border-gray-200 pt-6 mt-6">
<div class="flex justify-between items-center mb-2">
<span class="text-gray-600">Total billets:</span>
<span id="cart-count" class="font-medium">0</span>
<span data-ticket-cart-target="cartCount" class="font-medium">0</span>
</div>
<div class="flex justify-between items-center mb-4">
<span class="text-gray-600">Montant total:</span>
<span id="cart-total" class="text-xl font-bold text-purple-700">€0.00</span>
<span data-ticket-cart-target="cartTotal" class="text-xl font-bold text-purple-700">€0.00</span>
</div>
<button
id="checkout-btn"
data-ticket-cart-target="checkoutButton"
class="w-full bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-700 hover:to-indigo-700 text-white font-medium py-3 px-4 rounded-xl shadow-sm transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 flex items-center justify-center"
disabled
data-action="click->ticket-cart#proceedToCheckout">

322
docs/checkout-handle.md Executable file
View File

@@ -0,0 +1,322 @@
# Backend Checkout Handling Improvements
Based on your current Stripe integration, here are key improvements for robust checkout handling:
## 1. Enhanced Inventory Management with Concurrency Protection
The current implementation doesn't prevent overselling during concurrent purchases.
Add database-level concurrency protection:
```ruby
# app/controllers/events_controller.rb
def checkout
cart_data = JSON.parse(params[:cart] || "{}")
if cart_data.empty?
redirect_to event_path(@event.slug, @event), alert: "Veuillez sélectionner au moins un billet"
return
end
# Use transaction with row-level locking for inventory protection
ActiveRecord::Base.transaction do
line_items = []
order_items = []
cart_data.each do |ticket_type_id, item|
# Lock the ticket type row to prevent race conditions
ticket_type = @event.ticket_types.lock.find_by(id: ticket_type_id)
next unless ticket_type
quantity = item["quantity"].to_i
next if quantity <= 0
# Check real-time availability with locked row
sold_count = ticket_type.tickets.count
available = ticket_type.quantity - sold_count
if quantity > available
redirect_to event_path(@event.slug, @event), alert: "Plus que #{available} billets disponibles pour #{ticket_type.name}"
return
end
# Create line items and order data
line_items << {
price_data: {
currency: "eur",
product_data: {
name: "#{@event.name} - #{ticket_type.name}",
description: ticket_type.description
},
unit_amount: ticket_type.price_cents
},
quantity: quantity
}
order_items << {
ticket_type_id: ticket_type.id,
ticket_type_name: ticket_type.name,
quantity: quantity,
price_cents: ticket_type.price_cents
}
end
if order_items.empty?
redirect_to event_path(@event.slug, @event), alert: "Commande invalide"
return
end
# Create Stripe session only after inventory validation
session = Stripe::Checkout::Session.create({
payment_method_types: ["card"],
line_items: line_items,
mode: "payment",
success_url: payment_success_url(event_id: @event.id, session_id: "{CHECKOUT_SESSION_ID}"),
cancel_url: event_url(@event.slug, @event),
customer_email: current_user.email,
metadata: {
event_id: @event.id,
user_id: current_user.id,
order_items: order_items.to_json
}
})
redirect_to session.url, allow_other_host: true
end
rescue ActiveRecord::RecordNotFound
redirect_to event_path(@event.slug, @event), alert: "Type de billet introuvable"
rescue Stripe::StripeError => e
redirect_to event_path(@event.slug, @event), alert: "Erreur de traitement du paiement : #{e.message}"
end
```
## 2. Webhook Handler for Reliable Payment Confirmation
Create a dedicated webhook endpoint for more reliable payment processing:
### Routes Configuration
```ruby
# config/routes.rb
post '/webhooks/stripe', to: 'webhooks#stripe'
```
### Webhooks Controller
```ruby
# app/controllers/webhooks_controller.rb
class WebhooksController < ApplicationController
skip_before_action :verify_authenticity_token
before_action :verify_stripe_signature
def stripe
case @event.type
when 'checkout.session.completed'
handle_successful_payment(@event.data.object)
when 'payment_intent.payment_failed'
handle_failed_payment(@event.data.object)
end
head :ok
end
private
def handle_successful_payment(session)
# Process ticket creation in background job for reliability
CreateTicketsJob.perform_later(session.id)
end
def handle_failed_payment(session)
Rails.logger.error "Payment failed for session: #{session.id}"
# Add any additional handling for failed payments
end
def verify_stripe_signature
payload = request.body.read
sig_header = request.env['HTTP_STRIPE_SIGNATURE']
begin
@event = Stripe::Webhook.construct_event(
payload, sig_header, ENV['STRIPE_WEBHOOK_SECRET']
)
rescue JSON::ParserError, Stripe::SignatureVerificationError => e
Rails.logger.error "Stripe webhook signature verification failed: #{e.message}"
head :bad_request
end
end
end
```
## 3. Background Job for Ticket Creation
Use background jobs to prevent timeouts and improve reliability:
```ruby
# app/jobs/create_tickets_job.rb
class CreateTicketsJob < ApplicationJob
queue_as :default
retry_on StandardError, wait: :exponentially_longer, attempts: 5
def perform(session_id)
session = Stripe::Checkout::Session.retrieve(session_id)
return unless session.payment_status == 'paid'
# Prevent duplicate processing
return if Ticket.exists?(stripe_session_id: session_id)
order_items = JSON.parse(session.metadata['order_items'])
user = User.find(session.metadata['user_id'])
event = Event.find(session.metadata['event_id'])
ActiveRecord::Base.transaction do
order_items.each do |item|
ticket_type = TicketType.find(item['ticket_type_id'])
item['quantity'].times do
ticket = Ticket.create!(
user: user,
ticket_type: ticket_type,
status: 'active',
stripe_session_id: session_id, # Prevent duplicates
price_cents: item['price_cents'] # Store historical price
)
# Send email asynchronously
TicketMailer.purchase_confirmation(ticket).deliver_later
end
end
end
end
end
```
## 4. Enhanced Error Handling & Recovery in Payment Success
Improve the payment success handler with better error recovery:
```ruby
# app/controllers/events_controller.rb - Enhanced payment_success method
def payment_success
session_id = params[:session_id]
event_id = params[:event_id]
# Validate parameters
unless session_id.present? && event_id.present?
redirect_to dashboard_path, alert: "Paramètres de confirmation manquants"
return
end
begin
@tickets = Ticket.includes(:ticket_type, :event)
.where(stripe_session_id: session_id, user: current_user)
if @tickets.any?
# Tickets already created (webhook processed first)
@event = @tickets.first.event
render 'payment_success'
else
# Fallback: create tickets synchronously if webhook failed
session = Stripe::Checkout::Session.retrieve(session_id)
if session.payment_status == 'paid'
CreateTicketsJob.perform_now(session_id)
redirect_to payment_success_path(session_id: session_id, event_id: event_id)
else
redirect_to dashboard_path, alert: "Le paiement n'est pas encore confirmé"
end
end
rescue Stripe::StripeError => e
logger.error "Stripe error in payment_success: #{e.message}"
redirect_to dashboard_path, alert: "Erreur de confirmation de paiement"
rescue => e
logger.error "Unexpected error in payment_success: #{e.message}"
redirect_to dashboard_path, alert: "Une erreur inattendue s'est produite"
end
end
```
## 5. Database Schema Improvements
Add migration for better payment tracking:
```ruby
# db/migrate/xxx_add_payment_tracking_to_tickets.rb
class AddPaymentTrackingToTickets < ActiveRecord::Migration[7.0]
def change
add_column :tickets, :stripe_session_id, :string
add_column :tickets, :purchased_at, :timestamp, default: -> { 'CURRENT_TIMESTAMP' }
add_index :tickets, :stripe_session_id, unique: true
add_index :tickets, [:user_id, :purchased_at]
end
end
```
## 6. Security Considerations
1. **Rate Limiting**: Add rate limiting to checkout endpoints
2. **CSRF Protection**: Already implemented ✅
3. **Input Validation**: Validate all cart data thoroughly
4. **Audit Logging**: Log all payment attempts and outcomes
5. **PCI Compliance**: Never store card data (Stripe handles this) ✅
## 7. Monitoring & Observability
Add metrics tracking to monitor checkout performance:
```ruby
# Add to ApplicationController or EventsController
around_action :track_checkout_metrics, only: [:checkout]
private
def track_checkout_metrics
start_time = Time.current
begin
yield
# Log successful checkout
Rails.logger.info("Checkout completed", {
event_id: @event&.id,
user_id: current_user&.id,
duration: Time.current - start_time
})
rescue => e
# Log failed checkout
Rails.logger.error("Checkout failed", {
event_id: @event&.id,
user_id: current_user&.id,
error: e.message,
duration: Time.current - start_time
})
raise
end
end
```
## Summary of Improvements
Your ticket checkout system is already well-implemented with Stripe integration! The enhancements above will make it production-ready:
### Critical Improvements
1. Add database row locking to prevent overselling during concurrent purchases
2. Implement Stripe webhooks for reliable payment processing
3. Use background jobs for ticket creation to prevent timeouts
4. Add duplicate prevention with stripe_session_id tracking
### Security & Reliability
5. Enhanced error recovery with fallback ticket creation
6. Comprehensive logging for debugging and monitoring
7. Database schema improvements for better payment tracking
### Key Files to Modify
- `app/controllers/events_controller.rb` - Add inventory locking
- `app/controllers/webhooks_controller.rb` - New webhook handler
- `app/jobs/create_tickets_job.rb` - Background ticket creation
- Migration for `stripe_session_id` field
These enhancements will make your checkout system robust for high-traffic scenarios and edge cases.