feat(billing): migrate frontend templates to feature provider system

Replace hardcoded subscription fields (orders_limit, products_limit,
team_members_limit) across 5 frontend pages with dynamic feature
provider APIs. Add admin convenience endpoint for store subscription
lookup. Remove legacy stubs (StoreSubscription, FeatureCode, Feature,
TIER_LIMITS, FeatureInfo, FeatureUpgradeInfo) and schema aliases.

Pages updated:
- Admin subscriptions: dynamic feature overrides editor
- Admin tiers: correct feature catalog/limits API URLs
- Store billing: usage metrics from /store/billing/usage
- Merchant subscription detail: tier.feature_limits rendering
- Admin store detail: new GET /admin/subscriptions/store/{id} endpoint

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-07 15:18:16 +01:00
parent 922616c9e3
commit 1db7e8a087
19 changed files with 2508 additions and 1205 deletions

View File

@@ -0,0 +1,215 @@
{# app/modules/billing/templates/billing/merchant/subscription-detail.html #}
{% extends "merchant/base.html" %}
{% block title %}Subscription Details{% endblock %}
{% block content %}
<div x-data="merchantSubscriptionDetail()">
<!-- Back link and header -->
<div class="mb-8">
<a href="/merchants/billing/subscriptions" class="inline-flex items-center text-sm text-indigo-600 hover:text-indigo-800 mb-4">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
</svg>
Back to Subscriptions
</a>
<h2 class="text-2xl font-bold text-gray-900">Subscription Details</h2>
</div>
<!-- Loading -->
<div x-show="loading" class="text-center py-12 text-gray-500">
<svg class="inline w-6 h-6 animate-spin mr-2" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
Loading...
</div>
<!-- Error -->
<div x-show="error" x-cloak class="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg">
<p class="text-sm text-red-800" x-text="error"></p>
</div>
<!-- Success Message -->
<div x-show="successMessage" x-cloak class="mb-6 p-4 bg-green-50 border border-green-200 rounded-lg">
<p class="text-sm text-green-800" x-text="successMessage"></p>
</div>
<!-- Subscription Info -->
<div x-show="!loading && subscription" class="space-y-6">
<!-- 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>
<span class="px-3 py-1 text-sm font-semibold rounded-full"
:class="{
'bg-green-100 text-green-800': subscription?.status === 'active',
'bg-blue-100 text-blue-800': subscription?.status === 'trial',
'bg-yellow-100 text-yellow-800': subscription?.status === 'past_due',
'bg-red-100 text-red-800': subscription?.status === 'cancelled',
'bg-gray-100 text-gray-600': subscription?.status === 'expired'
}"
x-text="subscription?.status?.replace('_', ' ').toUpperCase()"></span>
</div>
<div class="p-6">
<dl class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
<div>
<dt class="text-sm font-medium text-gray-500">Tier</dt>
<dd class="mt-1 text-lg font-semibold text-gray-900" x-text="capitalize(subscription?.tier)"></dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Billing Period</dt>
<dd class="mt-1 text-lg font-semibold text-gray-900" x-text="subscription?.is_annual ? 'Annual' : 'Monthly'"></dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Period End</dt>
<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>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Created</dt>
<dd class="mt-1 text-sm text-gray-700" x-text="formatDate(subscription?.created_at)"></dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Auto Renew</dt>
<dd class="mt-1 text-sm text-gray-700" x-text="subscription?.auto_renew !== false ? 'Yes' : 'No'"></dd>
</div>
</dl>
</div>
</div>
<!-- Feature Limits Card -->
<div 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">Plan Features</h3>
</div>
<div class="p-6">
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<template x-for="fl in (subscription?.tier?.feature_limits || subscription?.feature_limits || [])" :key="fl.feature_code">
<div class="p-4 bg-gray-50 rounded-lg">
<p class="text-sm text-gray-500" x-text="fl.feature_code.replace(/_/g, ' ')"></p>
<p class="text-xl font-bold text-gray-900" x-text="fl.limit_value || 'Unlimited'"></p>
</div>
</template>
<template x-if="!(subscription?.tier?.feature_limits || subscription?.feature_limits || []).length">
<div class="p-4 bg-gray-50 rounded-lg sm:col-span-3">
<p class="text-sm text-gray-500 text-center">No feature limits configured for this tier</p>
</div>
</template>
</div>
</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>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
function merchantSubscriptionDetail() {
return {
loading: true,
error: null,
successMessage: null,
subscription: null,
upgrading: false,
init() {
this.loadSubscription();
},
getToken() {
const match = document.cookie.match(/(?:^|;\s*)merchant_token=([^;]*)/);
return match ? decodeURIComponent(match[1]) : null;
},
getSubscriptionId() {
// Extract ID from URL: /merchants/billing/subscriptions/{id}
const parts = window.location.pathname.split('/');
return parts[parts.length - 1];
},
async loadSubscription() {
const token = this.getToken();
if (!token) {
window.location.href = '/merchants/login';
return;
}
const subId = this.getSubscriptionId();
try {
const resp = await fetch(`/api/v1/merchants/billing/subscriptions/${subId}`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (resp.status === 401) {
window.location.href = '/merchants/login';
return;
}
if (!resp.ok) throw new Error('Failed to load subscription');
this.subscription = await resp.json();
} catch (err) {
console.error('Error:', err);
this.error = 'Failed to load subscription details.';
} finally {
this.loading = false;
}
},
async requestUpgrade() {
this.upgrading = true;
this.error = null;
this.successMessage = null;
const token = this.getToken();
const subId = this.getSubscriptionId();
try {
const resp = await fetch(`/api/v1/merchants/billing/subscriptions/${subId}/upgrade`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
if (!resp.ok) {
const data = await resp.json();
throw new Error(data.detail || 'Upgrade request failed');
}
this.successMessage = 'Upgrade request submitted. You will be contacted with available options.';
} catch (err) {
this.error = err.message;
} finally {
this.upgrading = false;
}
},
capitalize(str) {
if (!str) return '-';
return str.charAt(0).toUpperCase() + str.slice(1);
},
formatDate(dateStr) {
if (!dateStr) return '-';
return new Date(dateStr).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
}
};
}
</script>
{% endblock %}