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.""" """List all subscription tiers."""
tiers = admin_subscription_service.get_tiers(db, include_inactive=include_inactive, platform_id=platform_id) 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( return SubscriptionTierListResponse(
tiers=[SubscriptionTierResponse.model_validate(t) for t in tiers], tiers=tiers_response,
total=len(tiers), total=len(tiers),
) )

View File

@@ -19,6 +19,7 @@ registration under /api/v1/merchants/billing/*).
import logging import logging
from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request
from pydantic import BaseModel
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.deps import get_current_merchant_from_cookie_or_header 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) merchant = _get_user_merchant(db, current_user)
subscriptions = subscription_service.get_merchant_subscriptions(db, merchant.id) subscriptions = subscription_service.get_merchant_subscriptions(db, merchant.id)
return { items = []
"subscriptions": [ for sub in subscriptions:
MerchantSubscriptionResponse.model_validate(sub) data = MerchantSubscriptionResponse.model_validate(sub).model_dump()
for sub in subscriptions data["tier"] = sub.tier.code if sub.tier else None
], data["tier_name"] = sub.tier.name if sub.tier else None
"total": len(subscriptions), data["platform_name"] = sub.platform.name if sub.platform else ""
} items.append(data)
return {"subscriptions": items, "total": len(items)}
@router.get("/subscriptions/{platform_id}") @router.get("/subscriptions/{platform_id}")
@@ -129,6 +132,11 @@ def get_merchant_subscription(
detail=f"No subscription found for platform {platform_id}", 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 tier_info = None
if subscription.tier: if subscription.tier:
tier = subscription.tier tier = subscription.tier
@@ -142,7 +150,7 @@ def get_merchant_subscription(
) )
return { return {
"subscription": MerchantSubscriptionResponse.model_validate(subscription), "subscription": sub_data,
"tier": tier_info, "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( @router.post(
"/subscriptions/{platform_id}/checkout", "/subscriptions/{platform_id}/checkout",
response_model=CheckoutResponse, response_model=CheckoutResponse,

View File

@@ -74,6 +74,7 @@ class SubscriptionTierResponse(BaseModel):
price_monthly_cents: int price_monthly_cents: int
price_annual_cents: int | None = None price_annual_cents: int | None = None
platform_id: int | None = None platform_id: int | None = None
platform_name: str | None = None
stripe_product_id: str | None = None stripe_product_id: str | None = None
stripe_price_monthly_id: str | None = None stripe_price_monthly_id: str | None = None
stripe_price_annual_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) result = self.get_subscription(db, merchant_id, platform_id)
sub, merchant = result 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(): for field, value in update_data.items():
setattr(sub, field, value) setattr(sub, field, value)
logger.info( logger.info(
f"Admin updated subscription for merchant {merchant_id} " f"Admin updated subscription for merchant {merchant_id} "
f"on platform {platform_id}: {list(update_data.keys())}" f"on platform {platform_id}: {list(update_data.keys())}"
+ (f", tier_code={tier_code}" if tier_code else "")
) )
return sub, merchant return sub, merchant

View File

@@ -96,7 +96,8 @@ class SubscriptionService:
db.query(MerchantSubscription) db.query(MerchantSubscription)
.options( .options(
joinedload(MerchantSubscription.tier) joinedload(MerchantSubscription.tier)
.joinedload(SubscriptionTier.feature_limits) .joinedload(SubscriptionTier.feature_limits),
joinedload(MerchantSubscription.platform),
) )
.filter( .filter(
MerchantSubscription.merchant_id == merchant_id, MerchantSubscription.merchant_id == merchant_id,

View File

@@ -22,6 +22,8 @@ function adminSubscriptionTiers() {
tiers: [], tiers: [],
stats: null, stats: null,
includeInactive: false, includeInactive: false,
platforms: [],
filterPlatformId: '',
// Feature management // Feature management
features: [], features: [],
@@ -51,7 +53,8 @@ function adminSubscriptionTiers() {
stripe_product_id: '', stripe_product_id: '',
stripe_price_monthly_id: '', stripe_price_monthly_id: '',
is_active: true, is_active: true,
is_public: true is_public: true,
platform_id: null
}, },
async init() { async init() {
@@ -67,7 +70,8 @@ function adminSubscriptionTiers() {
await Promise.all([ await Promise.all([
this.loadTiers(), this.loadTiers(),
this.loadStats(), this.loadStats(),
this.loadFeatures() this.loadFeatures(),
this.loadPlatforms()
]); ]);
tiersLog.info('=== SUBSCRIPTION TIERS PAGE INITIALIZED ==='); tiersLog.info('=== SUBSCRIPTION TIERS PAGE INITIALIZED ===');
} catch (error) { } catch (error) {
@@ -92,6 +96,7 @@ function adminSubscriptionTiers() {
params.append('include_inactive', this.includeInactive); params.append('include_inactive', this.includeInactive);
if (this.sortBy) params.append('sort_by', this.sortBy); if (this.sortBy) params.append('sort_by', this.sortBy);
if (this.sortOrder) params.append('sort_order', this.sortOrder); 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}`); const data = await apiClient.get(`/admin/subscriptions/tiers?${params}`);
this.tiers = data.tiers || []; 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() { openCreateModal() {
this.editingTier = null; this.editingTier = null;
this.formData = { this.formData = {
@@ -137,7 +158,8 @@ function adminSubscriptionTiers() {
stripe_product_id: '', stripe_product_id: '',
stripe_price_monthly_id: '', stripe_price_monthly_id: '',
is_active: true, is_active: true,
is_public: true is_public: true,
platform_id: null
}; };
this.showModal = true; this.showModal = true;
}, },
@@ -154,7 +176,8 @@ function adminSubscriptionTiers() {
stripe_product_id: tier.stripe_product_id || '', stripe_product_id: tier.stripe_product_id || '',
stripe_price_monthly_id: tier.stripe_price_monthly_id || '', stripe_price_monthly_id: tier.stripe_price_monthly_id || '',
is_active: tier.is_active, is_active: tier.is_active,
is_public: tier.is_public is_public: tier.is_public,
platform_id: tier.platform_id || null
}; };
this.showModal = true; 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"> <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 Show inactive tiers
</label> </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> </div>
<button <button
@@ -87,6 +94,7 @@
<table class="w-full whitespace-nowrap"> <table class="w-full whitespace-nowrap">
{% call table_header_custom() %} {% call table_header_custom() %}
<th class="px-4 py-3">#</th> <th class="px-4 py-3">#</th>
<th class="px-4 py-3">Platform</th>
{{ th_sortable('code', 'Code', 'sortBy', 'sortOrder') }} {{ th_sortable('code', 'Code', 'sortBy', 'sortOrder') }}
{{ th_sortable('name', 'Name', 'sortBy', 'sortOrder') }} {{ th_sortable('name', 'Name', 'sortBy', 'sortOrder') }}
<th class="px-4 py-3 text-right">Monthly</th> <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"> <tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
<template x-if="loading"> <template x-if="loading">
<tr> <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> <span x-html="$icon('refresh', 'inline w-6 h-6 animate-spin mr-2')"></span>
Loading tiers... Loading tiers...
</td> </td>
@@ -106,7 +114,7 @@
</template> </template>
<template x-if="!loading && tiers.length === 0"> <template x-if="!loading && tiers.length === 0">
<tr> <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. No subscription tiers found.
</td> </td>
</tr> </tr>
@@ -114,6 +122,7 @@
<template x-for="(tier, index) in tiers" :key="tier.id"> <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 }"> <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.display_order"></td>
<td class="px-4 py-3 text-sm" x-text="tier.platform_name || 'Global'"></td>
<td class="px-4 py-3"> <td class="px-4 py-3">
<span class="px-2 py-1 text-xs font-medium rounded-full" <span class="px-2 py-1 text-xs font-medium rounded-full"
:class="{ :class="{
@@ -186,6 +195,21 @@
> >
</div> </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 --> <!-- Name -->
<div> <div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Name</label> <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"> <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 class="flex items-center justify-between p-4 border border-gray-100 rounded-lg hover:bg-gray-50 transition-colors">
<div> <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"> <p class="text-sm text-gray-500">
<span x-text="sub.tier" class="capitalize"></span> &middot; <span x-text="sub.tier" class="capitalize"></span> &middot;
Renews <span x-text="formatDate(sub.period_end)"></span> Renews <span x-text="formatDate(sub.period_end)"></span>

View File

@@ -42,7 +42,7 @@
<!-- Main Details Card --> <!-- Main Details Card -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200"> <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"> <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" <span class="px-3 py-1 text-sm font-semibold rounded-full"
:class="{ :class="{
'bg-green-100 text-green-800': subscription?.status === 'active', '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> <dd class="mt-1 text-lg font-semibold text-gray-900" x-text="formatDate(subscription?.period_end)"></dd>
</div> </div>
<div> <div>
<dt class="text-sm font-medium text-gray-500">Store Code</dt> <dt class="text-sm font-medium text-gray-500">Platform</dt>
<dd class="mt-1 text-sm font-mono text-gray-700" x-text="subscription?.store_code || '-'"></dd> <dd class="mt-1 text-sm text-gray-700" x-text="subscription?.platform_name || '-'"></dd>
</div> </div>
<div> <div>
<dt class="text-sm font-medium text-gray-500">Created</dt> <dt class="text-sm font-medium text-gray-500">Created</dt>
@@ -105,16 +105,32 @@
</div> </div>
</div> </div>
<!-- Upgrade Action --> <!-- Change Plan -->
<div class="flex justify-end" x-show="subscription?.status === 'active' || subscription?.status === 'trial'"> <div x-show="availableTiers.length > 0 && (subscription?.status === 'active' || subscription?.status === 'trial')"
<button @click="requestUpgrade()" class="bg-white rounded-lg shadow-sm border border-gray-200">
:disabled="upgrading" <div class="px-6 py-4 border-b border-gray-200">
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"> <h3 class="text-lg font-semibold text-gray-900">Change Plan</h3>
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> </div>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 10l7-7m0 0l7 7m-7-7v18"/> <div class="p-6 grid grid-cols-1 sm:grid-cols-3 gap-4">
</svg> <template x-for="t in availableTiers" :key="t.code">
<span x-text="upgrading ? 'Processing...' : 'Upgrade Plan'"></span> <div class="p-4 border rounded-lg transition-colors"
</button> :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>
</div> </div>
@@ -129,7 +145,8 @@ function merchantSubscriptionDetail() {
error: null, error: null,
successMessage: null, successMessage: null,
subscription: null, subscription: null,
upgrading: false, availableTiers: [],
changingTier: false,
init() { init() {
this.loadSubscription(); this.loadSubscription();
@@ -140,8 +157,8 @@ function merchantSubscriptionDetail() {
return match ? decodeURIComponent(match[1]) : null; return match ? decodeURIComponent(match[1]) : null;
}, },
getSubscriptionId() { getPlatformId() {
// Extract ID from URL: /merchants/billing/subscriptions/{id} // Extract platform_id from URL: /merchants/billing/subscriptions/{platform_id}
const parts = window.location.pathname.split('/'); const parts = window.location.pathname.split('/');
return parts[parts.length - 1]; return parts[parts.length - 1];
}, },
@@ -153,9 +170,9 @@ function merchantSubscriptionDetail() {
return; return;
} }
const subId = this.getSubscriptionId(); const platformId = this.getPlatformId();
try { 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}` } headers: { 'Authorization': `Bearer ${token}` }
}); });
if (resp.status === 401) { if (resp.status === 401) {
@@ -163,40 +180,68 @@ function merchantSubscriptionDetail() {
return; return;
} }
if (!resp.ok) throw new Error('Failed to load subscription'); 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) { } catch (err) {
console.error('Error:', err); console.error('Error:', err);
this.error = 'Failed to load subscription details.'; this.error = 'Failed to load subscription details.';
} finally { } finally {
this.loading = false; this.loading = false;
} }
// Load available tiers after subscription is loaded
await this.loadAvailableTiers(platformId);
}, },
async requestUpgrade() { async loadAvailableTiers(platformId) {
this.upgrading = true; 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.error = null;
this.successMessage = null; this.successMessage = null;
const token = this.getToken(); const token = this.getToken();
const subId = this.getSubscriptionId(); const platformId = this.getPlatformId();
try { 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', method: 'POST',
headers: { headers: {
'Authorization': `Bearer ${token}`, 'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json' 'Content-Type': 'application/json'
} },
body: JSON.stringify({ tier_code: tierCode, is_annual: this.subscription?.is_annual || false })
}); });
if (!resp.ok) { if (!resp.ok) {
const data = await resp.json(); 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) { } catch (err) {
this.error = err.message; this.error = err.message;
} finally { } finally {
this.upgrading = false; this.changingTier = false;
} }
}, },
@@ -205,6 +250,14 @@ function merchantSubscriptionDetail() {
return str.charAt(0).toUpperCase() + str.slice(1); 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) { formatDate(dateStr) {
if (!dateStr) return '-'; if (!dateStr) return '-';
return new Date(dateStr).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' }); 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"> <template x-for="sub in subscriptions" :key="sub.id">
<tr class="text-gray-700 hover:bg-gray-50 transition-colors"> <tr class="text-gray-700 hover:bg-gray-50 transition-colors">
<td class="px-6 py-4"> <td class="px-6 py-4">
<p class="font-semibold text-gray-900" x-text="sub.platform_name || sub.store_name"></p> <p class="font-semibold text-gray-900" x-text="sub.platform_name"></p>
<p class="text-xs text-gray-400" x-text="sub.store_code || ''"></p>
</td> </td>
<td class="px-6 py-4"> <td class="px-6 py-4">
<span class="px-2.5 py-1 text-xs font-semibold rounded-full" <span class="px-2.5 py-1 text-xs font-semibold rounded-full"
@@ -83,7 +82,7 @@
</td> </td>
<td class="px-6 py-4 text-sm" x-text="formatDate(sub.period_end)"></td> <td class="px-6 py-4 text-sm" x-text="formatDate(sub.period_end)"></td>
<td class="px-6 py-4 text-right"> <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"> 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 View Details
</a> </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() { async openCreateSubscriptionModal() {
const firstPlatformId = this.platforms.length > 0 ? this.platforms[0].id : null; const usedPlatformIds = this.subscriptions.map(e => e.platform_id);
this.createForm = { platform_id: firstPlatformId, tier_code: 'essential', status: 'trial', trial_days: 14, is_annual: false }; const available = this.platforms.filter(p => !usedPlatformIds.includes(p.id));
await this.loadTiers(firstPlatformId); 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; this.showCreateSubscriptionModal = true;
}, },

View File

@@ -320,7 +320,7 @@
<select x-model="createForm.platform_id" <select x-model="createForm.platform_id"
@change="onCreatePlatformChange()" @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"> 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> <option :value="p.id" x-text="p.name"></option>
</template> </template>
</select> </select>