feat: refunds, failed and pending payments work
This commit is contained in:
@@ -121,7 +121,10 @@ class CoursePage(Page):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
return CoursePurchase.objects.filter(
|
return CoursePurchase.objects.filter(
|
||||||
user=user, course=self, refunded=False
|
user=user,
|
||||||
|
course=self,
|
||||||
|
refunded=False,
|
||||||
|
status=CoursePurchase.Status.PAID,
|
||||||
).exists()
|
).exists()
|
||||||
|
|
||||||
def _user_purchase_id(self, user):
|
def _user_purchase_id(self, user):
|
||||||
@@ -129,7 +132,10 @@ class CoursePage(Page):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
purchase = CoursePurchase.objects.filter(
|
purchase = CoursePurchase.objects.filter(
|
||||||
user=user, course=self, refunded=False
|
user=user,
|
||||||
|
course=self,
|
||||||
|
refunded=False,
|
||||||
|
status=CoursePurchase.Status.PAID,
|
||||||
).first()
|
).first()
|
||||||
print(f"User {user} purchase for course {self}: {purchase}")
|
print(f"User {user} purchase for course {self}: {purchase}")
|
||||||
return purchase.id if purchase else None
|
return purchase.id if purchase else None
|
||||||
@@ -139,15 +145,19 @@ class CoursePage(Page):
|
|||||||
if not user.is_authenticated:
|
if not user.is_authenticated:
|
||||||
return False
|
return False
|
||||||
obj, created = CoursePurchase.objects.get_or_create(
|
obj, created = CoursePurchase.objects.get_or_create(
|
||||||
user=user, course=self, refunded=False
|
user=user,
|
||||||
|
course=self,
|
||||||
|
refunded=False,
|
||||||
|
defaults={"status": CoursePurchase.Status.PAID},
|
||||||
)
|
)
|
||||||
|
if obj.status != CoursePurchase.Status.PAID or obj.refunded:
|
||||||
|
obj.status = CoursePurchase.Status.PAID
|
||||||
|
obj.refunded = False
|
||||||
|
obj.save(update_fields=["status", "refunded"])
|
||||||
# Add user to dedicated access group for this course
|
# Add user to dedicated access group for this course
|
||||||
group_name = f"course_{self.id}_access"
|
group_name = f"course_{self.id}_access"
|
||||||
group, _ = Group.objects.get_or_create(name=group_name)
|
group, _ = Group.objects.get_or_create(name=group_name)
|
||||||
user.groups.add(group)
|
user.groups.add(group)
|
||||||
# Ensure allowed_groups only includes this access group
|
|
||||||
if not self.allowed_groups.filter(id=group.id).exists():
|
|
||||||
self.allowed_groups.add(group)
|
|
||||||
return created
|
return created
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 6.0.4 on 2026-05-20 17:28
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('purchase', '0008_remove_purchasableproduct_stripe_payment_url'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='coursepurchase',
|
||||||
|
name='stripe_charge_id',
|
||||||
|
field=models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='coursepurchase',
|
||||||
|
name='stripe_payment_intent_id',
|
||||||
|
field=models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -16,11 +16,11 @@ logger = lg.getLogger(__name__)
|
|||||||
|
|
||||||
class CoursePurchase(models.Model):
|
class CoursePurchase(models.Model):
|
||||||
class Status(models.TextChoices):
|
class Status(models.TextChoices):
|
||||||
INITIATED = "initiated", "Initiated"
|
INITIATED = "initiated"
|
||||||
PENDING = "pending", "Pending"
|
PENDING = "pending"
|
||||||
PAID = "paid", "Paid"
|
PAID = "paid"
|
||||||
REFUNDED = "refunded", "Refunded"
|
REFUNDED = "refunded"
|
||||||
FAILED = "failed", "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)
|
||||||
@@ -32,11 +32,120 @@ class CoursePurchase(models.Model):
|
|||||||
default=Status.INITIATED,
|
default=Status.INITIATED,
|
||||||
)
|
)
|
||||||
stripe_checkout_session_id = models.CharField(max_length=255, blank=True, null=True)
|
stripe_checkout_session_id = models.CharField(max_length=255, blank=True, null=True)
|
||||||
|
# Stripe identifiers to help reconcile refunds coming from webhooks or admin actions
|
||||||
|
stripe_payment_intent_id = models.CharField(max_length=255, blank=True, null=True)
|
||||||
|
stripe_charge_id = models.CharField(max_length=255, blank=True, null=True)
|
||||||
|
|
||||||
def mock_refund(self):
|
def mock_refund(self):
|
||||||
|
"""Legacy helper used in dev: mark purchase refunded locally and perform cleanup.
|
||||||
|
|
||||||
|
Prefer using `refund_via_stripe` to perform an actual Stripe refund when appropriate.
|
||||||
|
"""
|
||||||
|
# If we have Stripe identifiers it's better to actually issue a refund via Stripe
|
||||||
|
if self.stripe_charge_id or self.stripe_payment_intent_id:
|
||||||
|
try:
|
||||||
|
self.refund_via_stripe()
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
# Fallback to local refund if Stripe refund cannot be performed
|
||||||
|
pass
|
||||||
|
|
||||||
self.refunded = True
|
self.refunded = True
|
||||||
|
self.status = CoursePurchase.Status.REFUNDED
|
||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
|
def refund_via_stripe(self, amount=None, reason=None):
|
||||||
|
"""Initiate a refund in Stripe for this purchase and mark it refunded locally.
|
||||||
|
|
||||||
|
- amount: integer in cents (optional) to perform a partial refund
|
||||||
|
- reason: optional string ("duplicate", "fraud", "requested_by_customer")
|
||||||
|
|
||||||
|
This method is idempotent: calling it for an already-refunded purchase will be
|
||||||
|
a no-op.
|
||||||
|
"""
|
||||||
|
# If already refunded, do nothing
|
||||||
|
if self.refunded:
|
||||||
|
return None
|
||||||
|
|
||||||
|
stripe_api_key = getattr(
|
||||||
|
settings, "STRIPE_SECRET_KEY", os.getenv("STRIPE_SECRET_KEY")
|
||||||
|
)
|
||||||
|
if not stripe_api_key:
|
||||||
|
# Can't call Stripe; mark refunded locally (useful for local testing)
|
||||||
|
self.refunded = True
|
||||||
|
self.status = CoursePurchase.Status.REFUNDED
|
||||||
|
self.save()
|
||||||
|
return None
|
||||||
|
|
||||||
|
import stripe as _stripe
|
||||||
|
|
||||||
|
_stripe.api_key = stripe_api_key
|
||||||
|
|
||||||
|
# Determine what identifier to use for refunding: prefer charge id if present,
|
||||||
|
# otherwise use payment_intent. Stripe accepts either when creating a refund.
|
||||||
|
refund_kwargs = {}
|
||||||
|
if amount is not None:
|
||||||
|
refund_kwargs["amount"] = int(amount)
|
||||||
|
if reason is not None:
|
||||||
|
refund_kwargs["reason"] = reason
|
||||||
|
|
||||||
|
try:
|
||||||
|
if self.stripe_charge_id:
|
||||||
|
refund = _stripe.Refund.create(
|
||||||
|
charge=self.stripe_charge_id, **refund_kwargs
|
||||||
|
)
|
||||||
|
elif self.stripe_payment_intent_id:
|
||||||
|
refund = _stripe.Refund.create(
|
||||||
|
payment_intent=self.stripe_payment_intent_id, **refund_kwargs
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# As a last resort, try to lookup the PaymentIntent from the Checkout Session
|
||||||
|
if self.stripe_checkout_session_id:
|
||||||
|
session = _stripe.checkout.Session.retrieve(
|
||||||
|
self.stripe_checkout_session_id
|
||||||
|
)
|
||||||
|
payment_intent = session.get("payment_intent")
|
||||||
|
if payment_intent:
|
||||||
|
refund = _stripe.Refund.create(
|
||||||
|
payment_intent=payment_intent, **refund_kwargs
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise RuntimeError(
|
||||||
|
"No Stripe identifiers available to perform refund"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise RuntimeError(
|
||||||
|
"No Stripe identifiers available to perform refund"
|
||||||
|
)
|
||||||
|
|
||||||
|
# On success, mark refunded locally and perform cleanup (remove group, gitea team)
|
||||||
|
self.refunded = True
|
||||||
|
self.status = CoursePurchase.Status.REFUNDED
|
||||||
|
# Try to persist charge/payment intent ids if Stripe returned them
|
||||||
|
try:
|
||||||
|
charge_id = getattr(refund, "charge", None)
|
||||||
|
if charge_id and not self.stripe_charge_id:
|
||||||
|
self.stripe_charge_id = charge_id
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Some Refund objects include payment_intent
|
||||||
|
pi = getattr(refund, "payment_intent", None)
|
||||||
|
if pi and not self.stripe_payment_intent_id:
|
||||||
|
self.stripe_payment_intent_id = pi
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Save and trigger model save logic (which will remove Gitea/team membership)
|
||||||
|
self.save()
|
||||||
|
return refund
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(
|
||||||
|
f"Failed to initiate Stripe refund for CoursePurchase {self.id}: {e}"
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
def _get_gitea_team_id(self, team_name):
|
def _get_gitea_team_id(self, team_name):
|
||||||
api_url = getattr(settings, "GITEA_URL", None)
|
api_url = getattr(settings, "GITEA_URL", None)
|
||||||
if not api_url:
|
if not api_url:
|
||||||
@@ -131,6 +240,14 @@ class CoursePurchase(models.Model):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
|
previous_state = None
|
||||||
|
if self.pk:
|
||||||
|
previous_state = (
|
||||||
|
self.__class__.objects.filter(pk=self.pk)
|
||||||
|
.values("status", "refunded")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
group_name = f"course_{self.course.id}_access"
|
group_name = f"course_{self.course.id}_access"
|
||||||
|
|
||||||
@@ -140,15 +257,28 @@ class CoursePurchase(models.Model):
|
|||||||
f"Saving CoursePurchase for user {self.user} and course {self.course.title}, refunded={self.refunded}"
|
f"Saving CoursePurchase for user {self.user} and course {self.course.title}, refunded={self.refunded}"
|
||||||
)
|
)
|
||||||
|
|
||||||
if self.refunded:
|
should_grant_access = (
|
||||||
print(f"Removing user {self.user} from group {group_name} due to refund")
|
self.status == CoursePurchase.Status.PAID and not self.refunded
|
||||||
self.remove_from_gitea_team()
|
)
|
||||||
self.user.groups.remove(group)
|
had_granted_access = (
|
||||||
else:
|
bool(previous_state)
|
||||||
|
and previous_state["status"] == CoursePurchase.Status.PAID
|
||||||
|
and not previous_state["refunded"]
|
||||||
|
)
|
||||||
|
|
||||||
|
if should_grant_access:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"Adding user {self.user} to group {group_name} for course {self.course.title}"
|
f"Adding user {self.user} to group {group_name} for course {self.course.title}"
|
||||||
)
|
)
|
||||||
self.add_to_gitea_team()
|
self.add_to_gitea_team()
|
||||||
|
self.user.groups.add(group)
|
||||||
|
else:
|
||||||
|
if had_granted_access:
|
||||||
|
print(
|
||||||
|
f"Removing user {self.user} from group {group_name} due to status {self.status}"
|
||||||
|
)
|
||||||
|
self.remove_from_gitea_team()
|
||||||
|
self.user.groups.remove(group)
|
||||||
|
|
||||||
|
|
||||||
class PurchasableProduct(Orderable, models.Model):
|
class PurchasableProduct(Orderable, models.Model):
|
||||||
|
|||||||
@@ -1,3 +1,133 @@
|
|||||||
from django.test import TestCase
|
from unittest import mock
|
||||||
|
|
||||||
# Create your tests here.
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.test import TestCase, override_settings
|
||||||
|
from django.urls import reverse
|
||||||
|
from wagtail.models import Page
|
||||||
|
|
||||||
|
from home.models.pages import CoursePage
|
||||||
|
from purchase.models import CoursePurchase, PurchasableProduct
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
STRIPE_SECRET_KEY=None,
|
||||||
|
STRIPE_WEBHOOK_SECRET="test",
|
||||||
|
GITEA_URL=None,
|
||||||
|
)
|
||||||
|
class PurchaseStatusTests(TestCase):
|
||||||
|
"""Regression tests for purchase status tracking."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.user = get_user_model().objects.create_user(
|
||||||
|
username="alice",
|
||||||
|
email="alice@example.com",
|
||||||
|
password="pass12345",
|
||||||
|
)
|
||||||
|
root_page = Page.get_first_root_node()
|
||||||
|
self.course = CoursePage(title="Django Course", slug="django-course")
|
||||||
|
root_page.add_child(instance=self.course)
|
||||||
|
self.product = PurchasableProduct.objects.create(
|
||||||
|
name="Django Course",
|
||||||
|
course=self.course,
|
||||||
|
price_cents=1000,
|
||||||
|
currency="pln",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _create_purchase(self, status, session_id="cs_test", refunded=False):
|
||||||
|
return CoursePurchase.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
course=self.course,
|
||||||
|
status=status,
|
||||||
|
refunded=refunded,
|
||||||
|
stripe_checkout_session_id=session_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _post_stripe_event(self, event):
|
||||||
|
with mock.patch(
|
||||||
|
"purchase.views.stripe.Webhook.construct_event", return_value=event
|
||||||
|
):
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("stripe-webhook"),
|
||||||
|
data="{}",
|
||||||
|
content_type="application/json",
|
||||||
|
HTTP_STRIPE_SIGNATURE="test-signature",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_access_depends_on_paid_status(self):
|
||||||
|
purchase = self._create_purchase(CoursePurchase.Status.PENDING)
|
||||||
|
|
||||||
|
self.assertFalse(self.course._user_has_access(self.user))
|
||||||
|
|
||||||
|
purchase.status = CoursePurchase.Status.FAILED
|
||||||
|
purchase.save(update_fields=["status"])
|
||||||
|
self.assertFalse(self.course._user_has_access(self.user))
|
||||||
|
|
||||||
|
purchase.status = CoursePurchase.Status.PAID
|
||||||
|
purchase.refunded = False
|
||||||
|
purchase.save(update_fields=["status", "refunded"])
|
||||||
|
self.assertTrue(self.course._user_has_access(self.user))
|
||||||
|
|
||||||
|
def test_checkout_session_completed_unpaid_stays_pending(self):
|
||||||
|
purchase = self._create_purchase(
|
||||||
|
CoursePurchase.Status.PENDING, session_id="cs_completed"
|
||||||
|
)
|
||||||
|
|
||||||
|
self._post_stripe_event(
|
||||||
|
{
|
||||||
|
"type": "checkout.session.completed",
|
||||||
|
"data": {
|
||||||
|
"object": {
|
||||||
|
"id": "cs_completed",
|
||||||
|
"payment_status": "unpaid",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
purchase.refresh_from_db()
|
||||||
|
self.assertEqual(purchase.status, CoursePurchase.Status.PENDING)
|
||||||
|
|
||||||
|
def test_async_payment_failed_marks_purchase_failed(self):
|
||||||
|
purchase = self._create_purchase(
|
||||||
|
CoursePurchase.Status.PENDING, session_id="cs_async_failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
self._post_stripe_event(
|
||||||
|
{
|
||||||
|
"type": "checkout.session.async_payment_failed",
|
||||||
|
"data": {"object": {"id": "cs_async_failed"}},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
purchase.refresh_from_db()
|
||||||
|
self.assertEqual(purchase.status, CoursePurchase.Status.FAILED)
|
||||||
|
self.assertFalse(self.course._user_has_access(self.user))
|
||||||
|
|
||||||
|
def test_payment_intent_failed_marks_purchase_failed_with_metadata_fallback(self):
|
||||||
|
purchase = self._create_purchase(
|
||||||
|
CoursePurchase.Status.PENDING, session_id="cs_pi_failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
self._post_stripe_event(
|
||||||
|
{
|
||||||
|
"type": "payment_intent.payment_failed",
|
||||||
|
"data": {
|
||||||
|
"object": {
|
||||||
|
"id": "pi_failed",
|
||||||
|
"metadata": {
|
||||||
|
"user_id": str(self.user.id),
|
||||||
|
"client_reference_id": str(self.user.id),
|
||||||
|
"purchasable_id": str(self.product.id),
|
||||||
|
},
|
||||||
|
"receipt_email": self.user.email,
|
||||||
|
"charges": {"data": []},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
purchase.refresh_from_db()
|
||||||
|
self.assertEqual(purchase.status, CoursePurchase.Status.FAILED)
|
||||||
|
self.assertFalse(self.course._user_has_access(self.user))
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ def create_checkout_session(request, purchasable_id):
|
|||||||
try:
|
try:
|
||||||
purch = PurchasableProduct.objects.get(pk=purchasable_id)
|
purch = PurchasableProduct.objects.get(pk=purchasable_id)
|
||||||
except PurchasableProduct.DoesNotExist:
|
except PurchasableProduct.DoesNotExist:
|
||||||
raise HttpResponse("Purchasable product not found", status=404)
|
return HttpResponse("Purchasable product not found", status=404)
|
||||||
|
|
||||||
if not purch.stripe_price_id:
|
if not purch.stripe_price_id:
|
||||||
return HttpResponse("Product not configured for Stripe", status=400)
|
return HttpResponse("Product not configured for Stripe", status=400)
|
||||||
@@ -65,6 +65,13 @@ def create_checkout_session(request, purchasable_id):
|
|||||||
mode="payment",
|
mode="payment",
|
||||||
client_reference_id=str(request.user.id),
|
client_reference_id=str(request.user.id),
|
||||||
metadata={"user_id": str(request.user.id), "purchasable_id": str(purch.id)},
|
metadata={"user_id": str(request.user.id), "purchasable_id": str(purch.id)},
|
||||||
|
payment_intent_data={
|
||||||
|
"metadata": {
|
||||||
|
"user_id": str(request.user.id),
|
||||||
|
"purchasable_id": str(purch.id),
|
||||||
|
"client_reference_id": str(request.user.id),
|
||||||
|
}
|
||||||
|
},
|
||||||
success_url=getattr(
|
success_url=getattr(
|
||||||
settings, "STRIPE_SUCCESS_URL", "https://example.com/success"
|
settings, "STRIPE_SUCCESS_URL", "https://example.com/success"
|
||||||
),
|
),
|
||||||
@@ -83,6 +90,7 @@ def create_checkout_session(request, purchasable_id):
|
|||||||
defaults={
|
defaults={
|
||||||
"status": CoursePurchase.Status.PENDING,
|
"status": CoursePurchase.Status.PENDING,
|
||||||
"stripe_checkout_session_id": session.id,
|
"stripe_checkout_session_id": session.id,
|
||||||
|
"stripe_payment_intent_id": session.payment_intent,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
@@ -95,10 +103,28 @@ def create_checkout_session(request, purchasable_id):
|
|||||||
stripe_checkout_session_id=session.id
|
stripe_checkout_session_id=session.id
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
# If purchase exists but stripe_checkout_session_id is missing, set it
|
if purchase:
|
||||||
if purchase and not purchase.stripe_checkout_session_id:
|
if purchase.status == CoursePurchase.Status.PAID and not purchase.refunded:
|
||||||
|
return redirect(purch.course.url)
|
||||||
|
|
||||||
|
update_fields = []
|
||||||
|
if purchase.status != CoursePurchase.Status.PENDING:
|
||||||
|
purchase.status = CoursePurchase.Status.PENDING
|
||||||
|
update_fields.append("status")
|
||||||
|
if purchase.refunded:
|
||||||
|
purchase.refunded = False
|
||||||
|
update_fields.append("refunded")
|
||||||
|
if purchase.stripe_checkout_session_id != session.id:
|
||||||
purchase.stripe_checkout_session_id = session.id
|
purchase.stripe_checkout_session_id = session.id
|
||||||
purchase.save(update_fields=["stripe_checkout_session_id"])
|
update_fields.append("stripe_checkout_session_id")
|
||||||
|
if purchase.stripe_payment_intent_id is not None:
|
||||||
|
purchase.stripe_payment_intent_id = None
|
||||||
|
update_fields.append("stripe_payment_intent_id")
|
||||||
|
if purchase.stripe_charge_id is not None:
|
||||||
|
purchase.stripe_charge_id = None
|
||||||
|
update_fields.append("stripe_charge_id")
|
||||||
|
if update_fields:
|
||||||
|
purchase.save(update_fields=update_fields)
|
||||||
|
|
||||||
# Redirect to Stripe Checkout
|
# Redirect to Stripe Checkout
|
||||||
return redirect(session.url)
|
return redirect(session.url)
|
||||||
@@ -107,7 +133,6 @@ def create_checkout_session(request, purchasable_id):
|
|||||||
f"Failed to create checkout session for purchasable {purchasable_id}: {e}"
|
f"Failed to create checkout session for purchasable {purchasable_id}: {e}"
|
||||||
)
|
)
|
||||||
raise e
|
raise e
|
||||||
# return HttpResponse(status=500)
|
|
||||||
|
|
||||||
|
|
||||||
@csrf_exempt
|
@csrf_exempt
|
||||||
@@ -132,26 +157,121 @@ def stripe_webhook(request):
|
|||||||
)
|
)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
logger.error(f"Invalid payload: {e}")
|
logger.error(f"Invalid payload: {e}")
|
||||||
# return HttpResponse(status=400)
|
|
||||||
raise e
|
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)
|
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
|
# Helper to safely index Stripe objects / nested dict-like objects using [] semantics
|
||||||
|
def _s(obj, key):
|
||||||
|
try:
|
||||||
|
return obj[key]
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
# Handle the event
|
# Handle the event
|
||||||
logger.info(f"Received Stripe event: {event['type']}")
|
logger.info(f"Received Stripe event: {event['type']}")
|
||||||
|
|
||||||
|
# Helper to mark a purchase refunded by Stripe identifiers
|
||||||
|
def _mark_purchase_refunded(purchase):
|
||||||
|
if not purchase:
|
||||||
|
return
|
||||||
|
purchase.refunded = True
|
||||||
|
purchase.status = CoursePurchase.Status.REFUNDED
|
||||||
|
# Save normally so our CoursePurchase.save() logic runs (this will remove groups/gitea membership)
|
||||||
|
purchase.save()
|
||||||
|
logger.info(
|
||||||
|
f"Marked CoursePurchase {purchase.id} as REFUNDED due to Stripe event"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _find_purchase_from_payment_intent(pi):
|
||||||
|
pi_id = _s(pi, "id")
|
||||||
|
metadata = _s(pi, "metadata") or {}
|
||||||
|
|
||||||
|
purchase = None
|
||||||
|
|
||||||
|
session_id = (
|
||||||
|
_s(metadata, "checkout_session")
|
||||||
|
or _s(metadata, "session_id")
|
||||||
|
or _s(metadata, "stripe_checkout_session_id")
|
||||||
|
)
|
||||||
|
if session_id:
|
||||||
|
purchase = CoursePurchase.objects.filter(
|
||||||
|
stripe_checkout_session_id=session_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not purchase and pi_id:
|
||||||
|
purchase = CoursePurchase.objects.filter(
|
||||||
|
stripe_payment_intent_id=pi_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
user = None
|
||||||
|
|
||||||
|
user_id = _s(metadata, "user_id")
|
||||||
|
if user_id:
|
||||||
|
try:
|
||||||
|
user = User.objects.get(pk=int(user_id))
|
||||||
|
except Exception:
|
||||||
|
user = None
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
client_ref = _s(metadata, "client_reference_id") or _s(
|
||||||
|
metadata, "client_id"
|
||||||
|
)
|
||||||
|
if client_ref:
|
||||||
|
try:
|
||||||
|
user = User.objects.get(pk=int(client_ref))
|
||||||
|
except Exception:
|
||||||
|
user = None
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
email = _s(pi, "receipt_email")
|
||||||
|
if not email:
|
||||||
|
charges = _s(_s(pi, "charges") or {}, "data") or []
|
||||||
|
if charges:
|
||||||
|
billing = _s(charges[0], "billing_details") or {}
|
||||||
|
email = _s(billing, "email")
|
||||||
|
if email:
|
||||||
|
user = User.objects.filter(email=email).first()
|
||||||
|
|
||||||
|
course = None
|
||||||
|
purchasable_id = _s(metadata, "purchasable_id")
|
||||||
|
if purchasable_id:
|
||||||
|
try:
|
||||||
|
purch = PurchasableProduct.objects.get(pk=int(purchasable_id))
|
||||||
|
course = purch.course
|
||||||
|
except Exception:
|
||||||
|
course = None
|
||||||
|
|
||||||
|
if not purchase and user and course:
|
||||||
|
purchase = (
|
||||||
|
CoursePurchase.objects.filter(user=user, course=course, refunded=False)
|
||||||
|
.order_by("-id")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
return purchase
|
||||||
|
|
||||||
|
# Handle checkout session completion: ensure purchase exists and store PaymentIntent / Charge ids
|
||||||
if event["type"] == "checkout.session.completed":
|
if event["type"] == "checkout.session.completed":
|
||||||
session = event["data"]["object"]
|
session = event["data"]["object"]
|
||||||
session_id = session.get("id")
|
session_id = session["id"]
|
||||||
|
payment_status = _s(session, "payment_status")
|
||||||
logger.info(f"Checkout session completed: {session}")
|
logger.info(f"Checkout session completed: {session}")
|
||||||
|
|
||||||
# Try to find the CoursePurchase by Stripe session ID first
|
# First try to find existing CoursePurchase by session id
|
||||||
try:
|
purchase = CoursePurchase.objects.filter(
|
||||||
purchase = CoursePurchase.objects.get(stripe_checkout_session_id=session_id)
|
stripe_checkout_session_id=session_id
|
||||||
|
).first()
|
||||||
|
if purchase:
|
||||||
updated = False
|
updated = False
|
||||||
if purchase.status != CoursePurchase.Status.PAID:
|
if (
|
||||||
|
payment_status == "paid"
|
||||||
|
and purchase.status != CoursePurchase.Status.PAID
|
||||||
|
):
|
||||||
purchase.status = CoursePurchase.Status.PAID
|
purchase.status = CoursePurchase.Status.PAID
|
||||||
updated = True
|
updated = True
|
||||||
if purchase.refunded:
|
if purchase.refunded:
|
||||||
@@ -159,11 +279,17 @@ def stripe_webhook(request):
|
|||||||
updated = True
|
updated = True
|
||||||
if updated:
|
if updated:
|
||||||
purchase.save(update_fields=["status", "refunded"])
|
purchase.save(update_fields=["status", "refunded"])
|
||||||
|
if payment_status == "paid":
|
||||||
logger.info(f"Marked CoursePurchase {purchase.id} as PAID.")
|
logger.info(f"Marked CoursePurchase {purchase.id} as PAID.")
|
||||||
except CoursePurchase.DoesNotExist:
|
else:
|
||||||
|
logger.info(
|
||||||
|
"Processed checkout.session.completed for CoursePurchase %s without changing status (payment_status=%s).",
|
||||||
|
purchase.id,
|
||||||
|
payment_status,
|
||||||
|
)
|
||||||
|
else:
|
||||||
# No existing purchase — attempt to create one by inspecting the Stripe session
|
# No existing purchase — attempt to create one by inspecting the Stripe session
|
||||||
try:
|
try:
|
||||||
# Retrieve expanded session to inspect line items and product metadata
|
|
||||||
session_obj = stripe.checkout.Session.retrieve(
|
session_obj = stripe.checkout.Session.retrieve(
|
||||||
session_id,
|
session_id,
|
||||||
expand=[
|
expand=[
|
||||||
@@ -178,48 +304,49 @@ def stripe_webhook(request):
|
|||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
user = None
|
user = None
|
||||||
metadata = session_obj.get("metadata") or {}
|
|
||||||
|
metadata = _s(session_obj, "metadata") or {}
|
||||||
|
|
||||||
# Prefer explicit user_id in metadata
|
# Prefer explicit user_id in metadata
|
||||||
if metadata.get("user_id"):
|
user_id = _s(metadata, "user_id")
|
||||||
|
if user_id:
|
||||||
try:
|
try:
|
||||||
user = User.objects.get(pk=int(metadata.get("user_id")))
|
user = User.objects.get(pk=int(user_id))
|
||||||
except Exception:
|
except Exception:
|
||||||
user = None
|
user = None
|
||||||
|
|
||||||
# Fallback to client_reference_id (often used to pass local user id)
|
# Fallback to client_reference_id (often used to pass local user id)
|
||||||
if not user and session_obj.get("client_reference_id"):
|
if not user:
|
||||||
|
client_ref = _s(session_obj, "client_reference_id")
|
||||||
|
if client_ref:
|
||||||
try:
|
try:
|
||||||
user = User.objects.get(
|
user = User.objects.get(pk=int(client_ref))
|
||||||
pk=int(session_obj.get("client_reference_id"))
|
|
||||||
)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
user = None
|
user = None
|
||||||
|
|
||||||
# Fallback to customer email
|
# Fallback to customer email
|
||||||
if not user:
|
if not user:
|
||||||
email = None
|
email = None
|
||||||
cust_details = session_obj.get("customer_details") or {}
|
cust_details = _s(session_obj, "customer_details") or {}
|
||||||
if cust_details.get("email"):
|
email = _s(cust_details, "email") or _s(
|
||||||
email = cust_details.get("email")
|
session_obj, "customer_email"
|
||||||
elif session_obj.get("customer_email"):
|
)
|
||||||
email = session_obj.get("customer_email")
|
if not email:
|
||||||
elif isinstance(session_obj.get("customer"), dict) and session_obj[
|
customer = _s(session_obj, "customer")
|
||||||
"customer"
|
if isinstance(customer, dict):
|
||||||
].get("email"):
|
email = _s(customer, "email")
|
||||||
email = session_obj["customer"].get("email")
|
|
||||||
|
|
||||||
if email:
|
if email:
|
||||||
user = User.objects.filter(email=email).first()
|
user = User.objects.filter(email=email).first()
|
||||||
|
|
||||||
# Find the purchasable product from line items using product.metadata.local_id
|
# Find the purchasable product from line items using product.metadata.local_id
|
||||||
course = None
|
course = None
|
||||||
line_items = (session_obj.get("line_items") or {}).get("data", [])
|
line_items = _s(_s(session_obj, "line_items") or {}, "data") or []
|
||||||
for item in line_items:
|
for item in line_items:
|
||||||
price = item.get("price") or {}
|
price = _s(item, "price") or {}
|
||||||
product = price.get("product")
|
product = _s(price, "product")
|
||||||
if isinstance(product, dict):
|
if isinstance(product, dict):
|
||||||
local_id = (product.get("metadata") or {}).get("local_id")
|
local_id = _s(_s(product, "metadata") or {}, "local_id")
|
||||||
if local_id:
|
if local_id:
|
||||||
try:
|
try:
|
||||||
purch = PurchasableProduct.objects.get(pk=int(local_id))
|
purch = PurchasableProduct.objects.get(pk=int(local_id))
|
||||||
@@ -235,7 +362,11 @@ def stripe_webhook(request):
|
|||||||
user=user,
|
user=user,
|
||||||
course=course,
|
course=course,
|
||||||
defaults={
|
defaults={
|
||||||
"status": CoursePurchase.Status.PAID,
|
"status": (
|
||||||
|
CoursePurchase.Status.PAID
|
||||||
|
if payment_status == "paid"
|
||||||
|
else CoursePurchase.Status.PENDING
|
||||||
|
),
|
||||||
"refunded": False,
|
"refunded": False,
|
||||||
"stripe_checkout_session_id": session_id,
|
"stripe_checkout_session_id": session_id,
|
||||||
},
|
},
|
||||||
@@ -253,7 +384,10 @@ def stripe_webhook(request):
|
|||||||
if purchase:
|
if purchase:
|
||||||
# Ensure fields are up to date idempotently
|
# Ensure fields are up to date idempotently
|
||||||
update_fields = []
|
update_fields = []
|
||||||
if purchase.status != CoursePurchase.Status.PAID:
|
if (
|
||||||
|
payment_status == "paid"
|
||||||
|
and purchase.status != CoursePurchase.Status.PAID
|
||||||
|
):
|
||||||
purchase.status = CoursePurchase.Status.PAID
|
purchase.status = CoursePurchase.Status.PAID
|
||||||
update_fields.append("status")
|
update_fields.append("status")
|
||||||
if purchase.refunded:
|
if purchase.refunded:
|
||||||
@@ -263,6 +397,27 @@ def stripe_webhook(request):
|
|||||||
purchase.stripe_checkout_session_id = session_id
|
purchase.stripe_checkout_session_id = session_id
|
||||||
update_fields.append("stripe_checkout_session_id")
|
update_fields.append("stripe_checkout_session_id")
|
||||||
|
|
||||||
|
# Store PaymentIntent and Charge (if present) for later refund handling
|
||||||
|
pi = _s(session_obj, "payment_intent")
|
||||||
|
if pi and not purchase.stripe_payment_intent_id:
|
||||||
|
purchase.stripe_payment_intent_id = pi
|
||||||
|
update_fields.append("stripe_payment_intent_id")
|
||||||
|
# Try to obtain charge id from PaymentIntent
|
||||||
|
try:
|
||||||
|
pi_obj = stripe.PaymentIntent.retrieve(
|
||||||
|
pi, expand=["charges.data"]
|
||||||
|
)
|
||||||
|
charges = _s(_s(pi_obj, "charges") or {}, "data") or []
|
||||||
|
if charges:
|
||||||
|
charge_id = _s(charges[0], "id")
|
||||||
|
if charge_id and not purchase.stripe_charge_id:
|
||||||
|
purchase.stripe_charge_id = charge_id
|
||||||
|
update_fields.append("stripe_charge_id")
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"Failed to retrieve PaymentIntent/charges for storing charge id"
|
||||||
|
)
|
||||||
|
|
||||||
if update_fields:
|
if update_fields:
|
||||||
purchase.save(update_fields=update_fields)
|
purchase.save(update_fields=update_fields)
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -301,6 +456,35 @@ def stripe_webhook(request):
|
|||||||
f"Failed to create CoursePurchase for session {session_id}: {e}"
|
f"Failed to create CoursePurchase for session {session_id}: {e}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Refund-related events: charge.refunded, refund.created, refund.updated
|
||||||
|
elif event["type"] in ("charge.refunded", "refund.created", "refund.updated"):
|
||||||
|
obj = event["data"]["object"]
|
||||||
|
# obj may be a Charge (for charge.refunded) or a Refund (for refund.*)
|
||||||
|
obj_object = _s(obj, "object")
|
||||||
|
if obj_object == "charge":
|
||||||
|
charge_id = _s(obj, "id")
|
||||||
|
else:
|
||||||
|
charge_id = _s(obj, "charge")
|
||||||
|
payment_intent_id = _s(obj, "payment_intent")
|
||||||
|
|
||||||
|
# Try to find the CoursePurchase by charge id or payment_intent
|
||||||
|
purchase = None
|
||||||
|
if charge_id:
|
||||||
|
purchase = CoursePurchase.objects.filter(stripe_charge_id=charge_id).first()
|
||||||
|
if not purchase and payment_intent_id:
|
||||||
|
purchase = CoursePurchase.objects.filter(
|
||||||
|
stripe_payment_intent_id=payment_intent_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if purchase:
|
||||||
|
_mark_purchase_refunded(purchase)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"Received refund webhook but could not find CoursePurchase for charge=%s pi=%s",
|
||||||
|
charge_id,
|
||||||
|
payment_intent_id,
|
||||||
|
)
|
||||||
|
|
||||||
elif event["type"] == "checkout.session.expired":
|
elif event["type"] == "checkout.session.expired":
|
||||||
session = event["data"]["object"]
|
session = event["data"]["object"]
|
||||||
session_id = session["id"]
|
session_id = session["id"]
|
||||||
@@ -340,5 +524,105 @@ def stripe_webhook(request):
|
|||||||
logger.warning(
|
logger.warning(
|
||||||
f"No CoursePurchase found for async succeeded session {session_id}"
|
f"No CoursePurchase found for async succeeded session {session_id}"
|
||||||
)
|
)
|
||||||
|
elif event["type"] == "payment_intent.created":
|
||||||
|
pi = event["data"]["object"]
|
||||||
|
pi_id = pi["id"]
|
||||||
|
purchase = None
|
||||||
|
try:
|
||||||
|
# Try to find CoursePurchase by checkout session id present in PaymentIntent metadata
|
||||||
|
metadata = _s(pi, "metadata") or {}
|
||||||
|
session_id = (
|
||||||
|
_s(metadata, "checkout_session")
|
||||||
|
or _s(metadata, "session_id")
|
||||||
|
or _s(metadata, "stripe_checkout_session_id")
|
||||||
|
)
|
||||||
|
if session_id:
|
||||||
|
purchase = CoursePurchase.objects.filter(
|
||||||
|
stripe_checkout_session_id=session_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
# Fallback: try client_reference_id (may be copied into metadata)
|
||||||
|
if not purchase:
|
||||||
|
client_ref = _s(metadata, "client_reference_id") or _s(
|
||||||
|
metadata, "client_id"
|
||||||
|
)
|
||||||
|
if client_ref:
|
||||||
|
try:
|
||||||
|
purchase = (
|
||||||
|
CoursePurchase.objects.filter(
|
||||||
|
user__pk=int(client_ref), refunded=False
|
||||||
|
)
|
||||||
|
.order_by("-id")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
purchase = None
|
||||||
|
|
||||||
|
# Fallback: try receipt email / billing details
|
||||||
|
if not purchase:
|
||||||
|
email = _s(pi, "receipt_email")
|
||||||
|
if not email:
|
||||||
|
charges = _s(_s(pi, "charges") or {}, "data") or []
|
||||||
|
if charges:
|
||||||
|
billing = _s(charges[0], "billing_details") or {}
|
||||||
|
email = _s(billing, "email")
|
||||||
|
if email:
|
||||||
|
purchase = (
|
||||||
|
CoursePurchase.objects.filter(user__email=email, refunded=False)
|
||||||
|
.order_by("-id")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if purchase:
|
||||||
|
update_fields = []
|
||||||
|
if not purchase.stripe_payment_intent_id and pi_id:
|
||||||
|
purchase.stripe_payment_intent_id = pi_id
|
||||||
|
update_fields.append("stripe_payment_intent_id")
|
||||||
|
|
||||||
|
# If charge id is present in the PaymentIntent payload, store it too
|
||||||
|
charges = _s(_s(pi, "charges") or {}, "data") or []
|
||||||
|
if charges:
|
||||||
|
charge_id = _s(charges[0], "id")
|
||||||
|
if charge_id and not purchase.stripe_charge_id:
|
||||||
|
purchase.stripe_charge_id = charge_id
|
||||||
|
update_fields.append("stripe_charge_id")
|
||||||
|
|
||||||
|
if update_fields:
|
||||||
|
purchase.save(update_fields=update_fields)
|
||||||
|
logger.info(
|
||||||
|
"Updated CoursePurchase %s with fields %s from payment_intent.created",
|
||||||
|
purchase.id,
|
||||||
|
update_fields,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.debug(
|
||||||
|
"payment_intent.created: CoursePurchase %s already has payment fields set",
|
||||||
|
purchase.id,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.info(
|
||||||
|
"payment_intent.created: no matching CoursePurchase for pi=%s",
|
||||||
|
pi_id,
|
||||||
|
)
|
||||||
|
logger.debug("payment_intent.created payload: %s", pi)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed processing payment_intent.created")
|
||||||
|
|
||||||
|
elif event["type"] == "payment_intent.payment_failed":
|
||||||
|
pi = event["data"]["object"]
|
||||||
|
pi_id = pi["id"]
|
||||||
|
try:
|
||||||
|
purchase = _find_purchase_from_payment_intent(pi)
|
||||||
|
if purchase:
|
||||||
|
if purchase.status != CoursePurchase.Status.FAILED:
|
||||||
|
purchase.status = CoursePurchase.Status.FAILED
|
||||||
|
purchase.save(update_fields=["status"])
|
||||||
|
logger.info(
|
||||||
|
f"Marked CoursePurchase {purchase.id} as FAILED (payment intent failed)."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise CoursePurchase.DoesNotExist
|
||||||
|
except CoursePurchase.DoesNotExist:
|
||||||
|
logger.warning(f"No CoursePurchase found for failed payment intent {pi_id}")
|
||||||
|
|
||||||
return HttpResponse(status=200)
|
return HttpResponse(status=200)
|
||||||
|
|||||||
Reference in New Issue
Block a user