Files
kursy-mirror/purchase/views.py

345 lines
14 KiB
Python

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)