From 363d10dd400e7620a31ed3ca741645f6feff86be Mon Sep 17 00:00:00 2001 From: Richard Tibbles Date: Wed, 1 Apr 2026 20:23:37 -0700 Subject: [PATCH] Add Stripe subscription integration for paid storage upgrades Users can purchase additional storage (1-50 GB at $15/GB/year) via Stripe Checkout, manage subscriptions through the Customer Portal, and see their subscription status on the Storage settings page. Environment-gated configuration ensures non-production deployments use Stripe test/sandbox keys automatically. Co-Authored-By: Claude Opus 4.6 (1M context) --- Makefile | 23 ++ .../pages/Storage/SubscriptionCard.vue | 334 ++++++++++++++++ .../frontend/settings/pages/Storage/index.vue | 4 + .../settings/pages/Storage/useSubscription.js | 70 ++++ .../contentcuration/frontend/shared/mixins.js | 17 +- .../migrations/0166_add_usersubscription.py | 62 +++ contentcuration/contentcuration/models.py | 44 ++ contentcuration/contentcuration/settings.py | 10 + .../contentcuration/tests/test_user.py | 34 ++ .../tests/views/test_subscription.py | 378 ++++++++++++++++++ contentcuration/contentcuration/urls.py | 25 ++ contentcuration/contentcuration/views/base.py | 1 + .../contentcuration/views/subscription.py | 286 +++++++++++++ docs/stripe_integration.md | 151 +++++++ requirements.in | 1 + requirements.txt | 8 + 16 files changed, 1445 insertions(+), 3 deletions(-) create mode 100644 contentcuration/contentcuration/frontend/settings/pages/Storage/SubscriptionCard.vue create mode 100644 contentcuration/contentcuration/frontend/settings/pages/Storage/useSubscription.js create mode 100644 contentcuration/contentcuration/migrations/0166_add_usersubscription.py create mode 100644 contentcuration/contentcuration/tests/views/test_subscription.py create mode 100644 contentcuration/contentcuration/views/subscription.py create mode 100644 docs/stripe_integration.md diff --git a/Makefile b/Makefile index a55f10788e..a1101b2ce4 100644 --- a/Makefile +++ b/Makefile @@ -188,6 +188,29 @@ dcshell: # bash shell inside the (running!) studio-app container $(DOCKER_COMPOSE) exec studio-app /usr/bin/fish +devserver-stripe: + # Start stripe CLI listener and dev server with webhook secret auto-configured. + # Requires: stripe CLI installed and authenticated (stripe login). + # The listener output is teed to a temp file so we can extract the signing secret. + @STRIPE_LOG=$$(mktemp); \ + stripe listen --api-key $$STRIPE_TEST_SECRET_KEY --forward-to localhost:8080/api/stripe/webhook/ > "$$STRIPE_LOG" 2>&1 & \ + STRIPE_PID=$$!; \ + trap "kill $$STRIPE_PID 2>/dev/null; rm -f $$STRIPE_LOG" EXIT; \ + echo "Waiting for Stripe CLI..."; \ + for i in 1 2 3 4 5 6 7 8 9 10; do \ + WEBHOOK_SECRET=$$(grep -o 'whsec_[a-zA-Z0-9_]*' "$$STRIPE_LOG" | head -1); \ + [ -n "$$WEBHOOK_SECRET" ] && break; \ + sleep 1; \ + done; \ + if [ -z "$$WEBHOOK_SECRET" ]; then \ + echo "ERROR: Could not extract webhook secret from Stripe CLI"; \ + exit 1; \ + fi; \ + echo "Stripe webhook secret: $$WEBHOOK_SECRET"; \ + tail -f "$$STRIPE_LOG" & \ + export STRIPE_TEST_WEBHOOK_SECRET=$$WEBHOOK_SECRET; \ + pnpm devserver + dcpsql: .docker/pgpass PGPASSFILE=.docker/pgpass psql --host localhost --port 5432 --username learningequality --dbname "kolibri-studio" diff --git a/contentcuration/contentcuration/frontend/settings/pages/Storage/SubscriptionCard.vue b/contentcuration/contentcuration/frontend/settings/pages/Storage/SubscriptionCard.vue new file mode 100644 index 0000000000..93f408a7ce --- /dev/null +++ b/contentcuration/contentcuration/frontend/settings/pages/Storage/SubscriptionCard.vue @@ -0,0 +1,334 @@ + + + + + + + diff --git a/contentcuration/contentcuration/frontend/settings/pages/Storage/index.vue b/contentcuration/contentcuration/frontend/settings/pages/Storage/index.vue index cc6ea9ad14..fdf153ce83 100644 --- a/contentcuration/contentcuration/frontend/settings/pages/Storage/index.vue +++ b/contentcuration/contentcuration/frontend/settings/pages/Storage/index.vue @@ -54,6 +54,8 @@
+ +

{{ $tr('requestMoreSpaceHeading') }}

