add webhook

shop
Raz 8 months ago
parent 9a3b920058
commit 8777706865
  1. 5
      padelclub_backend/settings.py
  2. 18
      shop/migrations/0016_order_webhook_processed.py
  3. 1
      shop/models.py
  4. 59
      shop/signals.py
  5. 2
      shop/templates/shop/cart.html
  6. 2
      shop/urls.py
  7. 135
      shop/views.py

@ -157,8 +157,11 @@ SESSION_COOKIE_SECURE = True # Si vous utilisez HTTPS
# Stripe Settings # Stripe Settings
STRIPE_PUBLISHABLE_KEY = 'pk_test_51R4LrTPEZkECCx484C2KbmRpcO2ZkZb0NoNi8QJB4X3E5JFu3bvLk4JZQmz9grKbk6O40z3xI8DawHrGyUY0fOT600VEKC9ran' # Replace with your actual key STRIPE_PUBLISHABLE_KEY = 'pk_test_51R4LrTPEZkECCx484C2KbmRpcO2ZkZb0NoNi8QJB4X3E5JFu3bvLk4JZQmz9grKbk6O40z3xI8DawHrGyUY0fOT600VEKC9ran' # Replace with your actual key
STRIPE_SECRET_KEY = 'sk_test_51R4LrTPEZkECCx48PkSbEYarhts7J7XNYpS1mJgows5z5dcv38l0G2tImvhXCjzvMgUH9ML0vLMOEPeyUBtYVf5H00Qvz8t3rE' # Replace with your actual key STRIPE_SECRET_KEY = 'sk_test_51R4LrTPEZkECCx48PkSbEYarhts7J7XNYpS1mJgows5z5dcv38l0G2tImvhXCjzvMgUH9ML0vLMOEPeyUBtYVf5H00Qvz8t3rE' # Replace with your actual key
STRIPE_WEBHOOK_SECRET = 'your_webhook_secret' # Optional for later STRIPE_WEBHOOK_SECRET = 'whsec_cbaa9c0c7b24041136e063a7d60fb674ec0646b2c4b821512c41a27634d7b1ba' # Optional for later
STRIPE_CURRENCY = 'eur' # Set to your preferred currency STRIPE_CURRENCY = 'eur' # Set to your preferred currency
# STRIPE_PUBLISHABLE_KEY = os.environ.get('STRIPE_PUBLISHABLE_KEY', 'your_test_publishable_key')
# STRIPE_SECRET_KEY = os.environ.get('STRIPE_SECRET_KEY', 'your_test_secret_key')
# STRIPE_WEBHOOK_SECRET = os.environ.get('STRIPE_WEBHOOK_SECRET', 'your_test_webhook_secret')
# Add managers who should receive internal emails # Add managers who should receive internal emails
MANAGERS = [ MANAGERS = [

@ -0,0 +1,18 @@
# Generated by Django 4.2.11 on 2025-03-21 05:58
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shop', '0015_alter_product_image'),
]
operations = [
migrations.AddField(
model_name='order',
name='webhook_processed',
field=models.BooleanField(default=False),
),
]

@ -79,6 +79,7 @@ class Order(models.Model):
('PAID', 'Paid'), ('PAID', 'Paid'),
('FAILED', 'Failed'), ('FAILED', 'Failed'),
]) ])
webhook_processed = models.BooleanField(default=False)
def __str__(self): def __str__(self):
return f"Order #{self.id} - {self.status}" return f"Order #{self.id} - {self.status}"

