diff --git a/app/modules/billing/definition.py b/app/modules/billing/definition.py index 36e05886..5cb26951 100644 --- a/app/modules/billing/definition.py +++ b/app/modules/billing/definition.py @@ -165,6 +165,10 @@ billing_module = ModuleDefinition( "billing", # Store billing dashboard "invoices", # Store invoice history ], + FrontendType.MERCHANT: [ + "subscriptions", # Merchant subscriptions + "invoices", # Merchant billing history + ], }, # New module-driven menu definitions menus={ @@ -199,6 +203,31 @@ billing_module = ModuleDefinition( ], ), ], + FrontendType.MERCHANT: [ + MenuSectionDefinition( + id="billing", + label_key="billing.menu.billing_subscriptions", + icon="credit-card", + order=50, + items=[ + MenuItemDefinition( + id="subscriptions", + label_key="billing.menu.subscriptions", + icon="clipboard-list", + route="/merchants/billing/subscriptions", + order=10, + is_mandatory=True, + ), + MenuItemDefinition( + id="invoices", + label_key="billing.menu.billing_history", + icon="currency-euro", + route="/merchants/billing/invoices", + order=20, + ), + ], + ), + ], FrontendType.STORE: [ MenuSectionDefinition( id="sales", diff --git a/app/modules/core/definition.py b/app/modules/core/definition.py index 7f04fc3b..12944a6b 100644 --- a/app/modules/core/definition.py +++ b/app/modules/core/definition.py @@ -74,6 +74,9 @@ core_module = ModuleDefinition( "settings", "email-templates", ], + FrontendType.MERCHANT: [ + "dashboard", + ], }, # New module-driven menu definitions menus={ @@ -121,6 +124,25 @@ core_module = ModuleDefinition( ], ), ], + FrontendType.MERCHANT: [ + MenuSectionDefinition( + id="main", + label_key=None, + icon=None, + order=0, + is_collapsible=False, + items=[ + MenuItemDefinition( + id="dashboard", + label_key="core.menu.dashboard", + icon="home", + route="/merchants/dashboard", + order=10, + is_mandatory=True, + ), + ], + ), + ], FrontendType.STORE: [ MenuSectionDefinition( id="main", diff --git a/app/modules/core/routes/api/merchant.py b/app/modules/core/routes/api/merchant.py index 88f95b1e..9f97266a 100644 --- a/app/modules/core/routes/api/merchant.py +++ b/app/modules/core/routes/api/merchant.py @@ -12,6 +12,7 @@ Aggregates: from fastapi import APIRouter from .merchant_dashboard import merchant_dashboard_router +from .merchant_menu import merchant_menu_router ROUTE_CONFIG = { "prefix": "/core", @@ -20,3 +21,4 @@ ROUTE_CONFIG = { router = APIRouter() router.include_router(merchant_dashboard_router, tags=["merchant-dashboard"]) +router.include_router(merchant_menu_router, tags=["merchant-menu"]) diff --git a/app/modules/core/routes/api/merchant_menu.py b/app/modules/core/routes/api/merchant_menu.py new file mode 100644 index 00000000..71d5be7e --- /dev/null +++ b/app/modules/core/routes/api/merchant_menu.py @@ -0,0 +1,141 @@ +# app/modules/core/routes/api/merchant_menu.py +""" +Merchant menu rendering endpoint. + +Provides the dynamic sidebar menu for the merchant portal: +- GET /menu/render/merchant - Get rendered merchant menu for current user + +Menu sections are driven by module definitions (FrontendType.MERCHANT). +Only modules enabled on platforms the merchant is actively subscribed to +will appear in the sidebar. +""" + +import logging +from typing import Any + +from fastapi import APIRouter, Depends, Request +from pydantic import BaseModel +from sqlalchemy.orm import Session + +from app.api.deps import get_current_merchant_from_cookie_or_header, get_db +from app.modules.core.services.menu_service import menu_service +from app.modules.enums import FrontendType +from app.utils.i18n import translate +from models.schema.auth import UserContext + +logger = logging.getLogger(__name__) + +merchant_menu_router = APIRouter(prefix="/menu") + + +# ============================================================================= +# Schemas +# ============================================================================= + + +class MenuSectionResponse(BaseModel): + """Menu section for rendering.""" + + id: str + label: str | None = None + items: list[dict[str, Any]] + + +class RenderedMenuResponse(BaseModel): + """Rendered menu for frontend.""" + + frontend_type: str + sections: list[MenuSectionResponse] + + +# ============================================================================= +# Helpers +# ============================================================================= + + +def _translate_label(label_key: str | None, language: str) -> str | None: + """Translate a label key, falling back to a readable version of the key.""" + if not label_key: + return None + + translated = translate(label_key, language=language) + + # If translation returned the key itself, create a readable fallback + if translated == label_key: + parts = label_key.split(".") + last_part = parts[-1] + return last_part.replace("_", " ").title() + + return translated + + +# ============================================================================= +# Endpoint +# ============================================================================= + + +@merchant_menu_router.get("/render/merchant", response_model=RenderedMenuResponse) +async def get_rendered_merchant_menu( + request: Request, + db: Session = Depends(get_db), + current_user: UserContext = Depends(get_current_merchant_from_cookie_or_header), +): + """ + Get the rendered merchant menu for the current user. + + Returns the filtered menu structure based on modules enabled + on platforms the merchant is subscribed to. + + Used by the merchant frontend to render the sidebar dynamically. + """ + # Resolve the merchant for this user (via service layer) + merchant = menu_service.get_merchant_for_menu(db, current_user.id) + + if not merchant: + # No merchant found — return empty menu + return RenderedMenuResponse( + frontend_type=FrontendType.MERCHANT.value, + sections=[], + ) + + # Get union of enabled module codes across all subscribed platforms + enabled_codes = menu_service.get_merchant_enabled_module_codes(db, merchant.id) + + # Get filtered menu using enabled_module_codes override + menu = menu_service.get_menu_for_rendering( + db=db, + frontend_type=FrontendType.MERCHANT, + enabled_module_codes=enabled_codes, + ) + + # Resolve language + language = current_user.preferred_language or getattr( + request.state, "language", "en" + ) + + # Translate section and item labels + sections = [] + for section in menu: + translated_items = [] + for item in section.items: + translated_items.append( + { + "id": item.id, + "label": _translate_label(item.label_key, language), + "icon": item.icon, + "url": item.route, + } + ) + + sections.append( + MenuSectionResponse( + id=section.id, + label=_translate_label(section.label_key, language), + items=translated_items, + ) + ) + + return RenderedMenuResponse( + frontend_type=FrontendType.MERCHANT.value, + sections=sections, + ) diff --git a/app/modules/core/services/menu_discovery_service.py b/app/modules/core/services/menu_discovery_service.py index c94a0fd6..85b353a1 100644 --- a/app/modules/core/services/menu_discovery_service.py +++ b/app/modules/core/services/menu_discovery_service.py @@ -123,6 +123,7 @@ class MenuDiscoveryService: db: Session, frontend_type: FrontendType, platform_id: int | None = None, + enabled_module_codes: set[str] | None = None, ) -> list[DiscoveredMenuSection]: """ Get aggregated menu sections for a frontend type. @@ -134,6 +135,9 @@ class MenuDiscoveryService: db: Database session frontend_type: Frontend type to get menus for platform_id: Platform ID for module enablement filtering + enabled_module_codes: If provided, overrides single-platform lookup. + A module is considered enabled if its code is in this set. + Useful for merchant portal where a merchant may span multiple platforms. Returns: List of DiscoveredMenuSection sorted by order @@ -144,12 +148,15 @@ class MenuDiscoveryService: sections_map: dict[str, DiscoveredMenuSection] = {} for module_code, module_def in MODULES.items(): - # Check if module is enabled for this platform - is_module_enabled = True - if platform_id: + # Check if module is enabled + if enabled_module_codes is not None: + is_module_enabled = module_code in enabled_module_codes + elif platform_id: is_module_enabled = module_service.is_module_enabled( db, platform_id, module_code ) + else: + is_module_enabled = True # Get menu sections for this frontend type module_sections = module_def.menus.get(frontend_type, []) @@ -204,6 +211,7 @@ class MenuDiscoveryService: user_id: int | None = None, is_super_admin: bool = False, store_code: str | None = None, + enabled_module_codes: set[str] | None = None, ) -> list[DiscoveredMenuSection]: """ Get filtered menu structure for frontend rendering. @@ -221,12 +229,16 @@ class MenuDiscoveryService: user_id: User ID for user-specific visibility (super admins only) is_super_admin: Whether the user is a super admin store_code: Store code for route placeholder replacement + enabled_module_codes: If provided, overrides single-platform lookup + for module enablement. Passed through to get_menu_sections_for_frontend. Returns: List of DiscoveredMenuSection with filtered and sorted items """ # Get all sections with module enablement filtering - sections = self.get_menu_sections_for_frontend(db, frontend_type, platform_id) + sections = self.get_menu_sections_for_frontend( + db, frontend_type, platform_id, enabled_module_codes=enabled_module_codes + ) # Get visibility configuration visible_item_ids = self._get_visible_item_ids( diff --git a/app/modules/core/services/menu_service.py b/app/modules/core/services/menu_service.py index cdf104cb..1eb6243f 100644 --- a/app/modules/core/services/menu_service.py +++ b/app/modules/core/services/menu_service.py @@ -228,6 +228,7 @@ class MenuService: user_id: int | None = None, is_super_admin: bool = False, store_code: str | None = None, + enabled_module_codes: set[str] | None = None, ) -> list: """ Get filtered menu structure for frontend rendering. @@ -241,11 +242,14 @@ class MenuService: Args: db: Database session - frontend_type: Which frontend (admin or store) + frontend_type: Which frontend (admin, store, or merchant) platform_id: Platform ID (for platform admins and stores) user_id: User ID (for super admins only) is_super_admin: Whether user is super admin (affects admin-only sections) store_code: Store code for URL placeholder replacement (store frontend) + enabled_module_codes: If provided, overrides single-platform lookup + for module enablement. Used by merchant portal where a merchant + may have subscriptions across multiple platforms. Returns: List of DiscoveredMenuSection ready for rendering @@ -257,6 +261,95 @@ class MenuService: user_id=user_id, is_super_admin=is_super_admin, store_code=store_code, + enabled_module_codes=enabled_module_codes, + ) + + # ========================================================================= + # Merchant Menu + # ========================================================================= + + def get_merchant_enabled_module_codes( + self, + db: Session, + merchant_id: int, + ) -> set[str]: + """ + Get the union of enabled module codes across all platforms the merchant + has an active subscription on. + + Core modules (those with is_core=True) are always included. + + Args: + db: Database session + merchant_id: Merchant ID + + Returns: + Set of enabled module codes + """ + from app.modules.billing.models.merchant_subscription import ( + MerchantSubscription, + ) + from app.modules.billing.models.subscription import SubscriptionStatus + from app.modules.registry import MODULES + + # Always include core modules + core_codes = {code for code, mod in MODULES.items() if mod.is_core} + + # Find all platform IDs where merchant has active/trial subscriptions + active_statuses = [ + SubscriptionStatus.TRIAL.value, + SubscriptionStatus.ACTIVE.value, + SubscriptionStatus.PAST_DUE.value, + SubscriptionStatus.CANCELLED.value, + ] + + subscriptions = ( + db.query(MerchantSubscription.platform_id) + .filter( + MerchantSubscription.merchant_id == merchant_id, + MerchantSubscription.status.in_(active_statuses), + ) + .all() + ) + + platform_ids = {sub.platform_id for sub in subscriptions} + + if not platform_ids: + return core_codes + + # Union enabled module codes across all subscribed platforms + all_enabled = set(core_codes) + for platform_id in platform_ids: + platform_codes = module_service.get_enabled_module_codes(db, platform_id) + all_enabled |= platform_codes + + return all_enabled + + def get_merchant_for_menu( + self, + db: Session, + user_id: int, + ): + """ + Get the active merchant owned by a user, for menu rendering. + + Args: + db: Database session + user_id: Owner user ID + + Returns: + Merchant ORM object or None + """ + from app.modules.tenancy.models import Merchant + + return ( + db.query(Merchant) + .filter( + Merchant.owner_user_id == user_id, + Merchant.is_active == True, # noqa: E712 + ) + .order_by(Merchant.id) + .first() ) # ========================================================================= diff --git a/app/modules/core/static/merchant/js/init-alpine.js b/app/modules/core/static/merchant/js/init-alpine.js index 8dc416ee..c52a7dfb 100644 --- a/app/modules/core/static/merchant/js/init-alpine.js +++ b/app/modules/core/static/merchant/js/init-alpine.js @@ -21,11 +21,8 @@ function getMerchantSidebarSectionsFromStorage() { } catch (e) { console.warn('[MERCHANT INIT-ALPINE] Failed to load sidebar state from localStorage:', e); } - // Default: all sections open - return { - billing: true, - account: true - }; + // Default: empty — dynamic menu will initialize section state + return {}; } function saveMerchantSidebarSectionsToStorage(sections) { @@ -36,6 +33,19 @@ function saveMerchantSidebarSectionsToStorage(sections) { } } +// Helper to find section ID for a page from menu data +function findSectionForPage(menuData, pageId) { + if (!menuData?.sections) return null; + for (const section of menuData.sections) { + for (const item of (section.items || [])) { + if (item.id === pageId) { + return section.id; + } + } + } + return null; +} + function data() { console.log('[MERCHANT INIT-ALPINE] data() function called'); return { @@ -48,6 +58,10 @@ function data() { // Sidebar collapsible sections state openSections: getMerchantSidebarSectionsFromStorage(), + // Dynamic menu (loaded from API) + menuData: null, + menuLoading: false, + init() { // Set current page from URL const path = window.location.pathname; @@ -87,6 +101,9 @@ function data() { // Ignore storage errors } } + + // Load dynamic menu + this.loadMenuConfig(); }, toggleSideMenu() { @@ -116,6 +133,54 @@ function data() { saveMerchantSidebarSectionsToStorage(this.openSections); }, + // Auto-expand section containing current page + expandSectionForCurrentPage() { + if (!this.menuData) return; + const section = findSectionForPage(this.menuData, this.currentPage); + if (section && !this.openSections[section]) { + this.openSections[section] = true; + saveMerchantSidebarSectionsToStorage(this.openSections); + } + }, + + // Dynamic menu loading from API + async loadMenuConfig() { + if (this.menuData || this.menuLoading) return; + + // Skip if apiClient is not available (e.g., on login page) + if (typeof apiClient === 'undefined') { + console.debug('Menu config: apiClient not available'); + return; + } + + // Skip if not authenticated + if (!localStorage.getItem('merchant_token')) { + console.debug('Menu config: no merchant_token, skipping'); + return; + } + + this.menuLoading = true; + try { + this.menuData = await apiClient.get('/merchants/core/menu/render/merchant'); + const sections = this.menuData?.sections || []; + for (const section of sections) { + // Initialize openSections for new sections (default: open) + if (this.openSections[section.id] === undefined) { + this.openSections[section.id] = true; + } + } + saveMerchantSidebarSectionsToStorage(this.openSections); + console.debug('Menu config loaded:', sections.length, 'sections'); + + // Auto-expand section containing current page + this.expandSectionForCurrentPage(); + } catch (e) { + console.debug('Menu config not loaded, using fallback:', e?.message || e); + } finally { + this.menuLoading = false; + } + }, + async handleLogout() { console.log('Logging out merchant user...'); diff --git a/app/modules/core/tests/integration/test_merchant_menu_routes.py b/app/modules/core/tests/integration/test_merchant_menu_routes.py new file mode 100644 index 00000000..8288db2f --- /dev/null +++ b/app/modules/core/tests/integration/test_merchant_menu_routes.py @@ -0,0 +1,477 @@ +# app/modules/core/tests/integration/test_merchant_menu_routes.py +""" +Integration tests for merchant menu API routes. + +Tests the merchant menu rendering endpoint at: + GET /api/v1/merchants/core/menu/render/merchant + +Verifies: +- Dynamic menu rendering based on module enablement +- Subscription gating (only modules from subscribed platforms) +- Auth requirement +- Response structure +- Fallback when merchant has no subscriptions +""" + +import uuid +from datetime import UTC, datetime, timedelta + +import pytest + +from app.api.deps import get_current_merchant_from_cookie_or_header +from app.modules.billing.models import ( + MerchantSubscription, + SubscriptionStatus, + SubscriptionTier, +) +from app.modules.tenancy.models import Merchant, Platform, User +from app.modules.tenancy.models.platform_module import PlatformModule +from main import app +from models.schema.auth import UserContext + +# ============================================================================ +# Fixtures +# ============================================================================ + +BASE = "/api/v1/merchants/core/menu/render/merchant" + + +@pytest.fixture +def menu_owner(db): + """Create a merchant owner user.""" + from middleware.auth import AuthManager + + auth = AuthManager() + user = User( + email=f"menuowner_{uuid.uuid4().hex[:8]}@test.com", + username=f"menuowner_{uuid.uuid4().hex[:8]}", + hashed_password=auth.hash_password("pass123"), + role="merchant_owner", + is_active=True, + ) + db.add(user) + db.commit() + db.refresh(user) + return user + + +@pytest.fixture +def menu_admin(db): + """Create a super admin for enabling modules.""" + from middleware.auth import AuthManager + + auth = AuthManager() + user = User( + email=f"menuadmin_{uuid.uuid4().hex[:8]}@test.com", + username=f"menuadmin_{uuid.uuid4().hex[:8]}", + hashed_password=auth.hash_password("pass123"), + role="super_admin", + is_active=True, + ) + db.add(user) + db.commit() + db.refresh(user) + return user + + +@pytest.fixture +def menu_platform(db): + """Create a platform.""" + platform = Platform( + code=f"mp_{uuid.uuid4().hex[:8]}", + name="Menu Test Platform", + is_active=True, + ) + db.add(platform) + db.commit() + db.refresh(platform) + return platform + + +@pytest.fixture +def menu_merchant(db, menu_owner): + """Create a merchant owned by menu_owner.""" + merchant = Merchant( + name="Menu Test Merchant", + owner_user_id=menu_owner.id, + contact_email=menu_owner.email, + is_active=True, + is_verified=True, + ) + db.add(merchant) + db.commit() + db.refresh(merchant) + return merchant + + +@pytest.fixture +def menu_tier(db, menu_platform): + """Create a subscription tier.""" + tier = SubscriptionTier( + code=f"pro_{uuid.uuid4().hex[:8]}", + name="Professional", + description="Pro tier", + price_monthly_cents=2900, + price_annual_cents=29000, + display_order=1, + is_active=True, + is_public=True, + platform_id=menu_platform.id, + ) + db.add(tier) + db.commit() + db.refresh(tier) + return tier + + +@pytest.fixture +def menu_subscription(db, menu_merchant, menu_platform, menu_tier): + """Create an active subscription for the merchant on the platform.""" + sub = MerchantSubscription( + merchant_id=menu_merchant.id, + platform_id=menu_platform.id, + tier_id=menu_tier.id, + status=SubscriptionStatus.ACTIVE.value, + is_annual=False, + period_start=datetime.now(UTC), + period_end=datetime.now(UTC) + timedelta(days=30), + ) + db.add(sub) + db.commit() + db.refresh(sub) + return sub + + +@pytest.fixture +def menu_loyalty_module(db, menu_platform, menu_admin): + """Enable loyalty module on the platform.""" + pm = PlatformModule( + platform_id=menu_platform.id, + module_code="loyalty", + is_enabled=True, + enabled_at=datetime.now(UTC), + enabled_by_user_id=menu_admin.id, + config={}, + ) + db.add(pm) + db.commit() + db.refresh(pm) + return pm + + +@pytest.fixture +def menu_auth(menu_owner): + """Override auth dependency for merchant cookie/header auth.""" + user_context = UserContext( + id=menu_owner.id, + email=menu_owner.email, + username=menu_owner.username, + role="merchant_owner", + is_active=True, + ) + + def _override(): + return user_context + + app.dependency_overrides[get_current_merchant_from_cookie_or_header] = _override + yield {"Authorization": "Bearer fake-token"} + app.dependency_overrides.pop(get_current_merchant_from_cookie_or_header, None) + + +# ============================================================================ +# Tests +# ============================================================================ + + +@pytest.mark.integration +@pytest.mark.core +class TestMerchantMenuRendering: + """Tests for GET /api/v1/merchants/core/menu/render/merchant.""" + + def test_requires_auth(self, client): + """Returns 401 without auth.""" + app.dependency_overrides.pop(get_current_merchant_from_cookie_or_header, None) + response = client.get(BASE) + assert response.status_code == 401 + + def test_returns_valid_structure( + self, client, db, menu_auth, menu_merchant, menu_subscription + ): + """Response has correct top-level structure.""" + response = client.get(BASE, headers=menu_auth) + assert response.status_code == 200 + data = response.json() + assert "frontend_type" in data + assert data["frontend_type"] == "merchant" + assert "sections" in data + assert isinstance(data["sections"], list) + + def test_returns_core_sections( + self, client, db, menu_auth, menu_merchant, menu_subscription + ): + """Menu includes core sections: main, billing, account.""" + response = client.get(BASE, headers=menu_auth) + assert response.status_code == 200 + data = response.json() + section_ids = {s["id"] for s in data["sections"]} + assert "main" in section_ids + assert "billing" in section_ids + assert "account" in section_ids + + def test_main_section_has_dashboard( + self, client, db, menu_auth, menu_merchant, menu_subscription + ): + """Main section contains the dashboard item.""" + response = client.get(BASE, headers=menu_auth) + data = response.json() + main = next(s for s in data["sections"] if s["id"] == "main") + item_ids = [i["id"] for i in main["items"]] + assert "dashboard" in item_ids + + def test_section_items_have_required_fields( + self, client, db, menu_auth, menu_merchant, menu_subscription + ): + """Each menu item has id, label, icon, and url.""" + response = client.get(BASE, headers=menu_auth) + data = response.json() + for section in data["sections"]: + for item in section["items"]: + assert "id" in item + assert "label" in item + assert "icon" in item + assert "url" in item + assert item["label"] is not None + assert item["url"].startswith("/merchants/") + + def test_sections_have_correct_labels( + self, client, db, menu_auth, menu_merchant, menu_subscription + ): + """Labeled sections have non-null labels, main section has null label.""" + response = client.get(BASE, headers=menu_auth) + data = response.json() + for section in data["sections"]: + if section["id"] == "main": + assert section["label"] is None + else: + assert section["label"] is not None + + +@pytest.mark.integration +@pytest.mark.core +class TestMerchantMenuModuleGating: + """Tests for module-based menu filtering.""" + + def test_loyalty_appears_when_module_enabled( + self, client, db, menu_auth, menu_merchant, menu_subscription, + menu_loyalty_module, + ): + """Loyalty section appears when loyalty module is enabled on subscribed platform.""" + response = client.get(BASE, headers=menu_auth) + assert response.status_code == 200 + data = response.json() + section_ids = {s["id"] for s in data["sections"]} + assert "loyalty" in section_ids + + def test_loyalty_hidden_when_module_not_enabled( + self, client, db, menu_auth, menu_merchant, menu_subscription, + ): + """Loyalty section is hidden when loyalty module is NOT enabled.""" + response = client.get(BASE, headers=menu_auth) + assert response.status_code == 200 + data = response.json() + section_ids = {s["id"] for s in data["sections"]} + assert "loyalty" not in section_ids + + def test_loyalty_item_has_correct_route( + self, client, db, menu_auth, menu_merchant, menu_subscription, + menu_loyalty_module, + ): + """Loyalty overview item has the correct URL.""" + response = client.get(BASE, headers=menu_auth) + data = response.json() + loyalty = next(s for s in data["sections"] if s["id"] == "loyalty") + overview = next(i for i in loyalty["items"] if i["id"] == "loyalty-overview") + assert overview["url"] == "/merchants/loyalty/overview" + + +@pytest.mark.integration +@pytest.mark.core +class TestMerchantMenuNoSubscription: + """Tests for menu when merchant has no subscriptions.""" + + def test_empty_menu_when_no_merchant(self, client, db, menu_auth): + """Returns empty sections when user has no merchant.""" + # menu_auth provides a user, but no merchant fixture is loaded + # The endpoint queries for merchant by owner_user_id — none exists for this user + # unless menu_merchant is requested + response = client.get(BASE, headers=menu_auth) + assert response.status_code == 200 + data = response.json() + assert data["frontend_type"] == "merchant" + assert data["sections"] == [] + + def test_core_sections_with_merchant_no_subscription( + self, client, db, menu_auth, menu_merchant, + ): + """With merchant but no subscription, core sections still appear.""" + response = client.get(BASE, headers=menu_auth) + assert response.status_code == 200 + data = response.json() + section_ids = {s["id"] for s in data["sections"]} + # Core modules (core, billing, tenancy) are always included + assert "main" in section_ids + assert "billing" in section_ids + assert "account" in section_ids + # Non-core modules require subscription + assert "loyalty" not in section_ids + + +@pytest.mark.integration +@pytest.mark.core +class TestMerchantMenuSubscriptionStatus: + """Tests for subscription status filtering.""" + + def test_trial_subscription_shows_modules( + self, client, db, menu_auth, menu_merchant, menu_platform, menu_tier, + menu_loyalty_module, + ): + """Trial subscription shows modules from that platform.""" + sub = MerchantSubscription( + merchant_id=menu_merchant.id, + platform_id=menu_platform.id, + tier_id=menu_tier.id, + status=SubscriptionStatus.TRIAL.value, + is_annual=False, + period_start=datetime.now(UTC), + period_end=datetime.now(UTC) + timedelta(days=14), + trial_ends_at=datetime.now(UTC) + timedelta(days=14), + ) + db.add(sub) + db.commit() + + response = client.get(BASE, headers=menu_auth) + assert response.status_code == 200 + section_ids = {s["id"] for s in response.json()["sections"]} + assert "loyalty" in section_ids + + def test_expired_subscription_hides_non_core_modules( + self, client, db, menu_auth, menu_merchant, menu_platform, menu_tier, + menu_loyalty_module, + ): + """Expired subscription does not show modules from that platform.""" + sub = MerchantSubscription( + merchant_id=menu_merchant.id, + platform_id=menu_platform.id, + tier_id=menu_tier.id, + status=SubscriptionStatus.EXPIRED.value, + is_annual=False, + period_start=datetime.now(UTC) - timedelta(days=60), + period_end=datetime.now(UTC) - timedelta(days=30), + ) + db.add(sub) + db.commit() + + response = client.get(BASE, headers=menu_auth) + assert response.status_code == 200 + section_ids = {s["id"] for s in response.json()["sections"]} + # Loyalty should NOT appear because subscription is expired + assert "loyalty" not in section_ids + # Core sections always appear + assert "main" in section_ids + assert "billing" in section_ids + + +@pytest.mark.integration +@pytest.mark.core +class TestMerchantMenuMultiPlatform: + """Tests for merchants subscribed to multiple platforms.""" + + def test_union_of_modules_from_multiple_platforms( + self, client, db, menu_auth, menu_merchant, menu_admin, + ): + """Modules enabled on different platforms are unioned together.""" + # Platform A with loyalty enabled + platform_a = Platform( + code=f"mpa_{uuid.uuid4().hex[:8]}", + name="Platform A", + is_active=True, + ) + db.add(platform_a) + db.flush() + + tier_a = SubscriptionTier( + code=f"ta_{uuid.uuid4().hex[:8]}", + name="Tier A", + description="T", + price_monthly_cents=1000, + display_order=1, + is_active=True, + is_public=True, + platform_id=platform_a.id, + ) + db.add(tier_a) + db.flush() + + db.add(PlatformModule( + platform_id=platform_a.id, + module_code="loyalty", + is_enabled=True, + enabled_at=datetime.now(UTC), + enabled_by_user_id=menu_admin.id, + config={}, + )) + + sub_a = MerchantSubscription( + merchant_id=menu_merchant.id, + platform_id=platform_a.id, + tier_id=tier_a.id, + status=SubscriptionStatus.ACTIVE.value, + is_annual=False, + period_start=datetime.now(UTC), + period_end=datetime.now(UTC) + timedelta(days=30), + ) + db.add(sub_a) + + # Platform B without loyalty + platform_b = Platform( + code=f"mpb_{uuid.uuid4().hex[:8]}", + name="Platform B", + is_active=True, + ) + db.add(platform_b) + db.flush() + + tier_b = SubscriptionTier( + code=f"tb_{uuid.uuid4().hex[:8]}", + name="Tier B", + description="T", + price_monthly_cents=1000, + display_order=1, + is_active=True, + is_public=True, + platform_id=platform_b.id, + ) + db.add(tier_b) + db.flush() + + sub_b = MerchantSubscription( + merchant_id=menu_merchant.id, + platform_id=platform_b.id, + tier_id=tier_b.id, + status=SubscriptionStatus.ACTIVE.value, + is_annual=False, + period_start=datetime.now(UTC), + period_end=datetime.now(UTC) + timedelta(days=30), + ) + db.add(sub_b) + db.commit() + + response = client.get(BASE, headers=menu_auth) + assert response.status_code == 200 + section_ids = {s["id"] for s in response.json()["sections"]} + # Loyalty enabled on Platform A should appear in the union + assert "loyalty" in section_ids + # Core sections always present + assert "main" in section_ids + assert "billing" in section_ids + assert "account" in section_ids diff --git a/app/modules/core/tests/unit/test_menu_discovery_service.py b/app/modules/core/tests/unit/test_menu_discovery_service.py index 2fe6b4f5..ac206e58 100644 --- a/app/modules/core/tests/unit/test_menu_discovery_service.py +++ b/app/modules/core/tests/unit/test_menu_discovery_service.py @@ -3,6 +3,7 @@ import pytest from app.modules.core.services.menu_discovery_service import MenuDiscoveryService +from app.modules.enums import FrontendType @pytest.mark.unit @@ -16,3 +17,165 @@ class TestMenuDiscoveryService: def test_service_instantiation(self): """Service can be instantiated.""" assert self.service is not None + + def test_discover_all_menus_includes_merchant(self): + """discover_all_menus() includes FrontendType.MERCHANT entries.""" + menus = self.service.discover_all_menus() + assert FrontendType.MERCHANT in menus + merchant_sections = menus[FrontendType.MERCHANT] + assert len(merchant_sections) > 0, "Expected at least one MERCHANT menu section" + + def test_merchant_menu_has_expected_section_ids(self): + """MERCHANT menus contain main, billing, account, and loyalty sections.""" + menus = self.service.discover_all_menus() + section_ids = {s.id for s in menus[FrontendType.MERCHANT]} + assert "main" in section_ids + assert "billing" in section_ids + assert "account" in section_ids + assert "loyalty" in section_ids + + def test_merchant_main_section_has_dashboard(self): + """Main section contains the dashboard item.""" + menus = self.service.discover_all_menus() + main_sections = [s for s in menus[FrontendType.MERCHANT] if s.id == "main"] + assert len(main_sections) == 1 + item_ids = [i.id for i in main_sections[0].items] + assert "dashboard" in item_ids + + def test_merchant_billing_section_items(self): + """Billing section contains subscriptions and invoices.""" + menus = self.service.discover_all_menus() + billing_sections = [s for s in menus[FrontendType.MERCHANT] if s.id == "billing"] + assert len(billing_sections) == 1 + item_ids = [i.id for i in billing_sections[0].items] + assert "subscriptions" in item_ids + assert "invoices" in item_ids + + def test_merchant_account_section_items(self): + """Account section contains stores and profile.""" + menus = self.service.discover_all_menus() + account_sections = [s for s in menus[FrontendType.MERCHANT] if s.id == "account"] + assert len(account_sections) == 1 + item_ids = [i.id for i in account_sections[0].items] + assert "stores" in item_ids + assert "profile" in item_ids + + def test_merchant_loyalty_section_items(self): + """Loyalty section contains loyalty-overview.""" + menus = self.service.discover_all_menus() + loyalty_sections = [s for s in menus[FrontendType.MERCHANT] if s.id == "loyalty"] + assert len(loyalty_sections) == 1 + item_ids = [i.id for i in loyalty_sections[0].items] + assert "loyalty-overview" in item_ids + + def test_get_all_menu_items_merchant(self): + """get_all_menu_items returns items for MERCHANT frontend type.""" + items = self.service.get_all_menu_items(FrontendType.MERCHANT) + assert len(items) > 0 + item_ids = {i.id for i in items} + assert "dashboard" in item_ids + assert "subscriptions" in item_ids + assert "loyalty-overview" in item_ids + + def test_get_all_menu_item_ids_merchant(self): + """get_all_menu_item_ids returns IDs for MERCHANT frontend type.""" + item_ids = self.service.get_all_menu_item_ids(FrontendType.MERCHANT) + assert "dashboard" in item_ids + assert "subscriptions" in item_ids + assert "invoices" in item_ids + assert "stores" in item_ids + assert "profile" in item_ids + assert "loyalty-overview" in item_ids + + def test_get_mandatory_item_ids_merchant(self): + """Mandatory items for MERCHANT include dashboard and subscriptions.""" + mandatory = self.service.get_mandatory_item_ids(FrontendType.MERCHANT) + assert "dashboard" in mandatory + assert "subscriptions" in mandatory + + def test_merchant_sections_have_expected_orders(self): + """MERCHANT menu sections have the expected order values.""" + menus = self.service.discover_all_menus() + sections = menus[FrontendType.MERCHANT] + order_map = {s.id: s.order for s in sections} + # main is lowest, account is highest + assert order_map["main"] < order_map["billing"] + assert order_map["billing"] < order_map["loyalty"] + assert order_map["loyalty"] < order_map["account"] + + +@pytest.mark.unit +@pytest.mark.core +class TestMenuDiscoveryServiceEnabledModuleCodes: + """Test enabled_module_codes parameter on get_menu_sections_for_frontend.""" + + def setup_method(self): + self.service = MenuDiscoveryService() + + def test_all_modules_enabled_shows_all_sections(self, db): + """When all module codes are provided, all sections appear.""" + enabled = {"core", "billing", "tenancy", "loyalty"} + sections = self.service.get_menu_sections_for_frontend( + db, FrontendType.MERCHANT, enabled_module_codes=enabled, + ) + section_ids = {s.id for s in sections} + assert "main" in section_ids + assert "billing" in section_ids + assert "account" in section_ids + assert "loyalty" in section_ids + + def test_loyalty_disabled_hides_loyalty_section(self, db): + """When loyalty is not in enabled_module_codes, its section still appears + but items are marked as disabled (filtered out by get_menu_for_frontend).""" + enabled = {"core", "billing", "tenancy"} + sections = self.service.get_menu_sections_for_frontend( + db, FrontendType.MERCHANT, enabled_module_codes=enabled, + ) + # Sections still exist, but loyalty items are marked disabled + loyalty_sections = [s for s in sections if s.id == "loyalty"] + if loyalty_sections: + for item in loyalty_sections[0].items: + assert item.is_module_enabled is False + + def test_get_menu_for_frontend_filters_disabled_modules(self, db): + """get_menu_for_frontend removes items from disabled modules.""" + enabled = {"core", "billing", "tenancy"} # No loyalty + sections = self.service.get_menu_for_frontend( + db, FrontendType.MERCHANT, enabled_module_codes=enabled, + ) + section_ids = {s.id for s in sections} + assert "main" in section_ids + assert "billing" in section_ids + assert "account" in section_ids + assert "loyalty" not in section_ids, "Loyalty section should be filtered out" + + def test_get_menu_for_frontend_includes_loyalty_when_enabled(self, db): + """get_menu_for_frontend includes loyalty when its module is enabled.""" + enabled = {"core", "billing", "tenancy", "loyalty"} + sections = self.service.get_menu_for_frontend( + db, FrontendType.MERCHANT, enabled_module_codes=enabled, + ) + section_ids = {s.id for s in sections} + assert "loyalty" in section_ids + + def test_core_only_shows_minimal_sections(self, db): + """With only core modules, only main section appears.""" + enabled = {"core"} + sections = self.service.get_menu_for_frontend( + db, FrontendType.MERCHANT, enabled_module_codes=enabled, + ) + section_ids = {s.id for s in sections} + assert "main" in section_ids + assert "billing" not in section_ids + assert "loyalty" not in section_ids + + def test_enabled_module_codes_none_shows_all(self, db): + """When enabled_module_codes is None and no platform_id, all modules shown.""" + sections = self.service.get_menu_for_frontend( + db, FrontendType.MERCHANT, enabled_module_codes=None, + ) + section_ids = {s.id for s in sections} + assert "main" in section_ids + assert "billing" in section_ids + assert "loyalty" in section_ids + assert "account" in section_ids diff --git a/app/modules/core/tests/unit/test_menu_service.py b/app/modules/core/tests/unit/test_menu_service.py index 02f21d55..a4e0629a 100644 --- a/app/modules/core/tests/unit/test_menu_service.py +++ b/app/modules/core/tests/unit/test_menu_service.py @@ -3,6 +3,7 @@ import pytest from app.modules.core.services.menu_service import MenuService +from app.modules.enums import FrontendType @pytest.mark.unit @@ -16,3 +17,96 @@ class TestMenuService: def test_service_instantiation(self): """Service can be instantiated.""" assert self.service is not None + + +@pytest.mark.unit +@pytest.mark.core +class TestMenuServiceMerchantRendering: + """Test get_menu_for_rendering with FrontendType.MERCHANT and enabled_module_codes.""" + + def setup_method(self): + self.service = MenuService() + + def test_render_merchant_menu_all_modules(self, db): + """Rendering with all modules shows all sections.""" + enabled = {"core", "billing", "tenancy", "loyalty"} + sections = self.service.get_menu_for_rendering( + db=db, + frontend_type=FrontendType.MERCHANT, + enabled_module_codes=enabled, + ) + section_ids = {s.id for s in sections} + assert "main" in section_ids + assert "billing" in section_ids + assert "account" in section_ids + assert "loyalty" in section_ids + + def test_render_merchant_menu_without_loyalty(self, db): + """Rendering without loyalty module hides loyalty section.""" + enabled = {"core", "billing", "tenancy"} + sections = self.service.get_menu_for_rendering( + db=db, + frontend_type=FrontendType.MERCHANT, + enabled_module_codes=enabled, + ) + section_ids = {s.id for s in sections} + assert "loyalty" not in section_ids + assert "main" in section_ids + assert "billing" in section_ids + + def test_render_merchant_menu_section_item_structure(self, db): + """Each section has items with expected attributes.""" + enabled = {"core", "billing", "tenancy", "loyalty"} + sections = self.service.get_menu_for_rendering( + db=db, + frontend_type=FrontendType.MERCHANT, + enabled_module_codes=enabled, + ) + for section in sections: + assert section.id + assert len(section.items) > 0 + for item in section.items: + assert item.id + assert item.label_key + assert item.icon + assert item.route + assert item.route.startswith("/merchants/") + + def test_render_merchant_menu_section_ordering(self, db): + """Sections are returned in correct order.""" + enabled = {"core", "billing", "tenancy", "loyalty"} + sections = self.service.get_menu_for_rendering( + db=db, + frontend_type=FrontendType.MERCHANT, + enabled_module_codes=enabled, + ) + orders = [s.order for s in sections] + assert orders == sorted(orders) + + def test_render_merchant_menu_no_enabled_modules_returns_nothing(self, db): + """With empty enabled set, no sections are returned.""" + sections = self.service.get_menu_for_rendering( + db=db, + frontend_type=FrontendType.MERCHANT, + enabled_module_codes=set(), + ) + assert len(sections) == 0 + + def test_enabled_module_codes_propagated(self, db): + """enabled_module_codes parameter is properly propagated to discovery service.""" + # With loyalty + with_loyalty = self.service.get_menu_for_rendering( + db=db, + frontend_type=FrontendType.MERCHANT, + enabled_module_codes={"core", "billing", "tenancy", "loyalty"}, + ) + # Without loyalty + without_loyalty = self.service.get_menu_for_rendering( + db=db, + frontend_type=FrontendType.MERCHANT, + enabled_module_codes={"core", "billing", "tenancy"}, + ) + with_ids = {s.id for s in with_loyalty} + without_ids = {s.id for s in without_loyalty} + assert "loyalty" in with_ids + assert "loyalty" not in without_ids diff --git a/app/modules/loyalty/definition.py b/app/modules/loyalty/definition.py index 3489c5b7..63244f59 100644 --- a/app/modules/loyalty/definition.py +++ b/app/modules/loyalty/definition.py @@ -117,6 +117,9 @@ loyalty_module = ModuleDefinition( "loyalty-cards", # Customer cards "loyalty-stats", # Store stats ], + FrontendType.MERCHANT: [ + "loyalty-overview", # Merchant loyalty overview + ], }, # New module-driven menu definitions menus={ @@ -175,6 +178,23 @@ loyalty_module = ModuleDefinition( ], ), ], + FrontendType.MERCHANT: [ + MenuSectionDefinition( + id="loyalty", + label_key="loyalty.menu.loyalty", + icon="gift", + order=60, + items=[ + MenuItemDefinition( + id="loyalty-overview", + label_key="loyalty.menu.overview", + icon="gift", + route="/merchants/loyalty/overview", + order=10, + ), + ], + ), + ], FrontendType.STOREFRONT: [ MenuSectionDefinition( id="account", diff --git a/app/modules/tenancy/definition.py b/app/modules/tenancy/definition.py index 8edbf305..07101019 100644 --- a/app/modules/tenancy/definition.py +++ b/app/modules/tenancy/definition.py @@ -91,6 +91,10 @@ tenancy_module = ModuleDefinition( FrontendType.STORE: [ "team", ], + FrontendType.MERCHANT: [ + "stores", + "profile", + ], }, # New module-driven menu definitions menus={ @@ -160,6 +164,30 @@ tenancy_module = ModuleDefinition( ], ), ], + FrontendType.MERCHANT: [ + MenuSectionDefinition( + id="account", + label_key="tenancy.menu.account", + icon="cog", + order=900, + items=[ + MenuItemDefinition( + id="stores", + label_key="tenancy.menu.stores", + icon="shopping-bag", + route="/merchants/account/stores", + order=10, + ), + MenuItemDefinition( + id="profile", + label_key="tenancy.menu.profile", + icon="user", + route="/merchants/account/profile", + order=20, + ), + ], + ), + ], FrontendType.STORE: [ MenuSectionDefinition( id="account", diff --git a/app/templates/merchant/partials/sidebar.html b/app/templates/merchant/partials/sidebar.html index 7278fa6f..5323a6eb 100644 --- a/app/templates/merchant/partials/sidebar.html +++ b/app/templates/merchant/partials/sidebar.html @@ -1,60 +1,5 @@ {# app/templates/merchant/partials/sidebar.html #} -{# Collapsible sidebar sections with localStorage persistence - matching store pattern #} - -{# ============================================================================ - REUSABLE MACROS FOR SIDEBAR ITEMS - ============================================================================ #} - -{# Macro for collapsible section header #} -{% macro section_header(title, section_key, icon=none) %} -
-
-
- -{% endmacro %} - -{# Macro for collapsible section content wrapper #} -{% macro section_content(section_key) %} - -{% endmacro %} - -{# Macro for menu item - uses static href (no storeCode needed) #} -{% macro menu_item(page_id, path, icon, label) %} -
  • - - - - {{ label }} - -
  • -{% endmacro %} +{# Dynamic sidebar driven by menu discovery API - matching admin pattern #} {# ============================================================================ SIDEBAR CONTENT (shared between desktop and mobile) @@ -69,24 +14,92 @@ Merchant Portal - - + +
    +
    +
    +
    +
    - - {{ section_header('Billing', 'billing', 'credit-card') }} - {% call section_content('billing') %} - {{ menu_item('subscriptions', '/merchants/billing/subscriptions', 'clipboard-list', 'Subscriptions') }} - {{ menu_item('invoices', '/merchants/billing/invoices', 'currency-euro', 'Billing History') }} - {% endcall %} + +
    + +
    + + +
    + +
    {% endmacro %}