feat: add stripe webhook

This commit is contained in:
2026-05-19 21:14:04 +02:00
parent d2c870414f
commit 6d927856c8
4 changed files with 144 additions and 0 deletions

View File

@@ -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),
),
]

View File

@@ -15,10 +15,23 @@ logger = lg.getLogger(__name__)
class CoursePurchase(models.Model): 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) user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
course = models.ForeignKey("home.CoursePage", on_delete=models.CASCADE) course = models.ForeignKey("home.CoursePage", on_delete=models.CASCADE)
purchased_at = models.DateTimeField(auto_now_add=True) purchased_at = models.DateTimeField(auto_now_add=True)
refunded = models.BooleanField(default=False) 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): def mock_refund(self):
self.refunded = True self.refunded = True

View File

@@ -1,4 +1,5 @@
from django.urls import path from django.urls import path
from django.views.decorators.csrf import csrf_exempt
from . import views from . import views
@@ -14,4 +15,5 @@ urlpatterns = [
name="mock_refund_purchase", name="mock_refund_purchase",
), ),
path("success/", views.purchase_success, name="purchase_success"), path("success/", views.purchase_success, name="purchase_success"),
path("stripe/webhook/", csrf_exempt(views.stripe_webhook), name="stripe-webhook"),
] ]

View File

@@ -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.shortcuts import redirect, render
from django.urls import reverse from django.urls import reverse
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
logger = logging.getLogger(__name__)
def mock_purchase_course(request, course_id): def mock_purchase_course(request, course_id):
course = CoursePage.objects.get(id=course_id) course = CoursePage.objects.get(id=course_id)
@@ -24,3 +33,87 @@ def mock_refund_purchase(request, purchase_id):
def purchase_success(request): def purchase_success(request):
return render(request, "success.html") 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)