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