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>
84 lines
3.0 KiB
Python
84 lines
3.0 KiB
Python
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"
|