Files
resolutionflow/backend/tests/test_billing_portal.py
Michael Chihlas 2f8ec3775e 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>
2026-05-06 20:00:08 -04:00

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"