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

@@ -155,6 +155,23 @@ def _get_user_model(user_context: UserContext, db: Session) -> UserModel:
return user
# ============================================================================
# PLATFORM CONTEXT
# ============================================================================
def require_platform(request: Request):
"""Dependency that requires platform context from middleware.
Raises HTTPException(400) if no platform is set on request.state.
Use as a FastAPI dependency in endpoints that need platform context.
"""
platform = getattr(request.state, "platform", None)
if not platform:
raise HTTPException(status_code=400, detail="Platform context required")
return platform
# ============================================================================
# ADMIN AUTHENTICATION
# ============================================================================

View File

@@ -46,7 +46,6 @@ class FrontendDetector:
STOREFRONT_PATH_PREFIXES = (
"/storefront",
"/api/v1/storefront",
"/stores/", # Path-based store access
)
MERCHANT_PATH_PREFIXES = ("/merchants", "/api/v1/merchants")
PLATFORM_PATH_PREFIXES = ("/api/v1/platform",)

View File

@@ -380,6 +380,24 @@ class StripeWebhookHandler:
f"Tier changed to {tier.code} for merchant {subscription.merchant_id}"
)
# Sync store_platforms based on subscription status
from app.modules.billing.services.store_platform_sync_service import (
store_platform_sync,
)
active_statuses = {
SubscriptionStatus.TRIAL.value,
SubscriptionStatus.ACTIVE.value,
SubscriptionStatus.PAST_DUE.value,
SubscriptionStatus.CANCELLED.value,
}
store_platform_sync.sync_store_platforms_for_merchant(
db,
subscription.merchant_id,
subscription.platform_id,
is_active=subscription.status in active_statuses,
)
logger.info(f"Subscription updated for merchant {subscription.merchant_id}")
return {"action": "updated", "merchant_id": subscription.merchant_id}
@@ -435,6 +453,15 @@ class StripeWebhookHandler:
if addon_count > 0:
logger.info(f"Cancelled {addon_count} add-ons for merchant {merchant_id}")
# Deactivate store_platforms for the deleted subscription's platform
from app.modules.billing.services.store_platform_sync_service import (
store_platform_sync,
)
store_platform_sync.sync_store_platforms_for_merchant(
db, merchant_id, subscription.platform_id, is_active=False
)
logger.info(f"Subscription deleted for merchant {merchant_id}")
return {
"action": "cancelled",

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(
query = db.query(SubscriptionTier).filter(
SubscriptionTier.code == tier_code,
SubscriptionTier.is_active == True, # noqa: E712
)
.first()
)
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(
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,
)
.first()
)
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:
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", "/store/")
store_context.get("full_prefix", "/storefront/")
if store_context
else "/store/"
else "/storefront/"
)
base_url = f"{full_prefix}{store.subdomain}/"
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

View File

