feat: refunds, failed and pending payments work
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user