@@ -112,6 +114,7 @@ import { mapGetters } from 'vuex'; import useKShow from 'kolibri-design-system/lib/composables/useKShow'; import RequestForm from './RequestForm'; + import SubscriptionCard from './SubscriptionCard'; import { fileSizeMixin, constantsTranslationMixin } from 'shared/mixins'; import { ContentKindsList, ContentKindsNames } from 'shared/leUtils/ContentKinds'; import theme from 'shared/vuetify/theme'; @@ -122,6 +125,7 @@ components: { RequestForm, StudioLargeLoader, + SubscriptionCard, }, mixins: [fileSizeMixin, constantsTranslationMixin], setup() { diff --git a/contentcuration/contentcuration/frontend/settings/pages/Storage/useSubscription.js b/contentcuration/contentcuration/frontend/settings/pages/Storage/useSubscription.js new file mode 100644 index 0000000000..9cb6dc04a2 --- /dev/null +++ b/contentcuration/contentcuration/frontend/settings/pages/Storage/useSubscription.js @@ -0,0 +1,70 @@ +import { ref, computed } from 'vue'; +import client from 'shared/client'; +import urls from 'shared/urls'; + +/** + * Composable for managing subscription state and actions. + * State is component-scoped (not global) - each component instance gets fresh state. + */ +export function useSubscription() { + const subscription = ref(null); + const loading = ref(false); + const redirecting = ref(false); + const error = ref(null); + + const isActive = computed(() => subscription.value?.is_active || false); + const storageBytes = computed(() => subscription.value?.storage_bytes || 0); + const cancelAtPeriodEnd = computed(() => subscription.value?.cancel_at_period_end || false); + const currentPeriodEnd = computed(() => subscription.value?.current_period_end || null); + + const fetchSubscriptionStatus = async () => { + loading.value = true; + error.value = null; + try { + const response = await client.get(urls.stripe_subscription_status()); + subscription.value = response.data; + } catch (err) { + error.value = err.message; + } finally { + loading.value = false; + } + }; + + const _redirectToStripe = async (url, body, urlField) => { + redirecting.value = true; + error.value = null; + try { + const response = await client.post(url, body); + if (response.data[urlField]) { + window.location.href = response.data[urlField]; + } + } catch (err) { + error.value = true; + redirecting.value = false; + throw err; + } + }; + + const createCheckoutSession = storageGb => + _redirectToStripe( + urls.stripe_create_checkout_session(), + { storage_gb: storageGb }, + 'checkout_url', + ); + + const createPortalSession = () => + _redirectToStripe(urls.stripe_create_portal_session(), null, 'portal_url'); + + return { + loading, + redirecting, + error, + isActive, + storageBytes, + cancelAtPeriodEnd, + currentPeriodEnd, + fetchSubscriptionStatus, + createCheckoutSession, + createPortalSession, + }; +} diff --git a/contentcuration/contentcuration/frontend/shared/mixins.js b/contentcuration/contentcuration/frontend/shared/mixins.js index c99f8e033c..6241799feb 100644 --- a/contentcuration/contentcuration/frontend/shared/mixins.js +++ b/contentcuration/contentcuration/frontend/shared/mixins.js @@ -21,8 +21,8 @@ const sizeStrings = createTranslator('BytesForHumansStrings', { fileSizeInBytes: '{n, number, integer} B', fileSizeInKilobytes: '{n, number, integer} KB', fileSizeInMegabytes: '{n, number, integer} MB', - fileSizeInGigabytes: '{n, number, integer} GB', - fileSizeInTerabytes: '{n, number, integer} TB', + fileSizeInGigabytes: '{n, number} GB', + fileSizeInTerabytes: '{n, number} TB', }); const stringMap = { @@ -33,10 +33,21 @@ const stringMap = { [ONE_TB]: 'fileSizeInTerabytes', }; +const decimalsMap = { + [ONE_GB]: 1, + [ONE_TB]: 2, +}; + +function _roundTo(value, unit) { + const decimals = decimalsMap[unit] ?? 0; + const factor = 10 ** decimals; + return Math.round(value / (unit / factor)) / factor; +} + export default function bytesForHumans(bytes) { bytes = bytes || 0; const unit = [ONE_TB, ONE_GB, ONE_MB, ONE_KB].find(x => bytes >= x) || ONE_B; - return sizeStrings.$tr(stringMap[unit], { n: Math.round(bytes / unit) }); + return sizeStrings.$tr(stringMap[unit], { n: _roundTo(bytes, unit) }); } export const fileSizeMixin = { diff --git a/contentcuration/contentcuration/migrations/0166_add_usersubscription.py b/contentcuration/contentcuration/migrations/0166_add_usersubscription.py new file mode 100644 index 0000000000..2188beac91 --- /dev/null +++ b/contentcuration/contentcuration/migrations/0166_add_usersubscription.py @@ -0,0 +1,62 @@ +# Generated by Django 3.2.24 on 2026-04-02 03:53 +import django.db.models.deletion +from django.conf import settings +from django.db import migrations +from django.db import models + + +class Migration(migrations.Migration): + + dependencies = [ + ("contentcuration", "0165_alter_channelversion_size"), + ] + + operations = [ + migrations.CreateModel( + name="UserSubscription", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "stripe_customer_id", + models.CharField(blank=True, db_index=True, max_length=255), + ), + ( + "stripe_subscription_id", + models.CharField(blank=True, db_index=True, max_length=255), + ), + ( + "stripe_subscription_status", + models.CharField(blank=True, max_length=50), + ), + ( + "subscription_disk_space", + models.BigIntegerField( + default=0, help_text="Additional bytes granted by subscription" + ), + ), + ("cancel_at_period_end", models.BooleanField(default=False)), + ("current_period_end", models.DateTimeField(blank=True, null=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "user", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="subscription", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "db_table": "contentcuration_usersubscription", + }, + ), + ] diff --git a/contentcuration/contentcuration/models.py b/contentcuration/contentcuration/models.py index 4630f00529..22e7c96f94 100644 --- a/contentcuration/contentcuration/models.py +++ b/contentcuration/contentcuration/models.py @@ -549,6 +549,17 @@ def get_space_used_by_kind(self): kind_dict[item["preset__kind_id"]] = item["space"] return kind_dict + def get_effective_disk_space(self): + """ + Returns total disk space including any active subscription bonus. + Subscription space is additive to preserve admin-granted quotas. + """ + base = self.disk_space + subscription = getattr(self, "subscription", None) + if subscription and subscription.is_active: + return base + subscription.subscription_disk_space + return base + def email_user(self, subject, message, from_email=None, **kwargs): try: # msg = EmailMultiAlternatives(subject, message, from_email, [self.email]) @@ -720,6 +731,39 @@ def notify_users(cls, users_queryset, date): ) +class UserSubscription(models.Model): + """ + Tracks Stripe subscription data for a user. + Kept separate from User model for clean separation of payment concerns. + """ + + user = models.OneToOneField( + User, + on_delete=models.CASCADE, + related_name="subscription", + ) + stripe_customer_id = models.CharField(max_length=255, blank=True, db_index=True) + stripe_subscription_id = models.CharField(max_length=255, blank=True, db_index=True) + stripe_subscription_status = models.CharField(max_length=50, blank=True) + subscription_disk_space = models.BigIntegerField( + default=0, help_text="Additional bytes granted by subscription" + ) + cancel_at_period_end = models.BooleanField(default=False) + current_period_end = models.DateTimeField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "contentcuration_usersubscription" + + @property + def is_active(self): + return self.stripe_subscription_status in ("active", "trialing") + + def __str__(self): + return f"Subscription for {self.user.email}: {self.stripe_subscription_status}" + + class UUIDField(models.CharField): def __init__(self, *args, **kwargs): kwargs["max_length"] = 32 diff --git a/contentcuration/contentcuration/settings.py b/contentcuration/contentcuration/settings.py index 285e7bef76..2d8bafaa9b 100644 --- a/contentcuration/contentcuration/settings.py +++ b/contentcuration/contentcuration/settings.py @@ -433,3 +433,13 @@ def gettext(s): # Curriculum Automation Settings CURRICULUM_AUTOMATION_API_URL = os.getenv("CURRICULUM_AUTOMATION_API_URL") + +# Stripe configuration +# Production (BRANCH_ENVIRONMENT=master) uses STRIPE_LIVE_* env vars. +# All other environments use STRIPE_TEST_* (safe — no real charges possible). +STRIPE_API_VERSION = "2026-03-25.dahlia" +_stripe_is_production = os.getenv("BRANCH_ENVIRONMENT") == "master" +_stripe_prefix = "STRIPE_LIVE" if _stripe_is_production else "STRIPE_TEST" +STRIPE_SECRET_KEY = os.getenv(f"{_stripe_prefix}_SECRET_KEY", "") +STRIPE_WEBHOOK_SECRET = os.getenv(f"{_stripe_prefix}_WEBHOOK_SECRET", "") +STRIPE_PRICE_ID = os.getenv(f"{_stripe_prefix}_PRICE_ID", "") diff --git a/contentcuration/contentcuration/tests/test_user.py b/contentcuration/contentcuration/tests/test_user.py index 0be1b140bb..d772007d57 100644 --- a/contentcuration/contentcuration/tests/test_user.py +++ b/contentcuration/contentcuration/tests/test_user.py @@ -13,10 +13,13 @@ from django.urls import reverse_lazy from .base import BaseAPITestCase +from .base import StudioTestCase from .testdata import fileobj_video from contentcuration.models import DEFAULT_CONTENT_DEFAULTS from contentcuration.models import Invitation from contentcuration.models import User +from contentcuration.models import UserSubscription +from contentcuration.tests import testdata from contentcuration.tests.utils import mixer from contentcuration.utils.csv_writer import _format_size from contentcuration.utils.csv_writer import write_user_csv @@ -159,3 +162,34 @@ def test_user_csv_export(self): self.assertIn(videos[index - 1].original_filename, row) self.assertIn(_format_size(videos[index - 1].file_size), row) self.assertEqual(index, len(videos)) + + +class UserEffectiveDiskSpaceTest(StudioTestCase): + def setUp(self): + self.user = testdata.user(email="diskspace@test.com") + # Set a known disk_space value + self.user.disk_space = 500 * 1024 * 1024 # 500MB + self.user.save() + + def test_effective_disk_space_without_subscription(self): + """User without subscription gets base disk_space.""" + self.assertEqual(self.user.get_effective_disk_space(), 500 * 1024 * 1024) + + def test_effective_disk_space_with_active_subscription(self): + """User with active subscription gets base + subscription space.""" + UserSubscription.objects.create( + user=self.user, + stripe_subscription_status="active", + subscription_disk_space=50 * 1024 * 1024 * 1024, # 50GB + ) + expected = 500 * 1024 * 1024 + 50 * 1024 * 1024 * 1024 + self.assertEqual(self.user.get_effective_disk_space(), expected) + + def test_effective_disk_space_with_canceled_subscription(self): + """User with canceled subscription only gets base space.""" + UserSubscription.objects.create( + user=self.user, + stripe_subscription_status="canceled", + subscription_disk_space=50 * 1024 * 1024 * 1024, + ) + self.assertEqual(self.user.get_effective_disk_space(), 500 * 1024 * 1024) diff --git a/contentcuration/contentcuration/tests/views/test_subscription.py b/contentcuration/contentcuration/tests/views/test_subscription.py new file mode 100644 index 0000000000..889d3f4f58 --- /dev/null +++ b/contentcuration/contentcuration/tests/views/test_subscription.py @@ -0,0 +1,378 @@ +from unittest import mock + +import stripe +from django.test import override_settings +from django.urls import reverse + +from contentcuration.models import UserSubscription +from contentcuration.tests import testdata +from contentcuration.tests.base import StudioAPITestCase + + +@override_settings( + STRIPE_SECRET_KEY="sk_test_fake", + STRIPE_PRICE_ID="price_test_fake", +) +class CreateCheckoutSessionViewTest(StudioAPITestCase): + def setUp(self): + self.user = testdata.user(email="checkout@test.com") + self.url = reverse("stripe_create_checkout_session") + + def test_requires_authentication(self): + """Unauthenticated users should be rejected.""" + response = self.client.post(self.url) + self.assertEqual(response.status_code, 403) + + @mock.patch("contentcuration.views.subscription.stripe.checkout.Session.create") + def test_creates_checkout_session(self, mock_create): + """Authenticated user can create checkout session.""" + mock_create.return_value = mock.Mock(url="https://checkout.stripe.com/test") + + self.client.force_authenticate(self.user) + response = self.client.post( + self.url, + data={"storage_gb": 10}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertEqual(data["checkout_url"], "https://checkout.stripe.com/test") + + @mock.patch("contentcuration.views.subscription.stripe.checkout.Session.create") + def test_rejects_user_with_active_subscription(self, mock_create): + """User with active subscription cannot create new checkout.""" + UserSubscription.objects.create( + user=self.user, + stripe_subscription_status="active", + ) + + self.client.force_authenticate(self.user) + response = self.client.post( + self.url, + data={"storage_gb": 10}, + format="json", + ) + + self.assertEqual(response.status_code, 400) + mock_create.assert_not_called() + + @mock.patch("contentcuration.views.subscription.stripe.checkout.Session.create") + def test_user_with_canceled_subscription_can_checkout_again(self, mock_create): + """User whose subscription was canceled can create a new checkout session.""" + UserSubscription.objects.create( + user=self.user, + stripe_customer_id="cus_test123", + stripe_subscription_id="sub_old", + stripe_subscription_status="canceled", + subscription_disk_space=0, + ) + mock_create.return_value = mock.Mock(url="https://checkout.stripe.com/new") + + self.client.force_authenticate(self.user) + response = self.client.post( + self.url, + data={"storage_gb": 5}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertEqual(data["checkout_url"], "https://checkout.stripe.com/new") + # Should reuse the existing customer ID + mock_create.assert_called_once() + call_kwargs = mock_create.call_args[1] + self.assertEqual(call_kwargs["customer"], "cus_test123") + + +@override_settings( + STRIPE_SECRET_KEY="sk_test_fake", +) +class CreatePortalSessionViewTest(StudioAPITestCase): + def setUp(self): + self.user = testdata.user(email="portal@test.com") + self.url = reverse("stripe_create_portal_session") + + def test_requires_authentication(self): + """Unauthenticated users should be rejected.""" + response = self.client.post(self.url) + self.assertEqual(response.status_code, 403) + + def test_rejects_user_without_subscription(self): + """User without subscription cannot access portal.""" + self.client.force_authenticate(self.user) + response = self.client.post(self.url) + + self.assertEqual(response.status_code, 400) + + @mock.patch( + "contentcuration.views.subscription.stripe.billing_portal.Session.create" + ) + def test_creates_portal_session(self, mock_create): + """User with subscription can create portal session.""" + UserSubscription.objects.create( + user=self.user, + stripe_customer_id="cus_test123", + stripe_subscription_status="active", + ) + mock_create.return_value = mock.Mock(url="https://billing.stripe.com/test") + + self.client.force_authenticate(self.user) + response = self.client.post(self.url) + + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertEqual(data["portal_url"], "https://billing.stripe.com/test") + + +@override_settings( + STRIPE_SECRET_KEY="sk_test_fake", +) +class GetSubscriptionStatusViewTest(StudioAPITestCase): + def setUp(self): + self.user = testdata.user(email="status@test.com") + self.url = reverse("stripe_subscription_status") + + def test_requires_authentication(self): + """Unauthenticated users should be rejected.""" + response = self.client.get(self.url) + self.assertEqual(response.status_code, 403) + + def test_returns_no_subscription(self): + """User without subscription gets appropriate response.""" + self.client.force_authenticate(self.user) + response = self.client.get(self.url) + + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertFalse(data["is_active"]) + + def test_returns_active_subscription(self): + """User with active subscription gets appropriate response.""" + UserSubscription.objects.create( + user=self.user, + stripe_subscription_status="active", + subscription_disk_space=50 * 1024 * 1024 * 1024, + ) + + self.client.force_authenticate(self.user) + response = self.client.get(self.url) + + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertTrue(data["is_active"]) + self.assertEqual(data["status"], "active") + + +@override_settings( + STRIPE_SECRET_KEY="sk_test_fake", + STRIPE_WEBHOOK_SECRET="whsec_test_fake", +) +class StripeWebhookViewTest(StudioAPITestCase): + def setUp(self): + self.user = testdata.user(email="webhook@test.com") + self.webhook_url = reverse("stripe_webhook") + + @mock.patch("contentcuration.views.subscription.stripe.Subscription.retrieve") + @mock.patch("contentcuration.views.subscription.stripe.Webhook.construct_event") + def test_handles_checkout_completed(self, mock_construct, mock_retrieve): + """Webhook activates subscription on checkout.session.completed.""" + mock_construct.return_value = { + "id": "evt_test", + "type": "checkout.session.completed", + "data": { + "object": { + "id": "cs_test", + "client_reference_id": str(self.user.id), + "customer": "cus_test123", + "subscription": "sub_test123", + } + }, + } + mock_retrieve.return_value = { + "items": {"data": [{"quantity": 10}]}, + } + + response = self.client.post( + self.webhook_url, + data="{}", + format="json", + HTTP_STRIPE_SIGNATURE="test_sig", + ) + + self.assertEqual(response.status_code, 200) + + # Verify subscription was created + self.user.refresh_from_db() + subscription = self.user.subscription + self.assertEqual(subscription.stripe_customer_id, "cus_test123") + self.assertEqual(subscription.stripe_subscription_id, "sub_test123") + self.assertEqual(subscription.stripe_subscription_status, "active") + self.assertEqual(subscription.subscription_disk_space, 10 * 10 ** 9) + + @mock.patch("contentcuration.views.subscription.stripe.Webhook.construct_event") + def test_handles_subscription_deleted(self, mock_construct): + """Webhook revokes access on customer.subscription.deleted.""" + subscription = UserSubscription.objects.create( + user=self.user, + stripe_customer_id="cus_test123", + stripe_subscription_id="sub_test123", + stripe_subscription_status="active", + subscription_disk_space=50 * 1024 * 1024 * 1024, + ) + + mock_construct.return_value = { + "id": "evt_test", + "type": "customer.subscription.deleted", + "data": { + "object": { + "id": "sub_test123", + "customer": "cus_test123", + } + }, + } + + response = self.client.post( + self.webhook_url, + data="{}", + format="json", + HTTP_STRIPE_SIGNATURE="test_sig", + ) + + self.assertEqual(response.status_code, 200) + + subscription.refresh_from_db() + self.assertEqual(subscription.stripe_subscription_status, "canceled") + self.assertEqual(subscription.subscription_disk_space, 0) + + @mock.patch("contentcuration.views.subscription.stripe.Webhook.construct_event") + def test_handles_subscription_updated(self, mock_construct): + """Webhook updates status on customer.subscription.updated.""" + subscription = UserSubscription.objects.create( + user=self.user, + stripe_customer_id="cus_test123", + stripe_subscription_id="sub_test123", + stripe_subscription_status="active", + subscription_disk_space=50 * 1024 * 1024 * 1024, + ) + + mock_construct.return_value = { + "id": "evt_test", + "type": "customer.subscription.updated", + "data": { + "object": { + "id": "sub_test123", + "status": "past_due", + "cancel_at_period_end": False, + "items": {"data": [{"quantity": 50}]}, + } + }, + } + + response = self.client.post( + self.webhook_url, + data="{}", + format="json", + HTTP_STRIPE_SIGNATURE="test_sig", + ) + + self.assertEqual(response.status_code, 200) + + subscription.refresh_from_db() + self.assertEqual(subscription.stripe_subscription_status, "past_due") + + @mock.patch("contentcuration.views.subscription.stripe.Webhook.construct_event") + def test_handles_cancel_at_period_end(self, mock_construct): + """Webhook sets cancel_at_period_end when user cancels via portal.""" + subscription = UserSubscription.objects.create( + user=self.user, + stripe_customer_id="cus_test123", + stripe_subscription_id="sub_test123", + stripe_subscription_status="active", + subscription_disk_space=5 * 10 ** 9, + ) + + mock_construct.return_value = { + "id": "evt_test", + "type": "customer.subscription.updated", + "data": { + "object": { + "id": "sub_test123", + "status": "active", + "cancel_at_period_end": False, + "cancel_at": 1806883200, + "current_period_end": 1806883200, + "items": {"data": [{"quantity": 5}]}, + } + }, + } + + response = self.client.post( + self.webhook_url, + data="{}", + format="json", + HTTP_STRIPE_SIGNATURE="test_sig", + ) + + self.assertEqual(response.status_code, 200) + + subscription.refresh_from_db() + self.assertEqual(subscription.stripe_subscription_status, "active") + self.assertTrue(subscription.cancel_at_period_end) + self.assertIsNotNone(subscription.current_period_end) + # Storage preserved — still active until period end + self.assertEqual(subscription.subscription_disk_space, 5 * 10 ** 9) + + @mock.patch("contentcuration.views.subscription.stripe.Webhook.construct_event") + def test_rejects_invalid_signature(self, mock_construct): + """Webhook rejects invalid signatures.""" + mock_construct.side_effect = stripe.error.SignatureVerificationError( + "Invalid signature", "sig" + ) + + response = self.client.post( + self.webhook_url, + data="{}", + format="json", + HTTP_STRIPE_SIGNATURE="bad_sig", + ) + + self.assertEqual(response.status_code, 400) + + @mock.patch("contentcuration.views.subscription.stripe.Subscription.retrieve") + @mock.patch("contentcuration.views.subscription.stripe.Webhook.construct_event") + def test_idempotent_checkout_processing(self, mock_construct, mock_retrieve): + """Same checkout event processed twice doesn't duplicate.""" + mock_retrieve.return_value = { + "items": {"data": [{"quantity": 10}]}, + } + UserSubscription.objects.create( + user=self.user, + stripe_customer_id="cus_test123", + stripe_subscription_id="sub_test123", + stripe_subscription_status="active", + ) + + mock_construct.return_value = { + "id": "evt_test", + "type": "checkout.session.completed", + "data": { + "object": { + "id": "cs_test", + "client_reference_id": str(self.user.id), + "customer": "cus_test123", + "subscription": "sub_test123", + } + }, + } + + response = self.client.post( + self.webhook_url, + data="{}", + format="json", + HTTP_STRIPE_SIGNATURE="test_sig", + ) + + self.assertEqual(response.status_code, 200) + # Should still only have one subscription + self.assertEqual(UserSubscription.objects.filter(user=self.user).count(), 1) diff --git a/contentcuration/contentcuration/urls.py b/contentcuration/contentcuration/urls.py index 9b942b79ae..6f36a5ac68 100644 --- a/contentcuration/contentcuration/urls.py +++ b/contentcuration/contentcuration/urls.py @@ -30,6 +30,7 @@ import contentcuration.views.internal as internal_views import contentcuration.views.nodes as node_views import contentcuration.views.settings as settings_views +import contentcuration.views.subscription as subscription_views import contentcuration.views.users as registration_views import contentcuration.views.zip as zip_views from contentcuration.views import pwa @@ -309,6 +310,30 @@ def get_redirect_url(self, *args, **kwargs): ), ] +# Add Stripe subscription endpoints +urlpatterns += [ + re_path( + r"^api/stripe/create-checkout-session/$", + subscription_views.CreateCheckoutSessionView.as_view(), + name="stripe_create_checkout_session", + ), + re_path( + r"^api/stripe/create-portal-session/$", + subscription_views.CreatePortalSessionView.as_view(), + name="stripe_create_portal_session", + ), + re_path( + r"^api/stripe/subscription-status/$", + subscription_views.SubscriptionStatusView.as_view(), + name="stripe_subscription_status", + ), + re_path( + r"^api/stripe/webhook/$", + subscription_views.stripe_webhook, + name="stripe_webhook", + ), +] + urlpatterns += [ re_path(r"^jsreverse/$", django_js_reverse_views.urls_js, name="js_reverse") ] diff --git a/contentcuration/contentcuration/views/base.py b/contentcuration/contentcuration/views/base.py index 01af89cc96..327128f163 100644 --- a/contentcuration/contentcuration/views/base.py +++ b/contentcuration/contentcuration/views/base.py @@ -87,6 +87,7 @@ def current_user_for_context(user): user_data = {field: getattr(user, field) for field in user_fields} + user_data["disk_space"] = user.get_effective_disk_space() user_data["user_rev"] = user.get_server_rev() return json_for_parse_from_data(user_data) diff --git a/contentcuration/contentcuration/views/subscription.py b/contentcuration/contentcuration/views/subscription.py new file mode 100644 index 0000000000..784ba29798 --- /dev/null +++ b/contentcuration/contentcuration/views/subscription.py @@ -0,0 +1,286 @@ +import logging +from datetime import datetime +from datetime import timezone + +import stripe +from django.conf import settings +from django.http import JsonResponse +from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_POST +from rest_framework import serializers +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from contentcuration.models import User +from contentcuration.models import UserSubscription + +BYTES_PER_GB = 10 ** 9 +MIN_STORAGE_GB = 1 +MAX_STORAGE_GB = 50 + +logger = logging.getLogger(__name__) + +stripe.api_key = settings.STRIPE_SECRET_KEY +stripe.api_version = settings.STRIPE_API_VERSION + + +class CheckoutSerializer(serializers.Serializer): + storage_gb = serializers.IntegerField( + min_value=MIN_STORAGE_GB, max_value=MAX_STORAGE_GB + ) + + +class CreateCheckoutSessionView(APIView): + """ + Create a Stripe Checkout Session and return the URL for redirect. + """ + + permission_classes = [IsAuthenticated] + + def post(self, request): + user = request.user + + subscription = getattr(user, "subscription", None) + if subscription and subscription.is_active: + return Response( + {"error": "You already have an active subscription"}, status=400 + ) + + serializer = CheckoutSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + storage_gb = serializer.validated_data["storage_gb"] + + try: + success_url = request.build_absolute_uri( + "/settings/#/storage?upgrade=success" + ) + cancel_url = request.build_absolute_uri("/settings/#/storage") + + checkout_session_params = { + "mode": "subscription", + "line_items": [ + { + "price": settings.STRIPE_PRICE_ID, + "quantity": storage_gb, + } + ], + "success_url": success_url, + "cancel_url": cancel_url, + "client_reference_id": str(user.id), + "customer_email": user.email, + } + + if subscription and subscription.stripe_customer_id: + checkout_session_params["customer"] = subscription.stripe_customer_id + del checkout_session_params["customer_email"] + + session = stripe.checkout.Session.create(**checkout_session_params) + + return Response({"checkout_url": session.url}) + + except stripe.error.StripeError as e: + logger.error(f"Stripe error creating checkout session: {e}") + return Response({"error": str(e)}, status=400) + + +class CreatePortalSessionView(APIView): + """ + Create a Stripe Customer Portal session for subscription management. + """ + + permission_classes = [IsAuthenticated] + + def post(self, request): + user = request.user + subscription = getattr(user, "subscription", None) + + if not subscription or not subscription.stripe_customer_id: + return Response({"error": "No subscription found"}, status=400) + + try: + session = stripe.billing_portal.Session.create( + customer=subscription.stripe_customer_id, + return_url=request.build_absolute_uri("/settings/#/storage"), + ) + return Response({"portal_url": session.url}) + + except stripe.error.StripeError as e: + logger.error(f"Stripe error creating portal session: {e}") + return Response({"error": str(e)}, status=400) + + +class SubscriptionStatusView(APIView): + """ + Returns subscription status for the current user. + """ + + permission_classes = [IsAuthenticated] + + def get(self, request): + user = request.user + subscription = getattr(user, "subscription", None) + + if not subscription: + return Response( + { + "status": None, + "is_active": False, + "storage_bytes": 0, + "cancel_at_period_end": False, + } + ) + + data = { + "status": subscription.stripe_subscription_status, + "is_active": subscription.is_active, + "storage_bytes": subscription.subscription_disk_space, + "cancel_at_period_end": subscription.cancel_at_period_end, + } + if subscription.current_period_end: + data["current_period_end"] = subscription.current_period_end.isoformat() + + return Response(data) + + +class CheckoutCompletedSerializer(serializers.Serializer): + """Validates and processes checkout.session.completed events.""" + + client_reference_id = serializers.PrimaryKeyRelatedField( + queryset=User.objects.all() + ) + customer = serializers.CharField() + subscription = serializers.CharField() + + def save(self): + data = self.validated_data + + # Retrieve the subscription to get the quantity (GB purchased). + # Also set by customer.subscription.updated, but that webhook may + # arrive later or not at all in some configurations. + stripe_sub = stripe.Subscription.retrieve(data["subscription"]) + quantity_gb = stripe_sub["items"]["data"][0]["quantity"] + + UserSubscription.objects.update_or_create( + user=data["client_reference_id"], + defaults={ + "stripe_customer_id": data["customer"], + "stripe_subscription_id": data["subscription"], + "stripe_subscription_status": "active", + "subscription_disk_space": quantity_gb * BYTES_PER_GB, + }, + ) + + +class SubscriptionDeletedSerializer(serializers.Serializer): + """ + Validates and processes customer.subscription.deleted events. + Fires only after the subscription has fully ended (including any + remaining paid period), so it is safe to revoke storage here. + """ + + id = serializers.SlugRelatedField( + slug_field="stripe_subscription_id", + queryset=UserSubscription.objects.all(), + ) + + def save(self): + sub = self.validated_data["id"] + sub.stripe_subscription_status = "canceled" + sub.subscription_disk_space = 0 + sub.save() + + +class TimestampField(serializers.IntegerField): + """Converts a Unix timestamp to a timezone-aware datetime.""" + + def to_internal_value(self, data): + value = super().to_internal_value(data) + return datetime.fromtimestamp(value, tz=timezone.utc) + + +class SubscriptionItemSerializer(serializers.Serializer): + quantity = serializers.IntegerField() + + +class SubscriptionItemsDataSerializer(serializers.Serializer): + data = SubscriptionItemSerializer(many=True) + + +STRIPE_SUBSCRIPTION_STATUSES = [ + "active", + "canceled", + "incomplete", + "incomplete_expired", + "past_due", + "paused", + "trialing", + "unpaid", +] + + +class SubscriptionUpdatedSerializer(serializers.Serializer): + """Validates and processes customer.subscription.updated events.""" + + id = serializers.SlugRelatedField( + slug_field="stripe_subscription_id", + queryset=UserSubscription.objects.all(), + ) + status = serializers.ChoiceField(choices=STRIPE_SUBSCRIPTION_STATUSES) + cancel_at_period_end = serializers.BooleanField(default=False) + cancel_at = serializers.IntegerField(allow_null=True, default=None) + current_period_end = TimestampField(required=False) + items = SubscriptionItemsDataSerializer() + + def save(self): + sub = self.validated_data["id"] + data = self.validated_data + new_status = data["status"] + sub.stripe_subscription_status = new_status + + sub.cancel_at_period_end = ( + data["cancel_at_period_end"] or data["cancel_at"] is not None + ) + + if data.get("current_period_end"): + sub.current_period_end = data["current_period_end"] + + if new_status in ("active", "trialing"): + quantity_gb = data["items"]["data"][0]["quantity"] + sub.subscription_disk_space = quantity_gb * BYTES_PER_GB + else: + sub.subscription_disk_space = 0 + + sub.save() + + +_webhook_serializers = { + "checkout.session.completed": CheckoutCompletedSerializer, + "customer.subscription.deleted": SubscriptionDeletedSerializer, + "customer.subscription.updated": SubscriptionUpdatedSerializer, +} + + +@csrf_exempt +@require_POST +def stripe_webhook(request): + try: + event = stripe.Webhook.construct_event( + request.body, + request.META.get("HTTP_STRIPE_SIGNATURE"), + settings.STRIPE_WEBHOOK_SECRET, + ) + except (ValueError, stripe.error.SignatureVerificationError): + return JsonResponse({"error": "Invalid payload or signature"}, status=400) + + serializer_class = _webhook_serializers.get(event["type"]) + if serializer_class: + serializer = serializer_class(data=event["data"]["object"]) + if serializer.is_valid(): + serializer.save() + else: + logger.warning( + f"Invalid webhook payload for {event['type']}: {serializer.errors}" + ) + + return JsonResponse({"status": "success"}) diff --git a/docs/stripe_integration.md b/docs/stripe_integration.md new file mode 100644 index 0000000000..4f4b5f1544 --- /dev/null +++ b/docs/stripe_integration.md @@ -0,0 +1,151 @@ +# Stripe Subscription Integration + +Studio uses [Stripe](https://stripe.com/) to offer paid storage upgrades via subscriptions. This document covers the architecture, configuration, and testing workflow. + +## Architecture Overview + +The integration uses Stripe's recommended approach for subscriptions: + +- **Checkout Sessions** (`mode: "subscription"`) handle the initial payment flow +- **Per-unit pricing** at $15/GB/year — the user selects how many GB they want, which becomes the `quantity` on the Stripe line item +- **Customer Portal** lets users manage their subscription (cancel, update payment method, change quantity) +- **Webhooks** keep Studio in sync with Stripe's subscription lifecycle events +- **Dynamic payment methods** are configured in the Stripe Dashboard (not hardcoded) + +### Data Model + +`UserSubscription` is a separate model (one-to-one with `User`) that tracks: + +- `stripe_customer_id` — Stripe's customer reference +- `stripe_subscription_id` — Stripe's subscription reference +- `stripe_subscription_status` — current status (`active`, `trialing`, `canceled`, etc.) +- `subscription_disk_space` — additional bytes granted by the subscription + +The user's effective storage quota is `disk_space + subscription_disk_space` (computed by `User.get_effective_disk_space()`), so admin-granted quotas are preserved. Storage is derived from the Stripe subscription quantity: `quantity_gb * 1 GB`. + +### API Endpoints + +| Endpoint | Method | Auth | Purpose | +|---|---|---|---| +| `/api/stripe/create-checkout-session/` | POST | Login required | Creates a Checkout Session, returns redirect URL | +| `/api/stripe/create-portal-session/` | POST | Login required | Creates a Customer Portal session | +| `/api/stripe/subscription-status/` | GET | Login required | Returns current user's subscription status | +| `/api/stripe/webhook/` | POST | CSRF exempt | Receives Stripe webhook events | + +### Webhook Events Handled + +| Event | Behavior | +|---|---| +| `checkout.session.completed` | Activates subscription, grants storage | +| `customer.subscription.updated` | Syncs status; revokes storage if no longer `active`/`trialing` | +| `customer.subscription.deleted` | Marks as canceled, revokes storage (fires after period ends) | + +## Configuration + +### Environment Variables + +The integration uses **separate environment variables for test and live keys**, selected automatically based on `BRANCH_ENVIRONMENT`: + +- **Production** (`BRANCH_ENVIRONMENT=master`): reads `STRIPE_LIVE_*` variables +- **All other environments** (QA, staging, dev): reads `STRIPE_TEST_*` variables + +This means it is not possible for a non-production server to accidentally process real payments, even if it shares the same settings file. + +| Variable | Description | +|---|---| +| `STRIPE_TEST_SECRET_KEY` | Stripe sandbox secret key (`sk_test_...`) — shared across all non-production deployments | +| `STRIPE_TEST_WEBHOOK_SECRET` | Webhook signing secret for this deployment's test endpoint — **unique per deployment** | +| `STRIPE_TEST_PRICE_ID` | Price ID for the sandbox subscription product — shared across all non-production deployments | +| `STRIPE_LIVE_SECRET_KEY` | Stripe live mode secret key (`sk_live_...`) | +| `STRIPE_LIVE_WEBHOOK_SECRET` | Webhook signing secret for the production endpoint | +| `STRIPE_LIVE_PRICE_ID` | Price ID for the live subscription product | + +**Note on webhook secrets:** The secret key and price ID can be shared across QA/staging deployments since they belong to the same Stripe sandbox. However, each deployment needs its **own webhook endpoint** in Stripe (since each has a different URL), and therefore its own `STRIPE_TEST_WEBHOOK_SECRET`. + +### Stripe Dashboard Setup + +#### Sandbox (test) setup + +1. Create a [Stripe sandbox](https://dashboard.stripe.com/test/developers) for non-production testing +2. In the sandbox, create a Product with a **Volume tiered recurring Price** at $15/unit/year with a single tier (each unit = 1 GB). Note: per-unit pricing at $15 hits Stripe's minimum price-per-unit threshold, so use Volume tiered pricing with one tier as a workaround. +3. Enable desired payment methods in Dashboard > Settings > Payment methods +4. Configure the Customer Portal in Dashboard > Settings > Customer portal (enable "Update subscriptions" for quantity changes and "Cancel subscriptions") +5. Create a **separate webhook endpoint for each deployment** (Developers > Event destinations): + - `https:///api/stripe/webhook/` + - `https:///api/stripe/webhook/` + - Subscribe each to: `checkout.session.completed`, `customer.subscription.updated`, `customer.subscription.deleted` + - Each endpoint gets its own signing secret (`whsec_...`) + +#### Production setup + +1. Create a Product and Price in live mode with the same structure as the sandbox +2. Create a single webhook endpoint for the production domain +3. Store the live keys via the secret management system (accessed via `get_secret()` in `production_settings.py`) + +## Testing + +### QA / Staging Servers + +QA and staging servers automatically use Stripe **sandbox** keys (via `STRIPE_TEST_*` env vars). Sandbox mode is fully functional but never charges real money. + +Set `STRIPE_TEST_SECRET_KEY` and `STRIPE_TEST_PRICE_ID` from the sandbox (shared across deployments), and set `STRIPE_TEST_WEBHOOK_SECRET` from the webhook endpoint created for that specific deployment. + +### Test Card Numbers + +Stripe provides [test card numbers](https://docs.stripe.com/testing#cards) for simulating different scenarios: + +| Card Number | Scenario | +|---|---| +| `4242 4242 4242 4242` | Payment succeeds | +| `4000 0000 0000 0341` | Payment is declined | +| `4000 0000 0000 3220` | Requires 3D Secure authentication | +| `4000 0000 0000 3063` | Requires 3D Secure, then is declined | + +Use any future expiry date, any 3-digit CVC, and any postal code. + +### Local Development + +For local development, use the [Stripe CLI](https://docs.stripe.com/stripe-cli) instead of creating a webhook endpoint in the dashboard. The CLI forwards sandbox events directly to your local server without needing a public URL. + +#### Setup + +1. Install the Stripe CLI: `brew install stripe/stripe-cli/stripe` (macOS) or see [install docs](https://docs.stripe.com/stripe-cli#install) +2. Set `STRIPE_TEST_SECRET_KEY` and `STRIPE_TEST_PRICE_ID` in your environment +3. Run the combined dev server: + +```bash +make devserver-stripe +``` + +This authenticates the Stripe CLI using your `STRIPE_TEST_SECRET_KEY`, starts the webhook listener, automatically extracts the signing secret, and launches `pnpm devserver` with everything configured. No separate `stripe login` or manual webhook secret needed. + +#### Manual setup + +If you prefer to run things separately: + +```bash +# Terminal 1: start the Stripe listener +stripe listen --forward-to localhost:8080/api/stripe/webhook/ +# Copy the whsec_... secret it prints + +# Terminal 2: start the dev server with the secret +STRIPE_TEST_WEBHOOK_SECRET=whsec_... pnpm devserver +``` + +#### Testing the full flow + +With the listener running, open Studio in your browser and go through the upgrade flow. The CLI will forward the resulting webhook events to your local server in real time. + +You can also trigger events manually in a separate terminal to test specific scenarios: + +```bash +stripe trigger checkout.session.completed +stripe trigger customer.subscription.updated +stripe trigger customer.subscription.deleted +``` + +### Unit Tests + +Backend tests are in: +- `contentcuration/tests/test_user.py` (`UserEffectiveDiskSpaceTest`) — disk space calculation tests +- `contentcuration/tests/views/test_subscription.py` — view/webhook tests diff --git a/requirements.in b/requirements.in index 01fc569a9a..c38cd30797 100644 --- a/requirements.in +++ b/requirements.in @@ -38,3 +38,4 @@ langcodes==3.5.1 pydantic==2.12.5 latex2mathml==3.78.1 markdown-it-py==4.0.0 +stripe>=5.0.0,<6.0.0 diff --git a/requirements.txt b/requirements.txt index 82ccec2cab..8ed6d01764 100644 --- a/requirements.txt +++ b/requirements.txt @@ -254,6 +254,7 @@ requests==2.33.0 # -r requirements.in # google-api-core # google-cloud-storage + # stripe rpds-py==0.30.0 # via # jsonschema @@ -264,6 +265,11 @@ s3transfer==0.4.2 # via boto3 sentry-sdk==2.48.0 # via -r requirements.in +setuptools==80.9.0 + # via + # google-api-core + # google-auth + # marisa-trie six==1.16.0 # via # click-repl @@ -271,6 +277,8 @@ six==1.16.0 # python-dateutil sqlparse==0.4.1 # via django +stripe==5.5.0 + # via -r requirements.in typing-extensions==4.15.0 # via # cryptography