From 32acc76b4953042862ab5a3c71025160d47940cd Mon Sep 17 00:00:00 2001 From: Samir Boulahtit Date: Mon, 23 Feb 2026 23:42:41 +0100 Subject: [PATCH] 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 --- app/api/deps.py | 17 ++ app/core/frontend_detector.py | 1 - app/handlers/stripe_webhook.py | 27 ++ app/modules/billing/routes/api/admin.py | 12 +- app/modules/billing/services/__init__.py | 6 + .../services/admin_subscription_service.py | 33 ++- .../billing/services/billing_service.py | 23 +- .../services/platform_pricing_service.py | 19 +- .../services/store_platform_sync_service.py | 92 +++++++ .../billing/services/subscription_service.py | 43 ++- .../tests/integration/test_admin_routes.py | 53 ++++ .../unit/test_admin_subscription_service.py | 5 +- .../tests/unit/test_billing_service.py | 13 +- .../unit/test_store_platform_sync_service.py | 247 ++++++++++++++++++ .../tests/unit/test_subscription_service.py | 8 +- app/modules/cart/definition.py | 2 +- app/modules/catalog/definition.py | 2 +- app/modules/cms/definition.py | 8 +- app/modules/cms/routes/api/storefront.py | 20 +- app/modules/cms/routes/pages/platform.py | 28 +- app/modules/cms/routes/pages/store.py | 4 +- app/modules/cms/routes/pages/storefront.py | 4 +- .../core/routes/api/merchant_dashboard.py | 6 +- .../core/routes/api/store_dashboard.py | 8 +- app/modules/core/utils/page_context.py | 19 +- app/modules/customers/definition.py | 8 +- app/modules/loyalty/definition.py | 2 +- .../integration/test_storefront_loyalty.py | 8 +- app/modules/messaging/definition.py | 2 +- .../services/capacity_forecast_service.py | 4 +- app/modules/orders/definition.py | 2 +- .../tenancy/routes/api/admin_stores.py | 2 + app/modules/tenancy/routes/api/store.py | 1 + app/modules/tenancy/schemas/store.py | 1 + .../tenancy/tests/unit/test_store_schema.py | 2 + app/templates/storefront/base.html | 28 +- app/templates/storefront/errors/400.html | 4 +- app/templates/storefront/errors/401.html | 6 +- app/templates/storefront/errors/403.html | 6 +- app/templates/storefront/errors/404.html | 6 +- app/templates/storefront/errors/422.html | 4 +- app/templates/storefront/errors/429.html | 4 +- app/templates/storefront/errors/500.html | 4 +- app/templates/storefront/errors/502.html | 4 +- app/templates/storefront/errors/base.html | 6 +- app/templates/storefront/errors/generic.html | 4 +- main.py | 57 +--- middleware/platform_context.py | 72 +++-- middleware/store_context.py | 47 ++-- middleware/storefront_access.py | 17 +- scripts/seed/init_production.py | 18 +- scripts/seed/seed_demo.py | 174 ++++++++++-- scripts/show_urls.py | 18 +- tests/unit/core/test_frontend_detector.py | 13 +- .../unit/middleware/test_platform_context.py | 10 +- .../unit/middleware/test_storefront_access.py | 23 +- 56 files changed, 951 insertions(+), 306 deletions(-) create mode 100644 app/modules/billing/services/store_platform_sync_service.py create mode 100644 app/modules/billing/tests/unit/test_store_platform_sync_service.py diff --git a/app/api/deps.py b/app/api/deps.py index 5c0a6a86..acebb04e 100644 --- a/app/api/deps.py +++ b/app/api/deps.py @@ -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 # ============================================================================ diff --git a/app/core/frontend_detector.py b/app/core/frontend_detector.py index e1c3712b..035a1803 100644 --- a/app/core/frontend_detector.py +++ b/app/core/frontend_detector.py @@ -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",) diff --git a/app/handlers/stripe_webhook.py b/app/handlers/stripe_webhook.py index e97ac463..b2e4a785 100644 --- a/app/handlers/stripe_webhook.py +++ b/app/handlers/stripe_webhook.py @@ -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", diff --git a/app/modules/billing/routes/api/admin.py b/app/modules/billing/routes/api/admin.py index f34c09e6..cf340e68 100644 --- a/app/modules/billing/routes/api/admin.py +++ b/app/modules/billing/routes/api/admin.py @@ -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} diff --git a/app/modules/billing/services/__init__.py b/app/modules/billing/services/__init__.py index 5bf6a6d5..dcf64902 100644 --- a/app/modules/billing/services/__init__.py +++ b/app/modules/billing/services/__init__.py @@ -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", diff --git a/app/modules/billing/services/admin_subscription_service.py b/app/modules/billing/services/admin_subscription_service.py index ed204e28..29ede8ef 100644 --- a/app/modules/billing/services/admin_subscription_service.py +++ b/app/modules/billing/services/admin_subscription_service.py @@ -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 # ========================================================================= diff --git a/app/modules/billing/services/billing_service.py b/app/modules/billing/services/billing_service.py index e83f283e..fdd13240 100644 --- a/app/modules/billing/services/billing_service.py +++ b/app/modules/billing/services/billing_service.py @@ -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 diff --git a/app/modules/billing/services/platform_pricing_service.py b/app/modules/billing/services/platform_pricing_service.py index 615ec13c..8f3d18d1 100644 --- a/app/modules/billing/services/platform_pricing_service.py +++ b/app/modules/billing/services/platform_pricing_service.py @@ -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.""" diff --git a/app/modules/billing/services/store_platform_sync_service.py b/app/modules/billing/services/store_platform_sync_service.py new file mode 100644 index 00000000..115cfef5 --- /dev/null +++ b/app/modules/billing/services/store_platform_sync_service.py @@ -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() diff --git a/app/modules/billing/services/subscription_service.py b/app/modules/billing/services/subscription_service.py index 45e27fdf..24982a0b 100644 --- a/app/modules/billing/services/subscription_service.py +++ b/app/modules/billing/services/subscription_service.py @@ -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}" diff --git a/app/modules/billing/tests/integration/test_admin_routes.py b/app/modules/billing/tests/integration/test_admin_routes.py index 71a81ed6..cc5d806b 100644 --- a/app/modules/billing/tests/integration/test_admin_routes.py +++ b/app/modules/billing/tests/integration/test_admin_routes.py @@ -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 diff --git a/app/modules/billing/tests/unit/test_admin_subscription_service.py b/app/modules/billing/tests/unit/test_admin_subscription_service.py index 1c7bec11..bc74044e 100644 --- a/app/modules/billing/tests/unit/test_admin_subscription_service.py +++ b/app/modules/billing/tests/unit/test_admin_subscription_service.py @@ -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) diff --git a/app/modules/billing/tests/unit/test_billing_service.py b/app/modules/billing/tests/unit/test_billing_service.py index fd902ec1..f9b61cc5 100644 --- a/app/modules/billing/tests/unit/test_billing_service.py +++ b/app/modules/billing/tests/unit/test_billing_service.py @@ -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", diff --git a/app/modules/billing/tests/unit/test_store_platform_sync_service.py b/app/modules/billing/tests/unit/test_store_platform_sync_service.py new file mode 100644 index 00000000..aa66c685 --- /dev/null +++ b/app/modules/billing/tests/unit/test_store_platform_sync_service.py @@ -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 diff --git a/app/modules/billing/tests/unit/test_subscription_service.py b/app/modules/billing/tests/unit/test_subscription_service.py index 5139ec2b..01113be3 100644 --- a/app/modules/billing/tests/unit/test_subscription_service.py +++ b/app/modules/billing/tests/unit/test_subscription_service.py @@ -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) diff --git a/app/modules/cart/definition.py b/app/modules/cart/definition.py index 4c0d668f..50af19ed 100644 --- a/app/modules/cart/definition.py +++ b/app/modules/cart/definition.py @@ -70,7 +70,7 @@ cart_module = ModuleDefinition( id="cart", label_key="storefront.actions.cart", icon="shopping-cart", - route="storefront/cart", + route="cart", order=20, ), ], diff --git a/app/modules/catalog/definition.py b/app/modules/catalog/definition.py index b5431954..670c2cd7 100644 --- a/app/modules/catalog/definition.py +++ b/app/modules/catalog/definition.py @@ -135,7 +135,7 @@ catalog_module = ModuleDefinition( id="products", label_key="storefront.nav.products", icon="shopping-bag", - route="storefront/products", + route="products", order=10, ), ], diff --git a/app/modules/cms/definition.py b/app/modules/cms/definition.py index 5fcf2f6b..8328b813 100644 --- a/app/modules/cms/definition.py +++ b/app/modules/cms/definition.py @@ -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 = [] diff --git a/app/modules/cms/routes/api/storefront.py b/app/modules/cms/routes/api/storefront.py index f9e51202..95246c19 100644 --- a/app/modules/cms/routes/api/storefront.py +++ b/app/modules/cms/routes/api/storefront.py @@ -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, diff --git a/app/modules/cms/routes/pages/platform.py b/app/modules/cms/routes/pages/platform.py index 757555a9..d9929a1a 100644 --- a/app/modules/cms/routes/pages/platform.py +++ b/app/modules/cms/routes/pages/platform.py @@ -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( diff --git a/app/modules/cms/routes/pages/store.py b/app/modules/cms/routes/pages/store.py index 89ee0693..100864f3 100644 --- a/app/modules/cms/routes/pages/store.py +++ b/app/modules/cms/routes/pages/store.py @@ -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( diff --git a/app/modules/cms/routes/pages/storefront.py b/app/modules/cms/routes/pages/storefront.py index 1d0b8944..a9acb254 100644 --- a/app/modules/cms/routes/pages/storefront.py +++ b/app/modules/cms/routes/pages/storefront.py @@ -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( diff --git a/app/modules/core/routes/api/merchant_dashboard.py b/app/modules/core/routes/api/merchant_dashboard.py index 11fa010f..85ef597c 100644 --- a/app/modules/core/routes/api/merchant_dashboard.py +++ b/app/modules/core/routes/api/merchant_dashboard.py @@ -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, diff --git a/app/modules/core/routes/api/store_dashboard.py b/app/modules/core/routes/api/store_dashboard.py index 46edac0d..ea8edfde 100644 --- a/app/modules/core/routes/api/store_dashboard.py +++ b/app/modules/core/routes/api/store_dashboard.py @@ -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, diff --git a/app/modules/core/utils/page_context.py b/app/modules/core/utils/page_context.py index bfcc50fa..4c7c9b9a 100644 --- a/app/modules/core/utils/page_context.py +++ b/app/modules/core/utils/page_context.py @@ -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) diff --git a/app/modules/customers/definition.py b/app/modules/customers/definition.py index e77e87fb..07330311 100644 --- a/app/modules/customers/definition.py +++ b/app/modules/customers/definition.py @@ -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, ), ], diff --git a/app/modules/loyalty/definition.py b/app/modules/loyalty/definition.py index 63244f59..b01e4f36 100644 --- a/app/modules/loyalty/definition.py +++ b/app/modules/loyalty/definition.py @@ -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, ), ], diff --git a/app/modules/loyalty/tests/integration/test_storefront_loyalty.py b/app/modules/loyalty/tests/integration/test_storefront_loyalty.py index 4d07f22b..e20ee7fc 100644 --- a/app/modules/loyalty/tests/integration/test_storefront_loyalty.py +++ b/app/modules/loyalty/tests/integration/test_storefront_loyalty.py @@ -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] diff --git a/app/modules/messaging/definition.py b/app/modules/messaging/definition.py index 517534b2..a3716f48 100644 --- a/app/modules/messaging/definition.py +++ b/app/modules/messaging/definition.py @@ -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, ), ], diff --git a/app/modules/monitoring/services/capacity_forecast_service.py b/app/modules/monitoring/services/capacity_forecast_service.py index 57ac815d..f84651ea 100644 --- a/app/modules/monitoring/services/capacity_forecast_service.py +++ b/app/modules/monitoring/services/capacity_forecast_service.py @@ -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, diff --git a/app/modules/orders/definition.py b/app/modules/orders/definition.py index 3c302c3f..4d9f8afa 100644 --- a/app/modules/orders/definition.py +++ b/app/modules/orders/definition.py @@ -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, ), ], diff --git a/app/modules/tenancy/routes/api/admin_stores.py b/app/modules/tenancy/routes/api/admin_stores.py index bafd33a4..86d5c206 100644 --- a/app/modules/tenancy/routes/api/admin_stores.py +++ b/app/modules/tenancy/routes/api/admin_stores.py @@ -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 diff --git a/app/modules/tenancy/routes/api/store.py b/app/modules/tenancy/routes/api/store.py index 8edb3a5f..faf909f5 100644 --- a/app/modules/tenancy/routes/api/store.py +++ b/app/modules/tenancy/routes/api/store.py @@ -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, ) diff --git a/app/modules/tenancy/schemas/store.py b/app/modules/tenancy/schemas/store.py index 7754174f..7fc26669 100644 --- a/app/modules/tenancy/schemas/store.py +++ b/app/modules/tenancy/schemas/store.py @@ -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)" ) diff --git a/app/modules/tenancy/tests/unit/test_store_schema.py b/app/modules/tenancy/tests/unit/test_store_schema.py index 46af9adb..3784caae 100644 --- a/app/modules/tenancy/tests/unit/test_store_schema.py +++ b/app/modules/tenancy/tests/unit/test_store_schema.py @@ -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 diff --git a/app/templates/storefront/base.html b/app/templates/storefront/base.html index 471dae45..0d082ab4 100644 --- a/app/templates/storefront/base.html +++ b/app/templates/storefront/base.html @@ -63,7 +63,7 @@ {# Store Logo #} @@ -307,7 +307,7 @@

