diff --git a/app/modules/billing/services/billing_metrics.py b/app/modules/billing/services/billing_metrics.py index 7fbc4e8c..4d5eb803 100644 --- a/app/modules/billing/services/billing_metrics.py +++ b/app/modules/billing/services/billing_metrics.py @@ -108,6 +108,45 @@ class BillingMetricsProvider: logger.warning(f"Failed to get billing platform metrics: {e}") return [] + def get_merchant_metrics( + self, + db: Session, + merchant_id: int, + context: MetricsContext | None = None, + ) -> list[MetricValue]: + """ + Get subscription metrics for a specific merchant. + + Provides: + - Active subscriptions (active + trial) + """ + from app.modules.billing.models import MerchantSubscription + + try: + active_subs = ( + db.query(func.count(MerchantSubscription.id)) + .filter( + MerchantSubscription.merchant_id == merchant_id, + MerchantSubscription.status.in_(["active", "trial"]), + ) + .scalar() + or 0 + ) + + return [ + MetricValue( + key="billing.active_subscriptions", + value=active_subs, + label="Active Subscriptions", + category="billing", + icon="clipboard-list", + description="Active or trial subscriptions for this merchant", + ), + ] + except Exception as e: + logger.warning(f"Failed to get billing merchant metrics: {e}") + return [] + # Singleton instance billing_metrics_provider = BillingMetricsProvider() diff --git a/app/modules/contracts/metrics.py b/app/modules/contracts/metrics.py index c4c949c1..c7a67e94 100644 --- a/app/modules/contracts/metrics.py +++ b/app/modules/contracts/metrics.py @@ -207,6 +207,28 @@ class MetricsProviderProtocol(Protocol): """ ... + def get_merchant_metrics( + self, + db: "Session", + merchant_id: int, + context: MetricsContext | None = None, + ) -> list[MetricValue]: + """ + Get metrics scoped to a specific merchant. + + Called by the merchant dashboard to display merchant-scoped statistics. + Should only include data belonging to the specified merchant. + + Args: + db: Database session for queries + merchant_id: ID of the merchant to get metrics for + context: Optional filtering/scoping context + + Returns: + List of MetricValue objects for this merchant + """ + ... + __all__ = [ "MetricValue", diff --git a/app/modules/core/routes/api/merchant.py b/app/modules/core/routes/api/merchant.py new file mode 100644 index 00000000..88f95b1e --- /dev/null +++ b/app/modules/core/routes/api/merchant.py @@ -0,0 +1,22 @@ +# app/modules/core/routes/api/merchant.py +""" +Core module merchant API routes. + +Auto-discovered by the route system (merchant.py in routes/api/ triggers +registration under /api/v1/merchants/core/*). + +Aggregates: +- /dashboard/* - Merchant dashboard statistics +""" + +from fastapi import APIRouter + +from .merchant_dashboard import merchant_dashboard_router + +ROUTE_CONFIG = { + "prefix": "/core", +} + +router = APIRouter() + +router.include_router(merchant_dashboard_router, tags=["merchant-dashboard"]) diff --git a/app/modules/core/routes/api/merchant_dashboard.py b/app/modules/core/routes/api/merchant_dashboard.py new file mode 100644 index 00000000..11fa010f --- /dev/null +++ b/app/modules/core/routes/api/merchant_dashboard.py @@ -0,0 +1,58 @@ +# app/modules/core/routes/api/merchant_dashboard.py +""" +Merchant dashboard statistics endpoint. + +Merchant Context: Uses get_merchant_for_current_user to resolve the merchant +from the JWT token. The dependency guarantees an active merchant exists. + +This module uses the StatsAggregator service from core to collect metrics from all +enabled modules via the MetricsProvider protocol. +""" + +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.core.database import get_db +from app.modules.core.schemas.dashboard import MerchantDashboardStatsResponse +from app.modules.core.services.stats_aggregator import stats_aggregator + +merchant_dashboard_router = APIRouter(prefix="/dashboard") +logger = logging.getLogger(__name__) + + +@merchant_dashboard_router.get("/stats", response_model=MerchantDashboardStatsResponse) +def get_merchant_dashboard_stats( + request: Request, + merchant=Depends(get_merchant_for_current_user), + db: Session = Depends(get_db), +): + """ + Get merchant-scoped dashboard statistics. + + Returns aggregated statistics for the current merchant: + - Active subscriptions + - Total stores + - Total customers across all stores + - Team members across all stores + + 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 + + flat = stats_aggregator.get_merchant_stats_flat( + db=db, + merchant_id=merchant.id, + platform_id=platform_id, + ) + + return MerchantDashboardStatsResponse( + active_subscriptions=int(flat.get("billing.active_subscriptions", 0)), + total_stores=int(flat.get("tenancy.total_stores", 0)), + total_customers=int(flat.get("customers.total", 0)), + team_members=int(flat.get("tenancy.team_members", 0)), + ) diff --git a/app/modules/core/schemas/dashboard.py b/app/modules/core/schemas/dashboard.py index be5ed065..f1601e5f 100644 --- a/app/modules/core/schemas/dashboard.py +++ b/app/modules/core/schemas/dashboard.py @@ -221,6 +221,23 @@ class StoreDashboardStatsResponse(BaseModel): revenue: StoreRevenueStats +# ============================================================================ +# Merchant Dashboard Statistics +# ============================================================================ + + +class MerchantDashboardStatsResponse(BaseModel): + """Merchant dashboard statistics response schema. + + Used by: GET /api/v1/merchants/core/dashboard/stats + """ + + active_subscriptions: int = Field(0, description="Active or trial subscriptions") + total_stores: int = Field(0, description="Total stores owned by this merchant") + total_customers: int = Field(0, description="Total customers across all stores") + team_members: int = Field(0, description="Distinct active team members across stores") + + __all__ = [ # Stats responses "StatsResponse", @@ -240,4 +257,6 @@ __all__ = [ "StoreRevenueStats", "StoreInfo", "StoreDashboardStatsResponse", + # Merchant dashboard + "MerchantDashboardStatsResponse", ] diff --git a/app/modules/core/services/stats_aggregator.py b/app/modules/core/services/stats_aggregator.py index 90f6aff1..f8f636d2 100644 --- a/app/modules/core/services/stats_aggregator.py +++ b/app/modules/core/services/stats_aggregator.py @@ -196,6 +196,68 @@ class StatsAggregatorService: categorized = self.get_store_dashboard_stats(db, store_id, platform_id, context) return self._flatten_metrics(categorized) + def get_merchant_dashboard_stats( + self, + db: Session, + merchant_id: int, + platform_id: int, + context: MetricsContext | None = None, + ) -> dict[str, list[MetricValue]]: + """ + Get all metrics for a merchant, grouped by category. + + Called by the merchant dashboard to display merchant-scoped statistics. + + Args: + db: Database session + merchant_id: ID of the merchant to get metrics for + platform_id: Platform ID (for module enablement check) + context: Optional filtering/scoping context + + Returns: + Dict mapping category name to list of MetricValue objects + """ + providers = self._get_enabled_providers(db, platform_id) + result: dict[str, list[MetricValue]] = {} + + for module, provider in providers: + if not hasattr(provider, "get_merchant_metrics"): + continue + try: + metrics = provider.get_merchant_metrics(db, merchant_id, context) + if metrics: + result[provider.metrics_category] = metrics + except Exception as e: + logger.warning( + f"Failed to get merchant metrics from module {module.code}: {e}" + ) + + return result + + def get_merchant_stats_flat( + self, + db: Session, + merchant_id: int, + platform_id: int, + context: MetricsContext | None = None, + ) -> dict[str, Any]: + """ + Get merchant metrics as a flat dictionary. + + Args: + db: Database session + merchant_id: ID of the merchant to get metrics for + platform_id: Platform ID (for module enablement check) + context: Optional filtering/scoping context + + Returns: + Flat dict mapping metric keys to values + """ + categorized = self.get_merchant_dashboard_stats( + db, merchant_id, platform_id, context + ) + return self._flatten_metrics(categorized) + def get_admin_stats_flat( self, db: Session, diff --git a/app/modules/core/templates/core/merchant/dashboard.html b/app/modules/core/templates/core/merchant/dashboard.html index 2a388f98..a0d9d0e1 100644 --- a/app/modules/core/templates/core/merchant/dashboard.html +++ b/app/modules/core/templates/core/merchant/dashboard.html @@ -13,7 +13,7 @@ -
+
@@ -36,14 +36,25 @@
- +
-
- +
+
-

