From 67f16565e9cbc062dcfc3050c4effa5f8e89942c Mon Sep 17 00:00:00 2001 From: Ryan Westfall Date: Fri, 31 Jul 2026 03:54:28 -0700 Subject: [PATCH] Add Stripe Customer Portal session API for account billing (#35) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Companion to [chat_web_app#33](https://git.aimloperations.com/ai_ml_operations/chat_web_app/issues/33) (Account billing + Customer Portal) - Follow-on from finance MVP [#21](https://git.aimloperations.com/ai_ml_operations/chat_backend/issues/21): add authenticated `POST /api/finance/portal/` that creates a Stripe Billing Portal session and returns `portal_url` - Resolve Stripe customer from the user's latest `Invoice.stripe_customer_id`; return `400` when missing (user must complete Checkout first) - Document `STRIPE_PORTAL_RETURN_URL` (default `{FRONTEND_BASE_URL}/account/`) in settings + env examples ## Test plan - [ ] `manage.py test finance.tests.test_portal finance.tests.test_checkout` - [ ] Authenticated portal create with invoice that has `stripe_customer_id` → `201` + `portal_url` - [ ] No customer / unpaid user → `400` with clear detail - [ ] Missing `STRIPE_SECRET_KEY` → `503` - [ ] Unauthenticated → `401` - [ ] Custom `return_url` in body overrides default portal return URLReviewed-on: https://git.aimloperations.com/ai_ml_operations/chat_backend/pulls/35 --- .env.example | 2 + .env.prod.example | 1 + README.md | 3 +- llm_be/finance/serializers.py | 4 + llm_be/finance/services/stripe_service.py | 28 +++++- llm_be/finance/tests/test_portal.py | 102 ++++++++++++++++++++++ llm_be/finance/urls.py | 6 ++ llm_be/finance/views.py | 45 ++++++++++ llm_be/llm_be/settings.py | 4 + 9 files changed, 193 insertions(+), 2 deletions(-) create mode 100644 llm_be/finance/tests/test_portal.py diff --git a/.env.example b/.env.example index 0202b28..c434bfc 100644 --- a/.env.example +++ b/.env.example @@ -54,6 +54,8 @@ STRIPE_PRICE_ID= FRONTEND_BASE_URL=http://localhost:3000 # STRIPE_CHECKOUT_SUCCESS_URL=http://localhost:3000/billing/success?session_id={CHECKOUT_SESSION_ID} # STRIPE_CHECKOUT_CANCEL_URL=http://localhost:3000/billing/cancel +# Customer Portal return URL (plan change / cancel / payment method). +# STRIPE_PORTAL_RETURN_URL=http://localhost:3000/account/ # Gunicorn / ASGI GUNICORN_WORKERS=2 diff --git a/.env.prod.example b/.env.prod.example index 5810340..1c6321e 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -70,6 +70,7 @@ STRIPE_PRICE_ID= FRONTEND_BASE_URL=https://chat.aimloperations.com # STRIPE_CHECKOUT_SUCCESS_URL=https://chat.aimloperations.com/billing/success?session_id={CHECKOUT_SESSION_ID} # STRIPE_CHECKOUT_CANCEL_URL=https://chat.aimloperations.com/billing/cancel +# STRIPE_PORTAL_RETURN_URL=https://chat.aimloperations.com/account/ # Gunicorn / ASGI (UvicornWorker for WebSockets) GUNICORN_WORKERS=2 diff --git a/README.md b/README.md index 1b6038c..9cba6c0 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,8 @@ with `COMPOSE_DATABASE_URL` if needed. | `ENABLE_ACCOUNT_REGISTRATION` | `false` | optional | Self-serve sign-up; keep false until ready | | `STRIPE_SECRET_KEY` / `STRIPE_PUBLISHABLE_KEY` / `STRIPE_WEBHOOK_SECRET` | empty | yes for billing | Stripe API + webhook | | `STRIPE_PRICE_ID` | empty | optional | Pre-created Price; else `$10/mo` from settings | -| `FRONTEND_BASE_URL` | `http://localhost:3000` | set in prod/beta | Checkout success/cancel + OAuth return | +| `FRONTEND_BASE_URL` | `http://localhost:3000` | set in prod/beta | Checkout success/cancel, portal return, OAuth return | +| `STRIPE_PORTAL_RETURN_URL` | `{FRONTEND}/account/` | optional | Stripe Customer Portal return URL | | `CORS_ALLOWED_ORIGINS` | local + chat FE (+ beta FE default) | set in prod/beta | Frontend origin(s) | | `USE_TLS_PROXY` | false (dev) | true behind NPM | Sets `SECURE_PROXY_SSL_HEADER` | | `GUNICORN_WORKERS` / `GUNICORN_BIND` | 2 / `0.0.0.0:8000` | optional | Entrypoint | diff --git a/llm_be/finance/serializers.py b/llm_be/finance/serializers.py index d2416fd..6f9ea59 100644 --- a/llm_be/finance/serializers.py +++ b/llm_be/finance/serializers.py @@ -49,3 +49,7 @@ class PaymentSerializer(serializers.ModelSerializer): class CheckoutSessionSerializer(serializers.Serializer): success_url = serializers.URLField(required=False, allow_blank=False) cancel_url = serializers.URLField(required=False, allow_blank=False) + + +class PortalSessionSerializer(serializers.Serializer): + return_url = serializers.URLField(required=False, allow_blank=False) diff --git a/llm_be/finance/services/stripe_service.py b/llm_be/finance/services/stripe_service.py index 513043e..211110d 100644 --- a/llm_be/finance/services/stripe_service.py +++ b/llm_be/finance/services/stripe_service.py @@ -1,4 +1,4 @@ -"""Stripe Checkout session helpers.""" +"""Stripe Checkout and Billing Portal session helpers.""" from __future__ import annotations @@ -7,6 +7,8 @@ from typing import Any import stripe from django.conf import settings +from finance.models import Invoice + class StripeNotConfiguredError(RuntimeError): """Raised when Stripe secret key is missing.""" @@ -69,3 +71,27 @@ def create_checkout_session( subscription_data={"metadata": metadata}, ) return session + + +def resolve_stripe_customer_id(*, user) -> str | None: + """Return the most recent Stripe customer id stored on the user's invoices.""" + return ( + Invoice.objects.filter(user=user) + .exclude(stripe_customer_id="") + .order_by("-created") + .values_list("stripe_customer_id", flat=True) + .first() + ) + + +def create_billing_portal_session( + *, + customer_id: str, + return_url: str | None = None, +): + """Create a Stripe Customer Portal session for plan/payment/cancel management.""" + configure_stripe() + return stripe.billing_portal.Session.create( + customer=customer_id, + return_url=return_url or settings.STRIPE_PORTAL_RETURN_URL, + ) diff --git a/llm_be/finance/tests/test_portal.py b/llm_be/finance/tests/test_portal.py new file mode 100644 index 0000000..6954c35 --- /dev/null +++ b/llm_be/finance/tests/test_portal.py @@ -0,0 +1,102 @@ +"""Tests for Stripe Customer Portal session API (mocked Stripe SDK).""" + +from unittest.mock import MagicMock, patch + +from django.test import override_settings +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APITestCase + +from chat_backend.tests.factories import make_company, make_user +from finance.models import Invoice + + +class CreateBillingPortalSessionViewTestCase(APITestCase): + def setUp(self): + self.company = make_company() + self.user = make_user(company=self.company) + self.client.force_authenticate(user=self.user) + self.url = reverse("finance_portal") + + def _create_invoice_with_customer(self, customer_id="cus_test_abc"): + return Invoice.objects.create( + user=self.user, + company=self.company, + amount_due=1000, + amount_paid=1000, + status=Invoice.Status.PAID, + stripe_checkout_session_id="cs_portal_test", + stripe_customer_id=customer_id, + stripe_subscription_id="sub_test_abc", + description="Chat Subscription", + ) + + @override_settings( + STRIPE_SECRET_KEY="sk_test_fake", + STRIPE_PORTAL_RETURN_URL="http://localhost:3000/account/", + ) + @patch("finance.services.stripe_service.stripe.billing_portal.Session.create") + def test_creates_portal_session(self, mock_create): + self._create_invoice_with_customer() + mock_session = MagicMock() + mock_session.url = "https://billing.stripe.com/p/session/test_portal" + mock_create.return_value = mock_session + + response = self.client.post(self.url, {}, format="json") + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual( + response.data["portal_url"], + "https://billing.stripe.com/p/session/test_portal", + ) + mock_create.assert_called_once_with( + customer="cus_test_abc", + return_url="http://localhost:3000/account/", + ) + + @override_settings( + STRIPE_SECRET_KEY="sk_test_fake", + STRIPE_PORTAL_RETURN_URL="http://localhost:3000/account/", + ) + @patch("finance.services.stripe_service.stripe.billing_portal.Session.create") + def test_accepts_custom_return_url(self, mock_create): + self._create_invoice_with_customer() + mock_session = MagicMock() + mock_session.url = "https://billing.stripe.com/p/session/custom" + mock_create.return_value = mock_session + + response = self.client.post( + self.url, + {"return_url": "http://localhost:3000/account/#billing"}, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual( + mock_create.call_args.kwargs["return_url"], + "http://localhost:3000/account/#billing", + ) + + @override_settings(STRIPE_SECRET_KEY="sk_test_fake") + def test_no_stripe_customer_returns_400(self): + Invoice.objects.create( + user=self.user, + company=self.company, + amount_due=1000, + stripe_checkout_session_id="cs_no_customer", + stripe_customer_id="", + ) + response = self.client.post(self.url, {}, format="json") + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("No Stripe customer", response.data["detail"]) + + @override_settings(STRIPE_SECRET_KEY="") + def test_missing_stripe_key_returns_503(self): + self._create_invoice_with_customer() + response = self.client.post(self.url, {}, format="json") + self.assertEqual(response.status_code, status.HTTP_503_SERVICE_UNAVAILABLE) + + def test_unauthenticated_rejected(self): + self.client.force_authenticate(user=None) + response = self.client.post(self.url, {}, format="json") + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) diff --git a/llm_be/finance/urls.py b/llm_be/finance/urls.py index ffca6d5..2ca88d1 100644 --- a/llm_be/finance/urls.py +++ b/llm_be/finance/urls.py @@ -1,6 +1,7 @@ from django.urls import path from finance.views import ( + CreateBillingPortalSessionView, CreateCheckoutSessionView, InvoiceListView, PaymentListView, @@ -13,6 +14,11 @@ urlpatterns = [ CreateCheckoutSessionView.as_view(), name="finance_checkout", ), + path( + "portal/", + CreateBillingPortalSessionView.as_view(), + name="finance_portal", + ), path( "invoices/", InvoiceListView.as_view(), diff --git a/llm_be/finance/views.py b/llm_be/finance/views.py index 99c5683..3cfcc60 100644 --- a/llm_be/finance/views.py +++ b/llm_be/finance/views.py @@ -11,10 +11,13 @@ from finance.serializers import ( CheckoutSessionSerializer, InvoiceSerializer, PaymentSerializer, + PortalSessionSerializer, ) from finance.services.stripe_service import ( StripeNotConfiguredError, + create_billing_portal_session, create_checkout_session, + resolve_stripe_customer_id, ) from finance.services.webhooks import dispatch_stripe_event @@ -72,6 +75,48 @@ class CreateCheckoutSessionView(APIView): ) +class CreateBillingPortalSessionView(APIView): + """Create a Stripe Customer Portal session and return the hosted URL.""" + + def post(self, request): + serializer = PortalSessionSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + customer_id = resolve_stripe_customer_id(user=request.user) + if not customer_id: + return Response( + { + "detail": ( + "No Stripe customer found for this account. " + "Complete Checkout first to manage billing." + ) + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + session = create_billing_portal_session( + customer_id=customer_id, + return_url=serializer.validated_data.get("return_url"), + ) + except StripeNotConfiguredError as exc: + return Response( + {"detail": str(exc)}, + status=status.HTTP_503_SERVICE_UNAVAILABLE, + ) + except stripe.StripeError as exc: + logger.exception("Stripe Billing Portal Session creation failed") + return Response( + {"detail": str(getattr(exc, "user_message", None) or exc)}, + status=status.HTTP_502_BAD_GATEWAY, + ) + + return Response( + {"portal_url": session.url}, + status=status.HTTP_201_CREATED, + ) + + class InvoiceListView(APIView): def get(self, request): invoices = Invoice.objects.filter(user=request.user) diff --git a/llm_be/llm_be/settings.py b/llm_be/llm_be/settings.py index 176b513..ba363ed 100644 --- a/llm_be/llm_be/settings.py +++ b/llm_be/llm_be/settings.py @@ -360,6 +360,10 @@ STRIPE_CHECKOUT_CANCEL_URL = env( "STRIPE_CHECKOUT_CANCEL_URL", f"{FRONTEND_BASE_URL}/billing/cancel", ) or f"{FRONTEND_BASE_URL}/billing/cancel" +STRIPE_PORTAL_RETURN_URL = env( + "STRIPE_PORTAL_RETURN_URL", + f"{FRONTEND_BASE_URL}/account/", +) or f"{FRONTEND_BASE_URL}/account/" if DJANGO_ENV in {"prod", "beta"}: # Compose treats $ in .env as variable expansion — escape each $ as $$.