From 2908519a846446f939a6e2977b7e085ae1c983e2 Mon Sep 17 00:00:00 2001 From: Raz Date: Fri, 11 Apr 2025 17:35:03 +0200 Subject: [PATCH] add registration webhook --- padelclub_backend/settings_local.py.dist | 3 +- shop/stripe_utils.py | 2 +- tournaments/services/payment_service.py | 130 ++++++++++++++++++++--- tournaments/urls.py | 3 + tournaments/utils/apns.py | 2 - tournaments/views.py | 42 ++++++++ 6 files changed, 161 insertions(+), 21 deletions(-) diff --git a/padelclub_backend/settings_local.py.dist b/padelclub_backend/settings_local.py.dist index 43ea805..e03dde2 100644 --- a/padelclub_backend/settings_local.py.dist +++ b/padelclub_backend/settings_local.py.dist @@ -40,4 +40,5 @@ DATABASES = { STRIPE_MODE = 'test' STRIPE_PUBLISHABLE_KEY = '' STRIPE_SECRET_KEY = '' -STRIPE_WEBHOOK_SECRET = '' +SHOP_STRIPE_WEBHOOK_SECRET = 'whsec_...' # Your existing webhook secret +TOURNAMENT_STRIPE_WEBHOOK_SECRET = 'whsec_...' # New webhook secret for tournaments diff --git a/shop/stripe_utils.py b/shop/stripe_utils.py index 2e7cba1..2fe4ede 100644 --- a/shop/stripe_utils.py +++ b/shop/stripe_utils.py @@ -15,7 +15,7 @@ class StripeService: # Get appropriate keys based on mode self.api_key = settings.STRIPE_SECRET_KEY self.publishable_key = settings.STRIPE_PUBLISHABLE_KEY - self.webhook_secret = settings.STRIPE_WEBHOOK_SECRET + self.webhook_secret = settings.SHOP_STRIPE_WEBHOOK_SECRET self.currency = getattr(settings, 'STRIPE_CURRENCY', 'eur') # Configure Stripe library diff --git a/tournaments/services/payment_service.py b/tournaments/services/payment_service.py index 86f00f2..255e87b 100644 --- a/tournaments/services/payment_service.py +++ b/tournaments/services/payment_service.py @@ -1,6 +1,9 @@ from django.shortcuts import get_object_or_404 from django.conf import settings from django.urls import reverse +from django.http import HttpResponse +from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_POST import stripe from ..models import TeamRegistration, PlayerRegistration, Tournament @@ -44,6 +47,50 @@ class PaymentService: reverse('register_tournament', kwargs={'tournament_id': tournament_id}) ) + base_metadata = { + 'tournament_id': str(tournament_id), + 'user_id': str(self.request.user.id) if self.request.user.is_authenticated else None, + 'payment_source': 'tournament', # Identify payment source + 'source_page': 'tournament_info' if team_registration_id else 'register_tournament', + } + + if tournament.is_corporate_tournament: + # Corporate tournament metadata + metadata = { + **base_metadata, + 'is_corporate_tournament': 'true', + 'stripe_account_type': 'direct' + } + else: + # Regular tournament metadata + metadata = { + **base_metadata, + 'is_corporate_tournament': 'false', + 'stripe_account_type': 'connect', + 'stripe_account_id': tournament.stripe_account_id + } + + if cart_data: + metadata.update({ + 'registration_cart_id': str(cart_data['cart_id']), + 'registration_type': 'cart', + 'player_count': str(cart_data.get('player_count', 0)), + 'waiting_list_position': str(cart_data.get('waiting_list_position', -1)) + }) + elif team_registration_id: + metadata.update({ + 'team_registration_id': str(team_registration_id), + 'registration_type': 'direct' + }) + self.request.session['team_registration_id'] = str(team_registration_id) + + metadata.update({ + 'tournament_name': tournament.broadcast_display_name(), + 'tournament_date': tournament.formatted_start_date(), + 'tournament_club': tournament.event.club.name, + 'tournament_fee': str(team_fee) + }) + # Common checkout session parameters if tournament.is_corporate_tournament: # Direct charge without transfers when umpire is platform owner @@ -65,12 +112,7 @@ class PaymentService: reverse('tournament-payment-success', kwargs={'tournament_id': tournament_id}) ), 'cancel_url': cancel_url, - 'metadata': { - 'tournament_id': str(tournament_id), - 'user_id': str(self.request.user.id) if self.request.user.is_authenticated else None, - 'source_page': 'tournament_info' if team_registration_id else 'register_tournament', - 'is_corporate_tournament': 'true' - } + 'metadata': metadata } else: # Get the umpire's Stripe account ID @@ -106,19 +148,15 @@ class PaymentService: 'destination': stripe_account_id, }, }, - 'metadata': { - 'tournament_id': str(tournament_id), # Convert UUID to string - 'user_id': str(self.request.user.id) if self.request.user.is_authenticated else None, # Convert UUID to string - 'source_page': 'tournament_info' if team_registration_id else 'register_tournament', - } + 'metadata': metadata } - # Add cart or team data to metadata based on payment context - if cart_data: - checkout_session_params['metadata']['registration_cart_id'] = str(cart_data['cart_id']) # Convert to string - elif team_registration_id: - checkout_session_params['metadata']['team_registration_id'] = str(team_registration_id) # Convert to string - self.request.session['team_registration_id'] = str(team_registration_id) # Convert to string + # # Add cart or team data to metadata based on payment context + # if cart_data: + # checkout_session_params['metadata']['registration_cart_id'] = str(cart_data['cart_id']) # Convert to string + # elif team_registration_id: + # checkout_session_params['metadata']['team_registration_id'] = str(team_registration_id) # Convert to string + # self.request.session['team_registration_id'] = str(team_registration_id) # Convert to string # Add customer_email if available if customer_email: @@ -287,3 +325,61 @@ class PaymentService: return False, f"Erreur de remboursement Stripe: {str(e)}", None except Exception as e: return False, f"Erreur lors du remboursement: {str(e)}", None + + @staticmethod + @csrf_exempt + @require_POST + def stripe_webhook(request): + payload = request.body + sig_header = request.META.get('HTTP_STRIPE_SIGNATURE') + print("Received webhook call") + print(f"Signature: {sig_header}") + + try: + event = stripe.Webhook.construct_event( + payload, sig_header, settings.TOURNAMENT_STRIPE_WEBHOOK_SECRET + ) + print(f"Tournament webhook event type: {event['type']}") + + if event['type'] == 'checkout.session.completed': + session = event['data']['object'] + metadata = session.get('metadata', {}) + tournament_id = metadata.get('tournament_id') + + if not tournament_id: + print("No tournament_id in metadata") + return HttpResponse(status=400) + + payment_service = PaymentService(request) + success = payment_service.process_successful_payment(tournament_id, session) + + if success: + print(f"Successfully processed webhook payment for tournament {tournament_id}") + return HttpResponse(status=200) + else: + print(f"Failed to process webhook payment for tournament {tournament_id}") + return HttpResponse(status=400) + + elif event['type'] == 'payment_intent.payment_failed': + intent = event['data']['object'] + metadata = intent.get('metadata', {}) + + tournament_id = metadata.get('tournament_id') + source_page = metadata.get('source_page') + + if tournament_id and source_page == 'register_tournament': + try: + tournament = Tournament.objects.get(id=tournament_id) + # Decrease reserved spots, minimum 0 + tournament.reserved_spots = max(0, tournament.reserved_spots - 1) + tournament.save() + print(f"Decreased reserved spots for tournament {tournament_id} after payment failure") + except Tournament.DoesNotExist: + print(f"Tournament {tournament_id} not found") + except Exception as e: + print(f"Error updating tournament reserved spots: {str(e)}") + + return HttpResponse(status=200) + + except Exception as e: + print(f"Tournament webhook error: {str(e)}") diff --git a/tournaments/urls.py b/tournaments/urls.py index aef1895..9f26e7e 100644 --- a/tournaments/urls.py +++ b/tournaments/urls.py @@ -4,6 +4,7 @@ from django.urls import include, path from .custom_views import CustomLoginView from . import views from django.conf import settings +from .services import payment_service urlpatterns = [ path('reset///', views.CustomPasswordResetConfirmView.as_view(), name='password_reset_confirm'), @@ -13,6 +14,7 @@ urlpatterns = [ path("clubs//", views.club, name="club"), path("c/", views.club_broadcast, name="club-broadcast"), path("c//go", views.club_broadcast_auto, name="club-broadcast-auto"), + path('tournaments/webhook/stripe/', payment_service.PaymentService.stripe_webhook, name='stripe_webhook'), path('tournaments//payment/success/', views.tournament_payment_success, name='tournament-payment-success'), path('tournaments//proceed-to-payment/', views.proceed_to_payment, name='proceed_to_payment'), path("tournament//", @@ -50,6 +52,7 @@ urlpatterns = [ path('download/', views.download, name='download'), path('apns/', views.test_apns, name='test-apns'), path('terms-of-use/', views.terms_of_use, name='terms-of-use'), + path('utils/validate-stripe-account/', views.validate_stripe_account, name='validate_stripe_account'), path('utils/xls-to-csv/', views.xls_to_csv, name='xls-to-csv'), path('mail-test/', views.simple_form_view, name='mail-test'), path('login/', CustomLoginView.as_view(), name='custom-login'), diff --git a/tournaments/utils/apns.py b/tournaments/utils/apns.py index 725d4b5..e4ce45e 100644 --- a/tournaments/utils/apns.py +++ b/tournaments/utils/apns.py @@ -1,9 +1,7 @@ -import http.client import json import jwt import time import httpx -import asyncio # APPLE WARNING: Reuse a connection as long as possible. # In most cases, you can reuse a connection for many hours to days. diff --git a/tournaments/views.py b/tournaments/views.py index b898507..4b73e91 100644 --- a/tournaments/views.py +++ b/tournaments/views.py @@ -8,6 +8,7 @@ from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth import logout from .utils.extensions import is_not_sqlite_backend import stripe +from django.views.decorators.http import require_http_methods from django.contrib.auth import update_session_auth_hash from django.contrib.auth.views import PasswordResetCompleteView @@ -1513,6 +1514,47 @@ def cancel_registration(request, tournament_id): messages.info(request, "Processus d'inscription annulé.") return redirect('tournament-info', tournament_id=tournament_id) +@csrf_exempt +@require_http_methods(["POST"]) +def validate_stripe_account(request): + stripe.api_key = settings.STRIPE_SECRET_KEY + # Parse the request body + data = json.loads(request.body) + account_id = data.get('account_id') + + if not account_id: + return JsonResponse({ + 'valid': False, + 'error': 'Account ID is required' + }, status=400) + + # Try to retrieve the account from Stripe + try: + # Basic account verification + account = stripe.Account.retrieve(account_id) + + # Only check if the account can receive payments + is_valid = account.id is not None + return JsonResponse({ + 'valid': is_valid, + 'account': { + 'id': account.id + } + }) + + except stripe.error.PermissionError: + return JsonResponse({ + 'valid': False, + 'error': 'No permission to access this account' + }, status=403) + + except stripe.error.InvalidRequestError: + return JsonResponse({ + 'valid': False, + 'error': 'Invalid account ID' + }, status=400) + + class UserListExportView(LoginRequiredMixin, View): def get(self, request, *args, **kwargs): users = CustomUser.objects.order_by('date_joined')