feat: refunds, failed and pending payments work

This commit is contained in:
2026-05-20 20:27:32 +02:00
parent 3338a1d3e7
commit ffc53f1b54
5 changed files with 636 additions and 59 deletions

View File

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