feat: add single endpoint for merchant subscriptions with usage data

Replace N+1 per-platform API calls on merchant detail page with a single
GET /admin/subscriptions/merchants/{id} endpoint. Extract shared
subscription+usage aggregation logic into a reusable service method and
refactor the store endpoint to use it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-22 20:58:02 +01:00
parent b9ac252a9f
commit 42b894094a
4 changed files with 92 additions and 66 deletions

View File

@@ -176,6 +176,19 @@ def list_merchant_subscriptions(
)
@admin_router.get("/merchants/{merchant_id}")
def get_merchant_subscriptions(
merchant_id: int = Path(..., description="Merchant ID"),
current_user: UserContext = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""Get all subscriptions for a merchant with tier info and feature usage."""
results = admin_subscription_service.get_merchant_subscriptions_with_usage(
db, merchant_id
)
return {"subscriptions": results}
@admin_router.post(
"/merchants/{merchant_id}/platforms/{platform_id}",
response_model=MerchantSubscriptionAdminResponse,
@@ -273,53 +286,14 @@ def get_subscription_for_store(
"""
from app.modules.billing.services.feature_service import feature_service
# Resolve store to merchant + all platform IDs
# 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))
platforms_map = admin_subscription_service.get_platform_names_map(db)
results = []
for pid in platform_ids:
try:
sub, merchant = admin_subscription_service.get_subscription(db, merchant_id, pid)
except ResourceNotFoundException:
continue
# Get feature summary
features_summary = feature_service.get_merchant_features_summary(db, merchant_id, pid)
# Build tier info
tier_info = None
if sub.tier:
tier_info = {
"code": sub.tier.code,
"name": sub.tier.name,
"feature_codes": [fl.feature_code for fl in (sub.tier.feature_limits or [])],
}
# Build usage metrics (quantitative features only)
usage_metrics = []
for fs in features_summary:
if fs.feature_type == "quantitative" and fs.enabled:
usage_metrics.append({
"name": fs.name_key.replace("_", " ").title(),
"current": fs.current or 0,
"limit": fs.limit,
"percentage": fs.percent_used or 0,
"is_unlimited": fs.limit is None,
"is_at_limit": fs.remaining == 0 if fs.remaining is not None else False,
"is_approaching_limit": (fs.percent_used or 0) >= 80,
})
results.append({
"subscription": MerchantSubscriptionAdminResponse.model_validate(sub).model_dump(),
"tier": tier_info,
"features": usage_metrics,
"platform_name": platforms_map.get(pid, ""),
})
results = admin_subscription_service.get_merchant_subscriptions_with_usage(
db, merchant_id
)
return {"subscriptions": results}

View File

@@ -286,6 +286,70 @@ class AdminSubscriptionService:
p = db.query(Platform).filter(Platform.id == platform_id).first()
return p.name if p else None
# =========================================================================
# Merchant Subscriptions with Usage
# =========================================================================
def get_merchant_subscriptions_with_usage(
self, db: Session, merchant_id: int
) -> list[dict]:
"""Get all subscriptions for a merchant with tier info and feature usage.
Returns a list of dicts, each containing:
- subscription: serialized MerchantSubscription
- tier: tier info dict (code, name, feature_codes)
- features: list of quantitative usage metrics
- platform_id: int
- platform_name: str
"""
from app.modules.billing.schemas import MerchantSubscriptionAdminResponse
from app.modules.billing.services.feature_service import feature_service
from app.modules.billing.services.subscription_service import (
subscription_service,
)
subs = subscription_service.get_merchant_subscriptions(db, merchant_id)
platforms_map = self.get_platform_names_map(db)
results = []
for sub in subs:
features_summary = feature_service.get_merchant_features_summary(
db, merchant_id, sub.platform_id
)
tier_info = None
if sub.tier:
tier_info = {
"code": sub.tier.code,
"name": sub.tier.name,
"feature_codes": [
fl.feature_code for fl in (sub.tier.feature_limits or [])
],
}
usage_metrics = []
for fs in features_summary:
if fs.feature_type == "quantitative" and fs.enabled:
usage_metrics.append({
"name": fs.name_key.replace("_", " ").title(),
"current": fs.current or 0,
"limit": fs.limit,
"percentage": fs.percent_used or 0,
"is_unlimited": fs.limit is None,
"is_at_limit": fs.remaining == 0 if fs.remaining is not None else False,
"is_approaching_limit": (fs.percent_used or 0) >= 80,
})
results.append({
"subscription": MerchantSubscriptionAdminResponse.model_validate(sub).model_dump(),
"tier": tier_info,
"features": usage_metrics,
"platform_id": sub.platform_id,
"platform_name": platforms_map.get(sub.platform_id, ""),
})
return results
# =========================================================================
# Statistics
# =========================================================================

View File

@@ -146,7 +146,8 @@ class SubscriptionService:
return (
db.query(MerchantSubscription)
.options(
joinedload(MerchantSubscription.tier),
joinedload(MerchantSubscription.tier)
.joinedload(SubscriptionTier.feature_limits),
joinedload(MerchantSubscription.platform),
)
.filter(MerchantSubscription.merchant_id == merchant_id)

View File

@@ -51,8 +51,7 @@ function adminMerchantDetail() {
this.merchantId = match[1];
merchantDetailLog.info('Viewing merchant:', this.merchantId);
await this.loadMerchant();
await this.loadPlatforms();
await this.loadSubscriptions();
await Promise.all([this.loadPlatforms(), this.loadSubscriptions()]);
} else {
merchantDetailLog.error('No merchant ID in URL');
this.error = 'Invalid merchant URL';
@@ -110,31 +109,19 @@ function adminMerchantDetail() {
}
},
// Load subscriptions for all platforms
// Load all subscriptions for this merchant in a single call
async loadSubscriptions() {
if (!this.merchantId || this.platforms.length === 0) return;
if (!this.merchantId) return;
merchantDetailLog.info('Loading subscriptions for merchant:', this.merchantId);
this.subscriptions = [];
for (const platform of this.platforms) {
try {
const url = `/admin/subscriptions/merchants/${this.merchantId}/platforms/${platform.id}`;
const response = await apiClient.get(url);
const sub = response.subscription || response;
this.subscriptions.push({
subscription: sub,
tier: response.tier || null,
features: response.features || [],
platform_id: platform.id,
platform_name: platform.name,
});
} catch (error) {
if (error.status !== 404) {
merchantDetailLog.warn(`Failed to load subscription for platform ${platform.name}:`, error.message);
}
}
try {
const url = `/admin/subscriptions/merchants/${this.merchantId}`;
const response = await apiClient.get(url);
this.subscriptions = response.subscriptions || [];
} catch (error) {
merchantDetailLog.warn('Failed to load subscriptions:', error.message);
}
merchantDetailLog.info('Subscriptions loaded:', {