@ -46,17 +46,20 @@ def _send_order_email(instance, **kwargs):
admin_url = f"{settings.SITE_URL}{reverse('admin:shop_order_change', args=[order_id])}" admin_url = f"{settings.SITE_URL}{reverse('admin:shop_order_change', args=[order_id])}"
# Get customer info # Get customer info
customer_email = None
if instance.user: if instance.user:
customer_info = f"Utilisateur: {instance.user.email}" customer_info = f"Utilisateur: {instance.user.email}"
customer_email = instance.user.email
elif instance.guest_user: elif instance.guest_user:
customer_info = f"Invité: {instance.guest_user.email} ({instance.guest_user.phone})" customer_info = f"Invité: {instance.guest_user.email} ({instance.guest_user.phone})"
customer_email = instance.guest_user.email
else: else:
customer_info = "Client inconnu" customer_info = "Client inconnu"
# Construire les détails des articles - obtenir une requête fraîche des articles de la commande # Build order item details
items_list = "" items_list = ""
if action != "DELETED": if action != "DELETED":
# Utiliser une requête fraîche pour s'assurer d'avoir les données les plus récentes # Use a fresh query to ensure we have the most recent data
order_items = OrderItem.objects.filter(order_id=order_id).select_related('product', 'color', 'size') order_items = OrderItem.objects.filter(order_id=order_id).select_related('product', 'color', 'size')
for item in order_items: for item in order_items:
color = item.color.name if item.color else "N/A" color = item.color.name if item.color else "N/A"
@ -64,7 +67,7 @@ def _send_order_email(instance, **kwargs):
item_line = f"- {item.quantity}x {item.product.title} (Couleur: {color}, Taille: {size}, Prix: {item.price}€)\n" item_line = f"- {item.quantity}x {item.product.title} (Couleur: {color}, Taille: {size}, Prix: {item.price}€)\n"
items_list += item_line items_list += item_line
# Composer l'email # Compose the email
if action == "CREATED": if action == "CREATED":
action_fr = "CRÉÉE" action_fr = "CRÉÉE"
elif action == "UPDATED": elif action == "UPDATED":
@ -74,7 +77,7 @@ def _send_order_email(instance, **kwargs):
else: else:
action_fr = action action_fr = action
# Traduire le statut actuel # Translate current status
status_fr_map = { status_fr_map = {
"PENDING": "EN ATTENTE", "PENDING": "EN ATTENTE",
"PAID": "PAYÉE", "PAID": "PAYÉE",
@ -84,7 +87,7 @@ def _send_order_email(instance, **kwargs):
} }
status_fr = status_fr_map.get(status, status) status_fr = status_fr_map.get(status, status)
# Traduire le statut de paiement # Translate payment status
payment_status_fr_map = { payment_status_fr_map = {
"UNPAID": "NON PAYÉE", "UNPAID": "NON PAYÉE",
"PAID": "PAYÉE", "PAID": "PAYÉE",
@ -92,6 +95,7 @@ def _send_order_email(instance, **kwargs):
} }
payment_status_fr = payment_status_fr_map.get(instance.payment_status, instance.payment_status) payment_status_fr = payment_status_fr_map.get(instance.payment_status, instance.payment_status)
# Send internal notification email
subject = f"Commande #{order_id} {action_fr}: {status_fr}" subject = f"Commande #{order_id} {action_fr}: {status_fr}"
message = f""" message = f"""
La commande #{order_id} a été {action_fr.lower()} La commande #{order_id} a été {action_fr.lower()}
@ -110,7 +114,7 @@ Voir la commande dans le panneau d'administration: {admin_url}
Ceci est un message automatique. Merci de ne pas répondre. Ceci est un message automatique. Merci de ne pas répondre.
""" """
# Send email # Send internal email
recipient_list = [email for name, email in settings.MANAGERS] recipient_list = [email for name, email in settings.MANAGERS]
if not recipient_list: if not recipient_list:
recipient_list = [settings.DEFAULT_FROM_EMAIL] recipient_list = [settings.DEFAULT_FROM_EMAIL]
@ -122,3 +126,46 @@ Ceci est un message automatique. Merci de ne pas répondre.
recipient_list=recipient_list, recipient_list=recipient_list,
fail_silently=False, fail_silently=False,
) )
# Only send customer email for PAID status and if we have customer email
if status == OrderStatus.PAID and customer_email and instance.payment_status == "PAID":
# Generate customer-facing URLs
shop_url = f"{settings.SITE_URL}/shop"
contact_email = "support@padelclub.app"
# Create a customer receipt email
customer_subject = f"Confirmation de votre commande #{order_id} - PadelClub"
customer_message = f"""
Bonjour,
Nous vous remercions pour votre commande sur PadelClub !
Récapitulatif de votre commande #{order_id} du {instance.date_ordered.strftime('%d/%m/%Y')} :
Statut: {status_fr}
Prix total: {total_price}
Détail de votre commande :
{items_list}
Nous nous occupons de préparer votre commande dans les plus brefs délais.
Pour toute question concernant votre commande, n'hésitez pas à contacter notre service client :
{contact_email}
Visitez notre boutique pour découvrir d'autres produits :
{shop_url}
Merci de votre confiance et à bientôt sur PadelClub !
L'équipe PadelClub
"""
# Send email to customer
send_mail(
subject=customer_subject,
message=customer_message,
from_email=settings.DEFAULT_FROM_EMAIL,
recipient_list=[customer_email],
fail_silently=False,
)

