diff --git a/app/modules/billing/definition.py b/app/modules/billing/definition.py index 3e716cf0..41d1bd54 100644 --- a/app/modules/billing/definition.py +++ b/app/modules/billing/definition.py @@ -171,7 +171,7 @@ billing_module = ModuleDefinition( ), MenuItemDefinition( id="subscriptions", - label_key="billing.menu.store_subscriptions", + label_key="billing.menu.merchant_subscriptions", icon="credit-card", route="/admin/subscriptions", order=20, diff --git a/app/modules/billing/locales/de.json b/app/modules/billing/locales/de.json index 8ff05f4a..c0036fb2 100644 --- a/app/modules/billing/locales/de.json +++ b/app/modules/billing/locales/de.json @@ -128,6 +128,7 @@ "billing_subscriptions": "Abrechnung & Abonnements", "subscription_tiers": "Abo-Stufen", "store_subscriptions": "Shop-Abonnements", + "merchant_subscriptions": "Händler-Abonnements", "billing_history": "Abrechnungsverlauf", "sales_orders": "Verkäufe & Bestellungen", "invoices": "Rechnungen", diff --git a/app/modules/billing/locales/en.json b/app/modules/billing/locales/en.json index 215ebbdd..c1f7b52b 100644 --- a/app/modules/billing/locales/en.json +++ b/app/modules/billing/locales/en.json @@ -128,6 +128,7 @@ "billing_subscriptions": "Billing & Subscriptions", "subscription_tiers": "Subscription Tiers", "store_subscriptions": "Store Subscriptions", + "merchant_subscriptions": "Merchant Subscriptions", "billing_history": "Billing History", "sales_orders": "Sales & Orders", "invoices": "Invoices", diff --git a/app/modules/billing/locales/fr.json b/app/modules/billing/locales/fr.json index d2afd269..da3df541 100644 --- a/app/modules/billing/locales/fr.json +++ b/app/modules/billing/locales/fr.json @@ -128,6 +128,7 @@ "billing_subscriptions": "Facturation et Abonnements", "subscription_tiers": "Niveaux d'abonnement", "store_subscriptions": "Abonnements des magasins", + "merchant_subscriptions": "Abonnements des marchands", "billing_history": "Historique de facturation", "sales_orders": "Ventes et Commandes", "invoices": "Factures", diff --git a/app/modules/billing/locales/lb.json b/app/modules/billing/locales/lb.json index a8e86afe..2a7042c0 100644 --- a/app/modules/billing/locales/lb.json +++ b/app/modules/billing/locales/lb.json @@ -128,6 +128,7 @@ "billing_subscriptions": "Ofrechnung & Abonnementer", "subscription_tiers": "Abo-Stufen", "store_subscriptions": "Buttek-Abonnementer", + "merchant_subscriptions": "Händler-Abonnementer", "billing_history": "Ofrechnungsverlaf", "sales_orders": "Verkaf & Bestellungen", "invoices": "Rechnungen", diff --git a/app/modules/billing/routes/api/admin.py b/app/modules/billing/routes/api/admin.py index 19f8a5bf..f5735e31 100644 --- a/app/modules/billing/routes/api/admin.py +++ b/app/modules/billing/routes/api/admin.py @@ -16,6 +16,7 @@ from sqlalchemy.orm import Session from app.api.deps import get_current_admin_api, require_module_access from app.core.database import get_db +from app.exceptions import ResourceNotFoundException from app.modules.billing.services import admin_subscription_service, subscription_service from app.modules.enums import FrontendType from app.modules.billing.schemas import ( @@ -51,11 +52,12 @@ admin_router = APIRouter( @admin_router.get("/tiers", response_model=SubscriptionTierListResponse) def list_subscription_tiers( include_inactive: bool = Query(False, description="Include inactive tiers"), + platform_id: int | None = Query(None, description="Filter tiers by platform"), current_user: UserContext = Depends(get_current_admin_api), db: Session = Depends(get_db), ): """List all subscription tiers.""" - tiers = admin_subscription_service.get_tiers(db, include_inactive=include_inactive) + tiers = admin_subscription_service.get_tiers(db, include_inactive=include_inactive, platform_id=platform_id) return SubscriptionTierListResponse( tiers=[SubscriptionTierResponse.model_validate(t) for t in tiers], @@ -133,15 +135,18 @@ def list_merchant_subscriptions( db, page=page, per_page=per_page, status=status, tier=tier, search=search ) + from app.modules.tenancy.models import Platform + subscriptions = [] for sub, merchant in data["results"]: sub_resp = MerchantSubscriptionAdminResponse.model_validate(sub) tier_name = sub.tier.name if sub.tier else None + platform = db.query(Platform).filter(Platform.id == sub.platform_id).first() subscriptions.append( MerchantSubscriptionWithMerchant( **sub_resp.model_dump(), merchant_name=merchant.name, - platform_name="", # Platform name can be resolved if needed + platform_name=platform.name if platform else "", tier_name=tier_name, ) ) @@ -244,63 +249,64 @@ def get_subscription_for_store( db: Session = Depends(get_db), ): """ - Get subscription + feature usage for a store (resolves to merchant). + Get subscriptions + feature usage for a store (resolves to merchant). Convenience endpoint for the admin store detail page. Resolves - store -> merchant -> subscription internally and returns subscription - info with feature usage metrics. + store -> merchant -> all platform subscriptions and returns a list + of subscription entries with feature usage metrics. """ from app.modules.billing.services.feature_service import feature_service - from app.modules.billing.schemas.subscription import FeatureSummaryResponse + from app.modules.tenancy.models import Platform - # Resolve store to merchant - merchant_id, platform_id = feature_service._get_merchant_for_store(db, store_id) - if merchant_id is None or platform_id is None: - raise HTTPException(status_code=404, detail="Store not found or has no merchant association") + # Resolve store to merchant + all platform IDs + 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 HTTPException(status_code=404, detail="Store not found or has no platform association") - # Get subscription - try: - sub, merchant = admin_subscription_service.get_subscription( - db, merchant_id, platform_id - ) - except Exception: - return { - "subscription": None, - "tier": None, - "features": [], - } + 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, platform_id) + # 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 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, - }) + # 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, + }) - return { - "subscription": MerchantSubscriptionAdminResponse.model_validate(sub).model_dump(), - "tier": tier_info, - "features": usage_metrics, - } + # Resolve platform name + platform = db.query(Platform).filter(Platform.id == pid).first() + + results.append({ + "subscription": MerchantSubscriptionAdminResponse.model_validate(sub).model_dump(), + "tier": tier_info, + "features": usage_metrics, + "platform_name": platform.name if platform else "", + }) + + return {"subscriptions": results} # ============================================================================ diff --git a/app/modules/billing/services/admin_subscription_service.py b/app/modules/billing/services/admin_subscription_service.py index 61a171bd..649b9b25 100644 --- a/app/modules/billing/services/admin_subscription_service.py +++ b/app/modules/billing/services/admin_subscription_service.py @@ -40,14 +40,20 @@ class AdminSubscriptionService: # ========================================================================= def get_tiers( - self, db: Session, include_inactive: bool = False + self, db: Session, include_inactive: bool = False, platform_id: int | None = None ) -> list[SubscriptionTier]: - """Get all subscription tiers.""" + """Get all subscription tiers, optionally filtered by platform.""" query = db.query(SubscriptionTier) if not include_inactive: query = query.filter(SubscriptionTier.is_active == True) # noqa: E712 + if platform_id is not None: + query = query.filter( + (SubscriptionTier.platform_id == platform_id) + | (SubscriptionTier.platform_id.is_(None)) + ) + return query.order_by(SubscriptionTier.display_order).all() def get_tier_by_code(self, db: Session, tier_code: str) -> SubscriptionTier: diff --git a/app/modules/billing/services/feature_service.py b/app/modules/billing/services/feature_service.py index 0ef8f332..85c30a25 100644 --- a/app/modules/billing/services/feature_service.py +++ b/app/modules/billing/services/feature_service.py @@ -115,27 +115,48 @@ class FeatureService: Returns: Tuple of (merchant_id, platform_id), either may be None """ - from app.modules.tenancy.models import Store + from app.modules.tenancy.models import Store, StorePlatform store = db.query(Store).filter(Store.id == store_id).first() if not store: return None, None merchant_id = store.merchant_id - # Get platform_id from store's platform association - platform_id = getattr(store, "platform_id", None) - if platform_id is None: - # Try StorePlatform junction - from app.modules.tenancy.models import StorePlatform - sp = ( - db.query(StorePlatform.platform_id) - .filter(StorePlatform.store_id == store_id) - .first() - ) - platform_id = sp[0] if sp else None + # Get primary platform_id from StorePlatform junction + sp = ( + db.query(StorePlatform.platform_id) + .filter(StorePlatform.store_id == store_id, StorePlatform.is_active == True) # noqa: E712 + .order_by(StorePlatform.is_primary.desc()) + .first() + ) + platform_id = sp[0] if sp else None return merchant_id, platform_id + def _get_merchant_and_platforms_for_store( + self, db: Session, store_id: int + ) -> tuple[int | None, list[int]]: + """ + Resolve store_id to (merchant_id, [platform_ids]). + + Returns all active platform IDs for the store's merchant, + ordered with the primary platform first. + """ + from app.modules.tenancy.models import Store, StorePlatform + + store = db.query(Store).filter(Store.id == store_id).first() + if not store: + return None, [] + + platform_ids = [ + sp[0] + for sp in db.query(StorePlatform.platform_id) + .filter(StorePlatform.store_id == store_id, StorePlatform.is_active == True) # noqa: E712 + .order_by(StorePlatform.is_primary.desc()) + .all() + ] + return store.merchant_id, platform_ids + def _get_subscription( self, db: Session, merchant_id: int, platform_id: int ) -> MerchantSubscription | None: diff --git a/app/modules/billing/static/admin/js/subscriptions.js b/app/modules/billing/static/admin/js/subscriptions.js index 7346b173..71e2be9d 100644 --- a/app/modules/billing/static/admin/js/subscriptions.js +++ b/app/modules/billing/static/admin/js/subscriptions.js @@ -39,17 +39,21 @@ function adminSubscriptions() { }, // Sorting - sortBy: 'store_name', + sortBy: 'merchant_name', sortOrder: 'asc', // Modal state showModal: false, editingSub: null, formData: { - tier: '', + tier_code: '', status: '' }, + // Tiers for edit modal + editTiers: [], + loadingTiers: false, + // Feature overrides featureOverrides: [], quantitativeFeatures: [], @@ -208,15 +212,34 @@ function adminSubscriptions() { async openEditModal(sub) { this.editingSub = sub; this.formData = { - tier: sub.tier, + tier_code: sub.tier, status: sub.status }; this.featureOverrides = []; this.quantitativeFeatures = []; this.showModal = true; - // Load feature catalog and merchant overrides - await this.loadFeatureOverrides(sub.merchant_id); + // Load tiers filtered by platform and feature overrides in parallel + await Promise.all([ + this.loadEditTiers(sub.platform_id), + this.loadFeatureOverrides(sub.merchant_id), + ]); + }, + + async loadEditTiers(platformId) { + this.loadingTiers = true; + try { + const url = platformId + ? `/admin/subscriptions/tiers?platform_id=${platformId}` + : '/admin/subscriptions/tiers'; + const response = await apiClient.get(url); + this.editTiers = response.tiers || []; + subsLog.info('Loaded tiers for edit modal:', this.editTiers.length); + } catch (error) { + subsLog.error('Failed to load tiers:', error); + } finally { + this.loadingTiers = false; + } }, closeModal() { @@ -312,7 +335,7 @@ function adminSubscriptions() { // Save feature overrides await this.saveFeatureOverrides(this.editingSub.merchant_id); - this.successMessage = `Subscription for "${this.editingSub.store_name || this.editingSub.merchant_name}" updated`; + this.successMessage = `Subscription for "${this.editingSub.merchant_name}" updated`; this.closeModal(); await this.loadSubscriptions(); diff --git a/app/modules/billing/templates/billing/admin/subscriptions.html b/app/modules/billing/templates/billing/admin/subscriptions.html index cda5501a..f8c332f4 100644 --- a/app/modules/billing/templates/billing/admin/subscriptions.html +++ b/app/modules/billing/templates/billing/admin/subscriptions.html @@ -5,12 +5,12 @@ {% from 'shared/macros/tables.html' import table_wrapper, table_header_custom, th_sortable %} {% from 'shared/macros/pagination.html' import pagination %} -{% block title %}Store Subscriptions{% endblock %} +{% block title %}Merchant Subscriptions{% endblock %} {% block alpine_data %}adminSubscriptions(){% endblock %} {% block content %} -{{ page_header_refresh('Store Subscriptions') }} +{{ page_header_refresh('Merchant Subscriptions') }} {{ alert_dynamic(type='success', title='Success', message_var='successMessage', show_condition='successMessage') }} @@ -94,7 +94,7 @@ type="text" x-model="filters.search" @input.debounce.300ms="loadSubscriptions()" - placeholder="Search store name..." + placeholder="Search merchant name..." class="w-full px-3 py-2 border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-white" > @@ -140,7 +140,8 @@ {% call table_wrapper() %} {% call table_header_custom() %} - {{ th_sortable('store_name', 'Store', 'sortBy', 'sortOrder') }} + {{ th_sortable('merchant_name', 'Merchant', 'sortBy', 'sortOrder') }} + {{ th_sortable('tier', 'Tier', 'sortBy', 'sortOrder') }} {{ th_sortable('status', 'Status', 'sortBy', 'sortOrder') }} @@ -150,7 +151,7 @@
PlatformFeatures