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

@@ -16,7 +16,6 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api, require_module_access
from app.core.database import get_db
from app.exceptions import ResourceNotFoundException
from app.modules.billing.schemas import (
BillingHistoryListResponse,
BillingHistoryWithMerchant,
@@ -284,16 +283,7 @@ def get_subscription_for_store(
store -> merchant -> all platform subscriptions and returns a list
of subscription entries with feature usage metrics.
"""
from app.modules.billing.services.feature_service import feature_service
# Resolve store to merchant
merchant_id, platform_ids = feature_service._get_merchant_and_platforms_for_store(db, store_id)
if merchant_id is None or not platform_ids:
raise ResourceNotFoundException("Store", str(store_id))
results = admin_subscription_service.get_merchant_subscriptions_with_usage(
db, merchant_id
)
results = admin_subscription_service.get_subscriptions_for_store(db, store_id)
return {"subscriptions": results}

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}"

View File

@@ -507,3 +507,56 @@ class TestAdminBillingHistory:
)
assert response.status_code == 200
assert response.json()["total"] == 0
# ============================================================================
# Store Subscription Convenience Endpoint
# ============================================================================
class TestAdminStoreSubscription:
"""Tests for GET /api/v1/admin/subscriptions/store/{store_id}."""
def test_get_subscriptions_for_store(
self, client, super_admin_headers, rt_subscription, rt_store
):
"""Returns subscriptions when store has a merchant with subscriptions."""
response = client.get(
f"{BASE}/store/{rt_store.id}",
headers=super_admin_headers,
)
assert response.status_code == 200
data = response.json()
assert "subscriptions" in data
assert len(data["subscriptions"]) >= 1
def test_get_subscriptions_for_nonexistent_store(
self, client, super_admin_headers
):
"""Returns 404 for non-existent store ID."""
response = client.get(
f"{BASE}/store/999999",
headers=super_admin_headers,
)
assert response.status_code == 404
@pytest.fixture
def rt_store(db, rt_merchant):
"""Create a store for route tests."""
from app.modules.tenancy.models import Store
store = Store(
merchant_id=rt_merchant.id,
store_code=f"RT_{uuid.uuid4().hex[:6].upper()}",
name="Route Test Store",
subdomain=f"rt-{uuid.uuid4().hex[:8]}",
is_active=True,
is_verified=True,
created_at=datetime.now(UTC),
updated_at=datetime.now(UTC),
)
db.add(store)
db.commit()
db.refresh(store)
return store

View File

@@ -497,7 +497,7 @@ class TestAdminGetStats:
@pytest.fixture
def admin_billing_tiers(db):
def admin_billing_tiers(db, test_platform):
"""Create essential, professional, business tiers for admin tests."""
tiers = [
SubscriptionTier(
@@ -508,6 +508,7 @@ def admin_billing_tiers(db):
display_order=1,
is_active=True,
is_public=True,
platform_id=test_platform.id,
),
SubscriptionTier(
code="professional",
@@ -517,6 +518,7 @@ def admin_billing_tiers(db):
display_order=2,
is_active=True,
is_public=True,
platform_id=test_platform.id,
),
SubscriptionTier(
code="business",
@@ -526,6 +528,7 @@ def admin_billing_tiers(db):
display_order=3,
is_active=True,
is_public=True,
platform_id=test_platform.id,
),
]
db.add_all(tiers)

View File

@@ -603,7 +603,7 @@ class TestBillingServiceUpcomingInvoice:
@pytest.fixture
def bs_tier_essential(db):
def bs_tier_essential(db, test_platform):
"""Create essential subscription tier."""
tier = SubscriptionTier(
code="essential",
@@ -614,6 +614,7 @@ def bs_tier_essential(db):
display_order=1,
is_active=True,
is_public=True,
platform_id=test_platform.id,
)
db.add(tier)
db.commit()
@@ -622,7 +623,7 @@ def bs_tier_essential(db):
@pytest.fixture
def bs_tiers(db):
def bs_tiers(db, test_platform):
"""Create three tiers without Stripe config."""
tiers = [
SubscriptionTier(
@@ -633,6 +634,7 @@ def bs_tiers(db):
display_order=1,
is_active=True,
is_public=True,
platform_id=test_platform.id,
),
SubscriptionTier(
code="professional",
@@ -642,6 +644,7 @@ def bs_tiers(db):
display_order=2,
is_active=True,
is_public=True,
platform_id=test_platform.id,
),
SubscriptionTier(
code="business",
@@ -651,6 +654,7 @@ def bs_tiers(db):
display_order=3,
is_active=True,
is_public=True,
platform_id=test_platform.id,
),
]
db.add_all(tiers)
@@ -661,7 +665,7 @@ def bs_tiers(db):
@pytest.fixture
def bs_tiers_with_stripe(db):
def bs_tiers_with_stripe(db, test_platform):
"""Create tiers with Stripe price IDs configured."""
tiers = [
SubscriptionTier(
@@ -672,6 +676,7 @@ def bs_tiers_with_stripe(db):
display_order=1,
is_active=True,
is_public=True,
platform_id=test_platform.id,
stripe_product_id="prod_essential",
stripe_price_monthly_id="price_ess_monthly",
stripe_price_annual_id="price_ess_annual",
@@ -684,6 +689,7 @@ def bs_tiers_with_stripe(db):
display_order=2,
is_active=True,
is_public=True,
platform_id=test_platform.id,
stripe_product_id="prod_professional",
stripe_price_monthly_id="price_pro_monthly",
stripe_price_annual_id="price_pro_annual",
@@ -696,6 +702,7 @@ def bs_tiers_with_stripe(db):
display_order=3,
is_active=True,
is_public=True,
platform_id=test_platform.id,
stripe_product_id="prod_business",
stripe_price_monthly_id="price_biz_monthly",
stripe_price_annual_id="price_biz_annual",

View File

@@ -0,0 +1,247 @@
# app/modules/billing/tests/unit/test_store_platform_sync.py
"""Unit tests for StorePlatformSync service."""
from datetime import UTC, datetime, timedelta
import pytest
from app.modules.billing.models import (
MerchantSubscription,
SubscriptionStatus,
SubscriptionTier,
)
from app.modules.billing.services.store_platform_sync_service import StorePlatformSync
from app.modules.tenancy.models import StorePlatform
@pytest.mark.unit
@pytest.mark.billing
class TestStorePlatformSyncCreate:
"""Tests for creating StorePlatform entries via sync."""
def setup_method(self):
self.service = StorePlatformSync()
def test_sync_creates_store_platform(self, db, test_store, test_platform):
"""Sync with is_active=True creates a new StorePlatform entry."""
self.service.sync_store_platforms_for_merchant(
db, test_store.merchant_id, test_platform.id, is_active=True
)
sp = (
db.query(StorePlatform)
.filter(
StorePlatform.store_id == test_store.id,
StorePlatform.platform_id == test_platform.id,
)
.first()
)
assert sp is not None
assert sp.is_active is True
def test_sync_sets_primary_when_none(self, db, test_store, test_platform):
"""First platform synced for a store gets is_primary=True."""
self.service.sync_store_platforms_for_merchant(
db, test_store.merchant_id, test_platform.id, is_active=True
)
sp = (
db.query(StorePlatform)
.filter(
StorePlatform.store_id == test_store.id,
StorePlatform.platform_id == test_platform.id,
)
.first()
)
assert sp.is_primary is True
def test_sync_no_primary_override(self, db, test_store, test_platform, another_platform):
"""Second platform synced does not override existing primary."""
# First platform becomes primary
self.service.sync_store_platforms_for_merchant(
db, test_store.merchant_id, test_platform.id, is_active=True
)
# Second platform should not be primary
self.service.sync_store_platforms_for_merchant(
db, test_store.merchant_id, another_platform.id, is_active=True
)
sp1 = (
db.query(StorePlatform)
.filter(
StorePlatform.store_id == test_store.id,
StorePlatform.platform_id == test_platform.id,
)
.first()
)
sp2 = (
db.query(StorePlatform)
.filter(
StorePlatform.store_id == test_store.id,
StorePlatform.platform_id == another_platform.id,
)
.first()
)
assert sp1.is_primary is True
assert sp2.is_primary is False
def test_sync_sets_tier_id(self, db, test_store, test_platform, sync_tier):
"""Sync passes tier_id to newly created StorePlatform."""
self.service.sync_store_platforms_for_merchant(
db, test_store.merchant_id, test_platform.id,
is_active=True, tier_id=sync_tier.id,
)
sp = (
db.query(StorePlatform)
.filter(
StorePlatform.store_id == test_store.id,
StorePlatform.platform_id == test_platform.id,
)
.first()
)
assert sp.tier_id == sync_tier.id
@pytest.mark.unit
@pytest.mark.billing
class TestStorePlatformSyncUpdate:
"""Tests for updating existing StorePlatform entries via sync."""
def setup_method(self):
self.service = StorePlatformSync()
def test_sync_updates_existing_is_active(self, db, test_store, test_platform):
"""Sync updates is_active on existing StorePlatform."""
# Create initial entry
sp = StorePlatform(
store_id=test_store.id,
platform_id=test_platform.id,
is_active=True,
is_primary=True,
)
db.add(sp)
db.flush()
# Deactivate via sync
self.service.sync_store_platforms_for_merchant(
db, test_store.merchant_id, test_platform.id, is_active=False
)
db.refresh(sp)
assert sp.is_active is False
def test_sync_updates_tier_id(self, db, test_store, test_platform, sync_tier):
"""Sync updates tier_id on existing StorePlatform."""
sp = StorePlatform(
store_id=test_store.id,
platform_id=test_platform.id,
is_active=True,
is_primary=True,
)
db.add(sp)
db.flush()
self.service.sync_store_platforms_for_merchant(
db, test_store.merchant_id, test_platform.id,
is_active=True, tier_id=sync_tier.id,
)
db.refresh(sp)
assert sp.tier_id == sync_tier.id
@pytest.mark.unit
@pytest.mark.billing
class TestStorePlatformSyncEdgeCases:
"""Tests for edge cases in sync."""
def setup_method(self):
self.service = StorePlatformSync()
def test_sync_noop_inactive_missing(self, db, test_store, test_platform):
"""Sync with is_active=False for non-existent entry is a no-op."""
self.service.sync_store_platforms_for_merchant(
db, test_store.merchant_id, test_platform.id, is_active=False
)
sp = (
db.query(StorePlatform)
.filter(
StorePlatform.store_id == test_store.id,
StorePlatform.platform_id == test_platform.id,
)
.first()
)
assert sp is None
def test_sync_no_stores(self, db, test_platform):
"""Sync with no stores for merchant is a no-op (no error)."""
self.service.sync_store_platforms_for_merchant(
db, 99999, test_platform.id, is_active=True
)
# No assertion needed — just verifying no exception
def test_sync_multiple_stores(self, db, test_merchant, test_platform):
"""Sync creates entries for all stores of a merchant."""
from app.modules.tenancy.models import Store
store1 = Store(
merchant_id=test_merchant.id,
store_code="SYNC_TEST_1",
name="Sync Store 1",
subdomain="sync-test-1",
is_active=True,
is_verified=True,
created_at=datetime.now(UTC),
updated_at=datetime.now(UTC),
)
store2 = Store(
merchant_id=test_merchant.id,
store_code="SYNC_TEST_2",
name="Sync Store 2",
subdomain="sync-test-2",
is_active=True,
is_verified=True,
created_at=datetime.now(UTC),
updated_at=datetime.now(UTC),
)
db.add_all([store1, store2])
db.flush()
self.service.sync_store_platforms_for_merchant(
db, test_merchant.id, test_platform.id, is_active=True
)
count = (
db.query(StorePlatform)
.filter(
StorePlatform.platform_id == test_platform.id,
StorePlatform.store_id.in_([store1.id, store2.id]),
)
.count()
)
assert count == 2
# ============================================================================
# Fixtures
# ============================================================================
@pytest.fixture
def sync_tier(db, test_platform):
"""Create a tier for sync tests."""
tier = SubscriptionTier(
platform_id=test_platform.id,
code="essential",
name="Essential",
price_monthly_cents=2900,
display_order=1,
is_active=True,
is_public=True,
)
db.add(tier)
db.commit()
db.refresh(tier)
return tier

View File

@@ -502,7 +502,7 @@ class TestSubscriptionServiceReactivate:
@pytest.fixture
def billing_tier_essential(db):
def billing_tier_essential(db, test_platform):
"""Create essential subscription tier."""
tier = SubscriptionTier(
code="essential",
@@ -513,6 +513,7 @@ def billing_tier_essential(db):
display_order=1,
is_active=True,
is_public=True,
platform_id=test_platform.id,
)
db.add(tier)
db.commit()
@@ -521,7 +522,7 @@ def billing_tier_essential(db):
@pytest.fixture
def billing_tiers(db):
def billing_tiers(db, test_platform):
"""Create essential, professional, and business tiers."""
tiers = [
SubscriptionTier(
@@ -532,6 +533,7 @@ def billing_tiers(db):
display_order=1,
is_active=True,
is_public=True,
platform_id=test_platform.id,
),
SubscriptionTier(
code="professional",
@@ -541,6 +543,7 @@ def billing_tiers(db):
display_order=2,
is_active=True,
is_public=True,
platform_id=test_platform.id,
),
SubscriptionTier(
code="business",
@@ -550,6 +553,7 @@ def billing_tiers(db):
display_order=3,
is_active=True,
is_public=True,
platform_id=test_platform.id,
),
]
db.add_all(tiers)