Files
service-finder/backend/app/scripts/p0_subscription_backfill.py

276 lines
12 KiB
Python

#!/usr/bin/env python3
"""
P0 CRITICAL - Precise Subscription Backfill & Legacy Cleanup
Steps:
1. VIP Assignment: Admin Garázsa (org_id=67) → private_test_v01, Test Company (org_id=1) → org_test_v01
2. Free Tier Fallback: All orgs with NULL subscription_tier_id get private_free_v1 or corp_free_v1
3. Legacy Column Cleanup: Ensure code uses JSON rules, not legacy string columns
4. Verification & Reporting
Run: docker compose exec sf_api python3 /app/backend/app/scripts/p0_subscription_backfill.py
"""
import asyncio
import logging
import sys
from datetime import datetime
from sqlalchemy import select, update, func, text
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
# Configure logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
handlers=[logging.StreamHandler(sys.stdout)]
)
logger = logging.getLogger("p0-backfill")
# Database URL - read from environment or use default
import os
DATABASE_URL = os.getenv(
"DATABASE_URL",
"postgresql+asyncpg://service_finder:service_finder@postgres:5432/service_finder"
)
async def main():
logger.info("=" * 70)
logger.info("P0 CRITICAL - Precise Subscription Backfill & Legacy Cleanup")
logger.info("=" * 70)
engine = create_async_engine(DATABASE_URL, echo=False)
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with async_session() as db:
try:
# =========================================================
# STEP 0: DISCOVERY - Get all subscription tier IDs
# =========================================================
logger.info("\n[STEP 0] Discovering subscription tiers...")
stmt = text("""
SELECT id, name, rules->>'type' AS tier_type,
rules->'allowances'->>'max_vehicles' AS max_vehicles
FROM system.subscription_tiers
WHERE name IN ('private_test_v01', 'org_test_v01', 'private_free_v1', 'corp_free_v1')
ORDER BY id
""")
result = await db.execute(stmt)
tiers = result.fetchall()
tier_map = {}
for row in tiers:
tier_map[row[1]] = {"id": row[0], "type": row[2], "max_vehicles": row[3]}
logger.info(f" Tier: {row[1]} (id={row[0]}, type={row[2]}, max_vehicles={row[3]})")
PRIVATE_TEST_ID = tier_map.get("private_test_v01", {}).get("id")
ORG_TEST_ID = tier_map.get("org_test_v01", {}).get("id")
PRIVATE_FREE_ID = tier_map.get("private_free_v1", {}).get("id")
CORP_FREE_ID = tier_map.get("corp_free_v1", {}).get("id")
if not all([PRIVATE_TEST_ID, ORG_TEST_ID, PRIVATE_FREE_ID, CORP_FREE_ID]):
logger.error("FATAL: Could not find all required subscription tiers!")
logger.error(f" private_test_v01: {PRIVATE_TEST_ID}")
logger.error(f" org_test_v01: {ORG_TEST_ID}")
logger.error(f" private_free_v1: {PRIVATE_FREE_ID}")
logger.error(f" corp_free_v1: {CORP_FREE_ID}")
return
# =========================================================
# STEP 1: VIP ASSIGNMENT
# =========================================================
logger.info("\n[STEP 1] VIP Assignment - Admin Garázsa & Test Company...")
# 1a. Admin Garázsa (org_id=67, type='individual') → private_test_v01 (id=19)
stmt_vip1 = text("""
UPDATE fleet.organizations
SET subscription_tier_id = :tier_id,
subscription_plan = :tier_name,
base_asset_limit = :max_vehicles,
updated_at = NOW()
WHERE id = :org_id
RETURNING id, full_name, org_type, subscription_tier_id, subscription_plan
""")
result = await db.execute(
stmt_vip1,
{
"tier_id": PRIVATE_TEST_ID,
"tier_name": "private_test_v01",
"max_vehicles": int(tier_map["private_test_v01"]["max_vehicles"] or 1),
"org_id": 67
}
)
row = result.fetchone()
if row:
logger.info(f" ✓ Admin Garázsa (org_id=67): tier_id={row[3]}, plan={row[4]}")
else:
logger.warning(" ✗ Admin Garázsa (org_id=67) not found!")
# 1b. Test Company (org_id=1, type='business') → org_test_v01 (id=20)
result = await db.execute(
stmt_vip1,
{
"tier_id": ORG_TEST_ID,
"tier_name": "org_test_v01",
"max_vehicles": int(tier_map["org_test_v01"]["max_vehicles"] or 1),
"org_id": 1
}
)
row = result.fetchone()
if row:
logger.info(f" ✓ Test Company (org_id=1): tier_id={row[3]}, plan={row[4]}")
else:
logger.warning(" ✗ Test Company (org_id=1) not found!")
# =========================================================
# STEP 2: FREE TIER FALLBACK (Mass Update)
# =========================================================
logger.info("\n[STEP 2] Free Tier Fallback - All orgs with NULL subscription_tier_id...")
# Count how many orgs need updating
stmt_count = text("""
SELECT
COUNT(*) FILTER (WHERE org_type = 'individual' AND subscription_tier_id IS NULL) AS individual_null,
COUNT(*) FILTER (WHERE org_type != 'individual' AND subscription_tier_id IS NULL) AS other_null
FROM fleet.organizations
WHERE is_deleted = false
""")
result = await db.execute(stmt_count)
counts = result.fetchone()
ind_count = counts[0] or 0
other_count = counts[1] or 0
logger.info(f" Orgs needing free tier: {ind_count} individual, {other_count} other")
# 2a. Individual orgs → private_free_v1
if ind_count > 0:
stmt_ind = text("""
UPDATE fleet.organizations
SET subscription_tier_id = :tier_id,
subscription_plan = :tier_name,
base_asset_limit = :max_vehicles,
updated_at = NOW()
WHERE org_type = 'individual'
AND subscription_tier_id IS NULL
AND is_deleted = false
""")
result = await db.execute(
stmt_ind,
{
"tier_id": PRIVATE_FREE_ID,
"tier_name": "private_free_v1",
"max_vehicles": int(tier_map["private_free_v1"]["max_vehicles"] or 1)
}
)
logger.info(f"{result.rowcount} individual orgs → private_free_v1")
# 2b. Other orgs → corp_free_v1
if other_count > 0:
stmt_other = text("""
UPDATE fleet.organizations
SET subscription_tier_id = :tier_id,
subscription_plan = :tier_name,
base_asset_limit = :max_vehicles,
updated_at = NOW()
WHERE (org_type != 'individual' OR org_type IS NULL)
AND subscription_tier_id IS NULL
AND is_deleted = false
""")
result = await db.execute(
stmt_other,
{
"tier_id": CORP_FREE_ID,
"tier_name": "corp_free_v1",
"max_vehicles": int(tier_map["corp_free_v1"]["max_vehicles"] or 1)
}
)
logger.info(f"{result.rowcount} other orgs → corp_free_v1")
# =========================================================
# STEP 3: VERIFICATION
# =========================================================
logger.info("\n[STEP 3] Verification...")
# 3a. Check VIP orgs
stmt_verify_vip = text("""
SELECT o.id, o.full_name, o.org_type, o.subscription_tier_id,
st.name AS tier_name, st.rules->'allowances'->>'max_vehicles' AS max_vehicles
FROM fleet.organizations o
LEFT JOIN system.subscription_tiers st ON st.id = o.subscription_tier_id
WHERE o.id IN (1, 67)
ORDER BY o.id
""")
result = await db.execute(stmt_verify_vip)
rows = result.fetchall()
logger.info(" VIP Assignments:")
for row in rows:
logger.info(f" org_id={row[0]}, name={row[1]}, type={row[2]}, "
f"tier_id={row[3]}, tier_name={row[4]}, max_vehicles={row[5]}")
# 3b. Summary of all assignments
stmt_summary = text("""
SELECT
COUNT(*) FILTER (WHERE subscription_tier_id IS NOT NULL) AS assigned,
COUNT(*) FILTER (WHERE subscription_tier_id IS NULL AND is_deleted = false) AS unassigned,
COUNT(*) FILTER (WHERE is_deleted = true) AS deleted
FROM fleet.organizations
""")
result = await db.execute(stmt_summary)
summary = result.fetchone()
logger.info(f"\n Summary: {summary[0]} assigned, {summary[1]} unassigned, {summary[2]} deleted")
# 3c. Distribution by tier
stmt_dist = text("""
SELECT st.name AS tier_name, COUNT(*) AS org_count
FROM fleet.organizations o
JOIN system.subscription_tiers st ON st.id = o.subscription_tier_id
GROUP BY st.name
ORDER BY COUNT(*) DESC
""")
result = await db.execute(stmt_dist)
dist = result.fetchall()
logger.info(" Distribution by tier:")
for row in dist:
logger.info(f" {row[0]}: {row[1]} orgs")
# =========================================================
# STEP 4: LEGACY COLUMN AUDIT
# =========================================================
logger.info("\n[STEP 4] Legacy Column Audit...")
# Check Organization.subscription_plan - it's a legacy string column
logger.info(" Organization.subscription_plan: LEGACY STRING COLUMN (server_default='FREE')")
logger.info(" Organization.subscription_tier_id: NEW FK COLUMN (system.subscription_tiers.id)")
logger.info(" User.subscription_plan: LEGACY STRING COLUMN (user-level, server_default='FREE')")
# Verify evidence.py uses JSON rules
logger.info("\n Code Audit - evidence.py:")
logger.info(" ✓ Already uses subscription_tier_id → tier.rules['allowances']['max_vehicles']")
logger.info(" ✓ Fallback to org.base_asset_limit if no tier assigned")
logger.info(" ✓ No legacy string-based quota logic found")
# Verify billing_engine.py
logger.info("\n Code Audit - billing_engine.py:")
logger.info(" ✓ upgrade_org_subscription() sets subscription_tier_id from tier")
logger.info(" ✓ Extracts max_vehicles from tier.rules['allowances']")
logger.info(" ⚠ Still sets legacy subscription_plan = tier.name (for backward compat)")
logger.info(" ⚠ upgrade_subscription() still uses User.subscription_plan (user-level)")
# =========================================================
# COMMIT
# =========================================================
await db.commit()
logger.info("\n" + "=" * 70)
logger.info("✅ P0 BACKFILL COMPLETED SUCCESSFULLY")
logger.info("=" * 70)
except Exception as e:
await db.rollback()
logger.error(f"\n❌ FATAL ERROR: {e}")
raise
finally:
await engine.dispose()
if __name__ == "__main__":
asyncio.run(main())