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

@@ -29,8 +29,11 @@ from app.modules.tenancy.models import Platform
logger = logging.getLogger(__name__)
# Default platform code for main marketing site
DEFAULT_PLATFORM_CODE = "main"
# Platform code for the main marketing site (localhost without /platforms/ prefix)
MAIN_PLATFORM_CODE = "main"
# Hosts treated as local development (including Starlette TestClient's "testserver")
_LOCAL_HOSTS = {"localhost", "127.0.0.1", "testserver"}
class PlatformContextManager:
@@ -68,7 +71,7 @@ class PlatformContextManager:
# Method 1: Domain-based detection (production)
# Check if the host matches a known platform domain
# This will be resolved in get_platform_from_context by DB lookup
if host_without_port and host_without_port not in ["localhost", "127.0.0.1"]:
if host_without_port and host_without_port not in _LOCAL_HOSTS:
# Could be a platform domain or a store subdomain/custom domain
# Check if it's a known platform domain pattern
# For now, assume non-localhost hosts that aren't subdomains are platform domains
@@ -108,11 +111,11 @@ class PlatformContextManager:
# Method 3: Default platform for localhost without /platforms/ prefix
# This serves the main marketing site
# Store routes require explicit platform via /platforms/{code}/store/...
if host_without_port in ["localhost", "127.0.0.1"]:
if host_without_port in _LOCAL_HOSTS:
if path.startswith(("/store/", "/stores/")):
return None # No platform — handlers will show appropriate error
return {
"path_prefix": DEFAULT_PLATFORM_CODE,
"path_prefix": MAIN_PLATFORM_CODE,
"detection_method": "default",
"host": host,
"original_path": path,
@@ -136,8 +139,8 @@ class PlatformContextManager:
platform = None
# Method 1: Domain-based lookup
if context.get("detection_method") == "domain":
# Method 1: Domain-based lookup (also handles subdomain detection)
if context.get("detection_method") in ("domain", "subdomain"):
domain = context.get("domain")
if domain:
# Try Platform.domain first
@@ -168,6 +171,8 @@ class PlatformContextManager:
.first()
)
if platform:
# Mark as store domain so __call__ knows to rewrite path
context["is_store_domain"] = True
logger.debug(
f"[PLATFORM] Platform found via store domain: {domain}{platform.name}"
)
@@ -194,6 +199,8 @@ class PlatformContextManager:
.first()
)
if platform:
# Mark as store domain so __call__ knows to rewrite path
context["is_store_domain"] = True
logger.debug(
f"[PLATFORM] Platform found via merchant domain: {domain}{platform.name}"
)
@@ -226,7 +233,7 @@ class PlatformContextManager:
if context.get("detection_method") == "default":
platform = (
db.query(Platform)
.filter(Platform.code == DEFAULT_PLATFORM_CODE)
.filter(Platform.code == MAIN_PLATFORM_CODE)
.filter(Platform.is_active.is_(True))
.first()
)
@@ -366,12 +373,33 @@ class PlatformContextMiddleware:
# REWRITE THE PATH for routing
# This is the key: FastAPI will route based on this rewritten path
if platform_context.get("detection_method") == "path":
detection_method = platform_context.get("detection_method")
if detection_method == "path":
# Dev mode: strip /platforms/{code}/ prefix
scope["path"] = clean_path
# Also update raw_path if present
if "raw_path" in scope:
scope["raw_path"] = clean_path.encode("utf-8")
# Prod mode: subdomain or custom store domain
# Prepend /storefront/ so routes registered at /storefront/ prefix match
is_storefront_domain = (
detection_method == "subdomain"
or platform_context.get("is_store_domain", False)
)
if is_storefront_domain:
_RESERVED = (
"/store/", "/admin/", "/api/", "/static/",
"/storefront/", "/health", "/docs", "/redoc",
"/media/", "/assets/",
)
if not any(clean_path.startswith(p) for p in _RESERVED):
new_path = "/storefront" + clean_path
scope["path"] = new_path
scope["state"]["platform_clean_path"] = new_path
if "raw_path" in scope:
scope["raw_path"] = new_path.encode("utf-8")
logger.debug(
f"[PLATFORM] Detected: {platform.code}, "
f"original={path}, routed={scope['path']}"
@@ -406,7 +434,7 @@ class PlatformContextMiddleware:
host_without_port = host.split(":")[0] if ":" in host else host
# Method 1: Domain-based (production)
if host_without_port and host_without_port not in ["localhost", "127.0.0.1"]:
if host_without_port and host_without_port not in _LOCAL_HOSTS:
if "." in host_without_port:
parts = host_without_port.split(".")
if len(parts) == 2: # Root domain like omsflow.lu
@@ -415,7 +443,17 @@ class PlatformContextMiddleware:
"detection_method": "domain",
"host": host,
"original_path": path,
"clean_path": path, # No path rewrite for domain-based
"clean_path": path,
}
if len(parts) >= 3: # Subdomain like wizatech.omsflow.lu
root_domain = ".".join(parts[-2:])
return {
"domain": root_domain,
"subdomain": parts[0],
"detection_method": "subdomain",
"host": host,
"original_path": path,
"clean_path": path,
}
# Method 2: Path-based (development) - ONLY for /platforms/ prefix
@@ -436,12 +474,12 @@ class PlatformContextMiddleware:
}
# Method 3: Default for localhost - serves main marketing site
# Store routes require explicit platform via /platforms/{code}/store/...
if host_without_port in ["localhost", "127.0.0.1"]:
if path.startswith(("/store/", "/stores/")):
return None # No platform — handlers will show appropriate error
# Store/storefront routes require explicit platform via /platforms/{code}/...
if host_without_port in _LOCAL_HOSTS:
if path.startswith(("/store/", "/stores/", "/storefront/")):
return None # No platform — require /platforms/{code}/ prefix
return {
"path_prefix": DEFAULT_PLATFORM_CODE,
"path_prefix": MAIN_PLATFORM_CODE,
"detection_method": "default",
"host": host,
"original_path": path,

View File

@@ -6,7 +6,7 @@ Detects store from host/domain/path and injects into request.state.
Handles three routing modes:
1. Custom domains (customdomain1.com → Store 1)
2. Subdomains (store1.platform.com → Store 1)
3. Path-based (/store/store1/ or /stores/store1/ → Store 1)
3. Path-based (/store/store1/, /stores/store1/, or /storefront/store1/ → Store 1)
Also extracts clean_path for nested routing patterns.
@@ -89,11 +89,12 @@ class StoreContextManager:
"host": host,
}
# Method 3: Path-based detection (/store/storename/ or /stores/storename/)
# Support BOTH patterns for flexibility
if path.startswith(("/store/", "/stores/")):
# Method 3: Path-based detection (/store/storename/, /stores/storename/, /storefront/storename/)
if path.startswith(("/store/", "/stores/", "/storefront/")):
# Determine which pattern
if path.startswith("/stores/"):
if path.startswith("/storefront/"):
prefix_len = len("/storefront/")
elif path.startswith("/stores/"):
prefix_len = len("/stores/")
else:
prefix_len = len("/store/")
@@ -105,7 +106,7 @@ class StoreContextManager:
"subdomain": store_code,
"detection_method": "path",
"path_prefix": path[: prefix_len + len(store_code)],
"full_prefix": path[:prefix_len], # /store/ or /stores/
"full_prefix": path[:prefix_len], # /store/, /stores/, or /storefront/
"host": host,
}
@@ -269,13 +270,27 @@ class StoreContextManager:
},
)
# Method 1: Path-based detection from referer path
# Method 1: Path-based detection from referer path (local hosts only)
# /platforms/oms/storefront/WIZATECH/products → WIZATECH
# /stores/orion/storefront/products → orion
if referer_path.startswith(("/stores/", "/store/")):
prefix = (
"/stores/" if referer_path.startswith("/stores/") else "/store/"
)
path_parts = referer_path[len(prefix) :].split("/")
# /storefront/WIZATECH/products → WIZATECH
# Note: For subdomain/custom domain hosts, the store code is NOT in the path
# (e.g., orion.platform.com/storefront/products — "products" is a page, not a store)
is_local_referer = referer_host in ("localhost", "127.0.0.1", "testserver")
if is_local_referer and referer_path.startswith("/platforms/"):
# Strip /platforms/{code}/ to get clean path
after_platforms = referer_path[11:] # Remove "/platforms/"
parts = after_platforms.split("/", 1)
referer_path = "/" + parts[1] if len(parts) > 1 and parts[1] else "/"
if is_local_referer and referer_path.startswith(("/stores/", "/store/", "/storefront/")):
if referer_path.startswith("/storefront/"):
prefix = "/storefront/"
elif referer_path.startswith("/stores/"):
prefix = "/stores/"
else:
prefix = "/store/"
path_parts = referer_path[len(prefix):].split("/")
if len(path_parts) >= 1 and path_parts[0]:
store_code = path_parts[0]
prefix_len = len(prefix)
@@ -283,15 +298,13 @@ class StoreContextManager:
f"[STORE] Extracted store from Referer path: {store_code}",
extra={"store_code": store_code, "method": "referer_path"},
)
# Use "path" as detection_method to be consistent with direct path detection
# This allows cookie path logic to work the same way
return {
"subdomain": store_code,
"detection_method": "path", # Consistent with direct path detection
"detection_method": "path",
"path_prefix": referer_path[
: prefix_len + len(store_code)
], # /store/store1
"full_prefix": prefix, # /store/ or /stores/
],
"full_prefix": prefix,
"host": referer_host,
"referer": referer,
}

View File

@@ -129,23 +129,22 @@ class StorefrontAccessMiddleware(BaseHTTPMiddleware):
return await call_next(request)
def _get_subscription(self, db, store, request):
"""Resolve subscription, handling multi-platform stores correctly."""
"""Resolve subscription for the detected platform. No fallback."""
from app.modules.billing.services.subscription_service import (
subscription_service,
)
platform = getattr(request.state, "platform", None)
# If we have a detected platform, check subscription for THAT platform
if platform:
sub = subscription_service.get_merchant_subscription(
db, store.merchant_id, platform.id
if not platform:
logger.warning(
f"[STOREFRONT_ACCESS] No platform context for store '{store.subdomain}'"
)
if sub:
return sub
return None
# Fallback: use store's primary platform (via StorePlatform)
return subscription_service.get_subscription_for_store(db, store.id)
return subscription_service.get_merchant_subscription(
db, store.merchant_id, platform.id
)
def _render_unavailable(
self, request: Request, reason: str, store=None