From 2f8ec3775e1e58b694f60776870b336c6b27fab3 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Wed, 6 May 2026 19:55:42 -0400 Subject: [PATCH] feat(billing): add BillingService.open_customer_portal + GET endpoint Authed users can now request a Stripe-hosted Customer Portal URL for card updates and cancellation via GET /api/v1/billing/portal-session. The path is already in both _SUBSCRIPTION_GUARD_ALLOWLIST and _EMAIL_VERIFICATION_ALLOWLIST so canceled or unverified-past-grace users can still update billing. - Returns 503 with {"error": "stripe_not_configured"} when STRIPE_SECRET_KEY unset. - Returns 400 with {"error": "no_stripe_customer"} when account has no stripe_customer_id (must complete checkout first). Co-Authored-By: Claude Opus 4.7 --- backend/app/api/endpoints/billing.py | 26 ++++++++- backend/app/schemas/billing.py | 4 ++ backend/app/services/billing.py | 19 +++++++ backend/tests/test_billing_portal.py | 83 ++++++++++++++++++++++++++++ 4 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 backend/tests/test_billing_portal.py diff --git a/backend/app/api/endpoints/billing.py b/backend/app/api/endpoints/billing.py index 23d067d4..7fa8694e 100644 --- a/backend/app/api/endpoints/billing.py +++ b/backend/app/api/endpoints/billing.py @@ -1,6 +1,6 @@ from typing import Annotated -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -10,6 +10,7 @@ from app.core.config import settings from app.models.account import Account from app.models.user import User from app.schemas.billing import ( + BillingPortalSessionResponse, BillingStateResponse, CheckoutSessionCreate, CheckoutSessionResponse, @@ -50,3 +51,26 @@ async def get_billing_state( )).scalar_one() state = await BillingService.get_billing_state(db, account) return BillingStateResponse(**state) + + +@router.get("/portal-session", response_model=BillingPortalSessionResponse) +async def get_billing_portal_session( + current_user: Annotated[User, Depends(get_current_active_user)], + db: Annotated[AsyncSession, Depends(get_admin_db)], +) -> BillingPortalSessionResponse: + """Return a Stripe-hosted Customer Portal URL for the account so the user + can update card / cancel. Allowlisted from the subscription + email-verify + guards (a canceled or unverified-past-grace user must still be able to + update billing).""" + if not settings.stripe_enabled: + raise HTTPException(status_code=503, detail={"error": "stripe_not_configured"}) + + account = (await db.execute( + select(Account).where(Account.id == current_user.account_id) + )).scalar_one() + + try: + url = await BillingService.open_customer_portal(account) + except ValueError: + raise HTTPException(status_code=400, detail={"error": "no_stripe_customer"}) + return BillingPortalSessionResponse(url=url) diff --git a/backend/app/schemas/billing.py b/backend/app/schemas/billing.py index ebe9ab9d..0a1bcf98 100644 --- a/backend/app/schemas/billing.py +++ b/backend/app/schemas/billing.py @@ -13,6 +13,10 @@ class CheckoutSessionResponse(BaseModel): url: str +class BillingPortalSessionResponse(BaseModel): + url: str + + class SubscriptionState(BaseModel): status: str plan: str diff --git a/backend/app/services/billing.py b/backend/app/services/billing.py index a104a5b1..b662ed47 100644 --- a/backend/app/services/billing.py +++ b/backend/app/services/billing.py @@ -105,6 +105,25 @@ class BillingService: ) return session.url + @staticmethod + async def open_customer_portal(account: Account) -> str: + """Create a Stripe-hosted Customer Portal session and return the URL. + + Raises RuntimeError if Stripe isn't configured (endpoint maps to 503). + Raises ValueError if the account has no stripe_customer_id yet — the + user must complete a checkout first (endpoint maps to 400). + """ + if not settings.stripe_enabled: + raise RuntimeError("Stripe not configured") + if account.stripe_customer_id is None: + raise ValueError("no_stripe_customer") + stripe.api_key = settings.STRIPE_SECRET_KEY + session = stripe.billing_portal.Session.create( + customer=account.stripe_customer_id, + return_url=f"{settings.FRONTEND_URL}/account/billing", + ) + return session.url + @staticmethod async def get_billing_state(db: AsyncSession, account): """Aggregate Subscription + PlanLimits + PlanBilling + resolved feature diff --git a/backend/tests/test_billing_portal.py b/backend/tests/test_billing_portal.py new file mode 100644 index 00000000..76841a7a --- /dev/null +++ b/backend/tests/test_billing_portal.py @@ -0,0 +1,83 @@ +import uuid +import pytest +from unittest.mock import patch, MagicMock +from sqlalchemy import select + +from app.models.account import Account + + +@pytest.mark.asyncio +async def test_billing_portal_returns_url_for_account_with_stripe_customer( + client, test_db, test_user, auth_headers, monkeypatch +): + """Happy path: account has a stripe_customer_id and Stripe is configured → + GET /billing/portal-session returns the portal URL.""" + from app.core.config import settings + monkeypatch.setattr(settings, "STRIPE_SECRET_KEY", "sk_test_dummy") + monkeypatch.setattr(settings, "FRONTEND_URL", "https://app.example.com") + + account_id = uuid.UUID(test_user["user_data"]["account_id"]) + account = (await test_db.execute( + select(Account).where(Account.id == account_id) + )).scalar_one() + account.stripe_customer_id = "cus_test_456" + await test_db.commit() + + fake_session = MagicMock() + fake_session.url = "https://billing.stripe.com/p/session/test_abc" + + with patch( + "stripe.billing_portal.Session.create", + return_value=fake_session, + ) as portal_mock: + response = await client.get( + "/api/v1/billing/portal-session", + headers=auth_headers, + ) + + assert response.status_code == 200, response.json() + assert response.json() == {"url": "https://billing.stripe.com/p/session/test_abc"} + portal_mock.assert_called_once() + call_kwargs = portal_mock.call_args.kwargs + assert call_kwargs["customer"] == "cus_test_456" + assert call_kwargs["return_url"] == "https://app.example.com/account/billing" + + +@pytest.mark.asyncio +async def test_billing_portal_returns_503_when_stripe_not_configured( + client, test_db, test_user, auth_headers, monkeypatch +): + """STRIPE_SECRET_KEY unset → settings.stripe_enabled is False → 503.""" + from app.core.config import settings + monkeypatch.setattr(settings, "STRIPE_SECRET_KEY", None) + + response = await client.get( + "/api/v1/billing/portal-session", + headers=auth_headers, + ) + assert response.status_code == 503 + assert response.json()["detail"]["error"] == "stripe_not_configured" + + +@pytest.mark.asyncio +async def test_billing_portal_returns_400_when_account_has_no_stripe_customer( + client, test_db, test_user, auth_headers, monkeypatch +): + """Account with no stripe_customer_id (never completed checkout) → 400 + with `no_stripe_customer` error.""" + from app.core.config import settings + monkeypatch.setattr(settings, "STRIPE_SECRET_KEY", "sk_test_dummy") + + # test_user fixture seeds an account with no stripe_customer_id by default. + account_id = uuid.UUID(test_user["user_data"]["account_id"]) + account = (await test_db.execute( + select(Account).where(Account.id == account_id) + )).scalar_one() + assert account.stripe_customer_id is None + + response = await client.get( + "/api/v1/billing/portal-session", + headers=auth_headers, + ) + assert response.status_code == 400 + assert response.json()["detail"]["error"] == "no_stripe_customer"