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>
200 lines
6.8 KiB
Python
200 lines
6.8 KiB
Python
#!/usr/bin/env python3
|
|
"""Sync plan_billing rows from Stripe products and prices.
|
|
|
|
Reads the active Stripe environment (test or live, determined by
|
|
STRIPE_SECRET_KEY in env), looks up the canonical ResolutionFlow products
|
|
by exact name match, picks the active monthly recurring price for tiers
|
|
that have one, and upserts plan_billing rows.
|
|
|
|
Idempotent. Safe to re-run after price changes, after live cutover, or
|
|
after rotating Stripe keys.
|
|
|
|
Tier mapping (name in Stripe -> plan slug in plan_limits):
|
|
ResolutionFlow Starter -> starter (monthly price required)
|
|
ResolutionFlow Pro -> pro (monthly price required)
|
|
ResolutionFlow Enterprise -> enterprise (no price, sales-led)
|
|
|
|
Annual prices are intentionally not supported in this iteration. The
|
|
plan_billing schema allows annual fields (stripe_annual_price_id,
|
|
annual_price_cents); this script leaves them NULL.
|
|
|
|
Usage:
|
|
docker exec -w /app resolutionflow_backend python -m scripts.sync_stripe_plan_ids
|
|
docker exec -w /app resolutionflow_backend python -m scripts.sync_stripe_plan_ids --dry-run
|
|
"""
|
|
import argparse
|
|
import asyncio
|
|
import logging
|
|
import sys
|
|
from typing import Optional
|
|
|
|
import stripe
|
|
|
|
from app.core.config import settings
|
|
from app.core.database import async_session_maker
|
|
from sqlalchemy import text
|
|
|
|
|
|
logger = logging.getLogger("sync_stripe_plan_ids")
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="%(asctime)s %(levelname)s %(message)s",
|
|
)
|
|
|
|
|
|
PLAN_NAME_TO_SLUG = {
|
|
"ResolutionFlow Starter": "starter",
|
|
"ResolutionFlow Pro": "pro",
|
|
"ResolutionFlow Enterprise": "enterprise",
|
|
}
|
|
|
|
PLANS_REQUIRING_PRICE = {"starter", "pro"}
|
|
|
|
PLAN_DEFAULTS = {
|
|
"starter": {"sort_order": 10, "is_public": True},
|
|
"pro": {"sort_order": 20, "is_public": True},
|
|
"enterprise": {"sort_order": 30, "is_public": True},
|
|
}
|
|
|
|
|
|
def find_product_by_name(target: str) -> Optional[stripe.Product]:
|
|
"""Page through active products and return the first exact name match."""
|
|
for product in stripe.Product.list(active=True, limit=100).auto_paging_iter():
|
|
if product.name == target:
|
|
return product
|
|
return None
|
|
|
|
|
|
def find_active_monthly_price(product_id: str) -> Optional[stripe.Price]:
|
|
"""Return the active recurring monthly price for a product, or None."""
|
|
candidates = [
|
|
p
|
|
for p in stripe.Price.list(product=product_id, active=True, limit=100).auto_paging_iter()
|
|
if p.type == "recurring"
|
|
and p.recurring is not None
|
|
and p.recurring.get("interval") == "month"
|
|
and p.recurring.get("interval_count", 1) == 1
|
|
]
|
|
if not candidates:
|
|
return None
|
|
if len(candidates) > 1:
|
|
logger.warning(
|
|
"Product %s has %d active monthly recurring prices; picking %s. "
|
|
"Archive the others to silence this warning.",
|
|
product_id, len(candidates), candidates[0].id,
|
|
)
|
|
return candidates[0]
|
|
|
|
|
|
async def upsert_plan_billing(
|
|
plan: str,
|
|
display_name: str,
|
|
description: Optional[str],
|
|
monthly_price_cents: Optional[int],
|
|
stripe_product_id: Optional[str],
|
|
stripe_monthly_price_id: Optional[str],
|
|
sort_order: int,
|
|
is_public: bool,
|
|
dry_run: bool,
|
|
) -> None:
|
|
"""Upsert one plan_billing row. Annual fields stay NULL."""
|
|
if dry_run:
|
|
logger.info(
|
|
"[dry-run] would upsert plan=%s display=%s monthly_cents=%s "
|
|
"product=%s monthly_price=%s",
|
|
plan, display_name, monthly_price_cents,
|
|
stripe_product_id, stripe_monthly_price_id,
|
|
)
|
|
return
|
|
|
|
sql = text("""
|
|
INSERT INTO plan_billing (
|
|
plan, display_name, description,
|
|
monthly_price_cents, annual_price_cents,
|
|
stripe_product_id, stripe_monthly_price_id, stripe_annual_price_id,
|
|
is_public, is_archived, sort_order
|
|
) VALUES (
|
|
:plan, :display_name, :description,
|
|
:monthly_price_cents, NULL,
|
|
:stripe_product_id, :stripe_monthly_price_id, NULL,
|
|
:is_public, FALSE, :sort_order
|
|
)
|
|
ON CONFLICT (plan) DO UPDATE SET
|
|
display_name = EXCLUDED.display_name,
|
|
description = EXCLUDED.description,
|
|
monthly_price_cents = EXCLUDED.monthly_price_cents,
|
|
stripe_product_id = EXCLUDED.stripe_product_id,
|
|
stripe_monthly_price_id = EXCLUDED.stripe_monthly_price_id,
|
|
is_public = EXCLUDED.is_public,
|
|
sort_order = EXCLUDED.sort_order,
|
|
updated_at = NOW()
|
|
""")
|
|
async with async_session_maker() as session:
|
|
await session.execute(sql, {
|
|
"plan": plan,
|
|
"display_name": display_name,
|
|
"description": description,
|
|
"monthly_price_cents": monthly_price_cents,
|
|
"stripe_product_id": stripe_product_id,
|
|
"stripe_monthly_price_id": stripe_monthly_price_id,
|
|
"is_public": is_public,
|
|
"sort_order": sort_order,
|
|
})
|
|
await session.commit()
|
|
logger.info("upserted plan_billing for plan=%s", plan)
|
|
|
|
|
|
async def main(dry_run: bool) -> int:
|
|
if not settings.STRIPE_SECRET_KEY:
|
|
logger.error("STRIPE_SECRET_KEY is not set. Refusing to run.")
|
|
return 2
|
|
|
|
stripe.api_key = settings.STRIPE_SECRET_KEY
|
|
mode = "live" if settings.STRIPE_SECRET_KEY.startswith("sk_live_") else "test"
|
|
logger.info("connected to Stripe in %s mode", mode)
|
|
|
|
errors: list[str] = []
|
|
|
|
for product_name, plan in PLAN_NAME_TO_SLUG.items():
|
|
defaults = PLAN_DEFAULTS[plan]
|
|
product = find_product_by_name(product_name)
|
|
if product is None:
|
|
errors.append(f"Stripe product not found: {product_name!r}")
|
|
continue
|
|
|
|
price = None
|
|
if plan in PLANS_REQUIRING_PRICE:
|
|
price = find_active_monthly_price(product.id)
|
|
if price is None:
|
|
errors.append(
|
|
f"No active monthly recurring price for {product_name!r} "
|
|
f"(product {product.id})"
|
|
)
|
|
continue
|
|
|
|
await upsert_plan_billing(
|
|
plan=plan,
|
|
display_name=product.name,
|
|
description=product.description,
|
|
monthly_price_cents=price.unit_amount if price else None,
|
|
stripe_product_id=product.id,
|
|
stripe_monthly_price_id=price.id if price else None,
|
|
sort_order=defaults["sort_order"],
|
|
is_public=defaults["is_public"],
|
|
dry_run=dry_run,
|
|
)
|
|
|
|
if errors:
|
|
for e in errors:
|
|
logger.error(e)
|
|
return 1
|
|
logger.info("done")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--dry-run", action="store_true", help="Log actions without writing.")
|
|
args = parser.parse_args()
|
|
sys.exit(asyncio.run(main(dry_run=args.dry_run)))
|