Add Stripe Customer Portal session API for account billing.
Expose POST /api/finance/portal/ so authenticated users can open Stripe's hosted portal for plan changes, payment methods, and cancellations (chat_web_app#33 companion).
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 $$.
|
||||||
|
|||||||
Reference in New Issue
Block a user