diff --git a/purchase/views.py b/purchase/views.py index 5acfa14..2a3f6b0 100644 --- a/purchase/views.py +++ b/purchase/views.py @@ -1,16 +1,18 @@ +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.urls import reverse 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 +from purchase.models import CoursePurchase, PurchasableProduct logger = logging.getLogger(__name__) @@ -35,6 +37,79 @@ 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( @@ -51,30 +126,180 @@ def stripe_webhook(request): if webhook_secret: event = stripe.Webhook.construct_event(payload, sig_header, webhook_secret) else: - event = stripe.Event.construct_from(request.json(), stripe.api_key) + # 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) + # return HttpResponse(status=400) + raise e except stripe.error.SignatureVerificationError as e: logger.error(f"Webhook signature verification failed: {e}") - return HttpResponse(status=400) + # return HttpResponse(status=400) + raise e # Handle the event logger.info(f"Received Stripe event: {event['type']}") - # Example: handle successful payment + if event["type"] == "checkout.session.completed": session = event["data"]["object"] - session_id = session["id"] + session_id = session.get("id") logger.info(f"Checkout session completed: {session}") - # Try to find the CoursePurchase by Stripe session ID + + # Try to find the CoursePurchase by Stripe session ID first try: purchase = CoursePurchase.objects.get(stripe_checkout_session_id=session_id) - purchase.status = CoursePurchase.Status.PAID - purchase.refunded = False - purchase.save() + 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: - logger.warning(f"No CoursePurchase found for session {session_id}") + # 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"] @@ -82,7 +307,7 @@ def stripe_webhook(request): try: purchase = CoursePurchase.objects.get(stripe_checkout_session_id=session_id) purchase.status = CoursePurchase.Status.FAILED - purchase.save() + 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}") @@ -93,7 +318,7 @@ def stripe_webhook(request): try: purchase = CoursePurchase.objects.get(stripe_checkout_session_id=session_id) purchase.status = CoursePurchase.Status.FAILED - purchase.save() + purchase.save(update_fields=["status"]) logger.info( f"Marked CoursePurchase {purchase.id} as FAILED (async payment failed)." ) @@ -107,7 +332,7 @@ def stripe_webhook(request): purchase = CoursePurchase.objects.get(stripe_checkout_session_id=session_id) purchase.status = CoursePurchase.Status.PAID purchase.refunded = False - purchase.save() + purchase.save(update_fields=["status", "refunded"]) logger.info( f"Marked CoursePurchase {purchase.id} as PAID (async succeeded)." )