feat(purchase/views.py): use custom stripe session and auto-finalize purchase via webhook
This commit is contained in:
@@ -1,16 +1,18 @@
|
|||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
|
||||||
import stripe
|
import stripe
|
||||||
from django.conf import settings
|
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.http import HttpResponse
|
||||||
from django.shortcuts import redirect, render
|
from django.shortcuts import redirect, render
|
||||||
from django.urls import reverse
|
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
from django.views.decorators.http import require_POST
|
from django.views.decorators.http import require_POST
|
||||||
|
|
||||||
from home.models import CoursePage
|
from home.models import CoursePage
|
||||||
from purchase.models import CoursePurchase
|
from purchase.models import CoursePurchase, PurchasableProduct
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -35,6 +37,79 @@ def purchase_success(request):
|
|||||||
return render(request, "success.html")
|
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
|
@csrf_exempt
|
||||||
def stripe_webhook(request):
|
def stripe_webhook(request):
|
||||||
stripe.api_key = getattr(
|
stripe.api_key = getattr(
|
||||||
@@ -51,30 +126,180 @@ def stripe_webhook(request):
|
|||||||
if webhook_secret:
|
if webhook_secret:
|
||||||
event = stripe.Webhook.construct_event(payload, sig_header, webhook_secret)
|
event = stripe.Webhook.construct_event(payload, sig_header, webhook_secret)
|
||||||
else:
|
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:
|
except ValueError as e:
|
||||||
logger.error(f"Invalid payload: {e}")
|
logger.error(f"Invalid payload: {e}")
|
||||||
return HttpResponse(status=400)
|
# return HttpResponse(status=400)
|
||||||
|
raise e
|
||||||
except stripe.error.SignatureVerificationError as e:
|
except stripe.error.SignatureVerificationError as e:
|
||||||
logger.error(f"Webhook signature verification failed: {e}")
|
logger.error(f"Webhook signature verification failed: {e}")
|
||||||
return HttpResponse(status=400)
|
# return HttpResponse(status=400)
|
||||||
|
raise e
|
||||||
|
|
||||||
# Handle the event
|
# Handle the event
|
||||||
logger.info(f"Received Stripe event: {event['type']}")
|
logger.info(f"Received Stripe event: {event['type']}")
|
||||||
# Example: handle successful payment
|
|
||||||
if event["type"] == "checkout.session.completed":
|
if event["type"] == "checkout.session.completed":
|
||||||
session = event["data"]["object"]
|
session = event["data"]["object"]
|
||||||
session_id = session["id"]
|
session_id = session.get("id")
|
||||||
logger.info(f"Checkout session completed: {session}")
|
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:
|
try:
|
||||||
purchase = CoursePurchase.objects.get(stripe_checkout_session_id=session_id)
|
purchase = CoursePurchase.objects.get(stripe_checkout_session_id=session_id)
|
||||||
purchase.status = CoursePurchase.Status.PAID
|
updated = False
|
||||||
purchase.refunded = False
|
if purchase.status != CoursePurchase.Status.PAID:
|
||||||
purchase.save()
|
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.")
|
logger.info(f"Marked CoursePurchase {purchase.id} as PAID.")
|
||||||
except CoursePurchase.DoesNotExist:
|
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":
|
elif event["type"] == "checkout.session.expired":
|
||||||
session = event["data"]["object"]
|
session = event["data"]["object"]
|
||||||
@@ -82,7 +307,7 @@ def stripe_webhook(request):
|
|||||||
try:
|
try:
|
||||||
purchase = CoursePurchase.objects.get(stripe_checkout_session_id=session_id)
|
purchase = CoursePurchase.objects.get(stripe_checkout_session_id=session_id)
|
||||||
purchase.status = CoursePurchase.Status.FAILED
|
purchase.status = CoursePurchase.Status.FAILED
|
||||||
purchase.save()
|
purchase.save(update_fields=["status"])
|
||||||
logger.info(f"Marked CoursePurchase {purchase.id} as FAILED (expired).")
|
logger.info(f"Marked CoursePurchase {purchase.id} as FAILED (expired).")
|
||||||
except CoursePurchase.DoesNotExist:
|
except CoursePurchase.DoesNotExist:
|
||||||
logger.warning(f"No CoursePurchase found for expired session {session_id}")
|
logger.warning(f"No CoursePurchase found for expired session {session_id}")
|
||||||
@@ -93,7 +318,7 @@ def stripe_webhook(request):
|
|||||||
try:
|
try:
|
||||||
purchase = CoursePurchase.objects.get(stripe_checkout_session_id=session_id)
|
purchase = CoursePurchase.objects.get(stripe_checkout_session_id=session_id)
|
||||||
purchase.status = CoursePurchase.Status.FAILED
|
purchase.status = CoursePurchase.Status.FAILED
|
||||||
purchase.save()
|
purchase.save(update_fields=["status"])
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Marked CoursePurchase {purchase.id} as FAILED (async payment failed)."
|
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 = CoursePurchase.objects.get(stripe_checkout_session_id=session_id)
|
||||||
purchase.status = CoursePurchase.Status.PAID
|
purchase.status = CoursePurchase.Status.PAID
|
||||||
purchase.refunded = False
|
purchase.refunded = False
|
||||||
purchase.save()
|
purchase.save(update_fields=["status", "refunded"])
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Marked CoursePurchase {purchase.id} as PAID (async succeeded)."
|
f"Marked CoursePurchase {purchase.id} as PAID (async succeeded)."
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user