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:
@@ -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(
|
@admin_router.post(
|
||||||
"/merchants/{merchant_id}/platforms/{platform_id}",
|
"/merchants/{merchant_id}/platforms/{platform_id}",
|
||||||
response_model=MerchantSubscriptionAdminResponse,
|
response_model=MerchantSubscriptionAdminResponse,
|
||||||
@@ -273,53 +286,14 @@ def get_subscription_for_store(
|
|||||||
"""
|
"""
|
||||||
from app.modules.billing.services.feature_service import feature_service
|
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)
|
merchant_id, platform_ids = feature_service._get_merchant_and_platforms_for_store(db, store_id)
|
||||||
if merchant_id is None or not platform_ids:
|
if merchant_id is None or not platform_ids:
|
||||||
raise ResourceNotFoundException("Store", str(store_id))
|
raise ResourceNotFoundException("Store", str(store_id))
|
||||||
|
|
||||||
platforms_map = admin_subscription_service.get_platform_names_map(db)
|
results = admin_subscription_service.get_merchant_subscriptions_with_usage(
|
||||||
|
db, merchant_id
|
||||||
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, ""),
|
|
||||||
})
|
|
||||||
|
|
||||||
return {"subscriptions": results}
|
return {"subscriptions": results}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -286,6 +286,70 @@ class AdminSubscriptionService:
|
|||||||
p = db.query(Platform).filter(Platform.id == platform_id).first()
|
p = db.query(Platform).filter(Platform.id == platform_id).first()
|
||||||
return p.name if p else None
|
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
|
# Statistics
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|||||||
@@ -146,7 +146,8 @@ class SubscriptionService:
|
|||||||
return (
|
return (
|
||||||
db.query(MerchantSubscription)
|
db.query(MerchantSubscription)
|
||||||
.options(
|
.options(
|
||||||
joinedload(MerchantSubscription.tier),
|
joinedload(MerchantSubscription.tier)
|
||||||
|
.joinedload(SubscriptionTier.feature_limits),
|
||||||
joinedload(MerchantSubscription.platform),
|
joinedload(MerchantSubscription.platform),
|
||||||
)
|
)
|
||||||
.filter(MerchantSubscription.merchant_id == merchant_id)
|
.filter(MerchantSubscription.merchant_id == merchant_id)
|
||||||
|
|||||||
@@ -51,8 +51,7 @@ function adminMerchantDetail() {
|
|||||||
this.merchantId = match[1];
|
this.merchantId = match[1];
|
||||||
merchantDetailLog.info('Viewing merchant:', this.merchantId);
|
merchantDetailLog.info('Viewing merchant:', this.merchantId);
|
||||||
await this.loadMerchant();
|
await this.loadMerchant();
|
||||||
await this.loadPlatforms();
|
await Promise.all([this.loadPlatforms(), this.loadSubscriptions()]);
|
||||||
await this.loadSubscriptions();
|
|
||||||
} else {
|
} else {
|
||||||
merchantDetailLog.error('No merchant ID in URL');
|
merchantDetailLog.error('No merchant ID in URL');
|
||||||
this.error = 'Invalid merchant 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() {
|
async loadSubscriptions() {
|
||||||
if (!this.merchantId || this.platforms.length === 0) return;
|
if (!this.merchantId) return;
|
||||||
|
|
||||||
merchantDetailLog.info('Loading subscriptions for merchant:', this.merchantId);
|
merchantDetailLog.info('Loading subscriptions for merchant:', this.merchantId);
|
||||||
this.subscriptions = [];
|
this.subscriptions = [];
|
||||||
|
|
||||||
for (const platform of this.platforms) {
|
try {
|
||||||
try {
|
const url = `/admin/subscriptions/merchants/${this.merchantId}`;
|
||||||
const url = `/admin/subscriptions/merchants/${this.merchantId}/platforms/${platform.id}`;
|
const response = await apiClient.get(url);
|
||||||
const response = await apiClient.get(url);
|
this.subscriptions = response.subscriptions || [];
|
||||||
const sub = response.subscription || response;
|
} catch (error) {
|
||||||
|
merchantDetailLog.warn('Failed to load subscriptions:', error.message);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
merchantDetailLog.info('Subscriptions loaded:', {
|
merchantDetailLog.info('Subscriptions loaded:', {
|
||||||
|
|||||||
Reference in New Issue
Block a user