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)

View File

@@ -70,7 +70,7 @@ cart_module = ModuleDefinition(
id="cart",
label_key="storefront.actions.cart",
icon="shopping-cart",
route="storefront/cart",
route="cart",
order=20,
),
],

View File

@@ -135,7 +135,7 @@ catalog_module = ModuleDefinition(
id="products",
label_key="storefront.nav.products",
icon="shopping-bag",
route="storefront/products",
route="products",
order=10,
),
],

View File

@@ -39,7 +39,9 @@ def _get_platform_context(request: Any, db: Any, platform: Any) -> dict[str, Any
"""
from app.modules.cms.services import content_page_service
platform_id = platform.id if platform else 1
if not platform:
return {"header_pages": [], "footer_pages": [], "legal_pages": []}
platform_id = platform.id
header_pages = []
footer_pages = []
@@ -73,7 +75,9 @@ def _get_storefront_context(request: Any, db: Any, platform: Any) -> dict[str, A
from app.modules.cms.services import content_page_service
store = getattr(request.state, "store", None)
platform_id = platform.id if platform else 1
if not platform:
return {"header_pages": [], "footer_pages": [], "legal_pages": []}
platform_id = platform.id
header_pages = []
footer_pages = []

View File

@@ -11,6 +11,7 @@ import logging
from fastapi import APIRouter, Depends, Request
from sqlalchemy.orm import Session
from app.api.deps import require_platform
from app.core.database import get_db
from app.modules.cms.schemas import (
ContentPageListItem,
@@ -29,7 +30,11 @@ logger = logging.getLogger(__name__)
# public - storefront content pages are publicly accessible
@router.get("/navigation", response_model=list[ContentPageListItem])
def get_navigation_pages(request: Request, db: Session = Depends(get_db)):
def get_navigation_pages(
request: Request,
platform=Depends(require_platform),
db: Session = Depends(get_db),
):
"""
Get list of content pages for navigation (footer/header).
@@ -37,9 +42,8 @@ def get_navigation_pages(request: Request, db: Session = Depends(get_db)):
Returns store overrides + platform defaults.
"""
store = getattr(request.state, "store", None)
platform = getattr(request.state, "platform", None)
store_id = store.id if store else None
platform_id = platform.id if platform else 1
platform_id = platform.id
# Get all published pages for this store
pages = content_page_service.list_pages_for_store(
@@ -59,7 +63,12 @@ def get_navigation_pages(request: Request, db: Session = Depends(get_db)):
@router.get("/{slug}", response_model=PublicContentPageResponse)
def get_content_page(slug: str, request: Request, db: Session = Depends(get_db)):
def get_content_page(
slug: str,
request: Request,
platform=Depends(require_platform),
db: Session = Depends(get_db),
):
"""
Get a specific content page by slug.
@@ -67,9 +76,8 @@ def get_content_page(slug: str, request: Request, db: Session = Depends(get_db))
Returns store override if exists, otherwise platform default.
"""
store = getattr(request.state, "store", None)
platform = getattr(request.state, "platform", None)
store_id = store.id if store else None
platform_id = platform.id if platform else 1
platform_id = platform.id
page = content_page_service.get_page_for_store_or_raise(
db,

View File

@@ -91,8 +91,9 @@ async def homepage(
if store:
logger.debug(f"[HOMEPAGE] Store detected: {store.subdomain}")
# Get platform_id (use platform from context or default to 1 for OMS)
platform_id = platform.id if platform else 1
if not platform:
raise HTTPException(status_code=400, detail="Platform context required")
platform_id = platform.id
# Try to find store landing page (slug='landing' or 'home')
landing_page = content_page_service.get_page_for_store(
@@ -133,21 +134,19 @@ async def homepage(
else "unknown"
)
if access_method == "path":
full_prefix = (
store_context.get("full_prefix", "/store/")
if store_context
else "/store/"
)
if access_method == "path" and platform:
return RedirectResponse(
url=f"{full_prefix}{store.subdomain}/storefront/", status_code=302
url=f"/platforms/{platform.code}/storefront/{store.store_code}/",
status_code=302,
)
# Domain/subdomain - redirect to /storefront/
return RedirectResponse(url="/storefront/", status_code=302)
# Domain/subdomain - root is storefront
return RedirectResponse(url="/", status_code=302)
# Scenario 2: Platform marketing site (no store)
# Load platform homepage from CMS (slug='home')
platform_id = platform.id if platform else 1
if not platform:
raise HTTPException(status_code=400, detail="Platform context required")
platform_id = platform.id
cms_homepage = content_page_service.get_platform_page(
db, platform_id=platform_id, slug="home", include_unpublished=False
@@ -227,9 +226,10 @@ async def content_page(
This is a catch-all route for dynamic content pages managed via the admin CMS.
Platform pages have store_id=None and is_platform_page=True.
"""
# Get platform from middleware (default to OMS platform_id=1)
platform = getattr(request.state, "platform", None)
platform_id = platform.id if platform else 1
if not platform:
raise HTTPException(status_code=400, detail="Platform context required")
platform_id = platform.id
# Load platform marketing page from database
page = content_page_service.get_platform_page(

View File

@@ -184,7 +184,9 @@ async def store_content_page(
store = getattr(request.state, "store", None)
platform = getattr(request.state, "platform", None)
store_id = store.id if store else None
platform_id = platform.id if platform else 1
if not platform:
raise HTTPException(status_code=400, detail="Platform context required")
platform_id = platform.id
# Load content page from database (store override → platform default)
page = content_page_service.get_page_for_store(

View File

@@ -66,7 +66,9 @@ async def generic_content_page(
store = getattr(request.state, "store", None)
platform = getattr(request.state, "platform", None)
store_id = store.id if store else None
platform_id = platform.id if platform else 1 # Default to OMS
if not platform:
raise HTTPException(status_code=400, detail="Platform context required")
platform_id = platform.id
# Load content page from database (store override -> store default)
page = content_page_service.get_page_for_store(

View File

@@ -14,7 +14,7 @@ import logging
from fastapi import APIRouter, Depends, Request
from sqlalchemy.orm import Session
from app.api.deps import get_merchant_for_current_user
from app.api.deps import get_merchant_for_current_user, require_platform
from app.core.database import get_db
from app.modules.core.schemas.dashboard import MerchantDashboardStatsResponse
from app.modules.core.services.stats_aggregator import stats_aggregator
@@ -27,6 +27,7 @@ logger = logging.getLogger(__name__)
def get_merchant_dashboard_stats(
request: Request,
merchant=Depends(get_merchant_for_current_user),
platform=Depends(require_platform),
db: Session = Depends(get_db),
):
"""
@@ -41,8 +42,7 @@ def get_merchant_dashboard_stats(
Merchant is resolved from the JWT token.
Requires Authorization header (API endpoint).
"""
platform = getattr(request.state, "platform", None)
platform_id = platform.id if platform else 1
platform_id = platform.id
flat = stats_aggregator.get_merchant_stats_flat(
db=db,

View File

@@ -14,7 +14,7 @@ import logging
from fastapi import APIRouter, Depends, Request
from sqlalchemy.orm import Session
from app.api.deps import get_current_store_api
from app.api.deps import get_current_store_api, require_platform
from app.core.database import get_db
from app.modules.core.schemas.dashboard import (
StoreCustomerStats,
@@ -49,6 +49,7 @@ def _extract_metric_value(
def get_store_dashboard_stats(
request: Request,
current_user: UserContext = Depends(get_current_store_api),
platform=Depends(require_platform),
db: Session = Depends(get_db),
):
"""
@@ -74,10 +75,7 @@ def get_store_dashboard_stats(
if not store.is_active:
raise StoreNotActiveException(store.store_code)
# Get aggregated metrics from all enabled modules
# Get platform_id from request context (set by PlatformContextMiddleware)
platform = getattr(request.state, "platform", None)
platform_id = platform.id if platform else 1
platform_id = platform.id
metrics = stats_aggregator.get_store_dashboard_stats(
db=db,
store_id=store_id,

View File

@@ -332,14 +332,21 @@ def get_storefront_context(
)
# Calculate base URL for links
# Dev path-based: /platforms/{code}/storefront/{store_code}/
# Prod subdomain/custom domain: /
base_url = "/"
if access_method == "path" and store:
full_prefix = (
store_context.get("full_prefix", "/store/")
if store_context
else "/store/"
)
base_url = f"{full_prefix}{store.subdomain}/"
platform = getattr(request.state, "platform", None)
platform_original_path = getattr(request.state, "platform_original_path", None)
if platform and platform_original_path and platform_original_path.startswith("/platforms/"):
base_url = f"/platforms/{platform.code}/storefront/{store.store_code}/"
else:
full_prefix = (
store_context.get("full_prefix", "/storefront/")
if store_context
else "/storefront/"
)
base_url = f"{full_prefix}{store.store_code}/"
# Read subscription info set by StorefrontAccessMiddleware
subscription = getattr(request.state, "subscription", None)

View File

@@ -142,28 +142,28 @@ customers_module = ModuleDefinition(
id="dashboard",
label_key="storefront.account.dashboard",
icon="home",
route="storefront/account/dashboard",
route="account/dashboard",
order=10,
),
MenuItemDefinition(
id="profile",
label_key="storefront.account.profile",
icon="user",
route="storefront/account/profile",
route="account/profile",
order=20,
),
MenuItemDefinition(
id="addresses",
label_key="storefront.account.addresses",
icon="map-pin",
route="storefront/account/addresses",
route="account/addresses",
order=30,
),
MenuItemDefinition(
id="settings",
label_key="storefront.account.settings",
icon="cog",
route="storefront/account/settings",
route="account/settings",
order=90,
),
],

View File

@@ -205,7 +205,7 @@ loyalty_module = ModuleDefinition(
id="loyalty",
label_key="storefront.account.loyalty",
icon="gift",
route="storefront/account/loyalty",
route="account/loyalty",
order=60,
),
],

View File

@@ -23,7 +23,7 @@ class TestStorefrontLoyaltyEndpoints:
# Without proper store context, should return 404 or error
response = client.get("/api/v1/storefront/loyalty/program")
# Endpoint exists but requires store context
assert response.status_code in [200, 404, 422, 500]
assert response.status_code in [200, 403, 404, 422, 500]
def test_enroll_endpoint_exists(self, client):
"""Test that enrollment endpoint is registered."""
@@ -35,16 +35,16 @@ class TestStorefrontLoyaltyEndpoints:
},
)
# Endpoint exists but requires store context
assert response.status_code in [200, 404, 422, 500]
assert response.status_code in [200, 403, 404, 422, 500]
def test_card_endpoint_exists(self, client):
"""Test that card endpoint is registered."""
response = client.get("/api/v1/storefront/loyalty/card")
# Endpoint exists but requires authentication and store context
assert response.status_code in [401, 404, 422, 500]
assert response.status_code in [401, 403, 404, 422, 500]
def test_transactions_endpoint_exists(self, client):
"""Test that transactions endpoint is registered."""
response = client.get("/api/v1/storefront/loyalty/transactions")
# Endpoint exists but requires authentication and store context
assert response.status_code in [401, 404, 422, 500]
assert response.status_code in [401, 403, 404, 422, 500]

View File

@@ -183,7 +183,7 @@ messaging_module = ModuleDefinition(
id="messages",
label_key="storefront.account.messages",
icon="chat-bubble-left-right",
route="storefront/account/messages",
route="account/messages",
order=50,
),
],

View File

@@ -74,7 +74,9 @@ class CapacityForecastService:
# Resource metrics via provider pattern (avoids cross-module imports)
start_of_month = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
platform = db.query(Platform).first()
platform_id = platform.id if platform else 1
if not platform:
raise ValueError("No platform found in database")
platform_id = platform.id
stats = stats_aggregator.get_admin_stats_flat(
db, platform_id,

View File

@@ -147,7 +147,7 @@ orders_module = ModuleDefinition(
id="orders",
label_key="storefront.account.orders",
icon="clipboard-list",
route="storefront/account/orders",
route="account/orders",
order=40,
),
],

View File

@@ -72,6 +72,7 @@ def create_store(
merchant_contact_phone=store.merchant.contact_phone,
merchant_website=store.merchant.website,
# Owner info (from merchant)
owner_user_id=store.merchant.owner.id,
owner_email=store.merchant.owner.email,
owner_username=store.merchant.owner.username,
login_url=f"http://localhost:8000/store/{store.subdomain}/login",
@@ -143,6 +144,7 @@ def _build_store_detail_response(store) -> StoreDetailResponse:
# Merchant info
merchant_name=store.merchant.name,
# Owner details (from merchant)
owner_user_id=store.merchant.owner_user_id,
owner_email=store.merchant.owner.email,
owner_username=store.merchant.owner.username,
# Resolved contact info with inheritance flags

View File

@@ -78,6 +78,7 @@ def get_store_info(
merchant_contact_phone=store.merchant.contact_phone,
merchant_website=store.merchant.website,
# Owner details (from merchant)
owner_user_id=store.merchant.owner_user_id,
owner_email=store.merchant.owner.email,
owner_username=store.merchant.owner.username,
)

View File

@@ -232,6 +232,7 @@ class StoreDetailResponse(StoreResponse):
merchant_name: str = Field(..., description="Name of the parent merchant")
# Owner info (at merchant level)
owner_user_id: int = Field(..., description="User ID of the merchant owner")
owner_email: str = Field(
..., description="Email of the merchant owner (for login/authentication)"
)

View File

@@ -271,11 +271,13 @@ class TestStoreDetailResponseSchema:
"owner_username": "owner",
"contact_email": "contact@techstore.com",
"contact_email_inherited": False,
"owner_user_id": 42,
}
response = StoreDetailResponse(**data)
assert response.merchant_name == "Tech Corp"
assert response.owner_email == "owner@techcorp.com"
assert response.contact_email_inherited is False
assert response.owner_user_id == 42
@pytest.mark.unit