@ -65,7 +65,7 @@
</div> </div>
</div> </div>
{% if user.is_authenticated and cart_items %} {% if user.is_authenticated and display_data.items %}
<!-- Stripe JavaScript for authenticated users --> <!-- Stripe JavaScript for authenticated users -->
<script src="https://js.stripe.com/v3/"></script> <script src="https://js.stripe.com/v3/"></script>
<script> <script>

@ -16,7 +16,7 @@ urlpatterns = [
path('payment/<int:order_id>/', views.payment, name='payment'), path('payment/<int:order_id>/', views.payment, name='payment'),
path('payment/success/<int:order_id>/', views.payment_success, name='payment_success'), path('payment/success/<int:order_id>/', views.payment_success, name='payment_success'),
path('payment/cancel/<int:order_id>/', views.payment_cancel, name='payment_cancel'), path('payment/cancel/<int:order_id>/', views.payment_cancel, name='payment_cancel'),
# path('webhook/stripe/', views.stripe_webhook, name='stripe_webhook'), path('webhook/stripe/', views.stripe_webhook, name='stripe_webhook'),
path('create-checkout-session/', views.create_checkout_session, name='create_checkout_session'), path('create-checkout-session/', views.create_checkout_session, name='create_checkout_session'),
path('cart/update-item/', views.update_cart_item, name='update_cart_item'), path('cart/update-item/', views.update_cart_item, name='update_cart_item'),
path('cart/remove-item/', views.remove_from_cart, name='remove_from_cart'), path('cart/remove-item/', views.remove_from_cart, name='remove_from_cart'),

@ -2,9 +2,6 @@ from django.shortcuts import render, redirect, get_object_or_404
from django.contrib import messages from django.contrib import messages
from .models import Product, Order, OrderItem, GuestUser, OrderStatus from .models import Product, Order, OrderItem, GuestUser, OrderStatus
from django.db.models import Sum from django.db.models import Sum
from django.contrib.auth.forms import AuthenticationForm
from django.contrib.auth import login
from django.contrib.auth.decorators import login_required
from .forms import GuestCheckoutForm from .forms import GuestCheckoutForm
import stripe import stripe
from django.conf import settings from django.conf import settings
@ -12,6 +9,8 @@ from django.urls import reverse
from django.http import JsonResponse from django.http import JsonResponse
from django.views.decorators.http import require_POST from django.views.decorators.http import require_POST
from django.views.decorators.csrf import ensure_csrf_cookie from django.views.decorators.csrf import ensure_csrf_cookie
from django.http import HttpResponse
from django.views.decorators.csrf import csrf_exempt
from . import cart from . import cart
@ -47,12 +46,12 @@ def _create_stripe_checkout_session(request, order, line_items):
# Create metadata to identify this order # Create metadata to identify this order
metadata = { metadata = {
'order_id': order.id, 'order_id': str(order.id), # Convert to string to be safe
} }
# Add user info to metadata if available # Add user info to metadata if available
if request.user.is_authenticated: if request.user.is_authenticated:
metadata['user_id'] = request.user.id metadata['user_id'] = str(request.user.id)
elif 'guest_email' in request.session: elif 'guest_email' in request.session:
metadata['guest_email'] = request.session.get('guest_email', '') metadata['guest_email'] = request.session.get('guest_email', '')
@ -298,17 +297,18 @@ def payment_success(request, order_id):
# Clear cart after successful payment # Clear cart after successful payment
cart.clear_cart(request) cart.clear_cart(request)
# Update order status # Only update if not already processed by webhook
print("payment_success") if not order.webhook_processed:
print(f"Updating order {order_id} via redirect (not webhook)")
order.status = OrderStatus.PAID order.status = OrderStatus.PAID
order.payment_status = 'PAID' order.payment_status = 'PAID'
order.save() order.save()
else:
print(f"Order {order_id} already processed by webhook")
# Get order items for template # Get order items for template
order_items = order.items.all() order_items = order.items.all()
total = sum(item.get_total_price() for item in order_items)
display_data = prepare_item_display_data(order_items, is_cart=False) display_data = prepare_item_display_data(order_items, is_cart=False)
print(display_data)
return render(request, 'shop/payment_success.html', { return render(request, 'shop/payment_success.html', {
'order': order, 'order': order,
@ -319,10 +319,14 @@ def payment_cancel(request, order_id):
"""Handle cancelled payment""" """Handle cancelled payment"""
order = get_object_or_404(Order, id=order_id) order = get_object_or_404(Order, id=order_id)
# Update order status # Only update if not already processed by webhook
if not order.webhook_processed:
print(f"Updating order {order_id} to CANCELED via redirect (not webhook)")
order.status = OrderStatus.CANCELED order.status = OrderStatus.CANCELED
order.payment_status = 'FAILED' order.payment_status = 'FAILED'
order.save() order.save()
else:
print(f"Order {order_id} already processed by webhook")
messages.warning(request, "Your payment was cancelled.") messages.warning(request, "Your payment was cancelled.")
return render(request, 'shop/payment_cancel.html', {'order': order}) return render(request, 'shop/payment_cancel.html', {'order': order})
@ -467,3 +471,114 @@ def simulate_payment_failure(request):
return redirect('shop:view_cart') return redirect('shop:view_cart')
return redirect('shop:payment_cancel', order_id=order.id) return redirect('shop:payment_cancel', order_id=order.id)
@require_POST
@csrf_exempt # Stripe can't provide CSRF tokens
def stripe_webhook(request):
"""Handle Stripe webhook events"""
payload = request.body
sig_header = request.META.get('HTTP_STRIPE_SIGNATURE')
# Log the received webhook for debugging
print(f"Webhook received: {sig_header}")
if not sig_header:
print("No signature header")
return HttpResponse(status=400)
try:
# Initialize Stripe
stripe.api_key = settings.STRIPE_SECRET_KEY
# Verify the event using webhook signing secret
event = stripe.Webhook.construct_event(
payload, sig_header, settings.STRIPE_WEBHOOK_SECRET
)
# Log the event type
print(f"Webhook event type: {event['type']}")
except ValueError as e:
# Invalid payload
print(f"Invalid payload: {str(e)}")
return HttpResponse(status=400)
except stripe.error.SignatureVerificationError as e:
# Invalid signature
print(f"Invalid signature: {str(e)}")
return HttpResponse(status=400)
# Handle the event based on type
event_type = event['type']
if event_type == 'checkout.session.completed':
handle_checkout_session_completed(event['data']['object'])
elif event_type == 'payment_intent.succeeded':
handle_payment_intent_succeeded(event['data']['object'])
elif event_type == 'payment_intent.payment_failed':
handle_payment_intent_failed(event['data']['object'])
# Return success response
return HttpResponse(status=200)
def handle_checkout_session_completed(session):
"""Handle completed checkout session webhook"""
print(f"Processing checkout.session.completed for session {session.id}")
# Get order from metadata
order_id = session.get('metadata', {}).get('order_id')
if not order_id:
print(f"Warning: No order_id in metadata for session {session.id}")
return
try:
order = Order.objects.get(id=order_id)
# Update order status if not already processed
if not order.webhook_processed:
print(f"Updating order {order_id} to PAID via webhook")
order.status = OrderStatus.PAID
order.payment_status = 'PAID'
order.webhook_processed = True
order.save()
# You could add additional processing here
# - Send order confirmation email
# - Update inventory
# - Etc.
else:
print(f"Order {order_id} already processed by webhook")
except Order.DoesNotExist:
print(f"Error: Order {order_id} not found for session {session.id}")
def handle_payment_intent_succeeded(payment_intent):
"""Handle successful payment intent webhook"""
print(f"Processing payment_intent.succeeded for intent {payment_intent.id}")
# If you're using payment intents directly, handle them here
# For Checkout Sessions, you'll likely rely on checkout.session.completed instead
def handle_payment_intent_failed(payment_intent):
"""Handle failed payment intent webhook"""
print(f"Processing payment_intent.payment_failed for intent {payment_intent.id}")
# Get order from metadata
order_id = payment_intent.get('metadata', {}).get('order_id')
if not order_id:
print(f"No order_id in metadata for payment intent {payment_intent.id}")
return
try:
order = Order.objects.get(id=order_id)
# Update order status
if not order.webhook_processed or order.payment_status != 'FAILED':
print(f"Updating order {order_id} to FAILED via webhook")
order.status = OrderStatus.CANCELED
order.payment_status = 'FAILED'
order.webhook_processed = True
order.save()
except Order.DoesNotExist:
print(f"Error: Order {order_id} not found for payment intent {payment_intent.id}")

Loading…
Cancel
Save