feat(subscriptions): migrate subscription management to merchant level and seed tiers
Move subscription create/edit from store detail (broken endpoint) to merchant detail page with proper modal UI. Seed 4 subscription tiers (Essential, Professional, Business, Enterprise) in init_production.py. Also includes cross-module dependency declarations, store domain platform_id migration, platform context middleware, CMS route fixes, and migration backups. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -40,6 +40,7 @@ from app.modules.tenancy.services.permission_discovery_service import (
|
||||
from middleware.auth import AuthManager
|
||||
from app.modules.tenancy.models import AdminSetting, Platform
|
||||
from app.modules.tenancy.models import User
|
||||
from app.modules.billing.models.subscription import SubscriptionTier
|
||||
|
||||
# Register all models with SQLAlchemy so string-based relationships resolve
|
||||
for _mod in [
|
||||
@@ -380,6 +381,72 @@ def create_admin_settings(db: Session) -> int:
|
||||
return settings_created
|
||||
|
||||
|
||||
def create_subscription_tiers(db: Session, platform: Platform) -> int:
|
||||
"""Create default subscription tiers for the OMS platform."""
|
||||
|
||||
tier_defs = [
|
||||
{
|
||||
"code": "essential",
|
||||
"name": "Essential",
|
||||
"price_monthly_cents": 2900,
|
||||
"price_annual_cents": 29000,
|
||||
"is_public": True,
|
||||
"display_order": 10,
|
||||
},
|
||||
{
|
||||
"code": "professional",
|
||||
"name": "Professional",
|
||||
"price_monthly_cents": 7900,
|
||||
"price_annual_cents": 79000,
|
||||
"is_public": True,
|
||||
"display_order": 20,
|
||||
},
|
||||
{
|
||||
"code": "business",
|
||||
"name": "Business",
|
||||
"price_monthly_cents": 14900,
|
||||
"price_annual_cents": 149000,
|
||||
"is_public": True,
|
||||
"display_order": 30,
|
||||
},
|
||||
{
|
||||
"code": "enterprise",
|
||||
"name": "Enterprise",
|
||||
"price_monthly_cents": 29900,
|
||||
"price_annual_cents": None,
|
||||
"is_public": False,
|
||||
"display_order": 40,
|
||||
},
|
||||
]
|
||||
|
||||
tiers_created = 0
|
||||
for tdef in tier_defs:
|
||||
existing = db.execute(
|
||||
select(SubscriptionTier).where(SubscriptionTier.code == tdef["code"])
|
||||
).scalar_one_or_none()
|
||||
|
||||
if existing:
|
||||
print_warning(f"Tier already exists: {existing.name} ({existing.code})")
|
||||
continue
|
||||
|
||||
tier = SubscriptionTier(
|
||||
platform_id=platform.id,
|
||||
code=tdef["code"],
|
||||
name=tdef["name"],
|
||||
price_monthly_cents=tdef["price_monthly_cents"],
|
||||
price_annual_cents=tdef["price_annual_cents"],
|
||||
is_public=tdef["is_public"],
|
||||
display_order=tdef["display_order"],
|
||||
is_active=True,
|
||||
)
|
||||
db.add(tier)
|
||||
db.flush()
|
||||
tiers_created += 1
|
||||
print_success(f"Created tier: {tier.name} ({tier.code})")
|
||||
|
||||
return tiers_created
|
||||
|
||||
|
||||
def verify_rbac_schema(db: Session) -> bool:
|
||||
"""Verify that RBAC schema is in place."""
|
||||
|
||||
@@ -456,6 +523,14 @@ def initialize_production(db: Session, auth_manager: AuthManager):
|
||||
print_step(5, "Creating admin settings...")
|
||||
create_admin_settings(db)
|
||||
|
||||
# Step 6: Seed subscription tiers
|
||||
print_step(6, "Seeding subscription tiers...")
|
||||
oms_platform = next((p for p in platforms if p.code == "oms"), None)
|
||||
if oms_platform:
|
||||
create_subscription_tiers(db, oms_platform)
|
||||
else:
|
||||
print_warning("OMS platform not found, skipping tier seeding")
|
||||
|
||||
# Commit all changes
|
||||
db.commit()
|
||||
print_success("All changes committed")
|
||||
@@ -470,11 +545,13 @@ def print_summary(db: Session):
|
||||
user_count = db.query(User).filter(User.role == "admin").count()
|
||||
setting_count = db.query(AdminSetting).count()
|
||||
platform_count = db.query(Platform).count()
|
||||
tier_count = db.query(SubscriptionTier).filter(SubscriptionTier.is_active.is_(True)).count()
|
||||
|
||||
print("\n📊 Database Status:")
|
||||
print(f" Admin users: {user_count}")
|
||||
print(f" Platforms: {platform_count}")
|
||||
print(f" Admin settings: {setting_count}")
|
||||
print(f" Sub. tiers: {tier_count}")
|
||||
|
||||
print("\n" + "─" * 70)
|
||||
print("🔐 ADMIN CREDENTIALS")
|
||||
|
||||
@@ -9,9 +9,11 @@ Usage:
|
||||
python scripts/show_urls.py # Show all URLs
|
||||
python scripts/show_urls.py --dev # Development URLs only
|
||||
python scripts/show_urls.py --prod # Production URLs only
|
||||
python scripts/show_urls.py --check # Check dev URLs with curl
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from sqlalchemy import text
|
||||
@@ -60,11 +62,107 @@ def get_store_domains(db):
|
||||
).fetchall()
|
||||
|
||||
|
||||
def get_store_platform_map(db):
|
||||
"""
|
||||
Get store-to-platform mapping.
|
||||
|
||||
Returns dict: store_id -> list of platform codes.
|
||||
Uses store_platforms junction table.
|
||||
"""
|
||||
rows = db.execute(
|
||||
text(
|
||||
"SELECT sp.store_id, p.code AS platform_code "
|
||||
"FROM store_platforms sp "
|
||||
"JOIN platforms p ON p.id = sp.platform_id "
|
||||
"WHERE sp.is_active = true "
|
||||
"ORDER BY sp.store_id, sp.is_primary DESC"
|
||||
)
|
||||
).fetchall()
|
||||
|
||||
mapping = {}
|
||||
for r in rows:
|
||||
mapping.setdefault(r.store_id, []).append(r.platform_code)
|
||||
return mapping
|
||||
|
||||
|
||||
def status_badge(is_active):
|
||||
return "active" if is_active else "INACTIVE"
|
||||
|
||||
|
||||
def print_dev_urls(platforms, stores, store_domains):
|
||||
def _store_dev_dashboard_url(store_code, platform_code=None):
|
||||
"""Build store dashboard URL for dev mode."""
|
||||
if platform_code and platform_code != "main":
|
||||
return f"{DEV_BASE}/platforms/{platform_code}/store/{store_code}/"
|
||||
return f"{DEV_BASE}/store/{store_code}/"
|
||||
|
||||
|
||||
def _store_dev_login_url(store_code, platform_code=None):
|
||||
"""Build store login URL for dev mode."""
|
||||
if platform_code and platform_code != "main":
|
||||
return f"{DEV_BASE}/platforms/{platform_code}/store/{store_code}/login"
|
||||
return f"{DEV_BASE}/store/{store_code}/login"
|
||||
|
||||
|
||||
def collect_dev_urls(platforms, stores, store_domains, store_platform_map):
|
||||
"""
|
||||
Collect all dev URLs with labels and expected status codes.
|
||||
|
||||
Returns list of (label, url, expected_codes) tuples.
|
||||
"""
|
||||
urls = []
|
||||
|
||||
# Admin
|
||||
urls.append(("Admin Login", f"{DEV_BASE}/admin/login", [200]))
|
||||
urls.append(("Admin Dashboard", f"{DEV_BASE}/admin/", [200, 302]))
|
||||
urls.append(("API Docs", f"{DEV_BASE}/docs", [200]))
|
||||
urls.append(("Health", f"{DEV_BASE}/health", [200]))
|
||||
|
||||
# Platforms
|
||||
for p in platforms:
|
||||
if not p.is_active:
|
||||
continue
|
||||
if p.code == "main":
|
||||
urls.append((f"Platform: {p.name}", f"{DEV_BASE}/", [200]))
|
||||
else:
|
||||
urls.append((f"Platform: {p.name}", f"{DEV_BASE}/platforms/{p.code}/", [200]))
|
||||
|
||||
# Store dashboards (redirect to login when unauthenticated)
|
||||
for v in stores:
|
||||
if not v.is_active:
|
||||
continue
|
||||
platform_codes = store_platform_map.get(v.id, [])
|
||||
if platform_codes:
|
||||
for pc in platform_codes:
|
||||
url = _store_dev_login_url(v.store_code, pc)
|
||||
urls.append((f"Store Login: {v.name} ({pc})", url, [200]))
|
||||
else:
|
||||
url = _store_dev_login_url(v.store_code)
|
||||
urls.append((f"Store Login: {v.name}", url, [200]))
|
||||
|
||||
# Storefronts
|
||||
for v in stores:
|
||||
if not v.is_active:
|
||||
continue
|
||||
urls.append((
|
||||
f"Storefront: {v.name}",
|
||||
f"{DEV_BASE}/stores/{v.store_code}/storefront/",
|
||||
[200, 302],
|
||||
))
|
||||
|
||||
# Store info API (public, no auth needed)
|
||||
for v in stores:
|
||||
if not v.is_active:
|
||||
continue
|
||||
urls.append((
|
||||
f"Store API: {v.name}",
|
||||
f"{DEV_BASE}/api/v1/store/info/{v.store_code}",
|
||||
[200],
|
||||
))
|
||||
|
||||
return urls
|
||||
|
||||
|
||||
def print_dev_urls(platforms, stores, store_domains, store_platform_map):
|
||||
"""Print all development URLs."""
|
||||
print()
|
||||
print("DEVELOPMENT URLS")
|
||||
@@ -84,24 +182,16 @@ def print_dev_urls(platforms, stores, store_domains):
|
||||
print(" PLATFORMS")
|
||||
for p in platforms:
|
||||
tag = f" [{status_badge(p.is_active)}]" if not p.is_active else ""
|
||||
prefix = p.path_prefix or ""
|
||||
if p.code == "main":
|
||||
print(f" {p.name}{tag}")
|
||||
print(f" Home: {DEV_BASE}/")
|
||||
else:
|
||||
print(f" {p.name} ({p.code}){tag}")
|
||||
if prefix:
|
||||
print(f" Home: {DEV_BASE}/platforms/{p.code}/")
|
||||
else:
|
||||
print(f" Home: {DEV_BASE}/platforms/{p.code}/")
|
||||
print(f" Home: {DEV_BASE}/platforms/{p.code}/")
|
||||
|
||||
# Stores
|
||||
print()
|
||||
print(" STORE DASHBOARDS")
|
||||
domains_by_store = {}
|
||||
for vd in store_domains:
|
||||
domains_by_store.setdefault(vd.store_id, []).append(vd)
|
||||
|
||||
current_merchant = None
|
||||
for v in stores:
|
||||
if v.merchant_name != current_merchant:
|
||||
@@ -110,9 +200,19 @@ def print_dev_urls(platforms, stores, store_domains):
|
||||
|
||||
tag = f" [{status_badge(v.is_active)}]" if not v.is_active else ""
|
||||
code = v.store_code
|
||||
|
||||
# Get platform(s) for this store
|
||||
platform_codes = store_platform_map.get(v.id, [])
|
||||
|
||||
print(f" {v.name} ({code}){tag}")
|
||||
print(f" Dashboard: {DEV_BASE}/store/{code}/")
|
||||
print(f" API: {DEV_BASE}/api/v1/store/{code}/")
|
||||
if platform_codes:
|
||||
for pc in platform_codes:
|
||||
print(f" Login: {_store_dev_login_url(code, pc)}")
|
||||
print(f" Dashboard: {_store_dev_dashboard_url(code, pc)}")
|
||||
else:
|
||||
print(f" Login: {_store_dev_login_url(code)}")
|
||||
print(f" Dashboard: {_store_dev_dashboard_url(code)}")
|
||||
print(f" (!) No platform assigned - use /platforms/{{code}}/store/{code}/ for platform context")
|
||||
|
||||
# Storefronts
|
||||
print()
|
||||
@@ -208,20 +308,81 @@ def print_prod_urls(platforms, stores, store_domains):
|
||||
print(f" Custom: https://{vd.domain}/{suffix}")
|
||||
|
||||
|
||||
def check_dev_urls(platforms, stores, store_domains, store_platform_map):
|
||||
"""Curl all dev URLs and report reachability."""
|
||||
urls = collect_dev_urls(platforms, stores, store_domains, store_platform_map)
|
||||
|
||||
print()
|
||||
print("URL HEALTH CHECK")
|
||||
print(f"Base: {DEV_BASE}")
|
||||
print(SEPARATOR)
|
||||
print()
|
||||
|
||||
passed = 0
|
||||
failed = 0
|
||||
errors = []
|
||||
|
||||
for label, url, expected_codes in urls:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}", "-L", "--max-time", "5", url],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
status = int(result.stdout.strip())
|
||||
|
||||
if status in expected_codes:
|
||||
print(f" PASS {status} {label}")
|
||||
print(f" {url}")
|
||||
passed += 1
|
||||
else:
|
||||
expected_str = "/".join(str(c) for c in expected_codes)
|
||||
print(f" FAIL {status} {label} (expected {expected_str})")
|
||||
print(f" {url}")
|
||||
failed += 1
|
||||
errors.append((label, url, status, expected_codes))
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, ValueError) as e:
|
||||
print(f" ERR --- {label} ({e})")
|
||||
print(f" {url}")
|
||||
failed += 1
|
||||
errors.append((label, url, 0, expected_codes))
|
||||
|
||||
# Summary
|
||||
print()
|
||||
print(SEPARATOR)
|
||||
total = passed + failed
|
||||
print(f" Results: {passed}/{total} passed", end="")
|
||||
if failed:
|
||||
print(f", {failed} failed")
|
||||
else:
|
||||
print()
|
||||
|
||||
if errors:
|
||||
print()
|
||||
print(" Failed URLs:")
|
||||
for label, url, status, expected in errors:
|
||||
expected_str = "/".join(str(c) for c in expected)
|
||||
print(f" [{status or 'ERR'}] {url} (expected {expected_str})")
|
||||
|
||||
print()
|
||||
return failed == 0
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Show all platform URLs")
|
||||
parser.add_argument("--dev", action="store_true", help="Development URLs only")
|
||||
parser.add_argument("--prod", action="store_true", help="Production URLs only")
|
||||
parser.add_argument("--check", action="store_true", help="Check dev URLs with curl")
|
||||
args = parser.parse_args()
|
||||
|
||||
show_dev = args.dev or (not args.dev and not args.prod)
|
||||
show_prod = args.prod or (not args.dev and not args.prod)
|
||||
show_dev = args.dev or (not args.dev and not args.prod and not args.check)
|
||||
show_prod = args.prod or (not args.dev and not args.prod and not args.check)
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
platforms = get_platforms(db)
|
||||
stores = get_stores(db)
|
||||
store_domains = get_store_domains(db)
|
||||
store_platform_map = get_store_platform_map(db)
|
||||
except Exception as e:
|
||||
print(f"Error querying database: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
@@ -234,8 +395,12 @@ def main():
|
||||
print(f" {len(platforms)} platform(s), {len(stores)} store(s), {len(store_domains)} custom domain(s)")
|
||||
print("=" * 72)
|
||||
|
||||
if args.check:
|
||||
success = check_dev_urls(platforms, stores, store_domains, store_platform_map)
|
||||
sys.exit(0 if success else 1)
|
||||
|
||||
if show_dev:
|
||||
print_dev_urls(platforms, stores, store_domains)
|
||||
print_dev_urls(platforms, stores, store_domains, store_platform_map)
|
||||
|
||||
if show_prod:
|
||||
print_prod_urls(platforms, stores, store_domains)
|
||||
|
||||
Reference in New Issue
Block a user