Information

@@ -318,19 +318,19 @@

Quick Links

Information

{% endif %} diff --git a/app/templates/storefront/errors/400.html b/app/templates/storefront/errors/400.html index 71af33c1..372815fa 100644 --- a/app/templates/storefront/errors/400.html +++ b/app/templates/storefront/errors/400.html @@ -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 - Go to Home {% endblock %} {% block support_link %} -Need help? Contact us +Need help? Contact us {% endblock %} diff --git a/app/templates/storefront/errors/401.html b/app/templates/storefront/errors/401.html index d487f6bf..935bd36e 100644 --- a/app/templates/storefront/errors/401.html +++ b/app/templates/storefront/errors/401.html @@ -7,16 +7,16 @@ {% block title %}401 - Authentication Required{% endblock %} {% block action_buttons %} - Log In - Create Account {% endblock %} {% block support_link %} -Don't have an account? Sign up now +Don't have an account? Sign up now {% endblock %} diff --git a/app/templates/storefront/errors/403.html b/app/templates/storefront/errors/403.html index 5c41e853..afcb0cff 100644 --- a/app/templates/storefront/errors/403.html +++ b/app/templates/storefront/errors/403.html @@ -7,16 +7,16 @@ {% block title %}403 - Access Restricted{% endblock %} {% block action_buttons %} - Log In - Go to Home {% endblock %} {% block support_link %} -Need help accessing your account? Contact support +Need help accessing your account? Contact support {% endblock %} diff --git a/app/templates/storefront/errors/404.html b/app/templates/storefront/errors/404.html index 03bc41dd..b2a7d4de 100644 --- a/app/templates/storefront/errors/404.html +++ b/app/templates/storefront/errors/404.html @@ -7,16 +7,16 @@ {% block title %}404 - Page Not Found{% endblock %} {% block action_buttons %} - Continue Shopping - View All Products {% endblock %} {% block support_link %} -Can't find what you're looking for? Contact us and we'll help you find it. +Can't find what you're looking for? Contact us and we'll help you find it. {% endblock %} diff --git a/app/templates/storefront/errors/422.html b/app/templates/storefront/errors/422.html index ff4daaee..e5c56acf 100644 --- a/app/templates/storefront/errors/422.html +++ b/app/templates/storefront/errors/422.html @@ -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 - Go to Home {% endblock %} {% block support_link %} -Having trouble? We're here to help +Having trouble? We're here to help {% endblock %} diff --git a/app/templates/storefront/errors/429.html b/app/templates/storefront/errors/429.html index ea30613f..2ed7965f 100644 --- a/app/templates/storefront/errors/429.html +++ b/app/templates/storefront/errors/429.html @@ -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 - Go to Home {% endblock %} {% block support_link %} -Questions? Contact us +Questions? Contact us {% endblock %} diff --git a/app/templates/storefront/errors/500.html b/app/templates/storefront/errors/500.html index 4ddb7158..c292a2f9 100644 --- a/app/templates/storefront/errors/500.html +++ b/app/templates/storefront/errors/500.html @@ -7,7 +7,7 @@ {% block title %}500 - Something Went Wrong{% endblock %} {% block action_buttons %} - Go to Home @@ -18,5 +18,5 @@ {% endblock %} {% block support_link %} -Issue persisting? Let us know and we'll help you out. +Issue persisting? Let us know and we'll help you out. {% endblock %} diff --git a/app/templates/storefront/errors/502.html b/app/templates/storefront/errors/502.html index 8a803c6e..4c26c027 100644 --- a/app/templates/storefront/errors/502.html +++ b/app/templates/storefront/errors/502.html @@ -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 - Go to Home {% endblock %} {% block support_link %} -If this continues, let us know +If this continues, let us know {% endblock %} diff --git a/app/templates/storefront/errors/base.html b/app/templates/storefront/errors/base.html index 8e27b531..2ca9fcf8 100644 --- a/app/templates/storefront/errors/base.html +++ b/app/templates/storefront/errors/base.html @@ -76,11 +76,11 @@ {# Action Buttons #}
{% block action_buttons %} - Continue Shopping - Contact Us @@ -92,7 +92,7 @@ {# Support Link #}
{% block support_link %} - Need help? Contact our support team + Need help? Contact our support team {% endblock %}
diff --git a/app/templates/storefront/errors/generic.html b/app/templates/storefront/errors/generic.html index 153c7388..c276eaca 100644 --- a/app/templates/storefront/errors/generic.html +++ b/app/templates/storefront/errors/generic.html @@ -7,7 +7,7 @@ {% block title %}{{ status_code }} - {{ status_name }}{% endblock %} {% block action_buttons %} - Continue Shopping @@ -18,5 +18,5 @@ {% endblock %} {% block support_link %} -Need assistance? Contact us +Need assistance? Contact us {% endblock %} diff --git a/main.py b/main.py index 4c3acbb5..8c6d5213 100644 --- a/main.py +++ b/main.py @@ -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) # diff --git a/middleware/platform_context.py b/middleware/platform_context.py index 1490f509..67e24297 100644 --- a/middleware/platform_context.py +++ b/middleware/platform_context.py @@ -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, diff --git a/middleware/store_context.py b/middleware/store_context.py index e73afb29..6d3d2d5d 100644 --- a/middleware/store_context.py +++ b/middleware/store_context.py @@ -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, } diff --git a/middleware/storefront_access.py b/middleware/storefront_access.py index c56ce4c5..0685c963 100644 --- a/middleware/storefront_access.py +++ b/middleware/storefront_access.py @@ -129,23 +129,22 @@ class StorefrontAccessMiddleware(BaseHTTPMiddleware): return await call_next(request) def _get_subscription(self, db, store, request): - """Resolve subscription, handling multi-platform stores correctly.""" + """Resolve subscription for the detected platform. No fallback.""" from app.modules.billing.services.subscription_service import ( subscription_service, ) platform = getattr(request.state, "platform", None) - # If we have a detected platform, check subscription for THAT platform - if platform: - sub = subscription_service.get_merchant_subscription( - db, store.merchant_id, platform.id + if not platform: + logger.warning( + f"[STOREFRONT_ACCESS] No platform context for store '{store.subdomain}'" ) - if sub: - return sub + return None - # Fallback: use store's primary platform (via StorePlatform) - return subscription_service.get_subscription_for_store(db, store.id) + return subscription_service.get_merchant_subscription( + db, store.merchant_id, platform.id + ) def _render_unavailable( self, request: Request, reason: str, store=None diff --git a/scripts/seed/init_production.py b/scripts/seed/init_production.py index 8cb54630..2be78d24 100644 --- a/scripts/seed/init_production.py +++ b/scripts/seed/init_production.py @@ -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...") diff --git a/scripts/seed/seed_demo.py b/scripts/seed/seed_demo.py index 9bd453ee..d7ac326d 100644 --- a/scripts/seed/seed_demo.py +++ b/scripts/seed/seed_demo.py @@ -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") # ============================================================================= diff --git a/scripts/show_urls.py b/scripts/show_urls.py index b3616f97..70132111 100644 --- a/scripts/show_urls.py +++ b/scripts/show_urls.py @@ -138,15 +138,17 @@ 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 - urls.append(( - f"Storefront: {v.name}", - f"{DEV_BASE}/stores/{v.store_code}/storefront/", - [200, 302], - )) + platform_codes = store_platform_map.get(v.id, []) + for pc in platform_codes: + urls.append(( + f"Storefront [{pc}]: {v.name}", + f"{DEV_BASE}/platforms/{pc}/storefront/{v.store_code}/", + [200, 302], + )) # Store info API (public, no auth needed) for v in stores: @@ -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}/") diff --git a/tests/unit/core/test_frontend_detector.py b/tests/unit/core/test_frontend_detector.py index 859d1c58..83c30b54 100644 --- a/tests/unit/core/test_frontend_detector.py +++ b/tests/unit/core/test_frontend_detector.py @@ -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): diff --git a/tests/unit/middleware/test_platform_context.py b/tests/unit/middleware/test_platform_context.py index fe59ea19..db53562e 100644 --- a/tests/unit/middleware/test_platform_context.py +++ b/tests/unit/middleware/test_platform_context.py @@ -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 diff --git a/tests/unit/middleware/test_storefront_access.py b/tests/unit/middleware/test_storefront_access.py index b5f6a235..f280f88c 100644 --- a/tests/unit/middleware/test_storefront_access.py +++ b/tests/unit/middleware/test_storefront_access.py @@ -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 # =============================================================================