feat: add stripe webhook
This commit is contained in:
36
purchase/migrations/0007_coursepurchase_status_and_more.py
Normal file
36
purchase/migrations/0007_coursepurchase_status_and_more.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user