Add Stripe Customer Portal session API for account billing (#35)
Deploy Beta / unit-tests (push) Successful in 9s
Unit Tests / test (push) Successful in 10s
Deploy Beta / docker (push) Successful in 26s
Deploy Beta / deploy-beta (push) Successful in 6m49s

## Summary
- Companion to [chat_web_app#33](ai_ml_operations/chat_web_app#33) (Account billing + Customer Portal)
- Follow-on from finance MVP [#21](#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: #35
This commit was merged in pull request #35.
This commit is contained in:
2026-07-31 03:54:28 -07:00
parent ee3d47c8c3
commit 67f16565e9
9 changed files with 193 additions and 2 deletions
+2
View File
@@ -54,6 +54,8 @@ STRIPE_PRICE_ID=
FRONTEND_BASE_URL=http://localhost:3000 FRONTEND_BASE_URL=http://localhost:3000
# STRIPE_CHECKOUT_SUCCESS_URL=http://localhost:3000/billing/success?session_id={CHECKOUT_SESSION_ID} # STRIPE_CHECKOUT_SUCCESS_URL=http://localhost:3000/billing/success?session_id={CHECKOUT_SESSION_ID}
# STRIPE_CHECKOUT_CANCEL_URL=http://localhost:3000/billing/cancel # 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 / ASGI
GUNICORN_WORKERS=2 GUNICORN_WORKERS=2
+1
View File
@@ -70,6 +70,7 @@ STRIPE_PRICE_ID=
FRONTEND_BASE_URL=https://chat.aimloperations.com FRONTEND_BASE_URL=https://chat.aimloperations.com
# STRIPE_CHECKOUT_SUCCESS_URL=https://chat.aimloperations.com/billing/success?session_id={CHECKOUT_SESSION_ID} # 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_CHECKOUT_CANCEL_URL=https://chat.aimloperations.com/billing/cancel
# STRIPE_PORTAL_RETURN_URL=https://chat.aimloperations.com/account/
# Gunicorn / ASGI (UvicornWorker for WebSockets) # Gunicorn / ASGI (UvicornWorker for WebSockets)
GUNICORN_WORKERS=2 GUNICORN_WORKERS=2
+2 -1
View File
@@ -93,7 +93,8 @@ with `COMPOSE_DATABASE_URL` if needed.
| `ENABLE_ACCOUNT_REGISTRATION` | `false` | optional | Self-serve sign-up; keep false until ready | | `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_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 | | `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) | | `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` | | `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 | | `GUNICORN_WORKERS` / `GUNICORN_BIND` | 2 / `0.0.0.0:8000` | optional | Entrypoint |
+4
View File
@@ -49,3 +49,7 @@ class PaymentSerializer(serializers.ModelSerializer):
class CheckoutSessionSerializer(serializers.Serializer): class CheckoutSessionSerializer(serializers.Serializer):
success_url = serializers.URLField(required=False, allow_blank=False) success_url = serializers.URLField(required=False, allow_blank=False)
cancel_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)
+27 -1
View File
@@ -1,4 +1,4 @@
"""Stripe Checkout session helpers.""" """Stripe Checkout and Billing Portal session helpers."""
from __future__ import annotations from __future__ import annotations
@@ -7,6 +7,8 @@ from typing import Any
import stripe import stripe
from django.conf import settings from django.conf import settings
from finance.models import Invoice
class StripeNotConfiguredError(RuntimeError): class StripeNotConfiguredError(RuntimeError):
"""Raised when Stripe secret key is missing.""" """Raised when Stripe secret key is missing."""
@@ -69,3 +71,27 @@ def create_checkout_session(
subscription_data={"metadata": metadata}, subscription_data={"metadata": metadata},
) )
return session 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,
)
+102
View File
@@ -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)
+6
View File
@@ -1,6 +1,7 @@
from django.urls import path from django.urls import path
from finance.views import ( from finance.views import (
CreateBillingPortalSessionView,
CreateCheckoutSessionView, CreateCheckoutSessionView,
InvoiceListView, InvoiceListView,
PaymentListView, PaymentListView,
@@ -13,6 +14,11 @@ urlpatterns = [
CreateCheckoutSessionView.as_view(), CreateCheckoutSessionView.as_view(),
name="finance_checkout", name="finance_checkout",
), ),
path(
"portal/",
CreateBillingPortalSessionView.as_view(),
name="finance_portal",
),
path( path(
"invoices/", "invoices/",
InvoiceListView.as_view(), InvoiceListView.as_view(),
+45
View File
@@ -11,10 +11,13 @@ from finance.serializers import (
CheckoutSessionSerializer, CheckoutSessionSerializer,
InvoiceSerializer, InvoiceSerializer,
PaymentSerializer, PaymentSerializer,
PortalSessionSerializer,
) )
from finance.services.stripe_service import ( from finance.services.stripe_service import (
StripeNotConfiguredError, StripeNotConfiguredError,
create_billing_portal_session,
create_checkout_session, create_checkout_session,
resolve_stripe_customer_id,
) )
from finance.services.webhooks import dispatch_stripe_event 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): class InvoiceListView(APIView):
def get(self, request): def get(self, request):
invoices = Invoice.objects.filter(user=request.user) invoices = Invoice.objects.filter(user=request.user)
+4
View File
@@ -360,6 +360,10 @@ STRIPE_CHECKOUT_CANCEL_URL = env(
"STRIPE_CHECKOUT_CANCEL_URL", "STRIPE_CHECKOUT_CANCEL_URL",
f"{FRONTEND_BASE_URL}/billing/cancel", f"{FRONTEND_BASE_URL}/billing/cancel",
) or 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"}: if DJANGO_ENV in {"prod", "beta"}:
# Compose treats $ in .env as variable expansion — escape each $ as $$. # Compose treats $ in .env as variable expansion — escape each $ as $$.