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>
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
"""add_starter_rename_team_to_enterprise
|
||||
|
||||
Revision ID: 4ce3e594cb87
|
||||
Revises: c6cbfc534fad
|
||||
Create Date: 2026-05-07 19:36:27.172082
|
||||
|
||||
Plan tier taxonomy reconciliation. Marketing surface and Stripe products
|
||||
named "Starter / Pro / Enterprise"; backend was on "free / pro / team".
|
||||
This migration:
|
||||
|
||||
1. Defensively migrates any existing subscriptions on plan='team' to
|
||||
plan='enterprise' (dev has zero such rows; prod is expected to have
|
||||
none, but the UPDATE is safe and idempotent).
|
||||
2. Renames the plan_limits row 'team' -> 'enterprise'. plan_billing
|
||||
and plan_feature_defaults are FK-referenced but currently empty;
|
||||
the rename works because PostgreSQL allows updating PK values when
|
||||
no FK rows reference them.
|
||||
3. Inserts a new plan_limits row for 'starter' between free and pro.
|
||||
|
||||
Resource visibility (Tree.visibility, StepLibrary.visibility) also uses
|
||||
the string 'team' for "shared with my account" — that is a separate
|
||||
domain and is intentionally not touched.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = '4ce3e594cb87'
|
||||
down_revision: Union[str, None] = 'c6cbfc534fad'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("UPDATE subscriptions SET plan = 'enterprise' WHERE plan = 'team'")
|
||||
op.execute("UPDATE plan_limits SET plan = 'enterprise' WHERE plan = 'team'")
|
||||
op.execute("""
|
||||
INSERT INTO plan_limits (
|
||||
plan,
|
||||
max_trees,
|
||||
max_sessions_per_month,
|
||||
max_users,
|
||||
custom_branding,
|
||||
priority_support,
|
||||
export_formats,
|
||||
max_ai_builds_per_month,
|
||||
max_ai_builds_per_24h,
|
||||
kb_accelerator_enabled,
|
||||
kb_max_lifetime_conversions,
|
||||
kb_batch_max_size,
|
||||
kb_allowed_formats,
|
||||
kb_detailed_analysis,
|
||||
kb_conversational_refinement,
|
||||
kb_step_library_matching,
|
||||
kb_history_limit
|
||||
) VALUES (
|
||||
'starter',
|
||||
10,
|
||||
75,
|
||||
1,
|
||||
FALSE,
|
||||
FALSE,
|
||||
'["markdown", "text", "html"]'::jsonb,
|
||||
15,
|
||||
5,
|
||||
FALSE,
|
||||
NULL,
|
||||
NULL,
|
||||
'["txt", "paste", "md"]'::jsonb,
|
||||
FALSE,
|
||||
FALSE,
|
||||
FALSE,
|
||||
NULL
|
||||
)
|
||||
ON CONFLICT (plan) DO NOTHING
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DELETE FROM plan_limits WHERE plan = 'starter'")
|
||||
op.execute("UPDATE plan_limits SET plan = 'team' WHERE plan = 'enterprise'")
|
||||
op.execute("UPDATE subscriptions SET plan = 'team' WHERE plan = 'enterprise'")
|
||||
Reference in New Issue
Block a user