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

@@ -21,6 +21,10 @@ from app.modules.billing.services.platform_pricing_service import (
PlatformPricingService,
platform_pricing_service,
)
from app.modules.billing.services.store_platform_sync_service import (
StorePlatformSync,
store_platform_sync,
)
from app.modules.billing.services.stripe_service import (
StripeService,
stripe_service,
@@ -42,6 +46,8 @@ from app.modules.billing.services.usage_service import (
__all__ = [
"SubscriptionService",
"subscription_service",
"StorePlatformSync",
"store_platform_sync",
"StripeService",
"stripe_service",
"AdminSubscriptionService",

View File

@@ -56,13 +56,14 @@ class AdminSubscriptionService:
return query.order_by(SubscriptionTier.display_order).all()
def get_tier_by_code(self, db: Session, tier_code: str) -> SubscriptionTier:
"""Get a subscription tier by code."""
tier = (
db.query(SubscriptionTier)
.filter(SubscriptionTier.code == tier_code)
.first()
)
def get_tier_by_code(
self, db: Session, tier_code: str, platform_id: int | None = None
) -> SubscriptionTier:
"""Get a subscription tier by code, optionally scoped to a platform."""
query = db.query(SubscriptionTier).filter(SubscriptionTier.code == tier_code)
if platform_id is not None:
query = query.filter(SubscriptionTier.platform_id == platform_id)
tier = query.first()
if not tier:
raise TierNotFoundException(tier_code)
@@ -214,7 +215,7 @@ class AdminSubscriptionService:
db, merchant_id, platform_id, tier_code, sub.is_annual
)
else:
tier = self.get_tier_by_code(db, tier_code)
tier = self.get_tier_by_code(db, tier_code, platform_id=platform_id)
sub.tier_id = tier.id
for field, value in update_data.items():
@@ -350,6 +351,22 @@ class AdminSubscriptionService:
return results
def get_subscriptions_for_store(
self, db: Session, store_id: int
) -> list[dict]:
"""Get subscriptions + feature usage for a store (resolves to merchant).
Convenience method for admin store detail page. Resolves
store -> merchant -> all platform subscriptions.
"""
from app.modules.tenancy.models import Store
store = db.query(Store).filter(Store.id == store_id).first()
if not store or not store.merchant_id:
raise ResourceNotFoundException("Store", str(store_id))
return self.get_merchant_subscriptions_with_usage(db, store.merchant_id)
# =========================================================================
# Statistics
# =========================================================================

View File

@@ -88,21 +88,22 @@ class BillingService:
return tier_list, tier_order
def get_tier_by_code(self, db: Session, tier_code: str) -> SubscriptionTier:
def get_tier_by_code(
self, db: Session, tier_code: str, platform_id: int | None = None
) -> SubscriptionTier:
"""
Get a tier by its code.
Get a tier by its code, optionally scoped to a platform.
Raises:
TierNotFoundException: If tier doesn't exist
"""
tier = (
db.query(SubscriptionTier)
.filter(
SubscriptionTier.code == tier_code,
SubscriptionTier.is_active == True, # noqa: E712
)
.first()
query = db.query(SubscriptionTier).filter(
SubscriptionTier.code == tier_code,
SubscriptionTier.is_active == True, # noqa: E712
)
if platform_id is not None:
query = query.filter(SubscriptionTier.platform_id == platform_id)
tier = query.first()
if not tier:
raise TierNotFoundException(tier_code)
@@ -133,7 +134,7 @@ class BillingService:
if not stripe_service.is_configured:
raise PaymentSystemNotConfiguredException()
tier = self.get_tier_by_code(db, tier_code)
tier = self.get_tier_by_code(db, tier_code, platform_id=platform_id)
price_id = (
tier.stripe_price_annual_id
@@ -410,7 +411,7 @@ class BillingService:
if not subscription or not subscription.stripe_subscription_id:
raise NoActiveSubscriptionException()
tier = self.get_tier_by_code(db, new_tier_code)
tier = self.get_tier_by_code(db, new_tier_code, platform_id=platform_id)
price_id = (
tier.stripe_price_annual_id

View File

@@ -28,16 +28,17 @@ class PlatformPricingService:
.all()
)
def get_tier_by_code(self, db: Session, tier_code: str) -> SubscriptionTier | None:
"""Get a specific tier by code from the database."""
return (
db.query(SubscriptionTier)
.filter(
SubscriptionTier.code == tier_code,
SubscriptionTier.is_active == True,
)
.first()
def get_tier_by_code(
self, db: Session, tier_code: str, platform_id: int | None = None
) -> SubscriptionTier | None:
"""Get a specific tier by code from the database, optionally scoped to a platform."""
query = db.query(SubscriptionTier).filter(
SubscriptionTier.code == tier_code,
SubscriptionTier.is_active == True,
)
if platform_id is not None:
query = query.filter(SubscriptionTier.platform_id == platform_id)
return query.first()
def get_active_addons(self, db: Session) -> list[AddOnProduct]:
"""Get all active add-on products from the database."""

View File

@@ -0,0 +1,92 @@
# app/modules/billing/services/store_platform_sync.py
"""
Keeps store_platforms in sync with merchant subscriptions.
When a subscription is created, reactivated, or deleted, this service
ensures all stores belonging to that merchant get corresponding
StorePlatform entries created or updated.
"""
import logging
from sqlalchemy.orm import Session
from app.modules.tenancy.models import Store, StorePlatform
logger = logging.getLogger(__name__)
class StorePlatformSync:
"""Syncs StorePlatform entries when merchant subscriptions change."""
def sync_store_platforms_for_merchant(
self,
db: Session,
merchant_id: int,
platform_id: int,
is_active: bool,
tier_id: int | None = None,
) -> None:
"""
Upsert StorePlatform for every store belonging to a merchant.
- Existing entry → update is_active (and tier_id if provided)
- Missing + is_active=True → create (set is_primary if store has none)
- Missing + is_active=False → no-op
"""
stores = (
db.query(Store)
.filter(Store.merchant_id == merchant_id)
.all()
)
if not stores:
return
for store in stores:
existing = (
db.query(StorePlatform)
.filter(
StorePlatform.store_id == store.id,
StorePlatform.platform_id == platform_id,
)
.first()
)
if existing:
existing.is_active = is_active
if tier_id is not None:
existing.tier_id = tier_id
logger.debug(
f"Updated StorePlatform store_id={store.id} "
f"platform_id={platform_id} is_active={is_active}"
)
elif is_active:
# Check if store already has a primary platform
has_primary = (
db.query(StorePlatform)
.filter(
StorePlatform.store_id == store.id,
StorePlatform.is_primary.is_(True),
)
.first()
) is not None
sp = StorePlatform(
store_id=store.id,
platform_id=platform_id,
is_active=True,
is_primary=not has_primary,
tier_id=tier_id,
)
db.add(sp)
logger.info(
f"Created StorePlatform store_id={store.id} "
f"platform_id={platform_id} is_primary={not has_primary}"
)
db.flush()
# Singleton instance
store_platform_sync = StorePlatformSync()

View File

@@ -82,17 +82,20 @@ class SubscriptionService:
# Tier Information
# =========================================================================
def get_tier_by_code(self, db: Session, tier_code: str) -> SubscriptionTier | None:
"""Get subscription tier by code."""
return (
db.query(SubscriptionTier)
.filter(SubscriptionTier.code == tier_code)
.first()
)
def get_tier_by_code(
self, db: Session, tier_code: str, platform_id: int | None = None
) -> SubscriptionTier | None:
"""Get subscription tier by code, optionally scoped to a platform."""
query = db.query(SubscriptionTier).filter(SubscriptionTier.code == tier_code)
if platform_id is not None:
query = query.filter(SubscriptionTier.platform_id == platform_id)
return query.first()
def get_tier_id(self, db: Session, tier_code: str) -> int | None:
def get_tier_id(
self, db: Session, tier_code: str, platform_id: int | None = None
) -> int | None:
"""Get tier ID from tier code. Returns None if tier not found."""
tier = self.get_tier_by_code(db, tier_code)
tier = self.get_tier_by_code(db, tier_code, platform_id=platform_id)
return tier.id if tier else None
def get_all_tiers(
@@ -254,7 +257,7 @@ class SubscriptionService:
trial_ends_at = None
status = SubscriptionStatus.ACTIVE.value
tier_id = self.get_tier_id(db, tier_code)
tier_id = self.get_tier_id(db, tier_code, platform_id=platform_id)
subscription = MerchantSubscription(
merchant_id=merchant_id,
@@ -271,6 +274,15 @@ class SubscriptionService:
db.flush()
db.refresh(subscription)
# Sync store_platforms for all merchant stores
from app.modules.billing.services.store_platform_sync_service import (
store_platform_sync,
)
store_platform_sync.sync_store_platforms_for_merchant(
db, merchant_id, platform_id, is_active=True, tier_id=subscription.tier_id
)
logger.info(
f"Created subscription for merchant {merchant_id} on platform {platform_id} "
f"(tier={tier_code}, status={status})"
@@ -305,7 +317,7 @@ class SubscriptionService:
subscription = self.get_subscription_or_raise(db, merchant_id, platform_id)
old_tier_id = subscription.tier_id
new_tier = self.get_tier_by_code(db, new_tier_code)
new_tier = self.get_tier_by_code(db, new_tier_code, platform_id=platform_id)
if not new_tier:
raise ValueError(f"Tier '{new_tier_code}' not found")
@@ -366,6 +378,15 @@ class SubscriptionService:
db.flush()
db.refresh(subscription)
# Sync store_platforms for all merchant stores
from app.modules.billing.services.store_platform_sync_service import (
store_platform_sync,
)
store_platform_sync.sync_store_platforms_for_merchant(
db, merchant_id, platform_id, is_active=True
)
logger.info(
f"Reactivated subscription for merchant {merchant_id} "
f"on platform {platform_id}"