feat(purchase/views.py): use custom stripe session and auto-finalize purchase via webhook

This commit is contained in:
2026-05-20 19:05:18 +02:00
parent 6d927856c8
commit 118a1188d5

View File

@@ -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)
updated = False
if purchase.status != CoursePurchase.Status.PAID:
purchase.status = CoursePurchase.Status.PAID
updated = True
if purchase.refunded:
purchase.refunded = False
purchase.save()
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)."
)