18 Commits

Author SHA1 Message Date
ffc53f1b54 feat: refunds, failed and pending payments work 2026-05-20 20:27:32 +02:00
3338a1d3e7 feat(course_page.html): show notice if course doesn't have an assigned product 2026-05-20 19:14:59 +02:00
365b28a165 feat(course_page.html): use form for buy course button as checkout now individually-managed 2026-05-20 19:13:59 +02:00
41af6dcb7c chore(purchase/migrations/0008): remove obsolete PurchasableProduct.stripe_payment_url field 2026-05-20 19:12:26 +02:00
e399d98f31 chore(purchase/models.py): remove obsolete PurchasableProduct.stripe_payment_url field 2026-05-20 19:12:11 +02:00
33d2b89b07 feat(purchase/urls.py): add route to create_checkout_session view 2026-05-20 19:05:52 +02:00
118a1188d5 feat(purchase/views.py): use custom stripe session and auto-finalize purchase via webhook 2026-05-20 19:05:18 +02:00
6d927856c8 feat: add stripe webhook 2026-05-19 21:14:04 +02:00
d2c870414f feat(course_page.html): use stripe payment links 2026-05-18 18:08:21 +02:00
4dbfb8fc41 feat(base.py): add STRIPE_SUCCESS_URL 2026-05-18 18:07:58 +02:00
2065f3c9c5 feat(pages.py): add support for creating PurchasableProduct from within CoursePage 2026-05-18 18:07:39 +02:00
b24e48f1b1 chore(purchase/migrations): add migrations 0005 & 0006 2026-05-18 18:06:45 +02:00
211dcc4f67 feat(purchase/models.py): create payment links and sync name and description from coursepage 2026-05-18 18:06:16 +02:00
a3cd8d42fa feat: remove Products admin page 2026-05-18 18:05:16 +02:00
6471b98ec2 feat: add Products form to admin 2026-05-18 17:07:15 +02:00
3b46a18b29 feat(stripe_client.py): use SITE_URL in callback 2026-05-18 15:56:41 +02:00
3bc11bf58d feat(base.py): add SITE_URL const 2026-05-18 15:54:00 +02:00
9041ecd206 feat: first stripe imp 2026-05-06 19:27:00 +02:00
21 changed files with 1411 additions and 21 deletions

View File

@@ -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"]

View File

@@ -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 %}

View File

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

View File

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

View File

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

View File

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

View 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)),
],
),
]

View File

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

View 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,
),
]

View File

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

View File

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

View 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),
),
]

View File

@@ -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',
),
]

View File

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

View File

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

View 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 %}

View File

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

View File

@@ -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"),
]

View File

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

View File

@@ -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
View File

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