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:
@@ -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>
|
||||
|
||||
@@ -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> ·
|
||||
Renews <span x-text="formatDate(sub.period_end)"></span>
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user