From 0b3727414054a9faa4911734d5a8e69ec1b12405 Mon Sep 17 00:00:00 2001 From: Samir Boulahtit Date: Tue, 10 Feb 2026 19:17:51 +0100 Subject: [PATCH] =?UTF-8?q?fix(subscriptions):=20fix=20subscription=20UI?= =?UTF-8?q?=20and=20API=20after=20store=E2=86=92merchant=20migration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Store detail page now shows all platform subscriptions instead of always "No Subscription Found". Subscriptions listing page renamed from Store to Merchant throughout (template, JS, menu, i18n) with Platform column added. Tiers API supports platform_id filtering. Merchant detail page no longer hardcodes 'oms' platform — loads all platforms, shows subscription cards per platform with labels, and the Create Subscription modal includes a platform selector with platform-filtered tiers. Create button always accessible in Quick Actions. Edit modal on /admin/subscriptions loads tiers from API filtered by platform instead of hardcoded options, sends tier_code (not tier) to match PATCH schema, and shows platform context. Co-Authored-By: Claude Opus 4.6 --- app/modules/billing/definition.py | 2 +- app/modules/billing/locales/de.json | 1 + app/modules/billing/locales/en.json | 1 + app/modules/billing/locales/fr.json | 1 + app/modules/billing/locales/lb.json | 1 + app/modules/billing/routes/api/admin.py | 104 +++++----- .../services/admin_subscription_service.py | 10 +- .../billing/services/feature_service.py | 45 +++-- .../billing/static/admin/js/subscriptions.js | 35 +++- .../billing/admin/subscriptions.html | 34 ++-- .../static/admin/js/merchant-detail.js | 117 +++++------ .../tenancy/static/admin/js/store-detail.js | 32 ++- .../tenancy/admin/merchant-detail.html | 182 ++++++++++-------- .../templates/tenancy/admin/store-detail.html | 175 +++++++++-------- 14 files changed, 414 insertions(+), 326 deletions(-) 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