diff --git a/purchase/migrations/0007_coursepurchase_status_and_more.py b/purchase/migrations/0007_coursepurchase_status_and_more.py new file mode 100644 index 0000000..897adc5 --- /dev/null +++ b/purchase/migrations/0007_coursepurchase_status_and_more.py @@ -0,0 +1,36 @@ +# Generated by Django 6.0.4 on 2026-05-18 16:18 + +import django.db.models.deletion +import modelcluster.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('home', '0023_eventindexpage'), + ('purchase', '0006_purchasableproduct_stripe_payment_url'), + ] + + operations = [ + migrations.AddField( + model_name='coursepurchase', + name='status', + field=models.CharField(choices=[('initiated', 'Initiated'), ('pending', 'Pending'), ('paid', 'Paid'), ('refunded', 'Refunded'), ('failed', 'Failed')], default='initiated', max_length=20), + ), + migrations.AddField( + model_name='coursepurchase', + name='stripe_checkout_session_id', + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AlterField( + model_name='purchasableproduct', + name='course', + field=modelcluster.fields.ParentalKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='purchasable_products', to='home.coursepage'), + ), + migrations.AlterField( + model_name='purchasableproduct', + name='stripe_payment_url', + field=models.URLField(blank=True, help_text='Stripe Checkout URL for this product', null=True), + ), + ] diff --git a/purchase/models.py b/purchase/models.py index f8320bd..6192869 100644 --- a/purchase/models.py +++ b/purchase/models.py @@ -15,10 +15,23 @@ logger = lg.getLogger(__name__) class CoursePurchase(models.Model): + class Status(models.TextChoices): + INITIATED = "initiated", "Initiated" + PENDING = "pending", "Pending" + PAID = "paid", "Paid" + REFUNDED = "refunded", "Refunded" + FAILED = "failed", "Failed" + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) course = models.ForeignKey("home.CoursePage", on_delete=models.CASCADE) purchased_at = models.DateTimeField(auto_now_add=True) refunded = models.BooleanField(default=False) + status = models.CharField( + max_length=20, + choices=Status.choices, + default=Status.INITIATED, + ) + stripe_checkout_session_id = models.CharField(max_length=255, blank=True, null=True) def mock_refund(self): self.refunded = True diff --git a/purchase/urls.py b/purchase/urls.py index 715e471..1df5cc0 100644 --- a/purchase/urls.py +++ b/purchase/urls.py @@ -1,4 +1,5 @@ from django.urls import path +from django.views.decorators.csrf import csrf_exempt from . import views @@ -14,4 +15,5 @@ urlpatterns = [ name="mock_refund_purchase", ), path("success/", views.purchase_success, name="purchase_success"), + path("stripe/webhook/", csrf_exempt(views.stripe_webhook), name="stripe-webhook"), ] diff --git a/purchase/views.py b/purchase/views.py index 61545e5..5acfa14 100644 --- a/purchase/views.py +++ b/purchase/views.py @@ -1,10 +1,19 @@ +import logging +import os + +import stripe +from django.conf import settings +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 +logger = logging.getLogger(__name__) + def mock_purchase_course(request, course_id): course = CoursePage.objects.get(id=course_id) @@ -24,3 +33,87 @@ def mock_refund_purchase(request, purchase_id): def purchase_success(request): return render(request, "success.html") + + +@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: + event = stripe.Event.construct_from(request.json(), stripe.api_key) + except ValueError as e: + logger.error(f"Invalid payload: {e}") + return HttpResponse(status=400) + except stripe.error.SignatureVerificationError as e: + logger.error(f"Webhook signature verification failed: {e}") + return HttpResponse(status=400) + + # 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"] + logger.info(f"Checkout session completed: {session}") + # Try to find the CoursePurchase by Stripe session ID + try: + purchase = CoursePurchase.objects.get(stripe_checkout_session_id=session_id) + purchase.status = CoursePurchase.Status.PAID + purchase.refunded = False + purchase.save() + logger.info(f"Marked CoursePurchase {purchase.id} as PAID.") + except CoursePurchase.DoesNotExist: + logger.warning(f"No CoursePurchase found for session {session_id}") + + 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() + 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() + 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() + 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)