feat: platform-aware storefront routing and billing improvements

Overhaul storefront URL routing to be platform-aware:
- Dev: /platforms/{code}/storefront/{store_code}/
- Prod: subdomain.platform.lu/ (internally rewritten to /storefront/)
- Add subdomain detection in PlatformContextMiddleware
- Add /storefront/ path rewrite for prod mode (subdomain/custom domain)
- Remove all silent platform fallbacks (platform_id=1)
- Add require_platform dependency for clean endpoint validation
- Update route registration, templates, module definitions, base_url calc
- Update StoreContextMiddleware for /storefront/ path detection
- Remove /stores/ from FrontendDetector STOREFRONT_PATH_PREFIXES

Billing service improvements:
- Add store_platform_sync_service to keep store_platforms in sync
- Make tier lookups platform-aware across billing services
- Add tiers for all platforms in seed data
- Add demo subscriptions to seed

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-23 23:42:41 +01:00
parent d36783a7f1
commit 32acc76b49
56 changed files with 951 additions and 306 deletions

View File

@@ -418,7 +418,7 @@ def create_admin_settings(db: Session) -> int:
def create_subscription_tiers(db: Session, platform: Platform) -> int:
"""Create default subscription tiers for the OMS platform."""
"""Create default subscription tiers for a platform."""
tier_defs = [
{
@@ -458,11 +458,14 @@ def create_subscription_tiers(db: Session, platform: Platform) -> int:
tiers_created = 0
for tdef in tier_defs:
existing = db.execute(
select(SubscriptionTier).where(SubscriptionTier.code == tdef["code"])
select(SubscriptionTier).where(
SubscriptionTier.code == tdef["code"],
SubscriptionTier.platform_id == platform.id,
)
).scalar_one_or_none()
if existing:
print_warning(f"Tier already exists: {existing.name} ({existing.code})")
print_warning(f"Tier already exists: {existing.name} ({existing.code}) for {platform.name}")
continue
tier = SubscriptionTier(
@@ -620,13 +623,10 @@ def initialize_production(db: Session, auth_manager: AuthManager):
print_step(5, "Creating admin settings...")
create_admin_settings(db)
# Step 6: Seed subscription tiers
# Step 6: Seed subscription tiers for all platforms
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")
for platform in platforms:
create_subscription_tiers(db, platform)
# Step 7: Create platform module records
print_step(7, "Creating platform module records...")

View File

@@ -68,12 +68,14 @@ from app.modules.orders.models import Order, OrderItem
# cross-module string relationships (e.g. Store→StoreEmailTemplate,
# Platform→SubscriptionTier, Product→Inventory).
# Core modules
from app.modules.billing.models.merchant_subscription import MerchantSubscription
from app.modules.tenancy.models import (
Merchant,
PlatformAlert,
Role,
Store,
StoreDomain,
StorePlatform,
StoreUser,
User,
)
@@ -207,6 +209,17 @@ DEMO_STORES = [
},
]
# Demo subscriptions (linked to merchants by index)
DEMO_SUBSCRIPTIONS = [
# WizaCorp: OMS (professional, active) + Loyalty (essential, trial)
{"merchant_index": 0, "platform_code": "oms", "tier_code": "professional", "trial_days": 0},
{"merchant_index": 0, "platform_code": "loyalty", "tier_code": "essential", "trial_days": 14},
# Fashion Group: Loyalty only (essential, trial)
{"merchant_index": 1, "platform_code": "loyalty", "tier_code": "essential", "trial_days": 14},
# BookWorld: OMS (business, active)
{"merchant_index": 2, "platform_code": "oms", "tier_code": "business", "trial_days": 0},
]
# Demo team members (linked to merchants by index, assigned to stores by store_code)
DEMO_TEAM_MEMBERS = [
# WizaCorp team
@@ -656,6 +669,76 @@ def create_demo_merchants(db: Session, auth_manager: AuthManager) -> list[Mercha
return merchants
def create_demo_subscriptions(db: Session, merchants: list[Merchant]) -> None:
"""Create demo merchant subscriptions."""
from app.modules.billing.models.subscription import SubscriptionTier
from app.modules.tenancy.models import Platform
sub_count = 1 if SEED_MODE == "minimal" else len(DEMO_SUBSCRIPTIONS)
subs_to_create = DEMO_SUBSCRIPTIONS[:sub_count]
for sub_data in subs_to_create:
merchant_index = sub_data["merchant_index"]
if merchant_index >= len(merchants):
print_error(f"Invalid merchant_index {merchant_index} for subscription")
continue
merchant = merchants[merchant_index]
# Look up platform by code
platform = db.execute(
select(Platform).where(Platform.code == sub_data["platform_code"])
).scalar_one_or_none()
if not platform:
print_warning(f"Platform '{sub_data['platform_code']}' not found, skipping")
continue
# Check if subscription already exists
existing = db.execute(
select(MerchantSubscription).where(
MerchantSubscription.merchant_id == merchant.id,
MerchantSubscription.platform_id == platform.id,
)
).scalar_one_or_none()
if existing:
print_warning(
f"Subscription already exists: {merchant.name} on {platform.name}"
)
continue
# Look up tier by code + platform
tier = db.execute(
select(SubscriptionTier).where(
SubscriptionTier.code == sub_data["tier_code"],
SubscriptionTier.platform_id == platform.id,
)
).scalar_one_or_none()
if not tier:
print_warning(
f"Tier '{sub_data['tier_code']}' not found for {platform.name}, skipping"
)
continue
from app.modules.billing.services.subscription_service import subscription_service
subscription = subscription_service.create_merchant_subscription(
db,
merchant_id=merchant.id,
platform_id=platform.id,
tier_code=sub_data["tier_code"],
trial_days=sub_data.get("trial_days", 14),
)
print_success(
f"Created subscription: {merchant.name}{platform.name} "
f"({tier.name}, {subscription.status})"
)
db.flush()
def create_demo_stores(
db: Session, merchants: list[Merchant], auth_manager: AuthManager
) -> list[Store]:
@@ -703,6 +786,29 @@ def create_demo_stores(
db.add(store) # noqa: PERF006
db.flush()
# Link store to merchant's subscribed platforms
merchant_subs = db.execute(
select(MerchantSubscription.platform_id).where(
MerchantSubscription.merchant_id == merchant.id
)
).all()
for i, (platform_id,) in enumerate(merchant_subs):
sp = StorePlatform(
store_id=store.id,
platform_id=platform_id,
is_active=True,
is_primary=(i == 0),
)
db.add(sp)
if merchant_subs:
db.flush()
print_success(
f" Linked to {len(merchant_subs)} platform(s): "
f"{[pid for (pid,) in merchant_subs]}"
)
# Owner relationship is via Merchant.owner_user_id — no StoreUser needed
# Create store theme
@@ -1060,28 +1166,32 @@ def seed_demo_data(db: Session, auth_manager: AuthManager):
print_step(4, "Creating demo merchants...")
merchants = create_demo_merchants(db, auth_manager)
# Step 5: Create stores
print_step(5, "Creating demo stores...")
# Step 5: Create merchant subscriptions (before stores, so StorePlatform linking works)
print_step(5, "Creating demo subscriptions...")
create_demo_subscriptions(db, merchants)
# Step 6: Create stores
print_step(6, "Creating demo stores...")
stores = create_demo_stores(db, merchants, auth_manager)
# Step 6: Create team members
print_step(6, "Creating demo team members...")
# Step 7: Create team members
print_step(7, "Creating demo team members...")
create_demo_team_members(db, stores, auth_manager)
# Step 7: Create customers
print_step(7, "Creating demo customers...")
# Step 8: Create customers
print_step(8, "Creating demo customers...")
for store in stores:
create_demo_customers(
db, store, auth_manager, count=settings.seed_customers_per_store
)
# Step 8: Create products
print_step(8, "Creating demo products...")
# Step 9: Create products
print_step(9, "Creating demo products...")
for store in stores:
create_demo_products(db, store, count=settings.seed_products_per_store)
# Step 9: Create store content pages
print_step(9, "Creating store content page overrides...")
# Step 10: Create store content pages
print_step(10, "Creating store content page overrides...")
create_demo_store_content_pages(db, stores)
# Commit all changes
@@ -1199,26 +1309,46 @@ def print_summary(db: Session):
print(" (Replace {subdomain} with store subdomain, e.g., wizatech)")
print()
print("\n🏪 Shop Access (Development):")
# Build store → platform code mapping from store_platforms
from app.modules.tenancy.models import Platform
store_platform_rows = db.execute(
select(StorePlatform.store_id, Platform.code).join(
Platform, Platform.id == StorePlatform.platform_id
).where(StorePlatform.is_active == True).order_by( # noqa: E712
StorePlatform.store_id, StorePlatform.is_primary.desc()
)
).all()
store_platform_map: dict[int, list[str]] = {}
for store_id, platform_code in store_platform_rows:
store_platform_map.setdefault(store_id, []).append(platform_code)
port = settings.api_port
base = f"http://localhost:{port}"
print("\n🏪 Store Access (Development):")
print("" * 70)
for store in stores:
print(f" {store.name}:")
print(
f" Path-based: http://localhost:8000/stores/{store.store_code}/shop/"
)
print(f" Subdomain: http://{store.subdomain}.localhost:8000/") # noqa: SEC034
platform_codes = store_platform_map.get(store.id, [])
print(f" {store.name} ({store.store_code}):")
if platform_codes:
for pc in platform_codes:
print(f" [{pc}] Storefront: {base}/platforms/{pc}/storefront/{store.store_code}/")
print(f" [{pc}] Dashboard: {base}/platforms/{pc}/store/{store.store_code}/")
print(f" [{pc}] Login: {base}/platforms/{pc}/store/{store.store_code}/login")
else:
print(" (!) No platform assigned")
print()
print("⚠️ ALL DEMO CREDENTIALS ARE INSECURE - For development only!")
port = settings.api_port
print("\n🚀 NEXT STEPS:")
print(" 1. Start development: make dev")
print(f" 2. Admin panel: http://localhost:{port}/admin/login")
print(f" 3. Merchant panel: http://localhost:{port}/merchants/login")
print(f" 4. Store panel: http://localhost:{port}/store/WIZATECH/login")
print(f" 5. Storefront: http://localhost:{port}/stores/WIZATECH/shop/")
print(f" 6. Customer login: http://localhost:{port}/stores/WIZATECH/shop/account")
print(f" 2. Admin panel: {base}/admin/login")
print(f" 3. Merchant panel: {base}/merchants/login")
print(f" 4. Store panel: {base}/platforms/oms/store/WIZATECH/login")
print(f" 5. Storefront: {base}/platforms/oms/storefront/WIZATECH/")
print(f" 6. Customer login: {base}/platforms/oms/storefront/WIZATECH/account")
# =============================================================================

View File

@@ -138,15 +138,17 @@ def collect_dev_urls(platforms, stores, store_domains, store_platform_map):
url = _store_dev_login_url(v.store_code)
urls.append((f"Store Login: {v.name}", url, [200]))
# Storefronts
# Storefronts (platform-aware)
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],
))
platform_codes = store_platform_map.get(v.id, [])
for pc in platform_codes:
urls.append((
f"Storefront [{pc}]: {v.name}",
f"{DEV_BASE}/platforms/{pc}/storefront/{v.store_code}/",
[200, 302],
))
# Store info API (public, no auth needed)
for v in stores:
@@ -225,7 +227,9 @@ def print_dev_urls(platforms, stores, store_domains, store_platform_map):
tag = f" [{status_badge(v.is_active)}]" if not v.is_active else ""
code = v.store_code
print(f" {v.name} ({code}){tag}")
print(f" Shop: {DEV_BASE}/stores/{code}/storefront/")
pcs = store_platform_map.get(v.id, [])
for pc in pcs:
print(f" Shop [{pc}]: {DEV_BASE}/platforms/{pc}/storefront/{code}/")
print(f" API: {DEV_BASE}/api/v1/storefront/{code}/")