feat(billing): add GET /billing/state aggregating subscription + plan + features

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-06 15:12:12 -04:00
parent 4768ae0648
commit 79942c3fd3
4 changed files with 169 additions and 2 deletions

View File

@@ -9,7 +9,11 @@ from app.core.admin_database import get_admin_db
from app.core.config import settings 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 CheckoutSessionCreate, CheckoutSessionResponse from app.schemas.billing import (
BillingStateResponse,
CheckoutSessionCreate,
CheckoutSessionResponse,
)
from app.services.billing import BillingService from app.services.billing import BillingService
router = APIRouter(prefix="/billing", tags=["billing"]) router = APIRouter(prefix="/billing", tags=["billing"])
@@ -34,3 +38,15 @@ async def create_checkout_session(
cancel_url=f"{settings.FRONTEND_URL}/account/billing/select-plan", cancel_url=f"{settings.FRONTEND_URL}/account/billing/select-plan",
) )
return CheckoutSessionResponse(url=url) return CheckoutSessionResponse(url=url)
@router.get("/state", response_model=BillingStateResponse)
async def get_billing_state(
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_admin_db)],
) -> BillingStateResponse:
account = (await db.execute(
select(Account).where(Account.id == current_user.account_id)
)).scalar_one()
state = await BillingService.get_billing_state(db, account)
return BillingStateResponse(**state)

View File

@@ -1,4 +1,5 @@
from typing import Literal from typing import Literal, Optional, Dict, Any
from datetime import datetime
from pydantic import BaseModel from pydantic import BaseModel
@@ -10,3 +11,30 @@ class CheckoutSessionCreate(BaseModel):
class CheckoutSessionResponse(BaseModel): class CheckoutSessionResponse(BaseModel):
url: str url: str
class SubscriptionState(BaseModel):
status: str
plan: str
current_period_start: Optional[datetime]
current_period_end: Optional[datetime]
cancel_at_period_end: bool
seat_limit: Optional[int]
has_pro_entitlement: bool
is_paid: bool
class PlanBillingState(BaseModel):
display_name: str
description: Optional[str] = None
monthly_price_cents: Optional[int] = None
annual_price_cents: Optional[int] = None
model_config = {"from_attributes": True}
class BillingStateResponse(BaseModel):
subscription: SubscriptionState
plan_billing: Optional[PlanBillingState]
plan_limits: Dict[str, Any]
enabled_features: Dict[str, bool]

View File

