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

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