276 lines
12 KiB
Python
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())
|