Current Plan

-

--

+

Customers

+

--

+
+
+ + +
+
+ +
+
+

Team Members

+

--

@@ -106,7 +117,8 @@ function merchantDashboard() { stats: { active_subscriptions: '--', total_stores: '--', - current_plan: '--' + total_customers: '--', + team_members: '--' }, subscriptions: [], @@ -124,15 +136,19 @@ function merchantDashboard() { async loadDashboard() { try { - const data = await apiClient.get('/merchants/billing/subscriptions'); - this.subscriptions = data.subscriptions || data.items || []; + const [statsData, subsData] = await Promise.all([ + apiClient.get('/merchants/core/dashboard/stats'), + apiClient.get('/merchants/billing/subscriptions'), + ]); - const active = this.subscriptions.filter(s => s.status === 'active' || s.status === 'trial'); - this.stats.active_subscriptions = active.length; - this.stats.total_stores = this.subscriptions.length; - this.stats.current_plan = active.length > 0 - ? active[0].tier.charAt(0).toUpperCase() + active[0].tier.slice(1) - : 'None'; + // Stats from the aggregated endpoint + this.stats.active_subscriptions = statsData.active_subscriptions ?? 0; + this.stats.total_stores = statsData.total_stores ?? 0; + this.stats.total_customers = statsData.total_customers ?? 0; + this.stats.team_members = statsData.team_members ?? 0; + + // Subscriptions list for the overview table + this.subscriptions = subsData.subscriptions || subsData.items || []; } catch (error) { console.error('Failed to load dashboard:', error); } finally { diff --git a/app/modules/customers/services/customer_metrics.py b/app/modules/customers/services/customer_metrics.py index 5bbed274..06886129 100644 --- a/app/modules/customers/services/customer_metrics.py +++ b/app/modules/customers/services/customer_metrics.py @@ -196,6 +196,47 @@ class CustomerMetricsProvider: logger.warning(f"Failed to get customer platform metrics: {e}") return [] + def get_merchant_metrics( + self, + db: Session, + merchant_id: int, + context: MetricsContext | None = None, + ) -> list[MetricValue]: + """ + Get customer metrics scoped to a merchant. + + Aggregates customer counts across all stores owned by the merchant. + """ + from app.modules.customers.models import Customer + from app.modules.tenancy.models import Store + + try: + merchant_store_ids = ( + db.query(Store.id) + .filter(Store.merchant_id == merchant_id) + .subquery() + ) + + total_customers = ( + db.query(Customer) + .filter(Customer.store_id.in_(merchant_store_ids)) + .count() + ) + + return [ + MetricValue( + key="customers.total", + value=total_customers, + label="Total Customers", + category="customers", + icon="users", + description="Total customers across all merchant stores", + ), + ] + except Exception as e: + logger.warning(f"Failed to get customer merchant metrics: {e}") + return [] + # Singleton instance customer_metrics_provider = CustomerMetricsProvider() diff --git a/app/modules/tenancy/services/tenancy_metrics.py b/app/modules/tenancy/services/tenancy_metrics.py index bcaf7939..c44e5797 100644 --- a/app/modules/tenancy/services/tenancy_metrics.py +++ b/app/modules/tenancy/services/tenancy_metrics.py @@ -394,6 +394,84 @@ class TenancyMetricsProvider: logger.warning(f"Failed to get tenancy platform metrics: {e}") return [] + def get_merchant_metrics( + self, + db: Session, + merchant_id: int, + context: MetricsContext | None = None, + ) -> list[MetricValue]: + """ + Get metrics scoped to a specific merchant. + + Provides: + - Total stores owned by this merchant + - Active stores + - Distinct active team members across all merchant stores + """ + from app.modules.tenancy.models import Store, StoreUser + + try: + total_stores = ( + db.query(Store) + .filter(Store.merchant_id == merchant_id) + .count() + ) + + active_stores = ( + db.query(Store) + .filter( + Store.merchant_id == merchant_id, + Store.is_active == True, + ) + .count() + ) + + # Distinct active team members across all merchant stores + merchant_store_ids = ( + db.query(Store.id) + .filter(Store.merchant_id == merchant_id) + .subquery() + ) + team_members = ( + db.query(func.count(func.distinct(StoreUser.user_id))) + .filter( + StoreUser.store_id.in_(merchant_store_ids), + StoreUser.is_active == True, + ) + .scalar() + or 0 + ) + + return [ + MetricValue( + key="tenancy.total_stores", + value=total_stores, + label="Total Stores", + category="tenancy", + icon="shopping-bag", + description="Total stores owned by this merchant", + ), + MetricValue( + key="tenancy.active_stores", + value=active_stores, + label="Active Stores", + category="tenancy", + icon="check-circle", + description="Active stores owned by this merchant", + ), + MetricValue( + key="tenancy.team_members", + value=team_members, + label="Team Members", + category="tenancy", + icon="users", + description="Distinct active team members across all stores", + ), + ] + except Exception as e: + logger.warning(f"Failed to get tenancy merchant metrics: {e}") + return [] + # Singleton instance tenancy_metrics_provider = TenancyMetricsProvider()