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.models.account import Account
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
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",
)
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
@@ -10,3 +11,30 @@ class CheckoutSessionCreate(BaseModel):
class CheckoutSessionResponse(BaseModel):
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
@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
async def apply_subscription_event(
db: AsyncSession, event_id: str, event_type: str, payload: dict
@@ -136,6 +191,10 @@ class BillingService:
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:
obj = payload.get("data", {}).get("object", {})
return {