fix: use metrics provider pattern for merchant dashboard stats

The merchant dashboard was showing subscription count as "Total Stores".
Add get_merchant_metrics() to MetricsProviderProtocol and implement it
in tenancy, billing, and customer providers. Dashboard now fetches real
stats from a new /merchants/core/dashboard/stats endpoint and displays
4 cards: active subscriptions, total stores, customers, team members.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-22 21:28:59 +01:00
parent 42b894094a
commit ff852f1ab3
9 changed files with 372 additions and 15 deletions

View File

@@ -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()

View File

@@ -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",

View File

@@ -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"])

View File

@@ -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)),
)

View File

@@ -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",
]

View File

@@ -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,

View File

@@ -13,7 +13,7 @@
</div>
<!-- Quick Stats -->
<div class="grid gap-6 mb-8 md:grid-cols-3">
<div class="grid gap-6 mb-8 md:grid-cols-4">
<!-- Active Subscriptions -->
<div class="flex items-center p-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
<div class="p-3 mr-4 text-purple-600 dark:text-purple-400 bg-purple-100 dark:bg-purple-900/30 rounded-full">
@@ -36,14 +36,25 @@
</div>
</div>
<!-- Current Plan -->
<!-- Customers -->
<div class="flex items-center p-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
<div class="p-3 mr-4 text-purple-600 dark:text-purple-400 bg-purple-100 dark:bg-purple-900/30 rounded-full">
<span x-html="$icon('sparkles', 'w-6 h-6')"></span>
<div class="p-3 mr-4 text-blue-600 dark:text-blue-400 bg-blue-100 dark:bg-blue-900/30 rounded-full">
<span x-html="$icon('users', 'w-6 h-6')"></span>
</div>
<div>
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Current Plan</p>
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100" x-text="stats.current_plan || '--'">--</p>
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Customers</p>
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100" x-text="stats.total_customers">--</p>
</div>
</div>
<!-- Team Members -->
<div class="flex items-center p-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
<div class="p-3 mr-4 text-orange-600 dark:text-orange-400 bg-orange-100 dark:bg-orange-900/30 rounded-full">
<span x-html="$icon('user-group', 'w-6 h-6')"></span>
</div>
<div>
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Team Members</p>
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100" x-text="stats.team_members">--</p>
</div>
</div>
</div>
@@ -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 {

View File

@@ -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()

View File

@@ -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()