diff --git a/app/modules/billing/routes/api/admin.py b/app/modules/billing/routes/api/admin.py index f5735e31..203a3785 100644 --- a/app/modules/billing/routes/api/admin.py +++ b/app/modules/billing/routes/api/admin.py @@ -59,8 +59,17 @@ def list_subscription_tiers( """List all subscription tiers.""" tiers = admin_subscription_service.get_tiers(db, include_inactive=include_inactive, platform_id=platform_id) + from app.modules.tenancy.models import Platform + + platforms_map = {p.id: p.name for p in db.query(Platform).all()} + tiers_response = [] + for t in tiers: + resp = SubscriptionTierResponse.model_validate(t) + resp.platform_name = platforms_map.get(t.platform_id) if t.platform_id else None + tiers_response.append(resp) + return SubscriptionTierListResponse( - tiers=[SubscriptionTierResponse.model_validate(t) for t in tiers], + tiers=tiers_response, total=len(tiers), ) diff --git a/app/modules/billing/routes/api/merchant.py b/app/modules/billing/routes/api/merchant.py index 926a396b..5ea5c7d0 100644 --- a/app/modules/billing/routes/api/merchant.py +++ b/app/modules/billing/routes/api/merchant.py @@ -19,6 +19,7 @@ registration under /api/v1/merchants/billing/*). import logging from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request +from pydantic import BaseModel from sqlalchemy.orm import Session from app.api.deps import get_current_merchant_from_cookie_or_header @@ -97,13 +98,15 @@ def list_merchant_subscriptions( merchant = _get_user_merchant(db, current_user) subscriptions = subscription_service.get_merchant_subscriptions(db, merchant.id) - return { - "subscriptions": [ - MerchantSubscriptionResponse.model_validate(sub) - for sub in subscriptions - ], - "total": len(subscriptions), - } + items = [] + for sub in subscriptions: + data = MerchantSubscriptionResponse.model_validate(sub).model_dump() + data["tier"] = sub.tier.code if sub.tier else None + data["tier_name"] = sub.tier.name if sub.tier else None + data["platform_name"] = sub.platform.name if sub.platform else "" + items.append(data) + + return {"subscriptions": items, "total": len(items)} @router.get("/subscriptions/{platform_id}") @@ -129,6 +132,11 @@ def get_merchant_subscription( detail=f"No subscription found for platform {platform_id}", ) + sub_data = MerchantSubscriptionResponse.model_validate(subscription).model_dump() + sub_data["tier"] = subscription.tier.code if subscription.tier else None + sub_data["tier_name"] = subscription.tier.name if subscription.tier else None + sub_data["platform_name"] = subscription.platform.name if subscription.platform else "" + tier_info = None if subscription.tier: tier = subscription.tier @@ -142,7 +150,7 @@ def get_merchant_subscription( ) return { - "subscription": MerchantSubscriptionResponse.model_validate(subscription), + "subscription": sub_data, "tier": tier_info, } @@ -180,6 +188,40 @@ def get_available_tiers( } +class ChangeTierRequest(BaseModel): + """Request for changing subscription tier.""" + + tier_code: str + is_annual: bool = False + + +@router.post("/subscriptions/{platform_id}/change-tier") +def change_subscription_tier( + request: Request, + tier_data: ChangeTierRequest, + platform_id: int = Path(..., description="Platform ID"), + current_user: UserContext = Depends(get_current_merchant_from_cookie_or_header), + db: Session = Depends(get_db), +): + """ + Change the subscription tier for a specific platform. + + Handles both Stripe-connected and non-Stripe subscriptions. + """ + merchant = _get_user_merchant(db, current_user) + result = billing_service.change_tier( + db, merchant.id, platform_id, tier_data.tier_code, tier_data.is_annual + ) + db.commit() + + logger.info( + f"Merchant {merchant.id} ({merchant.name}) changed tier to " + f"{tier_data.tier_code} on platform={platform_id}" + ) + + return result + + @router.post( "/subscriptions/{platform_id}/checkout", response_model=CheckoutResponse, diff --git a/app/modules/billing/schemas/billing.py b/app/modules/billing/schemas/billing.py index 1b3868a8..580c4323 100644 --- a/app/modules/billing/schemas/billing.py +++ b/app/modules/billing/schemas/billing.py @@ -74,6 +74,7 @@ class SubscriptionTierResponse(BaseModel): price_monthly_cents: int price_annual_cents: int | None = None platform_id: int | None = None + platform_name: str | None = None stripe_product_id: str | None = None stripe_price_monthly_id: str | None = None stripe_price_annual_id: str | None = None diff --git a/app/modules/billing/services/admin_subscription_service.py b/app/modules/billing/services/admin_subscription_service.py index 649b9b25..6a334a8e 100644 --- a/app/modules/billing/services/admin_subscription_service.py +++ b/app/modules/billing/services/admin_subscription_service.py @@ -204,12 +204,25 @@ class AdminSubscriptionService: result = self.get_subscription(db, merchant_id, platform_id) sub, merchant = result + # Handle tier_code separately: resolve to tier_id + tier_code = update_data.pop("tier_code", None) + if tier_code is not None: + if sub.stripe_subscription_id: + from app.modules.billing.services.billing_service import billing_service + billing_service.change_tier( + db, merchant_id, platform_id, tier_code, sub.is_annual + ) + else: + tier = self.get_tier_by_code(db, tier_code) + sub.tier_id = tier.id + for field, value in update_data.items(): setattr(sub, field, value) logger.info( f"Admin updated subscription for merchant {merchant_id} " f"on platform {platform_id}: {list(update_data.keys())}" + + (f", tier_code={tier_code}" if tier_code else "") ) return sub, merchant diff --git a/app/modules/billing/services/subscription_service.py b/app/modules/billing/services/subscription_service.py index fe893dc0..a0d7fb76 100644 --- a/app/modules/billing/services/subscription_service.py +++ b/app/modules/billing/services/subscription_service.py @@ -96,7 +96,8 @@ class SubscriptionService: db.query(MerchantSubscription) .options( joinedload(MerchantSubscription.tier) - .joinedload(SubscriptionTier.feature_limits) + .joinedload(SubscriptionTier.feature_limits), + joinedload(MerchantSubscription.platform), ) .filter( MerchantSubscription.merchant_id == merchant_id, diff --git a/app/modules/billing/static/admin/js/subscription-tiers.js b/app/modules/billing/static/admin/js/subscription-tiers.js index 46bbf4bc..66979f47 100644 --- a/app/modules/billing/static/admin/js/subscription-tiers.js +++ b/app/modules/billing/static/admin/js/subscription-tiers.js @@ -22,6 +22,8 @@ function adminSubscriptionTiers() { tiers: [], stats: null, includeInactive: false, + platforms: [], + filterPlatformId: '', // Feature management features: [], @@ -51,7 +53,8 @@ function adminSubscriptionTiers() { stripe_product_id: '', stripe_price_monthly_id: '', is_active: true, - is_public: true + is_public: true, + platform_id: null }, async init() { @@ -67,7 +70,8 @@ function adminSubscriptionTiers() { await Promise.all([ this.loadTiers(), this.loadStats(), - this.loadFeatures() + this.loadFeatures(), + this.loadPlatforms() ]); tiersLog.info('=== SUBSCRIPTION TIERS PAGE INITIALIZED ==='); } catch (error) { @@ -92,6 +96,7 @@ function adminSubscriptionTiers() { params.append('include_inactive', this.includeInactive); if (this.sortBy) params.append('sort_by', this.sortBy); if (this.sortOrder) params.append('sort_order', this.sortOrder); + if (this.filterPlatformId) params.append('platform_id', this.filterPlatformId); const data = await apiClient.get(`/admin/subscriptions/tiers?${params}`); this.tiers = data.tiers || []; @@ -125,6 +130,22 @@ function adminSubscriptionTiers() { } }, + async loadPlatforms() { + try { + const data = await apiClient.get('/admin/platforms'); + this.platforms = (data.platforms || []).map(p => ({ id: p.id, name: p.name })); + tiersLog.info(`Loaded ${this.platforms.length} platforms`); + } catch (error) { + tiersLog.error('Failed to load platforms:', error); + } + }, + + getPlatformName(platformId) { + if (!platformId) return 'Global'; + const platform = this.platforms.find(p => p.id === platformId); + return platform ? platform.name : `Platform #${platformId}`; + }, + openCreateModal() { this.editingTier = null; this.formData = { @@ -137,7 +158,8 @@ function adminSubscriptionTiers() { stripe_product_id: '', stripe_price_monthly_id: '', is_active: true, - is_public: true + is_public: true, + platform_id: null }; this.showModal = true; }, @@ -154,7 +176,8 @@ function adminSubscriptionTiers() { stripe_product_id: tier.stripe_product_id || '', stripe_price_monthly_id: tier.stripe_price_monthly_id || '', is_active: tier.is_active, - is_public: tier.is_public + is_public: tier.is_public, + platform_id: tier.platform_id || null }; this.showModal = true; }, diff --git a/app/modules/billing/templates/billing/admin/subscription-tiers.html b/app/modules/billing/templates/billing/admin/subscription-tiers.html index 782ca10b..df4b5ab8 100644 --- a/app/modules/billing/templates/billing/admin/subscription-tiers.html +++ b/app/modules/billing/templates/billing/admin/subscription-tiers.html @@ -71,6 +71,13 @@ Show inactive tiers +