@@ -105,6 +105,61 @@ class BillingService:
) )
return session.url return session.url
@staticmethod
async def get_billing_state(db: AsyncSession, account):
"""Aggregate Subscription + PlanLimits + PlanBilling + resolved feature
flags for the account."""
from app.models.plan_limits import PlanLimits
from app.models.plan_billing import PlanBilling
from app.models.feature_flag import (
FeatureFlag, PlanFeatureDefault, AccountFeatureOverride,
)
sub = (await db.execute(
select(Subscription).where(Subscription.account_id == account.id)
)).scalar_one_or_none()
if sub is None:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="No subscription for account")
pl = (await db.execute(
select(PlanLimits).where(PlanLimits.plan == sub.plan)
)).scalar_one_or_none()
pb = (await db.execute(
select(PlanBilling).where(PlanBilling.plan == sub.plan)
)).scalar_one_or_none()
# Resolved feature flags: plan defaults overridden by account overrides
defaults = (await db.execute(
select(PlanFeatureDefault, FeatureFlag)
.join(FeatureFlag, PlanFeatureDefault.flag_id == FeatureFlag.id)
.where(PlanFeatureDefault.plan == sub.plan)
)).all()
resolved = {flag.flag_key: pfd.enabled for pfd, flag in defaults}
overrides = (await db.execute(
select(AccountFeatureOverride, FeatureFlag)
.join(FeatureFlag, AccountFeatureOverride.flag_id == FeatureFlag.id)
.where(AccountFeatureOverride.account_id == account.id)
)).all()
for ovr, flag in overrides:
resolved[flag.flag_key] = ovr.enabled
return {
"subscription": {
"status": sub.status,
"plan": sub.plan,
"current_period_start": sub.current_period_start,
"current_period_end": sub.current_period_end,
"cancel_at_period_end": sub.cancel_at_period_end,
"seat_limit": sub.seat_limit,
"has_pro_entitlement": sub.has_pro_entitlement,
"is_paid": sub.is_paid,
},
"plan_billing": pb,
"plan_limits": _plan_limits_to_dict(pl) if pl else {},
"enabled_features": resolved,
}
@staticmethod @staticmethod
async def apply_subscription_event( async def apply_subscription_event(
db: AsyncSession, event_id: str, event_type: str, payload: dict db: AsyncSession, event_id: str, event_type: str, payload: dict
@@ -136,6 +191,10 @@ class BillingService:
return True return True
def _plan_limits_to_dict(pl) -> dict:
return {c.name: getattr(pl, c.name) for c in pl.__table__.columns}
def _excerpt(payload: dict) -> dict: def _excerpt(payload: dict) -> dict:
obj = payload.get("data", {}).get("object", {}) obj = payload.get("data", {}).get("object", {})
return { return {

View File

@@ -0,0 +1,64 @@
import uuid
import pytest
from sqlalchemy import select
from app.models.subscription import Subscription
from app.models.feature_flag import FeatureFlag, PlanFeatureDefault, AccountFeatureOverride
@pytest.mark.asyncio
async def test_billing_state_returns_subscription_plan_features(
client, test_db, test_user, auth_headers
):
"""Subscription is already seeded by test_user fixture (pro/active).
Add a feature flag default for `pro` and verify it shows up in the response."""
flag = FeatureFlag(flag_key="psa_integration", display_name="PSA Integration")
test_db.add(flag)
await test_db.flush()
test_db.add(PlanFeatureDefault(plan="pro", flag_id=flag.id, enabled=True))
await test_db.commit()
response = await client.get("/api/v1/billing/state", headers=auth_headers)
assert response.status_code == 200, response.json()
body = response.json()
assert body["subscription"]["status"] == "active"
assert body["subscription"]["plan"] == "pro"
assert body["subscription"]["has_pro_entitlement"] is True
assert body["subscription"]["is_paid"] is True
assert body["enabled_features"]["psa_integration"] is True
# plan_limits should be a dict with the seeded pro limits from conftest
assert body["plan_limits"]["plan"] == "pro"
assert body["plan_limits"]["max_trees"] == 25
@pytest.mark.asyncio
async def test_billing_state_account_override_beats_plan_default(
client, test_db, test_user, auth_headers
):
account_id = uuid.UUID(test_user["user_data"]["account_id"])
flag = FeatureFlag(flag_key="escalation_mode", display_name="Escalation Mode")
test_db.add(flag)
await test_db.flush()
test_db.add(PlanFeatureDefault(plan="pro", flag_id=flag.id, enabled=False))
test_db.add(AccountFeatureOverride(
account_id=account_id, flag_id=flag.id, enabled=True,
))
await test_db.commit()
response = await client.get("/api/v1/billing/state", headers=auth_headers)
assert response.status_code == 200
assert response.json()["enabled_features"]["escalation_mode"] is True
@pytest.mark.asyncio
async def test_billing_state_404_when_no_subscription(
client, test_db, test_user, auth_headers
):
"""Wipe the seeded subscription and verify the endpoint surfaces 404."""
from sqlalchemy import delete
account_id = uuid.UUID(test_user["user_data"]["account_id"])
await test_db.execute(delete(Subscription).where(Subscription.account_id == account_id))
await test_db.commit()
response = await client.get("/api/v1/billing/state", headers=auth_headers)
assert response.status_code == 404