fix(billing): complete billing module — fix tier change, platform support, merchant portal

- Fix admin tier change: resolve tier_code→tier_id in update_subscription(),
  delegate to billing_service.change_tier() for Stripe-connected subs
- Add platform support to admin tiers page: platform column, filter dropdown,
  platform selector in create/edit modal, platform_name in tier API response
- Filter used platforms in create subscription modal on merchant detail page
- Enrich merchant portal API responses with tier code, tier_name, platform_name
- Add eager-load of platform relationship in get_merchant_subscription()
- Remove stale store_name/store_code references from merchant templates
- Add merchant tier change endpoint (POST /change-tier) and tier selector UI
  replacing broken requestUpgrade() button
- Fix subscription detail link to use platform_id instead of sub.id

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-10 20:49:48 +01:00
parent 0b37274140
commit d1fe3584ff
54 changed files with 222 additions and 52 deletions

View File

@@ -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),
)

View File

@@ -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,

View File

@@ -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

View File

@@ -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

View File

@@ -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,

View File

@@ -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;
},

View File

@@ -71,6 +71,13 @@
<input type="checkbox" x-model="includeInactive" @change="loadTiers()" class="mr-2 rounded border-gray-300 dark:border-gray-600 dark:bg-gray-700">
Show inactive tiers
</label>
<select x-model="filterPlatformId" @change="loadTiers()"
class="px-3 py-1.5 text-sm border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
<option value="">All Platforms</option>
<template x-for="p in platforms" :key="p.id">
<option :value="p.id" x-text="p.name"></option>
</template>
</select>
</div>
<button
@@ -87,6 +94,7 @@
<table class="w-full whitespace-nowrap">
{% call table_header_custom() %}
<th class="px-4 py-3">#</th>
<th class="px-4 py-3">Platform</th>
{{ th_sortable('code', 'Code', 'sortBy', 'sortOrder') }}
{{ th_sortable('name', 'Name', 'sortBy', 'sortOrder') }}
<th class="px-4 py-3 text-right">Monthly</th>
@@ -98,7 +106,7 @@
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
<template x-if="loading">
<tr>
<td colspan="8" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
<td colspan="9" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
<span x-html="$icon('refresh', 'inline w-6 h-6 animate-spin mr-2')"></span>
Loading tiers...
</td>
@@ -106,7 +114,7 @@
</template>
<template x-if="!loading && tiers.length === 0">
<tr>
<td colspan="8" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
<td colspan="9" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
No subscription tiers found.
</td>
</tr>
@@ -114,6 +122,7 @@
<template x-for="(tier, index) in tiers" :key="tier.id">
<tr class="text-gray-700 dark:text-gray-400" :class="{ 'opacity-50': !tier.is_active }">
<td class="px-4 py-3 text-sm" x-text="tier.display_order"></td>
<td class="px-4 py-3 text-sm" x-text="tier.platform_name || 'Global'"></td>
<td class="px-4 py-3">
<span class="px-2 py-1 text-xs font-medium rounded-full"
:class="{
@@ -186,6 +195,21 @@
>
</div>
<!-- Platform -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Platform</label>
<select
x-model="formData.platform_id"
:disabled="editingTier"
class="w-full px-3 py-2 border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-white disabled:opacity-50"
>
<option :value="null">Global (all platforms)</option>
<template x-for="p in platforms" :key="p.id">
<option :value="p.id" x-text="p.name"></option>
</template>
</select>
</div>
<!-- Name -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Name</label>

View File

@@ -77,7 +77,7 @@
<template x-for="sub in subscriptions" :key="sub.id">
<div class="flex items-center justify-between p-4 border border-gray-100 rounded-lg hover:bg-gray-50 transition-colors">
<div>
<p class="font-semibold text-gray-900" x-text="sub.platform_name || sub.store_name || 'Subscription'"></p>
<p class="font-semibold text-gray-900" x-text="sub.platform_name || 'Subscription'"></p>
<p class="text-sm text-gray-500">
<span x-text="sub.tier" class="capitalize"></span> &middot;
Renews <span x-text="formatDate(sub.period_end)"></span>

View File

