Compare commits
18 Commits
main
...
feat/imple
| Author | SHA1 | Date | |
|---|---|---|---|
|
ffc53f1b54
|
|||
|
3338a1d3e7
|
|||
|
365b28a165
|
|||
|
41af6dcb7c
|
|||
|
e399d98f31
|
|||
|
33d2b89b07
|
|||
|
118a1188d5
|
|||
|
6d927856c8
|
|||
|
d2c870414f
|
|||
|
4dbfb8fc41
|
|||
|
2065f3c9c5
|
|||
|
b24e48f1b1
|
|||
|
211dcc4f67
|
|||
|
a3cd8d42fa
|
|||
|
6471b98ec2
|
|||
|
3b46a18b29
|
|||
|
3bc11bf58d
|
|||
|
9041ecd206
|
@@ -9,7 +9,8 @@ from modelcluster.contrib.taggit import ClusterTaggableManager
|
||||
from modelcluster.fields import ParentalKey
|
||||
from taggit.models import TaggedItemBase
|
||||
from wagtail import blocks
|
||||
from wagtail.admin.panels import FieldPanel
|
||||
from wagtail.admin.panels import FieldPanel, InlinePanel
|
||||
|
||||
from wagtail.fields import RichTextField, StreamField
|
||||
from wagtail.images.blocks import ImageBlock
|
||||
from wagtail.models import Page
|
||||
@@ -120,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):
|
||||
@@ -128,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
|
||||
@@ -138,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):
|
||||
@@ -168,6 +179,9 @@ class CoursePage(Page):
|
||||
FieldPanel("course_image"),
|
||||
FieldPanel("description"),
|
||||
FieldPanel("body"),
|
||||
InlinePanel(
|
||||
"purchasable_products", label="Purchasable product", min_num=0, max_num=1
|
||||
),
|
||||
FieldPanel("allowed_groups", widget=CheckboxSelectMultiple),
|
||||
FieldPanel(
|
||||
"repository_url",
|
||||
@@ -175,6 +189,7 @@ class CoursePage(Page):
|
||||
heading="Repository URL (auto-generated)",
|
||||
),
|
||||
]
|
||||
|
||||
parent_page_types = ["home.CourseIndexPage"]
|
||||
subpage_types = ["home.CourseModulePage"]
|
||||
|
||||
|
||||
@@ -49,10 +49,22 @@
|
||||
<a href="{% url 'account_signup' %}?next={{ request.path }}" class="mt-4 inline-block bg-green-600 text-white px-4 py-2 rounded hover:bg-green-700 transition ml-2">{% trans "Sign Up" %}</a>
|
||||
|
||||
</div>
|
||||
{% elif not page.purchasable_products.exists %}
|
||||
<div class="not-prose mt-8 p-4 bg-gray-100 border-l-4 border-gray-500 text-gray-700">
|
||||
<p>{% trans "Course is not yet available for purchase. Please check back later." %}</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="not-prose mt-8 p-4 bg-yellow-100 border-l-4 border-yellow-500 text-yellow-700">
|
||||
<p>{% trans "You don't have access to this course. Please purchase it to view the modules." %}</p>
|
||||
<a href="{% url 'mock_purchase_course' course_id=page.id %}" class="mt-4 inline-block bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 transition">{% trans "Purchase Course" %}</a>
|
||||
<h2 class="not-prose text-2xl mt-4 text-gray-700 font-semibold">
|
||||
{{ page.purchasable_products.first.price | floatformat:2 }} {{ page.purchasable_products.first.currency | upper }}
|
||||
</h2>
|
||||
<form method="post" action="{% url 'create_checkout_session' purchasable_id=page.purchasable_products.first.id %}">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="mt-2 inline-block bg-green-600 text-white px-4 py-2 rounded cursor-pointer hover:bg-green-700 transition">
|
||||
{% trans "Purchase Course" %}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock content %}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.contrib.auth.models import User
|
||||
from django.shortcuts import redirect, render
|
||||
|
||||
from django.shortcuts import redirect, render, get_object_or_404
|
||||
from home.models import ChatMessage
|
||||
from purchase.models import PurchasableProduct
|
||||
from django.views.decorators.http import require_http_methods
|
||||
|
||||
|
||||
# Chat admin + user views (restored)
|
||||
@login_required
|
||||
def admin_chat_dashboard(request):
|
||||
chats = ChatMessage.get_all_user_senders()
|
||||
|
||||
@@ -47,7 +47,7 @@ def register_code_block_feature(features):
|
||||
|
||||
|
||||
@hooks.register("register_admin_urls")
|
||||
def register_admin_chat_dashboard_url():
|
||||
def register_admin_urls():
|
||||
return [
|
||||
path("chat/", views.admin_chat_dashboard, name="admin_chat_dashboard"),
|
||||
path(
|
||||
|
||||
@@ -314,6 +314,11 @@ WAGTAILDOCS_EXTENSIONS = [
|
||||
|
||||
TAILWIND_APP_NAME = "theme"
|
||||
|
||||
SITE_URL = "http://localhost:8000"
|
||||
|
||||
STRIPE_DEFAULT_CURRENCY = "pln"
|
||||
STRIPE_SUCCESS_URL = f"{SITE_URL}/purchase/success/"
|
||||
|
||||
# Gitea API
|
||||
GITEA_ROOT_URL = "http://localhost:3000"
|
||||
GITEA_URL = f"{GITEA_ROOT_URL}/api/v1"
|
||||
|
||||
@@ -22,7 +22,7 @@ urlpatterns = [
|
||||
path("i18n/", include("django.conf.urls.i18n")),
|
||||
path("oauth2/", include("oauth2_provider.urls", namespace="oauth2_provider")),
|
||||
path("", include("home.urls")),
|
||||
path("", include("purchase.urls")),
|
||||
path("purchase/", include("purchase.urls")),
|
||||
path("calendar/", views.calendar, name="calendar"),
|
||||
# TODO: move occurrence related urls to home app
|
||||
path(
|
||||
|
||||
26
purchase/migrations/0002_purchasableproduct.py
Normal file
26
purchase/migrations/0002_purchasableproduct.py
Normal file
@@ -0,0 +1,26 @@
|
||||
# Generated by Django 6.0.4 on 2026-05-18 14:14
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('purchase', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='PurchasableProduct',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=255)),
|
||||
('description', models.TextField(blank=True)),
|
||||
('price_cents', models.PositiveIntegerField(help_text='Price in cents')),
|
||||
('currency', models.CharField(default='usd', max_length=10)),
|
||||
('stripe_product_id', models.CharField(blank=True, max_length=255, null=True)),
|
||||
('stripe_price_id', models.CharField(blank=True, max_length=255, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0.4 on 2026-05-18 14:54
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('purchase', '0002_purchasableproduct'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='purchasableproduct',
|
||||
name='currency',
|
||||
field=models.CharField(default='pln', max_length=10),
|
||||
),
|
||||
]
|
||||
21
purchase/migrations/0004_purchasableproduct_course.py
Normal file
21
purchase/migrations/0004_purchasableproduct_course.py
Normal file
@@ -0,0 +1,21 @@
|
||||
# Generated by Django 6.0.4 on 2026-05-18 15:05
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('home', '0023_eventindexpage'),
|
||||
('purchase', '0003_alter_purchasableproduct_currency'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='purchasableproduct',
|
||||
name='course',
|
||||
field=models.OneToOneField(default=0, on_delete=django.db.models.deletion.CASCADE, related_name='purchasable_product', to='home.coursepage'),
|
||||
preserve_default=False,
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,35 @@
|
||||
# Generated by Django 6.0.4 on 2026-05-18 15:35
|
||||
|
||||
import django.db.models.deletion
|
||||
import modelcluster.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('home', '0023_eventindexpage'),
|
||||
('purchase', '0004_purchasableproduct_course'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='purchasableproduct',
|
||||
options={'ordering': ['sort_order']},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='purchasableproduct',
|
||||
name='sort_order',
|
||||
field=models.IntegerField(blank=True, editable=False, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='purchasableproduct',
|
||||
name='course',
|
||||
field=modelcluster.fields.ParentalKey(blank=True, help_text='Link this PurchasableProduct to a CoursePage for inline editing.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='purchasable_products', to='home.coursepage'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='purchasableproduct',
|
||||
name='name',
|
||||
field=models.CharField(blank=True, max_length=255),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0.4 on 2026-05-18 15:47
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('purchase', '0005_alter_purchasableproduct_options_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='purchasableproduct',
|
||||
name='stripe_payment_url',
|
||||
field=models.URLField(blank=True, help_text='Stripe Checkout URL for this product (optional, can be set via admin or programmatically)', null=True),
|
||||
),
|
||||
]
|
||||
36
purchase/migrations/0007_coursepurchase_status_and_more.py
Normal file
36
purchase/migrations/0007_coursepurchase_status_and_more.py
Normal file
@@ -0,0 +1,36 @@
|
||||
# Generated by Django 6.0.4 on 2026-05-18 16:18
|
||||
|
||||
import django.db.models.deletion
|
||||
import modelcluster.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('home', '0023_eventindexpage'),
|
||||
('purchase', '0006_purchasableproduct_stripe_payment_url'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='coursepurchase',
|
||||
name='status',
|
||||
field=models.CharField(choices=[('initiated', 'Initiated'), ('pending', 'Pending'), ('paid', 'Paid'), ('refunded', 'Refunded'), ('failed', 'Failed')], default='initiated', max_length=20),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='coursepurchase',
|
||||
name='stripe_checkout_session_id',
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='purchasableproduct',
|
||||
name='course',
|
||||
field=modelcluster.fields.ParentalKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='purchasable_products', to='home.coursepage'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='purchasableproduct',
|
||||
name='stripe_payment_url',
|
||||
field=models.URLField(blank=True, help_text='Stripe Checkout URL for this product', null=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 6.0.4 on 2026-05-20 17:09
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('purchase', '0007_coursepurchase_status_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='purchasableproduct',
|
||||
name='stripe_payment_url',
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -2,24 +2,150 @@ import logging as lg
|
||||
import os
|
||||
|
||||
import requests
|
||||
import stripe
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import Group
|
||||
from django.db import models
|
||||
from wagtail.admin.panels import FieldPanel
|
||||
from modelcluster.fields import ParentalKey
|
||||
from wagtail.models import Orderable
|
||||
|
||||
GITEA_ORG_NAME = "Studio77"
|
||||
logger = lg.getLogger(__name__)
|
||||
|
||||
|
||||
class CoursePurchase(models.Model):
|
||||
class Status(models.TextChoices):
|
||||
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)
|
||||
purchased_at = models.DateTimeField(auto_now_add=True)
|
||||
refunded = models.BooleanField(default=False)
|
||||
status = models.CharField(
|
||||
max_length=20,
|
||||
choices=Status.choices,
|
||||
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:
|
||||
@@ -114,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"
|
||||
|
||||
@@ -123,12 +257,267 @@ 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):
|
||||
"""A product that can be purchased. When created it will create a Stripe Product and Price.
|
||||
|
||||
On delete it will try to deactivate the Price and delete the Product in Stripe.
|
||||
The code is defensive: if STRIPE_API_KEY is not configured the model will still work locally.
|
||||
"""
|
||||
|
||||
name = models.CharField(max_length=255, blank=True)
|
||||
description = models.TextField(blank=True)
|
||||
price_cents = models.PositiveIntegerField(help_text="Price in cents")
|
||||
currency = models.CharField(
|
||||
max_length=10, default=getattr(settings, "STRIPE_DEFAULT_CURRENCY", "pln")
|
||||
)
|
||||
stripe_product_id = models.CharField(max_length=255, blank=True, null=True)
|
||||
stripe_price_id = models.CharField(max_length=255, blank=True, null=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
course = ParentalKey(
|
||||
"home.CoursePage",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="purchasable_products",
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
|
||||
panels = [
|
||||
FieldPanel("price_cents"),
|
||||
FieldPanel("currency"),
|
||||
FieldPanel("stripe_product_id", read_only=True),
|
||||
FieldPanel("stripe_price_id", read_only=True),
|
||||
FieldPanel("stripe_payment_url", read_only=True),
|
||||
]
|
||||
|
||||
@property
|
||||
def price(self):
|
||||
return self.price_cents / 100
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.price_cents / 100:.2f} {self.currency.upper()})"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Save locally first to ensure we have a PK, then create or update Stripe product/price if needed.
|
||||
|
||||
Behavior:
|
||||
- On create: create Stripe Product and Price (if STRIPE_SECRET_KEY is set).
|
||||
- On update:
|
||||
- If name or description changed -> update Stripe Product.
|
||||
- If price_cents or currency changed -> create a new Stripe Price and deactivate the old one, then update stripe_price_id.
|
||||
- If STRIPE_SECRET_KEY is not configured the model will still work locally.
|
||||
"""
|
||||
# Capture whether this is a new object and the previous state (if any)
|
||||
is_new = self.pk is None
|
||||
previous = None
|
||||
if not is_new:
|
||||
try:
|
||||
previous = self.__class__.objects.get(pk=self.pk)
|
||||
except self.__class__.DoesNotExist:
|
||||
previous = None
|
||||
|
||||
# Get name, description and image from the linked course if not set explicitly on the product
|
||||
if self.course:
|
||||
course_name = self.course.title
|
||||
course_description = self.course.description or ""
|
||||
if not self.name:
|
||||
self.name = course_name
|
||||
if not self.description:
|
||||
self.description = course_description
|
||||
|
||||
# Persist local changes first so we have an id
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
stripe_api_key = getattr(
|
||||
settings, "STRIPE_SECRET_KEY", os.getenv("STRIPE_SECRET_KEY")
|
||||
)
|
||||
if not stripe_api_key:
|
||||
logger.debug(
|
||||
"STRIPE_SECRET_KEY not set, skipping Stripe product/price creation/update"
|
||||
)
|
||||
return
|
||||
|
||||
stripe.api_key = stripe_api_key
|
||||
try:
|
||||
changed_fields = []
|
||||
|
||||
# Create Stripe Product if missing
|
||||
if not self.stripe_product_id:
|
||||
prod = stripe.Product.create(
|
||||
name=self.name,
|
||||
description=self.description or None,
|
||||
metadata={"local_id": str(self.id)},
|
||||
)
|
||||
self.stripe_product_id = prod.id
|
||||
changed_fields.append("stripe_product_id")
|
||||
logger.info(
|
||||
f"Created Stripe product {self.stripe_product_id} for PurchasableProduct {self.id}"
|
||||
)
|
||||
|
||||
# If we don't have a price id, create one
|
||||
if not self.stripe_price_id:
|
||||
price = stripe.Price.create(
|
||||
product=self.stripe_product_id,
|
||||
unit_amount=self.price_cents,
|
||||
currency=self.currency.lower(),
|
||||
)
|
||||
self.stripe_price_id = price.id
|
||||
changed_fields.append("stripe_price_id")
|
||||
logger.info(
|
||||
f"Created Stripe price {self.stripe_price_id} for PurchasableProduct {self.id}"
|
||||
)
|
||||
|
||||
# Create Stripe Payment Link if missing or if price changed
|
||||
if not self.stripe_payment_url and self.stripe_price_id:
|
||||
try:
|
||||
payment_link = stripe.PaymentLink.create(
|
||||
line_items=[{"price": self.stripe_price_id, "quantity": 1}],
|
||||
after_completion={
|
||||
"type": "redirect",
|
||||
"redirect": {
|
||||
"url": getattr(
|
||||
settings,
|
||||
"STRIPE_SUCCESS_URL",
|
||||
"https://example.com/success",
|
||||
)
|
||||
},
|
||||
},
|
||||
)
|
||||
self.stripe_payment_url = payment_link.url
|
||||
changed_fields.append("stripe_payment_url")
|
||||
logger.info(
|
||||
f"Created Stripe payment link {self.stripe_payment_url} for PurchasableProduct {self.id}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
f"Failed to create Stripe payment link for PurchasableProduct {self.id}: {e}"
|
||||
)
|
||||
|
||||
# If this is an update (we had previous state) perform updates
|
||||
if previous:
|
||||
# Update product metadata/name/description if they changed
|
||||
try:
|
||||
if (previous.name != self.name) or (
|
||||
previous.description != self.description
|
||||
):
|
||||
stripe.Product.modify(
|
||||
self.stripe_product_id,
|
||||
name=self.name,
|
||||
description=self.description or None,
|
||||
)
|
||||
logger.info(
|
||||
f"Updated Stripe product {self.stripe_product_id} for PurchasableProduct {self.id}"
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
f"Failed to update Stripe product {self.stripe_product_id} for PurchasableProduct {self.id}"
|
||||
)
|
||||
|
||||
# If price or currency changed, create a new Price and deactivate the old one
|
||||
try:
|
||||
prev_currency = (previous.currency or "").lower()
|
||||
curr_currency = (self.currency or "").lower()
|
||||
if (previous.price_cents != self.price_cents) or (
|
||||
prev_currency != curr_currency
|
||||
):
|
||||
# Create new price for the same product
|
||||
new_price = stripe.Price.create(
|
||||
product=self.stripe_product_id,
|
||||
unit_amount=self.price_cents,
|
||||
currency=self.currency.lower(),
|
||||
)
|
||||
# Attempt to deactivate the old price, but don't fail the whole operation if it fails
|
||||
try:
|
||||
if self.stripe_price_id:
|
||||
stripe.Price.modify(self.stripe_price_id, active=False)
|
||||
logger.info(
|
||||
f"Deactivated old Stripe price {self.stripe_price_id} for PurchasableProduct {self.id}"
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
f"Failed to deactivate old Stripe price {self.stripe_price_id} for PurchasableProduct {self.id}"
|
||||
)
|
||||
|
||||
# Switch to the new price id and persist it
|
||||
self.stripe_price_id = new_price.id
|
||||
if "stripe_price_id" not in changed_fields:
|
||||
changed_fields.append("stripe_price_id")
|
||||
|
||||
logger.info(
|
||||
f"Created new Stripe price {self.stripe_price_id} for PurchasableProduct {self.id}"
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
f"Failed to create or switch Stripe price for PurchasableProduct {self.id}"
|
||||
)
|
||||
|
||||
# Persist any changed stripe ids without triggering further Stripe operations
|
||||
if changed_fields:
|
||||
super().save(update_fields=changed_fields)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
f"Failed to create/update Stripe product/price for PurchasableProduct {self.id}: {e}"
|
||||
)
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
"""Try to clean up Stripe resources when the product is deleted locally."""
|
||||
stripe_api_key = getattr(
|
||||
settings, "STRIPE_SECRET_KEY", os.getenv("STRIPE_SECRET_KEY")
|
||||
)
|
||||
|
||||
if not stripe_api_key:
|
||||
logger.debug(
|
||||
"STRIPE_SECRET_KEY not set, skipping Stripe product/price cleanup"
|
||||
)
|
||||
return super().delete(*args, **kwargs)
|
||||
|
||||
stripe.api_key = stripe_api_key
|
||||
# Attempt to deactivate price and delete product. Be tolerant of failures.
|
||||
if self.stripe_price_id:
|
||||
try:
|
||||
stripe.Price.modify(self.stripe_price_id, active=False)
|
||||
logger.info(
|
||||
f"Deactivated Stripe price {self.stripe_price_id} for PurchasableProduct {self.id}"
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
f"Failed to deactivate Stripe price {self.stripe_price_id} for PurchasableProduct {self.id}"
|
||||
)
|
||||
|
||||
if self.stripe_product_id:
|
||||
try:
|
||||
stripe.Product.modify(self.stripe_product_id, active=False)
|
||||
logger.info(
|
||||
f"Deactivated Stripe product {self.stripe_product_id} for PurchasableProduct {self.id}"
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
f"Failed to deactivate Stripe product {self.stripe_product_id} for PurchasableProduct {self.id}"
|
||||
)
|
||||
|
||||
return super().delete(*args, **kwargs)
|
||||
|
||||
12
purchase/templates/success.html
Normal file
12
purchase/templates/success.html
Normal file
@@ -0,0 +1,12 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-5">
|
||||
<div class="card">
|
||||
<div class="text-center">
|
||||
<h1 class="text-9xl">BRAWO KURWA!!!!</h1>
|
||||
<p>WYDAŁEŚ PIENIĄDZE NA JAKIEŚ TOTALNE GÓWNO!!!!</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -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))
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from django.urls import path
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
|
||||
from . import views
|
||||
|
||||
@@ -13,4 +14,11 @@ urlpatterns = [
|
||||
views.mock_refund_purchase,
|
||||
name="mock_refund_purchase",
|
||||
),
|
||||
path("stripe/webhook/", csrf_exempt(views.stripe_webhook), name="stripe-webhook"),
|
||||
path(
|
||||
"stripe/checkout/<int:purchasable_id>/",
|
||||
views.create_checkout_session,
|
||||
name="create_checkout_session",
|
||||
),
|
||||
path("success/", views.purchase_success, name="purchase_success"),
|
||||
]
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
||||
import stripe
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.db import IntegrityError, transaction
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import redirect, render
|
||||
from django.urls import reverse
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.decorators.http import require_POST
|
||||
|
||||
from home.models import CoursePage
|
||||
from purchase.models import CoursePurchase
|
||||
from purchase.models import CoursePurchase, PurchasableProduct
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def mock_purchase_course(request, course_id):
|
||||
@@ -19,3 +31,598 @@ def mock_refund_purchase(request, purchase_id):
|
||||
purchase.mock_refund()
|
||||
|
||||
return redirect(purchase.course.url)
|
||||
|
||||
|
||||
def purchase_success(request):
|
||||
return render(request, "success.html")
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def create_checkout_session(request, purchasable_id):
|
||||
"""Create a Stripe Checkout Session for the given PurchasableProduct and include the local user id in metadata.
|
||||
|
||||
This view requires an authenticated POST request and will redirect the user to Stripe Checkout.
|
||||
It will also create or get a pending CoursePurchase linked to the checkout session.
|
||||
"""
|
||||
stripe.api_key = getattr(
|
||||
settings, "STRIPE_SECRET_KEY", os.getenv("STRIPE_SECRET_KEY")
|
||||
)
|
||||
|
||||
try:
|
||||
purch = PurchasableProduct.objects.get(pk=purchasable_id)
|
||||
except PurchasableProduct.DoesNotExist:
|
||||
return HttpResponse("Purchasable product not found", status=404)
|
||||
|
||||
if not purch.stripe_price_id:
|
||||
return HttpResponse("Product not configured for Stripe", status=400)
|
||||
|
||||
try:
|
||||
session = stripe.checkout.Session.create(
|
||||
payment_method_types=["card", "blik", "p24"],
|
||||
line_items=[{"price": purch.stripe_price_id, "quantity": 1}],
|
||||
customer_email=request.user.email or None,
|
||||
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"
|
||||
),
|
||||
cancel_url=getattr(
|
||||
settings, "STRIPE_CANCEL_URL", "https://example.com/cancel"
|
||||
),
|
||||
)
|
||||
|
||||
# Create or get a pending CoursePurchase tied to this session (idempotent)
|
||||
try:
|
||||
with transaction.atomic():
|
||||
purchase, created = CoursePurchase.objects.get_or_create(
|
||||
user=request.user,
|
||||
course=purch.course,
|
||||
refunded=False,
|
||||
defaults={
|
||||
"status": CoursePurchase.Status.PENDING,
|
||||
"stripe_checkout_session_id": session.id,
|
||||
"stripe_payment_intent_id": session.payment_intent,
|
||||
},
|
||||
)
|
||||
except IntegrityError:
|
||||
# Race: another worker created the purchase concurrently. Re-fetch.
|
||||
purchase = CoursePurchase.objects.filter(
|
||||
user=request.user, course=purch.course
|
||||
).first()
|
||||
if not purchase:
|
||||
purchase = CoursePurchase.objects.filter(
|
||||
stripe_checkout_session_id=session.id
|
||||
).first()
|
||||
|
||||
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)
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
f"Failed to create checkout session for purchasable {purchasable_id}: {e}"
|
||||
)
|
||||
raise e
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
def stripe_webhook(request):
|
||||
stripe.api_key = getattr(
|
||||
settings, "STRIPE_SECRET_KEY", os.getenv("STRIPE_SECRET_KEY")
|
||||
)
|
||||
webhook_secret = getattr(
|
||||
settings, "STRIPE_WEBHOOK_SECRET", os.getenv("STRIPE_WEBHOOK_SECRET")
|
||||
)
|
||||
payload = request.body
|
||||
sig_header = request.META.get("HTTP_STRIPE_SIGNATURE")
|
||||
event = None
|
||||
|
||||
try:
|
||||
if webhook_secret:
|
||||
event = stripe.Webhook.construct_event(payload, sig_header, webhook_secret)
|
||||
else:
|
||||
# fallback: parse payload as JSON
|
||||
event = stripe.Event.construct_from(
|
||||
json.loads(payload.decode("utf-8")), stripe.api_key
|
||||
)
|
||||
except ValueError as e:
|
||||
logger.error(f"Invalid payload: {e}")
|
||||
raise e
|
||||
except stripe.error.SignatureVerificationError as e:
|
||||
logger.error(f"Webhook signature verification failed: {e}")
|
||||
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["id"]
|
||||
payment_status = _s(session, "payment_status")
|
||||
logger.info(f"Checkout session completed: {session}")
|
||||
|
||||
# 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 (
|
||||
payment_status == "paid"
|
||||
and purchase.status != CoursePurchase.Status.PAID
|
||||
):
|
||||
purchase.status = CoursePurchase.Status.PAID
|
||||
updated = True
|
||||
if purchase.refunded:
|
||||
purchase.refunded = False
|
||||
updated = True
|
||||
if updated:
|
||||
purchase.save(update_fields=["status", "refunded"])
|
||||
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:
|
||||
session_obj = stripe.checkout.Session.retrieve(
|
||||
session_id,
|
||||
expand=[
|
||||
"line_items.data.price.product",
|
||||
"customer",
|
||||
"customer_details",
|
||||
],
|
||||
)
|
||||
|
||||
# Determine user from session metadata, client_reference_id or customer details
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
User = get_user_model()
|
||||
user = None
|
||||
|
||||
metadata = _s(session_obj, "metadata") or {}
|
||||
|
||||
# Prefer explicit user_id in metadata
|
||||
user_id = _s(metadata, "user_id")
|
||||
if user_id:
|
||||
try:
|
||||
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:
|
||||
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 = _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 = _s(_s(session_obj, "line_items") or {}, "data") or []
|
||||
for item in line_items:
|
||||
price = _s(item, "price") or {}
|
||||
product = _s(price, "product")
|
||||
if isinstance(product, dict):
|
||||
local_id = _s(_s(product, "metadata") or {}, "local_id")
|
||||
if local_id:
|
||||
try:
|
||||
purch = PurchasableProduct.objects.get(pk=int(local_id))
|
||||
course = purch.course
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if user and course:
|
||||
try:
|
||||
with transaction.atomic():
|
||||
purchase, created = CoursePurchase.objects.get_or_create(
|
||||
user=user,
|
||||
course=course,
|
||||
defaults={
|
||||
"status": (
|
||||
CoursePurchase.Status.PAID
|
||||
if payment_status == "paid"
|
||||
else CoursePurchase.Status.PENDING
|
||||
),
|
||||
"refunded": False,
|
||||
"stripe_checkout_session_id": session_id,
|
||||
},
|
||||
)
|
||||
except IntegrityError:
|
||||
# Race: another worker created the purchase concurrently. Re-fetch.
|
||||
purchase = CoursePurchase.objects.filter(
|
||||
user=user, course=course
|
||||
).first()
|
||||
if not purchase:
|
||||
purchase = CoursePurchase.objects.filter(
|
||||
stripe_checkout_session_id=session_id
|
||||
).first()
|
||||
|
||||
if purchase:
|
||||
# Ensure fields are up to date idempotently
|
||||
update_fields = []
|
||||
if (
|
||||
payment_status == "paid"
|
||||
and purchase.status != CoursePurchase.Status.PAID
|
||||
):
|
||||
purchase.status = CoursePurchase.Status.PAID
|
||||
update_fields.append("status")
|
||||
if purchase.refunded:
|
||||
purchase.refunded = False
|
||||
update_fields.append("refunded")
|
||||
if not purchase.stripe_checkout_session_id:
|
||||
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(
|
||||
"Updated CoursePurchase %s (fields: %s).",
|
||||
purchase.id,
|
||||
update_fields,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"CoursePurchase %s already up-to-date.", purchase.id
|
||||
)
|
||||
|
||||
if created:
|
||||
logger.info(
|
||||
"Created CoursePurchase %s for user %s and course %s.",
|
||||
purchase.id,
|
||||
user,
|
||||
course,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Could not create CoursePurchase for session %s: user=%s, course=%s",
|
||||
session_id,
|
||||
user,
|
||||
course,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Could not create CoursePurchase for session %s: user=%s, course=%s",
|
||||
session_id,
|
||||
user,
|
||||
course,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
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"]
|
||||
try:
|
||||
purchase = CoursePurchase.objects.get(stripe_checkout_session_id=session_id)
|
||||
purchase.status = CoursePurchase.Status.FAILED
|
||||
purchase.save(update_fields=["status"])
|
||||
logger.info(f"Marked CoursePurchase {purchase.id} as FAILED (expired).")
|
||||
except CoursePurchase.DoesNotExist:
|
||||
logger.warning(f"No CoursePurchase found for expired session {session_id}")
|
||||
|
||||
elif event["type"] == "checkout.session.async_payment_failed":
|
||||
session = event["data"]["object"]
|
||||
session_id = session["id"]
|
||||
try:
|
||||
purchase = CoursePurchase.objects.get(stripe_checkout_session_id=session_id)
|
||||
purchase.status = CoursePurchase.Status.FAILED
|
||||
purchase.save(update_fields=["status"])
|
||||
logger.info(
|
||||
f"Marked CoursePurchase {purchase.id} as FAILED (async payment failed)."
|
||||
)
|
||||
except CoursePurchase.DoesNotExist:
|
||||
logger.warning(f"No CoursePurchase found for failed session {session_id}")
|
||||
|
||||
elif event["type"] == "checkout.session.async_payment_succeeded":
|
||||
session = event["data"]["object"]
|
||||
session_id = session["id"]
|
||||
try:
|
||||
purchase = CoursePurchase.objects.get(stripe_checkout_session_id=session_id)
|
||||
purchase.status = CoursePurchase.Status.PAID
|
||||
purchase.refunded = False
|
||||
purchase.save(update_fields=["status", "refunded"])
|
||||
logger.info(
|
||||
f"Marked CoursePurchase {purchase.id} as PAID (async succeeded)."
|
||||
)
|
||||
except CoursePurchase.DoesNotExist:
|
||||
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)
|
||||
|
||||
@@ -14,6 +14,7 @@ dependencies = [
|
||||
"gunicorn>=25.3.0",
|
||||
"python-dotenv>=1.2.2",
|
||||
"slippers>=0.6.2",
|
||||
"stripe>=15.1.0",
|
||||
"uvicorn>=0.42.0",
|
||||
"uvicorn-worker>=0.4.0",
|
||||
"wagtail==7.3rc1",
|
||||
|
||||
15
uv.lock
generated
15
uv.lock
generated
@@ -537,6 +537,7 @@ dependencies = [
|
||||
{ name = "gunicorn" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "slippers" },
|
||||
{ name = "stripe" },
|
||||
{ name = "uvicorn" },
|
||||
{ name = "uvicorn-worker" },
|
||||
{ name = "wagtail" },
|
||||
@@ -557,6 +558,7 @@ requires-dist = [
|
||||
{ name = "gunicorn", specifier = ">=25.3.0" },
|
||||
{ name = "python-dotenv", specifier = ">=1.2.2" },
|
||||
{ name = "slippers", specifier = ">=0.6.2" },
|
||||
{ name = "stripe", specifier = ">=15.1.0" },
|
||||
{ name = "uvicorn", specifier = ">=0.42.0" },
|
||||
{ name = "uvicorn-worker", specifier = ">=0.4.0" },
|
||||
{ name = "wagtail", specifier = "==7.3rc1" },
|
||||
@@ -898,6 +900,19 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stripe"
|
||||
version = "15.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "requests" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/45/26/5d6f5f5beae6f1ff78213e2e6f4fbd431518dcd98733cdd39fb4ba0d01d3/stripe-15.1.0.tar.gz", hash = "sha256:24bd3b6bd0969a4841bd4d7681556a9e35e46c414a07c8590a225fbd5a878450", size = 1501673, upload-time = "2026-04-24T00:18:58.612Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/4e/fd9cb74ddf1e61fb6241e2f6799a81ef99bf6cf2e94f8812ee1cd5458e5d/stripe-15.1.0-py3-none-any.whl", hash = "sha256:bdfb556be08662a41833e6403607ebf12e0062cae4f9b93e2b89b6ba926d7c82", size = 2143199, upload-time = "2026-04-24T00:18:56.027Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "telepath"
|
||||
version = "0.3.1"
|
||||
|
||||
Reference in New Issue
Block a user