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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user