@@ -63,7 +63,7 @@
{# Store Logo #}
<div class="flex items-center">
<a href="{{ base_url }}storefront/" class="flex items-center space-x-3">
<a href="{{ base_url }}" class="flex items-center space-x-3">
{% if theme.branding.logo %}
{# Show light logo in light mode, dark logo in dark mode #}
<img x-show="!dark"
@@ -96,7 +96,7 @@
{% endfor %}
{# CMS pages (About, Contact) are already dynamic via header_pages #}
{% for page in header_pages|default([]) %}
<a href="{{ base_url }}storefront/{{ page.slug }}" class="text-gray-700 dark:text-gray-300 hover:text-primary">
<a href="{{ base_url }}{{ page.slug }}" class="text-gray-700 dark:text-gray-300 hover:text-primary">
{{ page.title }}
</a>
{% endfor %}
@@ -117,7 +117,7 @@
{% if 'cart' in enabled_modules|default([]) %}
{# Cart #}
<a href="{{ base_url }}storefront/cart" class="relative p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700">
<a href="{{ base_url }}cart" class="relative p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z"></path>
@@ -187,7 +187,7 @@
{% endif %}
{# Account #}
<a href="{{ base_url }}storefront/account" class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700">
<a href="{{ base_url }}account" class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path>
@@ -228,7 +228,7 @@
</a>
{% endfor %}
{% for page in header_pages|default([]) %}
<a href="{{ base_url }}storefront/{{ page.slug }}" @click="closeMobileMenu()"
<a href="{{ base_url }}{{ page.slug }}" @click="closeMobileMenu()"
class="block px-3 py-2 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700">
{{ page.title }}
</a>
@@ -293,10 +293,10 @@
<h4 class="font-semibold mb-4">Quick Links</h4>
<ul class="space-y-2">
{% if 'catalog' in enabled_modules|default([]) %}
<li><a href="{{ base_url }}storefront/products" class="text-gray-600 hover:text-primary dark:text-gray-400">Products</a></li>
<li><a href="{{ base_url }}products" class="text-gray-600 hover:text-primary dark:text-gray-400">Products</a></li>
{% endif %}
{% for page in col1_pages %}
<li><a href="{{ base_url }}storefront/{{ page.slug }}" class="text-gray-600 hover:text-primary dark:text-gray-400">{{ page.title }}</a></li>
<li><a href="{{ base_url }}{{ page.slug }}" class="text-gray-600 hover:text-primary dark:text-gray-400">{{ page.title }}</a></li>
{% endfor %}
</ul>
</div>
@@ -307,7 +307,7 @@
<h4 class="font-semibold mb-4">Information</h4>
<ul class="space-y-2">
{% for page in col2_pages %}
<li><a href="{{ base_url }}storefront/{{ page.slug }}" class="text-gray-600 hover:text-primary dark:text-gray-400">{{ page.title }}</a></li>
<li><a href="{{ base_url }}{{ page.slug }}" class="text-gray-600 hover:text-primary dark:text-gray-400">{{ page.title }}</a></li>
{% endfor %}
</ul>
</div>
@@ -318,19 +318,19 @@
<h4 class="font-semibold mb-4">Quick Links</h4>
<ul class="space-y-2">
{% if 'catalog' in enabled_modules|default([]) %}
<li><a href="{{ base_url }}storefront/products" class="text-gray-600 hover:text-primary dark:text-gray-400">Products</a></li>
<li><a href="{{ base_url }}products" class="text-gray-600 hover:text-primary dark:text-gray-400">Products</a></li>
{% endif %}
<li><a href="{{ base_url }}storefront/about" class="text-gray-600 hover:text-primary dark:text-gray-400">About Us</a></li>
<li><a href="{{ base_url }}storefront/contact" class="text-gray-600 hover:text-primary dark:text-gray-400">Contact</a></li>
<li><a href="{{ base_url }}about" class="text-gray-600 hover:text-primary dark:text-gray-400">About Us</a></li>
<li><a href="{{ base_url }}contact" class="text-gray-600 hover:text-primary dark:text-gray-400">Contact</a></li>
</ul>
</div>
<div>
<h4 class="font-semibold mb-4">Information</h4>
<ul class="space-y-2">
<li><a href="{{ base_url }}storefront/faq" class="text-gray-600 hover:text-primary dark:text-gray-400">FAQ</a></li>
<li><a href="{{ base_url }}storefront/shipping" class="text-gray-600 hover:text-primary dark:text-gray-400">Shipping</a></li>
<li><a href="{{ base_url }}storefront/returns" class="text-gray-600 hover:text-primary dark:text-gray-400">Returns</a></li>
<li><a href="{{ base_url }}faq" class="text-gray-600 hover:text-primary dark:text-gray-400">FAQ</a></li>
<li><a href="{{ base_url }}shipping" class="text-gray-600 hover:text-primary dark:text-gray-400">Shipping</a></li>
<li><a href="{{ base_url }}returns" class="text-gray-600 hover:text-primary dark:text-gray-400">Returns</a></li>
</ul>
</div>
{% endif %}

View File

@@ -11,12 +11,12 @@
class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-white bg-theme-primary hover:opacity-90 hover:-translate-y-0.5 transition-all shadow-lg">
Go Back
</a>
<a href="{{ base_url }}storefront/"
<a href="{{ base_url }}"
class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-theme-primary border-2 border-theme-primary hover:bg-theme-primary hover:text-white hover:-translate-y-0.5 transition-all">
Go to Home
</a>
{% endblock %}
{% block support_link %}
Need help? <a href="{{ base_url }}storefront/contact" class="text-theme-primary font-semibold hover:underline">Contact us</a>
Need help? <a href="{{ base_url }}contact" class="text-theme-primary font-semibold hover:underline">Contact us</a>
{% endblock %}

View File

@@ -7,16 +7,16 @@
{% block title %}401 - Authentication Required{% endblock %}
{% block action_buttons %}
<a href="{{ base_url }}storefront/account/login"
<a href="{{ base_url }}account/login"
class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-white bg-theme-primary hover:opacity-90 hover:-translate-y-0.5 transition-all shadow-lg">
Log In
</a>
<a href="{{ base_url }}storefront/account/register"
<a href="{{ base_url }}account/register"
class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-theme-primary border-2 border-theme-primary hover:bg-theme-primary hover:text-white hover:-translate-y-0.5 transition-all">
Create Account
</a>
{% endblock %}
{% block support_link %}
Don't have an account? <a href="{{ base_url }}storefront/account/register" class="text-theme-primary font-semibold hover:underline">Sign up now</a>
Don't have an account? <a href="{{ base_url }}account/register" class="text-theme-primary font-semibold hover:underline">Sign up now</a>
{% endblock %}

View File

@@ -7,16 +7,16 @@
{% block title %}403 - Access Restricted{% endblock %}
{% block action_buttons %}
<a href="{{ base_url }}storefront/account/login"
<a href="{{ base_url }}account/login"
class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-white bg-theme-primary hover:opacity-90 hover:-translate-y-0.5 transition-all shadow-lg">
Log In
</a>
<a href="{{ base_url }}storefront/"
<a href="{{ base_url }}"
class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-theme-primary border-2 border-theme-primary hover:bg-theme-primary hover:text-white hover:-translate-y-0.5 transition-all">
Go to Home
</a>
{% endblock %}
{% block support_link %}
Need help accessing your account? <a href="{{ base_url }}storefront/contact" class="text-theme-primary font-semibold hover:underline">Contact support</a>
Need help accessing your account? <a href="{{ base_url }}contact" class="text-theme-primary font-semibold hover:underline">Contact support</a>
{% endblock %}

View File

@@ -7,16 +7,16 @@
{% block title %}404 - Page Not Found{% endblock %}
{% block action_buttons %}
<a href="{{ base_url }}storefront/"
<a href="{{ base_url }}"
class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-white bg-theme-primary hover:opacity-90 hover:-translate-y-0.5 transition-all shadow-lg">
Continue Shopping
</a>
<a href="{{ base_url }}storefront/products"
<a href="{{ base_url }}products"
class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-theme-primary border-2 border-theme-primary hover:bg-theme-primary hover:text-white hover:-translate-y-0.5 transition-all">
View All Products
</a>
{% endblock %}
{% block support_link %}
Can't find what you're looking for? <a href="{{ base_url }}storefront/contact" class="text-theme-primary font-semibold hover:underline">Contact us</a> and we'll help you find it.
Can't find what you're looking for? <a href="{{ base_url }}contact" class="text-theme-primary font-semibold hover:underline">Contact us</a> and we'll help you find it.
{% endblock %}

View File

@@ -24,12 +24,12 @@
class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-white bg-theme-primary hover:opacity-90 hover:-translate-y-0.5 transition-all shadow-lg">
Go Back and Fix
</a>
<a href="{{ base_url }}storefront/"
<a href="{{ base_url }}"
class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-theme-primary border-2 border-theme-primary hover:bg-theme-primary hover:text-white hover:-translate-y-0.5 transition-all">
Go to Home
</a>
{% endblock %}
{% block support_link %}
Having trouble? <a href="{{ base_url }}storefront/contact" class="text-theme-primary font-semibold hover:underline">We're here to help</a>
Having trouble? <a href="{{ base_url }}contact" class="text-theme-primary font-semibold hover:underline">We're here to help</a>
{% endblock %}

View File

@@ -21,12 +21,12 @@
class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-white bg-theme-primary hover:opacity-90 hover:-translate-y-0.5 transition-all shadow-lg">
Try Again
</a>
<a href="{{ base_url }}storefront/"
<a href="{{ base_url }}"
class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-theme-primary border-2 border-theme-primary hover:bg-theme-primary hover:text-white hover:-translate-y-0.5 transition-all">
Go to Home
</a>
{% endblock %}
{% block support_link %}
Questions? <a href="{{ base_url }}storefront/contact" class="text-theme-primary font-semibold hover:underline">Contact us</a>
Questions? <a href="{{ base_url }}contact" class="text-theme-primary font-semibold hover:underline">Contact us</a>
{% endblock %}

View File

@@ -7,7 +7,7 @@
{% block title %}500 - Something Went Wrong{% endblock %}
{% block action_buttons %}
<a href="{{ base_url }}storefront/"
<a href="{{ base_url }}"
class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-white bg-theme-primary hover:opacity-90 hover:-translate-y-0.5 transition-all shadow-lg">
Go to Home
</a>
@@ -18,5 +18,5 @@
{% endblock %}
{% block support_link %}
Issue persisting? <a href="{{ base_url }}storefront/contact" class="text-theme-primary font-semibold hover:underline">Let us know</a> and we'll help you out.
Issue persisting? <a href="{{ base_url }}contact" class="text-theme-primary font-semibold hover:underline">Let us know</a> and we'll help you out.
{% endblock %}

View File

@@ -11,12 +11,12 @@
class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-white bg-theme-primary hover:opacity-90 hover:-translate-y-0.5 transition-all shadow-lg">
Try Again
</a>
<a href="{{ base_url }}storefront/"
<a href="{{ base_url }}"
class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-theme-primary border-2 border-theme-primary hover:bg-theme-primary hover:text-white hover:-translate-y-0.5 transition-all">
Go to Home
</a>
{% endblock %}
{% block support_link %}
If this continues, <a href="{{ base_url }}storefront/contact" class="text-theme-primary font-semibold hover:underline">let us know</a>
If this continues, <a href="{{ base_url }}contact" class="text-theme-primary font-semibold hover:underline">let us know</a>
{% endblock %}

View File

@@ -76,11 +76,11 @@
{# Action Buttons #}
<div class="flex gap-4 justify-center flex-wrap mt-8">
{% block action_buttons %}
<a href="{{ base_url }}storefront/"
<a href="{{ base_url }}"
class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-white bg-theme-primary hover:opacity-90 hover:-translate-y-0.5 transition-all shadow-lg">
Continue Shopping
</a>
<a href="{{ base_url }}storefront/contact"
<a href="{{ base_url }}contact"
class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-theme-primary border-2 border-theme-primary hover:bg-theme-primary hover:text-white hover:-translate-y-0.5 transition-all">
Contact Us
</a>
@@ -92,7 +92,7 @@
{# Support Link #}
<div class="mt-10 pt-8 border-t border-gray-200 text-sm text-gray-500">
{% block support_link %}
Need help? <a href="{{ base_url }}storefront/contact" class="text-theme-primary font-semibold hover:underline">Contact our support team</a>
Need help? <a href="{{ base_url }}contact" class="text-theme-primary font-semibold hover:underline">Contact our support team</a>
{% endblock %}
</div>

View File

@@ -7,7 +7,7 @@
{% block title %}{{ status_code }} - {{ status_name }}{% endblock %}
{% block action_buttons %}
<a href="{{ base_url }}storefront/"
<a href="{{ base_url }}"
class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-white bg-theme-primary hover:opacity-90 hover:-translate-y-0.5 transition-all shadow-lg">
Continue Shopping
</a>
@@ -18,5 +18,5 @@
{% endblock %}
{% block support_link %}
Need assistance? <a href="{{ base_url }}storefront/contact" class="text-theme-primary font-semibold hover:underline">Contact us</a>
Need assistance? <a href="{{ base_url }}contact" class="text-theme-primary font-semibold hover:underline">Contact us</a>
{% endblock %}

57
main.py
View File

@@ -22,7 +22,7 @@ from datetime import UTC, datetime
from pathlib import Path
import sentry_sdk
from fastapi import Depends, FastAPI, HTTPException, Request, Response
from fastapi import Depends, FastAPI, Response
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
@@ -435,13 +435,13 @@ for route_info in store_page_routes:
# STOREFRONT PAGES (Customer Shop)
# =============================================================================
# Customer shop pages - Register at TWO prefixes:
# 1. /storefront/* (for subdomain/custom domain modes)
# 2. /stores/{code}/storefront/* (for path-based development mode)
# 1. /storefront/* (for prod: subdomain/custom domain, after path rewrite by middleware)
# 2. /storefront/{store_code}/* (for dev: path-based, after /platforms/{code}/ strip)
logger.info("Auto-discovering storefront page routes...")
storefront_page_routes = get_storefront_page_routes()
logger.info(f" Found {len(storefront_page_routes)} storefront page route modules")
# Register at /storefront/* (direct access)
# Register at /storefront/* (prod mode — middleware rewrites /products → /storefront/products)
logger.info(" Registering storefront routes at /storefront/*")
for route_info in storefront_page_routes:
prefix = f"/storefront{route_info.custom_prefix}" if route_info.custom_prefix else "/storefront"
@@ -453,10 +453,10 @@ for route_info in storefront_page_routes:
include_in_schema=False,
)
# Register at /stores/{code}/storefront/* (path-based development mode)
logger.info(" Registering storefront routes at /stores/{code}/storefront/*")
# Register at /storefront/{store_code}/* (dev mode — /platforms/oms/storefront/WIZATECH/...)
logger.info(" Registering storefront routes at /storefront/{store_code}/*")
for route_info in storefront_page_routes:
prefix = f"/stores/{{store_code}}/storefront{route_info.custom_prefix}" if route_info.custom_prefix else "/stores/{store_code}/storefront"
prefix = f"/storefront/{{store_code}}{route_info.custom_prefix}" if route_info.custom_prefix else "/storefront/{store_code}"
app.include_router(
route_info.router,
prefix=prefix,
@@ -465,49 +465,6 @@ for route_info in storefront_page_routes:
)
# Add handler for /stores/{store_code}/ root path
@app.get(
"/stores/{store_code}/", response_class=HTMLResponse, include_in_schema=False
)
async def store_root_path(
store_code: str, request: Request, db: Session = Depends(get_db)
):
"""Handle store root path (e.g., /stores/orion/)"""
# Store should already be in request.state from middleware
store = getattr(request.state, "store", None)
platform = getattr(request.state, "platform", None)
if not store:
raise HTTPException(status_code=404, detail=f"Store '{store_code}' not found")
from app.modules.cms.services import content_page_service
from app.modules.core.utils.page_context import get_storefront_context
# Get platform_id (use platform from context or default to 1 for OMS)
platform_id = platform.id if platform else 1
# Try to find landing page (with three-tier resolution)
landing_page = content_page_service.get_page_for_store(
db, platform_id=platform_id, slug="landing", store_id=store.id, include_unpublished=False
)
if not landing_page:
landing_page = content_page_service.get_page_for_store(
db, platform_id=platform_id, slug="home", store_id=store.id, include_unpublished=False
)
if landing_page:
# Render landing page with selected template
template_name = landing_page.template or "default"
template_path = f"store/landing-{template_name}.html"
return templates.TemplateResponse(
template_path, get_storefront_context(request, db=db, page=landing_page)
)
# No landing page - redirect to shop
return RedirectResponse(url=f"/stores/{store_code}/storefront/", status_code=302)
# ============================================================================
# PLATFORM ROUTING (via PlatformContextMiddleware)
#

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(
if not platform:
logger.warning(
f"[STOREFRONT_ACCESS] No platform context for store '{store.subdomain}'"
)
return None
return subscription_service.get_merchant_subscription(
db, store.merchant_id, platform.id
)
if sub:
return sub
# Fallback: use store's primary platform (via StorePlatform)
return subscription_service.get_subscription_for_store(db, store.id)
def _render_unavailable(
self, request: Request, reason: str, store=None

View File

@@ -418,7 +418,7 @@ def create_admin_settings(db: Session) -> int:
def create_subscription_tiers(db: Session, platform: Platform) -> int:
"""Create default subscription tiers for the OMS platform."""
"""Create default subscription tiers for a platform."""
tier_defs = [
{
@@ -458,11 +458,14 @@ def create_subscription_tiers(db: Session, platform: Platform) -> int:
tiers_created = 0
for tdef in tier_defs:
existing = db.execute(
select(SubscriptionTier).where(SubscriptionTier.code == tdef["code"])
select(SubscriptionTier).where(
SubscriptionTier.code == tdef["code"],
SubscriptionTier.platform_id == platform.id,
)
).scalar_one_or_none()
if existing:
print_warning(f"Tier already exists: {existing.name} ({existing.code})")
print_warning(f"Tier already exists: {existing.name} ({existing.code}) for {platform.name}")
continue
tier = SubscriptionTier(
@@ -620,13 +623,10 @@ def initialize_production(db: Session, auth_manager: AuthManager):
print_step(5, "Creating admin settings...")
create_admin_settings(db)
# Step 6: Seed subscription tiers
# Step 6: Seed subscription tiers for all platforms
print_step(6, "Seeding subscription tiers...")
oms_platform = next((p for p in platforms if p.code == "oms"), None)
if oms_platform:
create_subscription_tiers(db, oms_platform)
else:
print_warning("OMS platform not found, skipping tier seeding")
for platform in platforms:
create_subscription_tiers(db, platform)
# Step 7: Create platform module records
print_step(7, "Creating platform module records...")

View File

@@ -68,12 +68,14 @@ from app.modules.orders.models import Order, OrderItem
# cross-module string relationships (e.g. Store→StoreEmailTemplate,
# Platform→SubscriptionTier, Product→Inventory).
# Core modules
from app.modules.billing.models.merchant_subscription import MerchantSubscription
from app.modules.tenancy.models import (
Merchant,
PlatformAlert,
Role,
Store,
StoreDomain,
StorePlatform,
StoreUser,
User,
)
@@ -207,6 +209,17 @@ DEMO_STORES = [
},
]
# Demo subscriptions (linked to merchants by index)
DEMO_SUBSCRIPTIONS = [
# WizaCorp: OMS (professional, active) + Loyalty (essential, trial)
{"merchant_index": 0, "platform_code": "oms", "tier_code": "professional", "trial_days": 0},
{"merchant_index": 0, "platform_code": "loyalty", "tier_code": "essential", "trial_days": 14},
# Fashion Group: Loyalty only (essential, trial)
{"merchant_index": 1, "platform_code": "loyalty", "tier_code": "essential", "trial_days": 14},
# BookWorld: OMS (business, active)
{"merchant_index": 2, "platform_code": "oms", "tier_code": "business", "trial_days": 0},
]
# Demo team members (linked to merchants by index, assigned to stores by store_code)
DEMO_TEAM_MEMBERS = [
# WizaCorp team
@@ -656,6 +669,76 @@ def create_demo_merchants(db: Session, auth_manager: AuthManager) -> list[Mercha
return merchants
def create_demo_subscriptions(db: Session, merchants: list[Merchant]) -> None:
"""Create demo merchant subscriptions."""
from app.modules.billing.models.subscription import SubscriptionTier
from app.modules.tenancy.models import Platform
sub_count = 1 if SEED_MODE == "minimal" else len(DEMO_SUBSCRIPTIONS)
subs_to_create = DEMO_SUBSCRIPTIONS[:sub_count]
for sub_data in subs_to_create:
merchant_index = sub_data["merchant_index"]
if merchant_index >= len(merchants):
print_error(f"Invalid merchant_index {merchant_index} for subscription")
continue
merchant = merchants[merchant_index]
# Look up platform by code
platform = db.execute(
select(Platform).where(Platform.code == sub_data["platform_code"])
).scalar_one_or_none()
if not platform:
print_warning(f"Platform '{sub_data['platform_code']}' not found, skipping")
continue
# Check if subscription already exists
existing = db.execute(
select(MerchantSubscription).where(
MerchantSubscription.merchant_id == merchant.id,
MerchantSubscription.platform_id == platform.id,
)
).scalar_one_or_none()
if existing:
print_warning(
f"Subscription already exists: {merchant.name} on {platform.name}"
)
continue
# Look up tier by code + platform
tier = db.execute(
select(SubscriptionTier).where(
SubscriptionTier.code == sub_data["tier_code"],
SubscriptionTier.platform_id == platform.id,
)
).scalar_one_or_none()
if not tier:
print_warning(
f"Tier '{sub_data['tier_code']}' not found for {platform.name}, skipping"
)
continue
from app.modules.billing.services.subscription_service import subscription_service
subscription = subscription_service.create_merchant_subscription(
db,
merchant_id=merchant.id,
platform_id=platform.id,
tier_code=sub_data["tier_code"],
trial_days=sub_data.get("trial_days", 14),
)
print_success(
f"Created subscription: {merchant.name}{platform.name} "
f"({tier.name}, {subscription.status})"
)
db.flush()
def create_demo_stores(
db: Session, merchants: list[Merchant], auth_manager: AuthManager
) -> list[Store]:
@@ -703,6 +786,29 @@ def create_demo_stores(
db.add(store) # noqa: PERF006
db.flush()
# Link store to merchant's subscribed platforms
merchant_subs = db.execute(
select(MerchantSubscription.platform_id).where(
MerchantSubscription.merchant_id == merchant.id
)
).all()
for i, (platform_id,) in enumerate(merchant_subs):
sp = StorePlatform(
store_id=store.id,
platform_id=platform_id,
is_active=True,
is_primary=(i == 0),
)
db.add(sp)
if merchant_subs:
db.flush()
print_success(
f" Linked to {len(merchant_subs)} platform(s): "
f"{[pid for (pid,) in merchant_subs]}"
)
# Owner relationship is via Merchant.owner_user_id — no StoreUser needed
# Create store theme
@@ -1060,28 +1166,32 @@ def seed_demo_data(db: Session, auth_manager: AuthManager):
print_step(4, "Creating demo merchants...")
merchants = create_demo_merchants(db, auth_manager)
# Step 5: Create stores
print_step(5, "Creating demo stores...")
# Step 5: Create merchant subscriptions (before stores, so StorePlatform linking works)
print_step(5, "Creating demo subscriptions...")
create_demo_subscriptions(db, merchants)
# Step 6: Create stores
print_step(6, "Creating demo stores...")
stores = create_demo_stores(db, merchants, auth_manager)
# Step 6: Create team members
print_step(6, "Creating demo team members...")
# Step 7: Create team members
print_step(7, "Creating demo team members...")
create_demo_team_members(db, stores, auth_manager)
# Step 7: Create customers
print_step(7, "Creating demo customers...")
# Step 8: Create customers
print_step(8, "Creating demo customers...")
for store in stores:
create_demo_customers(
db, store, auth_manager, count=settings.seed_customers_per_store
)
# Step 8: Create products
print_step(8, "Creating demo products...")
# Step 9: Create products
print_step(9, "Creating demo products...")
for store in stores:
create_demo_products(db, store, count=settings.seed_products_per_store)
# Step 9: Create store content pages
print_step(9, "Creating store content page overrides...")
# Step 10: Create store content pages
print_step(10, "Creating store content page overrides...")
create_demo_store_content_pages(db, stores)
# Commit all changes
@@ -1199,26 +1309,46 @@ def print_summary(db: Session):
print(" (Replace {subdomain} with store subdomain, e.g., wizatech)")
print()
print("\n🏪 Shop Access (Development):")
# Build store → platform code mapping from store_platforms
from app.modules.tenancy.models import Platform
store_platform_rows = db.execute(
select(StorePlatform.store_id, Platform.code).join(
Platform, Platform.id == StorePlatform.platform_id
).where(StorePlatform.is_active == True).order_by( # noqa: E712
StorePlatform.store_id, StorePlatform.is_primary.desc()
)
).all()
store_platform_map: dict[int, list[str]] = {}
for store_id, platform_code in store_platform_rows:
store_platform_map.setdefault(store_id, []).append(platform_code)
port = settings.api_port
base = f"http://localhost:{port}"
print("\n🏪 Store Access (Development):")
print("" * 70)
for store in stores:
print(f" {store.name}:")
print(
f" Path-based: http://localhost:8000/stores/{store.store_code}/shop/"
)
print(f" Subdomain: http://{store.subdomain}.localhost:8000/") # noqa: SEC034
platform_codes = store_platform_map.get(store.id, [])
print(f" {store.name} ({store.store_code}):")
if platform_codes:
for pc in platform_codes:
print(f" [{pc}] Storefront: {base}/platforms/{pc}/storefront/{store.store_code}/")
print(f" [{pc}] Dashboard: {base}/platforms/{pc}/store/{store.store_code}/")
print(f" [{pc}] Login: {base}/platforms/{pc}/store/{store.store_code}/login")
else:
print(" (!) No platform assigned")
print()
print("⚠️ ALL DEMO CREDENTIALS ARE INSECURE - For development only!")
port = settings.api_port
print("\n🚀 NEXT STEPS:")
print(" 1. Start development: make dev")
print(f" 2. Admin panel: http://localhost:{port}/admin/login")
print(f" 3. Merchant panel: http://localhost:{port}/merchants/login")
print(f" 4. Store panel: http://localhost:{port}/store/WIZATECH/login")
print(f" 5. Storefront: http://localhost:{port}/stores/WIZATECH/shop/")
print(f" 6. Customer login: http://localhost:{port}/stores/WIZATECH/shop/account")
print(f" 2. Admin panel: {base}/admin/login")
print(f" 3. Merchant panel: {base}/merchants/login")
print(f" 4. Store panel: {base}/platforms/oms/store/WIZATECH/login")
print(f" 5. Storefront: {base}/platforms/oms/storefront/WIZATECH/")
print(f" 6. Customer login: {base}/platforms/oms/storefront/WIZATECH/account")
# =============================================================================

View File

@@ -138,13 +138,15 @@ def collect_dev_urls(platforms, stores, store_domains, store_platform_map):
url = _store_dev_login_url(v.store_code)
urls.append((f"Store Login: {v.name}", url, [200]))
# Storefronts
# Storefronts (platform-aware)
for v in stores:
if not v.is_active:
continue
platform_codes = store_platform_map.get(v.id, [])
for pc in platform_codes:
urls.append((
f"Storefront: {v.name}",
f"{DEV_BASE}/stores/{v.store_code}/storefront/",
f"Storefront [{pc}]: {v.name}",
f"{DEV_BASE}/platforms/{pc}/storefront/{v.store_code}/",
[200, 302],
))
@@ -225,7 +227,9 @@ def print_dev_urls(platforms, stores, store_domains, store_platform_map):
tag = f" [{status_badge(v.is_active)}]" if not v.is_active else ""
code = v.store_code
print(f" {v.name} ({code}){tag}")
print(f" Shop: {DEV_BASE}/stores/{code}/storefront/")
pcs = store_platform_map.get(v.id, [])
for pc in pcs:
print(f" Shop [{pc}]: {DEV_BASE}/platforms/{pc}/storefront/{code}/")
print(f" API: {DEV_BASE}/api/v1/storefront/{code}/")

View File

@@ -65,10 +65,11 @@ class TestFrontendDetectorStore:
result = FrontendDetector.detect(host="omsflow.lu", path="/store/dashboard/analytics")
assert result == FrontendType.STORE
def test_stores_plural_not_store_dashboard(self):
"""Test that /stores/ path is NOT store dashboard (it's storefront)."""
def test_stores_plural_is_not_storefront(self):
"""Test that /stores/ path is NOT storefront (old pattern removed)."""
result = FrontendDetector.detect(host="localhost", path="/stores/orion/storefront")
assert result == FrontendType.STOREFRONT
# /stores/ is no longer a storefront signal; /storefront/ is the canonical prefix
assert result != FrontendType.STOREFRONT
@pytest.mark.unit
@@ -85,9 +86,9 @@ class TestFrontendDetectorStorefront:
result = FrontendDetector.detect(host="localhost", path="/api/v1/storefront/cart")
assert result == FrontendType.STOREFRONT
def test_detect_storefront_from_stores_path(self):
"""Test storefront detection from /stores/ path (path-based store access)."""
result = FrontendDetector.detect(host="localhost", path="/stores/orion/products")
def test_detect_storefront_from_storefront_path(self):
"""Test storefront detection from /storefront/ path (path-based store access)."""
result = FrontendDetector.detect(host="localhost", path="/storefront/orion/products")
assert result == FrontendType.STOREFRONT
def test_detect_storefront_from_store_subdomain(self):

View File

@@ -24,7 +24,7 @@ from sqlalchemy.orm import Session
from app.core.frontend_detector import FrontendDetector
from middleware.platform_context import (
DEFAULT_PLATFORM_CODE,
MAIN_PLATFORM_CODE,
PlatformContextManager,
PlatformContextMiddleware,
get_current_platform,
@@ -174,7 +174,7 @@ class TestPlatformContextManager:
assert context is not None
assert context["detection_method"] == "default"
assert context["path_prefix"] == DEFAULT_PLATFORM_CODE
assert context["path_prefix"] == MAIN_PLATFORM_CODE
assert context["clean_path"] == "/about" # No path rewrite for main site
def test_detect_default_platform_127_0_0_1(self):
@@ -187,7 +187,7 @@ class TestPlatformContextManager:
assert context is not None
assert context["detection_method"] == "default"
assert context["path_prefix"] == DEFAULT_PLATFORM_CODE
assert context["path_prefix"] == MAIN_PLATFORM_CODE
def test_detect_default_platform_root_path(self):
"""Test default platform detection for root path."""
@@ -797,8 +797,8 @@ class TestHelperFunctions:
assert "Platform not found" in exc_info.value.detail
def test_default_platform_code_is_main(self):
"""Test that DEFAULT_PLATFORM_CODE is 'main'."""
assert DEFAULT_PLATFORM_CODE == "main"
"""Test that MAIN_PLATFORM_CODE is 'main'."""
assert MAIN_PLATFORM_CODE == "main"
@pytest.mark.unit

View File

@@ -430,51 +430,40 @@ class TestGetSubscription:
)
assert result is expected_sub
def test_falls_back_to_store_primary_platform(self):
"""Test fallback to get_subscription_for_store when platform sub is None."""
def test_no_fallback_when_merchant_subscription_none(self):
"""Test no fallback when get_merchant_subscription returns None."""
middleware = StorefrontAccessMiddleware(app=None)
store = _make_store(store_id=5, merchant_id=10)
platform = _make_platform(platform_id=2)
request = _make_request(store=store, platform=platform)
mock_db = MagicMock()
fallback_sub = _make_subscription(tier_code="starter")
with patch(
"app.modules.billing.services.subscription_service.subscription_service"
) as mock_svc:
mock_svc.get_merchant_subscription.return_value = None
mock_svc.get_subscription_for_store.return_value = fallback_sub
result = middleware._get_subscription(mock_db, store, request)
mock_svc.get_merchant_subscription.assert_called_once_with(
mock_db, 10, 2
)
mock_svc.get_subscription_for_store.assert_called_once_with(
mock_db, 5
)
assert result is fallback_sub
assert result is None
def test_no_platform_uses_store_fallback(self):
"""Test when no platform is detected, falls back to store-based lookup."""
def test_no_platform_returns_none(self):
"""Test when no platform is detected, returns None (no fallback)."""
middleware = StorefrontAccessMiddleware(app=None)
store = _make_store(store_id=7)
request = _make_request(store=store, platform=None)
mock_db = MagicMock()
fallback_sub = _make_subscription()
with patch(
"app.modules.billing.services.subscription_service.subscription_service"
) as mock_svc:
mock_svc.get_subscription_for_store.return_value = fallback_sub
result = middleware._get_subscription(mock_db, store, request)
mock_svc.get_merchant_subscription.assert_not_called()
mock_svc.get_subscription_for_store.assert_called_once_with(
mock_db, 7
)
assert result is fallback_sub
assert result is None
# =============================================================================