@@ -42,7 +42,7 @@
<!-- Main Details Card -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200">
<div class="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900" x-text="subscription?.platform_name || subscription?.store_name || 'Subscription'"></h3>
<h3 class="text-lg font-semibold text-gray-900" x-text="subscription?.platform_name || 'Subscription'"></h3>
<span class="px-3 py-1 text-sm font-semibold rounded-full"
:class="{
'bg-green-100 text-green-800': subscription?.status === 'active',
@@ -68,8 +68,8 @@
<dd class="mt-1 text-lg font-semibold text-gray-900" x-text="formatDate(subscription?.period_end)"></dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Store Code</dt>
<dd class="mt-1 text-sm font-mono text-gray-700" x-text="subscription?.store_code || '-'"></dd>
<dt class="text-sm font-medium text-gray-500">Platform</dt>
<dd class="mt-1 text-sm text-gray-700" x-text="subscription?.platform_name || '-'"></dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Created</dt>
@@ -105,16 +105,32 @@
</div>
</div>
<!-- Upgrade Action -->
<div class="flex justify-end" x-show="subscription?.status === 'active' || subscription?.status === 'trial'">
<button @click="requestUpgrade()"
:disabled="upgrading"
class="inline-flex items-center px-5 py-2.5 text-sm font-semibold text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition-colors disabled:opacity-50">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 10l7-7m0 0l7 7m-7-7v18"/>
</svg>
<span x-text="upgrading ? 'Processing...' : 'Upgrade Plan'"></span>
</button>
<!-- Change Plan -->
<div x-show="availableTiers.length > 0 && (subscription?.status === 'active' || subscription?.status === 'trial')"
class="bg-white rounded-lg shadow-sm border border-gray-200">
<div class="px-6 py-4 border-b border-gray-200">
<h3 class="text-lg font-semibold text-gray-900">Change Plan</h3>
</div>
<div class="p-6 grid grid-cols-1 sm:grid-cols-3 gap-4">
<template x-for="t in availableTiers" :key="t.code">
<div class="p-4 border rounded-lg transition-colors"
:class="t.is_current ? 'border-indigo-500 bg-indigo-50' : 'border-gray-200 hover:border-gray-300'">
<h4 class="font-semibold text-gray-900" x-text="t.name"></h4>
<p class="text-sm text-gray-500 mt-1" x-text="formatCurrency(t.price_monthly_cents) + '/mo'"></p>
<template x-if="t.is_current">
<span class="inline-block mt-3 px-3 py-1 text-xs font-semibold text-indigo-700 bg-indigo-100 rounded-full">Current Plan</span>
</template>
<template x-if="!t.is_current">
<button @click="changeTier(t.code)"
:disabled="changingTier"
class="mt-3 inline-flex items-center px-4 py-2 text-sm font-medium text-white rounded-lg transition-colors disabled:opacity-50"
:class="t.can_upgrade ? 'bg-indigo-600 hover:bg-indigo-700' : 'bg-gray-600 hover:bg-gray-700'"
x-text="changingTier ? 'Processing...' : (t.can_upgrade ? 'Upgrade' : 'Downgrade')">
</button>
</template>
</div>
</template>
</div>
</div>
</div>
@@ -129,7 +145,8 @@ function merchantSubscriptionDetail() {
error: null,
successMessage: null,
subscription: null,
upgrading: false,
availableTiers: [],
changingTier: false,
init() {
this.loadSubscription();
@@ -140,8 +157,8 @@ function merchantSubscriptionDetail() {
return match ? decodeURIComponent(match[1]) : null;
},
getSubscriptionId() {
// Extract ID from URL: /merchants/billing/subscriptions/{id}
getPlatformId() {
// Extract platform_id from URL: /merchants/billing/subscriptions/{platform_id}
const parts = window.location.pathname.split('/');
return parts[parts.length - 1];
},
@@ -153,9 +170,9 @@ function merchantSubscriptionDetail() {
return;
}
const subId = this.getSubscriptionId();
const platformId = this.getPlatformId();
try {
const resp = await fetch(`/api/v1/merchants/billing/subscriptions/${subId}`, {
const resp = await fetch(`/api/v1/merchants/billing/subscriptions/${platformId}`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (resp.status === 401) {
@@ -163,40 +180,68 @@ function merchantSubscriptionDetail() {
return;
}
if (!resp.ok) throw new Error('Failed to load subscription');
this.subscription = await resp.json();
const data = await resp.json();
this.subscription = data.subscription || data;
} catch (err) {
console.error('Error:', err);
this.error = 'Failed to load subscription details.';
} finally {
this.loading = false;
}
// Load available tiers after subscription is loaded
await this.loadAvailableTiers(platformId);
},
async requestUpgrade() {
this.upgrading = true;
async loadAvailableTiers(platformId) {
const token = this.getToken();
if (!token) return;
try {
const resp = await fetch(`/api/v1/merchants/billing/subscriptions/${platformId}/tiers`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!resp.ok) return;
const data = await resp.json();
this.availableTiers = data.tiers || [];
} catch (err) {
console.error('Failed to load tiers:', err);
}
},
async changeTier(tierCode) {
if (!confirm(`Are you sure you want to change your plan to this tier?`)) return;
this.changingTier = true;
this.error = null;
this.successMessage = null;
const token = this.getToken();
const subId = this.getSubscriptionId();
const platformId = this.getPlatformId();
try {
const resp = await fetch(`/api/v1/merchants/billing/subscriptions/${subId}/upgrade`, {
const resp = await fetch(`/api/v1/merchants/billing/subscriptions/${platformId}/change-tier`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
},
body: JSON.stringify({ tier_code: tierCode, is_annual: this.subscription?.is_annual || false })
});
if (!resp.ok) {
const data = await resp.json();
throw new Error(data.detail || 'Upgrade request failed');
throw new Error(data.detail || 'Failed to change tier');
}
this.successMessage = 'Upgrade request submitted. You will be contacted with available options.';
const result = await resp.json();
this.successMessage = result.message || 'Plan changed successfully.';
// Reload data
this.loading = true;
await this.loadSubscription();
} catch (err) {
this.error = err.message;
} finally {
this.upgrading = false;
this.changingTier = false;
}
},
@@ -205,6 +250,14 @@ function merchantSubscriptionDetail() {
return str.charAt(0).toUpperCase() + str.slice(1);
},
formatCurrency(cents) {
if (cents === null || cents === undefined) return '-';
return new Intl.NumberFormat('de-LU', {
style: 'currency',
currency: 'EUR'
}).format(cents / 100);
},
formatDate(dateStr) {
if (!dateStr) return '-';
return new Date(dateStr).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });

View File

@@ -57,8 +57,7 @@
<template x-for="sub in subscriptions" :key="sub.id">
<tr class="text-gray-700 hover:bg-gray-50 transition-colors">
<td class="px-6 py-4">
<p class="font-semibold text-gray-900" x-text="sub.platform_name || sub.store_name"></p>
<p class="text-xs text-gray-400" x-text="sub.store_code || ''"></p>
<p class="font-semibold text-gray-900" x-text="sub.platform_name"></p>
</td>
<td class="px-6 py-4">
<span class="px-2.5 py-1 text-xs font-semibold rounded-full"
@@ -83,7 +82,7 @@
</td>
<td class="px-6 py-4 text-sm" x-text="formatDate(sub.period_end)"></td>
<td class="px-6 py-4 text-right">
<a :href="'/merchants/billing/subscriptions/' + sub.id"
<a :href="'/merchants/billing/subscriptions/' + sub.platform_id"
class="inline-flex items-center px-3 py-1.5 text-sm font-medium text-indigo-600 bg-indigo-50 rounded-lg hover:bg-indigo-100 transition-colors">
View Details
</a>

View File

@@ -156,11 +156,16 @@ function adminMerchantDetail() {
}
},
// Open create subscription modal
// Open create subscription modal (only show platforms without existing subscriptions)
async openCreateSubscriptionModal() {
const firstPlatformId = this.platforms.length > 0 ? this.platforms[0].id : null;
this.createForm = { platform_id: firstPlatformId, tier_code: 'essential', status: 'trial', trial_days: 14, is_annual: false };
await this.loadTiers(firstPlatformId);
const usedPlatformIds = this.subscriptions.map(e => e.platform_id);
const available = this.platforms.filter(p => !usedPlatformIds.includes(p.id));
if (available.length === 0) {
Utils.showToast('All platforms already have subscriptions', 'info');
return;
}
this.createForm = { platform_id: available[0].id, tier_code: 'essential', status: 'trial', trial_days: 14, is_annual: false };
await this.loadTiers(available[0].id);
this.showCreateSubscriptionModal = true;
},

View File

@@ -320,7 +320,7 @@
<select x-model="createForm.platform_id"
@change="onCreatePlatformChange()"
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 focus:outline-none focus:ring-2 focus:ring-purple-500">
<template x-for="p in platforms" :key="p.id">
<template x-for="p in platforms.filter(p => !subscriptions.some(s => s.platform_id === p.id))" :key="p.id">
<option :value="p.id" x-text="p.name"></option>
</template>
</select>