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:
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
83
backend/tests/test_billing_portal.py
Normal file
83
backend/tests/test_billing_portal.py
Normal 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"
|
||||||
Reference in New Issue
Block a user