import json import logging import os import stripe from django.conf import settings from django.contrib.auth.decorators import login_required from django.db import IntegrityError, transaction from django.http import HttpResponse from django.shortcuts import redirect, render from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_POST from home.models import CoursePage from purchase.models import CoursePurchase, PurchasableProduct logger = logging.getLogger(__name__) def mock_purchase_course(request, course_id): course = CoursePage.objects.get(id=course_id) course.mock_purchase(request.user) return redirect(course.url) def mock_refund_purchase(request, purchase_id): purchase = CoursePurchase.objects.get(id=purchase_id) purchase.mock_refund() return redirect(purchase.course.url) def purchase_success(request): return render(request, "success.html") @login_required @require_POST def create_checkout_session(request, purchasable_id): """Create a Stripe Checkout Session for the given PurchasableProduct and include the local user id in metadata. This view requires an authenticated POST request and will redirect the user to Stripe Checkout. It will also create or get a pending CoursePurchase linked to the checkout session. """ stripe.api_key = getattr( settings, "STRIPE_SECRET_KEY", os.getenv("STRIPE_SECRET_KEY") ) try: purch = PurchasableProduct.objects.get(pk=purchasable_id) except PurchasableProduct.DoesNotExist: raise HttpResponse("Purchasable product not found", status=404) if not purch.stripe_price_id: return HttpResponse("Product not configured for Stripe", status=400) try: session = stripe.checkout.Session.create( payment_method_types=["card", "blik", "p24"], line_items=[{"price": purch.stripe_price_id, "quantity": 1}], customer_email=request.user.email or None, mode="payment", client_reference_id=str(request.user.id), metadata={"user_id": str(request.user.id), "purchasable_id": str(purch.id)}, success_url=getattr( settings, "STRIPE_SUCCESS_URL", "https://example.com/success" ), cancel_url=getattr( settings, "STRIPE_CANCEL_URL", "https://example.com/cancel" ), ) # Create or get a pending CoursePurchase tied to this session (idempotent) try: with transaction.atomic(): purchase, created = CoursePurchase.objects.get_or_create( user=request.user, course=purch.course, refunded=False, defaults={ "status": CoursePurchase.Status.PENDING, "stripe_checkout_session_id": session.id, }, ) except IntegrityError: # Race: another worker created the purchase concurrently. Re-fetch. purchase = CoursePurchase.objects.filter( user=request.user, course=purch.course ).first() if not purchase: purchase = CoursePurchase.objects.filter( stripe_checkout_session_id=session.id ).first() # If purchase exists but stripe_checkout_session_id is missing, set it if purchase and not purchase.stripe_checkout_session_id: purchase.stripe_checkout_session_id = session.id purchase.save(update_fields=["stripe_checkout_session_id"]) # Redirect to Stripe Checkout return redirect(session.url) except Exception as e: logger.exception( f"Failed to create checkout session for purchasable {purchasable_id}: {e}" ) raise e # return HttpResponse(status=500) @csrf_exempt def stripe_webhook(request): stripe.api_key = getattr( settings, "STRIPE_SECRET_KEY", os.getenv("STRIPE_SECRET_KEY") ) webhook_secret = getattr( settings, "STRIPE_WEBHOOK_SECRET", os.getenv("STRIPE_WEBHOOK_SECRET") ) payload = request.body sig_header = request.META.get("HTTP_STRIPE_SIGNATURE") event = None try: if webhook_secret: event = stripe.Webhook.construct_event(payload, sig_header, webhook_secret) else: # fallback: parse payload as JSON event = stripe.Event.construct_from( json.loads(payload.decode("utf-8")), stripe.api_key ) except ValueError as e: logger.error(f"Invalid payload: {e}") # return HttpResponse(status=400) raise e except stripe.error.SignatureVerificationError as e: logger.error(f"Webhook signature verification failed: {e}") # return HttpResponse(status=400) raise e # Handle the event logger.info(f"Received Stripe event: {event['type']}") if event["type"] == "checkout.session.completed": session = event["data"]["object"] session_id = session.get("id") logger.info(f"Checkout session completed: {session}") # Try to find the CoursePurchase by Stripe session ID first try: purchase = CoursePurchase.objects.get(stripe_checkout_session_id=session_id) updated = False if purchase.status != CoursePurchase.Status.PAID: purchase.status = CoursePurchase.Status.PAID updated = True if purchase.refunded: purchase.refunded = False updated = True if updated: purchase.save(update_fields=["status", "refunded"]) logger.info(f"Marked CoursePurchase {purchase.id} as PAID.") except CoursePurchase.DoesNotExist: # No existing purchase — attempt to create one by inspecting the Stripe session try: # Retrieve expanded session to inspect line items and product metadata session_obj = stripe.checkout.Session.retrieve( session_id, expand=[ "line_items.data.price.product", "customer", "customer_details", ], ) # Determine user from session metadata, client_reference_id or customer details from django.contrib.auth import get_user_model User = get_user_model() user = None metadata = session_obj.get("metadata") or {} # Prefer explicit user_id in metadata if metadata.get("user_id"): try: user = User.objects.get(pk=int(metadata.get("user_id"))) except Exception: user = None # Fallback to client_reference_id (often used to pass local user id) if not user and session_obj.get("client_reference_id"): try: user = User.objects.get( pk=int(session_obj.get("client_reference_id")) ) except Exception: user = None # Fallback to customer email if not user: email = None cust_details = session_obj.get("customer_details") or {} if cust_details.get("email"): email = cust_details.get("email") elif session_obj.get("customer_email"): email = session_obj.get("customer_email") elif isinstance(session_obj.get("customer"), dict) and session_obj[ "customer" ].get("email"): email = session_obj["customer"].get("email") if email: user = User.objects.filter(email=email).first() # Find the purchasable product from line items using product.metadata.local_id course = None line_items = (session_obj.get("line_items") or {}).get("data", []) for item in line_items: price = item.get("price") or {} product = price.get("product") if isinstance(product, dict): local_id = (product.get("metadata") or {}).get("local_id") if local_id: try: purch = PurchasableProduct.objects.get(pk=int(local_id)) course = purch.course break except Exception: continue if user and course: try: with transaction.atomic(): purchase, created = CoursePurchase.objects.get_or_create( user=user, course=course, defaults={ "status": CoursePurchase.Status.PAID, "refunded": False, "stripe_checkout_session_id": session_id, }, ) except IntegrityError: # Race: another worker created the purchase concurrently. Re-fetch. purchase = CoursePurchase.objects.filter( user=user, course=course ).first() if not purchase: purchase = CoursePurchase.objects.filter( stripe_checkout_session_id=session_id ).first() if purchase: # Ensure fields are up to date idempotently update_fields = [] if purchase.status != CoursePurchase.Status.PAID: purchase.status = CoursePurchase.Status.PAID update_fields.append("status") if purchase.refunded: purchase.refunded = False update_fields.append("refunded") if not purchase.stripe_checkout_session_id: purchase.stripe_checkout_session_id = session_id update_fields.append("stripe_checkout_session_id") if update_fields: purchase.save(update_fields=update_fields) logger.info( "Updated CoursePurchase %s (fields: %s).", purchase.id, update_fields, ) else: logger.info( "CoursePurchase %s already up-to-date.", purchase.id ) if created: logger.info( "Created CoursePurchase %s for user %s and course %s.", purchase.id, user, course, ) else: logger.warning( "Could not create CoursePurchase for session %s: user=%s, course=%s", session_id, user, course, ) else: logger.warning( "Could not create CoursePurchase for session %s: user=%s, course=%s", session_id, user, course, ) except Exception as e: logger.exception( f"Failed to create CoursePurchase for session {session_id}: {e}" ) elif event["type"] == "checkout.session.expired": session = event["data"]["object"] session_id = session["id"] try: purchase = CoursePurchase.objects.get(stripe_checkout_session_id=session_id) purchase.status = CoursePurchase.Status.FAILED purchase.save(update_fields=["status"]) logger.info(f"Marked CoursePurchase {purchase.id} as FAILED (expired).") except CoursePurchase.DoesNotExist: logger.warning(f"No CoursePurchase found for expired session {session_id}") elif event["type"] == "checkout.session.async_payment_failed": session = event["data"]["object"] session_id = session["id"] try: purchase = CoursePurchase.objects.get(stripe_checkout_session_id=session_id) purchase.status = CoursePurchase.Status.FAILED purchase.save(update_fields=["status"]) logger.info( f"Marked CoursePurchase {purchase.id} as FAILED (async payment failed)." ) except CoursePurchase.DoesNotExist: logger.warning(f"No CoursePurchase found for failed session {session_id}") elif event["type"] == "checkout.session.async_payment_succeeded": session = event["data"]["object"] session_id = session["id"] try: purchase = CoursePurchase.objects.get(stripe_checkout_session_id=session_id) purchase.status = CoursePurchase.Status.PAID purchase.refunded = False purchase.save(update_fields=["status", "refunded"]) logger.info( f"Marked CoursePurchase {purchase.id} as PAID (async succeeded)." ) except CoursePurchase.DoesNotExist: logger.warning( f"No CoursePurchase found for async succeeded session {session_id}" ) return HttpResponse(status=200)