Files
resolutionflow/backend/app/schemas/billing.py
Michael Chihlas ba36c47075 feat(billing): reconcile plan taxonomy and add Stripe sync script
The marketing surface (PricingPage, Stripe products) was wired for
"Starter / Pro / Enterprise" while the backend was on "free / pro / team",
leaving plan_billing unseeded and BillingPlan accepting a literal that
violated the FK to plan_limits.

This change:

- Migration 4ce3e594cb87: defensive UPDATE of any subscriptions on
  plan='team' to 'enterprise' (dev has zero), renames the plan_limits
  row team -> enterprise, inserts a starter row with caps interpolated
  between free and pro (max_trees=10, sessions=75, ai=15/mo).
- Renames the plan tier across schemas (invite_code, billing, admin,
  subscription comment), is_paid/has_pro_entitlement checks in the
  Subscription model, admin/admin_dashboard plan validators, and the
  frontend useSubscription isPaidPlan check. Resource visibility uses
  the same string 'team' in a separate domain (Tree/StepLibrary
  visibility) and is intentionally untouched.
- New backend/scripts/sync_stripe_plan_ids.py: idempotent upsert of
  plan_billing rows from Stripe products by exact name match. Picks
  the active monthly recurring price for tiers that have one; leaves
  annual fields NULL by design. Works against test or live keys.
- Test fixture updates: conftest seeds the new taxonomy, the public
  plans helper is a true upsert so tests can override max_users, and
  team -> enterprise across test_admin_plan_limits and test_invite_plan.

Verified: 86/86 passing across the subscription/billing/plan/invite/
admin sweep; sync script run against test mode populates plan_billing
correctly for all three tiers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 15:59:42 -04:00

65 lines
1.8 KiB
Python

from typing import Literal, Optional, Dict, Any
from datetime import datetime
from pydantic import BaseModel
class CheckoutSessionCreate(BaseModel):
plan: Literal["pro", "starter", "enterprise"]
seats: int
billing_interval: Literal["monthly", "annual"] = "monthly"
class CheckoutSessionResponse(BaseModel):
url: str
class BillingPortalSessionResponse(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]
class PublicPlanResponse(BaseModel):
"""Public-safe view of a billable plan, used by the marketing /pricing page.
Sourced from `plan_billing` joined with `plan_limits.max_users` (exposed
here as `max_seats`). Always filtered server-side to is_public=True and
is_archived=False, so `is_public` is a constant True for any row returned
here — included for clarity and forward compatibility.
"""
plan: str
display_name: str
description: Optional[str] = None
monthly_price_cents: Optional[int] = None
annual_price_cents: Optional[int] = None
max_seats: Optional[int] = None
sort_order: int
is_public: bool = True
model_config = {"from_attributes": True}