From ffc53f1b54931dd264d9ab1ccc7724bdc4626a78 Mon Sep 17 00:00:00 2001 From: Artur Borecki Date: Wed, 20 May 2026 20:27:32 +0200 Subject: [PATCH] feat: refunds, failed and pending payments work --- home/models/pages.py | 22 +- ...oursepurchase_stripe_charge_id_and_more.py | 23 ++ purchase/models.py | 150 ++++++- purchase/tests.py | 134 ++++++- purchase/views.py | 366 ++++++++++++++++-- 5 files changed, 636 insertions(+), 59 deletions(-) create mode 100644 purchase/migrations/0009_coursepurchase_stripe_charge_id_and_more.py diff --git a/home/models/pages.py b/home/models/pages.py index a5050c4..2935ea2 100644 --- a/home/models/pages.py +++ b/home/models/pages.py @@ -121,7 +121,10 @@ class CoursePage(Page): return True return CoursePurchase.objects.filter( - user=user, course=self, refunded=False + user=user, + course=self, + refunded=False, + status=CoursePurchase.Status.PAID, ).exists() def _user_purchase_id(self, user): @@ -129,7 +132,10 @@ class CoursePage(Page): return None purchase = CoursePurchase.objects.filter( - user=user, course=self, refunded=False + user=user, + course=self, + refunded=False, + status=CoursePurchase.Status.PAID, ).first() print(f"User {user} purchase for course {self}: {purchase}") return purchase.id if purchase else None @@ -139,15 +145,19 @@ class CoursePage(Page): if not user.is_authenticated: return False 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 group_name = f"course_{self.id}_access" group, _ = Group.objects.get_or_create(name=group_name) 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 def save(self, *args, **kwargs): diff --git a/purchase/migrations/0009_coursepurchase_stripe_charge_id_and_more.py b/purchase/migrations/0009_coursepurchase_stripe_charge_id_and_more.py new file mode 100644 index 0000000..92b8875 --- /dev/null +++ b/purchase/migrations/0009_coursepurchase_stripe_charge_id_and_more.py @@ -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), + ), + ] diff --git a/purchase/models.py b/purchase/models.py index d11d8df..2e0f28c 100644 --- a/purchase/models.py +++ b/purchase/models.py @@ -16,11 +16,11 @@ 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" + INITIATED = "initiated" + PENDING = "pending" + PAID = "paid" + REFUNDED = "refunded" + FAILED = "failed" user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) course = models.ForeignKey("home.CoursePage", on_delete=models.CASCADE) @@ -32,11 +32,120 @@ class CoursePurchase(models.Model): default=Status.INITIATED, ) 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): + """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.status = CoursePurchase.Status.REFUNDED 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): api_url = getattr(settings, "GITEA_URL", None) if not api_url: @@ -131,6 +240,14 @@ class CoursePurchase(models.Model): ) 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) 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}" ) - if self.refunded: - print(f"Removing user {self.user} from group {group_name} due to refund") - self.remove_from_gitea_team() - self.user.groups.remove(group) - else: + should_grant_access = ( + self.status == CoursePurchase.Status.PAID and not self.refunded + ) + had_granted_access = ( + bool(previous_state) + and previous_state["status"] == CoursePurchase.Status.PAID + and not previous_state["refunded"] + ) + + if should_grant_access: logger.debug( f"Adding user {self.user} to group {group_name} for course {self.course.title}" ) 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): diff --git a/purchase/tests.py b/purchase/tests.py index 7ce503c..2d269f9 100644 --- a/purchase/tests.py +++ b/purchase/tests.py @@ -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)) diff --git a/purchase/views.py b/purchase/views.py index 2a3f6b0..ce96ba2 100644 --- a/purchase/views.py +++ b/purchase/views.py @@ -52,7 +52,7 @@ def create_checkout_session(request, purchasable_id): try: purch = PurchasableProduct.objects.get(pk=purchasable_id) 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: return HttpResponse("Product not configured for Stripe", status=400) @@ -65,6 +65,13 @@ def create_checkout_session(request, purchasable_id): mode="payment", client_reference_id=str(request.user.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( settings, "STRIPE_SUCCESS_URL", "https://example.com/success" ), @@ -83,6 +90,7 @@ def create_checkout_session(request, purchasable_id): defaults={ "status": CoursePurchase.Status.PENDING, "stripe_checkout_session_id": session.id, + "stripe_payment_intent_id": session.payment_intent, }, ) except IntegrityError: @@ -95,10 +103,28 @@ def create_checkout_session(request, purchasable_id): 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"]) + if purchase: + 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 + 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 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}" ) raise e - # return HttpResponse(status=500) @csrf_exempt @@ -132,26 +157,121 @@ def stripe_webhook(request): ) 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 + # 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 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": 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}") - # Try to find the CoursePurchase by Stripe session ID first - try: - purchase = CoursePurchase.objects.get(stripe_checkout_session_id=session_id) + # First try to find existing CoursePurchase by session id + purchase = CoursePurchase.objects.filter( + stripe_checkout_session_id=session_id + ).first() + if purchase: updated = False - if purchase.status != CoursePurchase.Status.PAID: + if ( + payment_status == "paid" + and purchase.status != CoursePurchase.Status.PAID + ): purchase.status = CoursePurchase.Status.PAID updated = True if purchase.refunded: @@ -159,11 +279,17 @@ def stripe_webhook(request): updated = True if updated: purchase.save(update_fields=["status", "refunded"]) - logger.info(f"Marked CoursePurchase {purchase.id} as PAID.") - except CoursePurchase.DoesNotExist: + if payment_status == "paid": + logger.info(f"Marked CoursePurchase {purchase.id} as PAID.") + 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 try: - # Retrieve expanded session to inspect line items and product metadata session_obj = stripe.checkout.Session.retrieve( session_id, expand=[ @@ -178,48 +304,49 @@ def stripe_webhook(request): User = get_user_model() user = None - metadata = session_obj.get("metadata") or {} + + metadata = _s(session_obj, "metadata") or {} # Prefer explicit user_id in metadata - if metadata.get("user_id"): + user_id = _s(metadata, "user_id") + if user_id: try: - user = User.objects.get(pk=int(metadata.get("user_id"))) + user = User.objects.get(pk=int(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 + if not user: + client_ref = _s(session_obj, "client_reference_id") + if client_ref: + try: + user = User.objects.get(pk=int(client_ref)) + 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") + cust_details = _s(session_obj, "customer_details") or {} + email = _s(cust_details, "email") or _s( + session_obj, "customer_email" + ) + if not email: + customer = _s(session_obj, "customer") + if isinstance(customer, dict): + email = _s(customer, "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", []) + line_items = _s(_s(session_obj, "line_items") or {}, "data") or [] for item in line_items: - price = item.get("price") or {} - product = price.get("product") + price = _s(item, "price") or {} + product = _s(price, "product") 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: try: purch = PurchasableProduct.objects.get(pk=int(local_id)) @@ -235,7 +362,11 @@ def stripe_webhook(request): user=user, course=course, defaults={ - "status": CoursePurchase.Status.PAID, + "status": ( + CoursePurchase.Status.PAID + if payment_status == "paid" + else CoursePurchase.Status.PENDING + ), "refunded": False, "stripe_checkout_session_id": session_id, }, @@ -253,7 +384,10 @@ def stripe_webhook(request): if purchase: # Ensure fields are up to date idempotently update_fields = [] - if purchase.status != CoursePurchase.Status.PAID: + if ( + payment_status == "paid" + and purchase.status != CoursePurchase.Status.PAID + ): purchase.status = CoursePurchase.Status.PAID update_fields.append("status") if purchase.refunded: @@ -263,6 +397,27 @@ def stripe_webhook(request): purchase.stripe_checkout_session_id = 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: purchase.save(update_fields=update_fields) logger.info( @@ -301,6 +456,35 @@ def stripe_webhook(request): 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": session = event["data"]["object"] session_id = session["id"] @@ -340,5 +524,105 @@ def stripe_webhook(request): logger.warning( 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)