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:
2026-02-09 21:04:04 +01:00
parent 7feacd5af8
commit 68493dc6cb
97 changed files with 13286 additions and 77 deletions

View File

@@ -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")

View File

@@ -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)