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 <noreply@anthropic.com>
This commit is contained in:
2026-05-06 19:55:42 -04:00
parent f918b766b0
commit 2f8ec3775e
4 changed files with 131 additions and 1 deletions

View File

@@ -1,6 +1,6 @@
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession 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.account import Account
from app.models.user import User from app.models.user import User
from app.schemas.billing import ( from app.schemas.billing import (
BillingPortalSessionResponse,
BillingStateResponse, BillingStateResponse,
CheckoutSessionCreate, CheckoutSessionCreate,
CheckoutSessionResponse, CheckoutSessionResponse,
@@ -50,3 +51,26 @@ async def get_billing_state(
)).scalar_one() )).scalar_one()
state = await BillingService.get_billing_state(db, account) state = await BillingService.get_billing_state(db, account)
return BillingStateResponse(**state) 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)

View File

@@ -13,6 +13,10 @@ class CheckoutSessionResponse(BaseModel):
url: str url: str
class BillingPortalSessionResponse(BaseModel):
url: str
class SubscriptionState(BaseModel): class SubscriptionState(BaseModel):
status: str status: str
plan: str plan: str

View File

@@ -105,6 +105,25 @@ class BillingService:
) )
return session.url 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 @staticmethod
async def get_billing_state(db: AsyncSession, account): async def get_billing_state(db: AsyncSession, account):
"""Aggregate Subscription + PlanLimits + PlanBilling + resolved feature """Aggregate Subscription + PlanLimits + PlanBilling + resolved feature

View File

@@ -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"