feat: complete subscription billing system phases 6-10
Phase 6 - Database-driven tiers: - Update subscription_service to query database first with legacy fallback - Add get_tier_info() db parameter and _get_tier_from_legacy() method Phase 7 - Platform health integration: - Add get_subscription_capacity() for theoretical vs actual capacity - Include subscription capacity in full health report Phase 8 - Background subscription tasks: - Add reset_period_counters() for billing period resets - Add check_trial_expirations() for trial management - Add sync_stripe_status() for Stripe synchronization - Add cleanup_stale_subscriptions() for maintenance - Add capture_capacity_snapshot() for daily metrics Phase 10 - Capacity planning & forecasting: - Add CapacitySnapshot model for historical tracking - Create capacity_forecast_service with growth trends - Add /subscription-capacity, /trends, /recommendations endpoints - Add /snapshot endpoint for manual captures Also includes billing API enhancements from phase 4: - Add upcoming-invoice, change-tier, addon purchase/cancel endpoints - Add UsageSummary schema for billing page - Enhance billing.js with addon management functions 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -106,6 +106,146 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Subscription Card -->
|
||||
<div class="px-4 py-3 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800" x-show="subscription">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
Subscription
|
||||
</h3>
|
||||
<button
|
||||
@click="showSubscriptionModal = true"
|
||||
class="flex items-center px-3 py-1.5 text-sm font-medium text-purple-600 hover:text-purple-700 dark:text-purple-400 dark:hover:text-purple-300">
|
||||
<span x-html="$icon('edit', 'w-4 h-4 mr-1')"></span>
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tier and Status -->
|
||||
<div class="flex flex-wrap items-center gap-4 mb-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">Tier:</span>
|
||||
<span class="px-2.5 py-0.5 text-sm font-medium rounded-full"
|
||||
:class="{
|
||||
'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300': subscription?.tier === 'essential',
|
||||
'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300': subscription?.tier === 'professional',
|
||||
'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300': subscription?.tier === 'business',
|
||||
'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300': subscription?.tier === 'enterprise'
|
||||
}"
|
||||
x-text="subscription?.tier ? subscription.tier.charAt(0).toUpperCase() + subscription.tier.slice(1) : '-'">
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">Status:</span>
|
||||
<span class="px-2.5 py-0.5 text-sm font-medium rounded-full"
|
||||
:class="{
|
||||
'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300': subscription?.status === 'active',
|
||||
'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300': subscription?.status === 'trial',
|
||||
'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300': subscription?.status === 'past_due',
|
||||
'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300': subscription?.status === 'cancelled' || subscription?.status === 'expired'
|
||||
}"
|
||||
x-text="subscription?.status ? subscription.status.replace('_', ' ').charAt(0).toUpperCase() + subscription.status.slice(1) : '-'">
|
||||
</span>
|
||||
</div>
|
||||
<template x-if="subscription?.is_annual">
|
||||
<span class="px-2.5 py-0.5 text-xs font-medium text-purple-800 bg-purple-100 rounded-full dark:bg-purple-900 dark:text-purple-300">
|
||||
Annual
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Period Info -->
|
||||
<div class="flex flex-wrap gap-4 mb-4 text-sm">
|
||||
<div>
|
||||
<span class="text-gray-600 dark:text-gray-400">Period:</span>
|
||||
<span class="ml-1 text-gray-700 dark:text-gray-300" x-text="formatDate(subscription?.period_start)"></span>
|
||||
<span class="text-gray-400">→</span>
|
||||
<span class="text-gray-700 dark:text-gray-300" x-text="formatDate(subscription?.period_end)"></span>
|
||||
</div>
|
||||
<template x-if="subscription?.trial_ends_at">
|
||||
<div>
|
||||
<span class="text-gray-600 dark:text-gray-400">Trial ends:</span>
|
||||
<span class="ml-1 text-gray-700 dark:text-gray-300" x-text="formatDate(subscription?.trial_ends_at)"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Usage Meters -->
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<!-- Orders Usage -->
|
||||
<div class="p-3 bg-gray-50 rounded-lg dark:bg-gray-700">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs font-medium text-gray-600 dark:text-gray-400 uppercase">Orders This Period</span>
|
||||
</div>
|
||||
<div class="flex items-baseline gap-1">
|
||||
<span class="text-xl font-bold text-gray-700 dark:text-gray-200" x-text="subscription?.orders_this_period || 0"></span>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">
|
||||
/ <span x-text="subscription?.orders_limit || '∞'"></span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-2 h-1.5 bg-gray-200 rounded-full dark:bg-gray-600" x-show="subscription?.orders_limit">
|
||||
<div class="h-1.5 rounded-full transition-all"
|
||||
:class="getUsageBarColor(subscription?.orders_this_period, subscription?.orders_limit)"
|
||||
:style="`width: ${Math.min(100, (subscription?.orders_this_period / subscription?.orders_limit) * 100)}%`">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Products Usage -->
|
||||
<div class="p-3 bg-gray-50 rounded-lg dark:bg-gray-700">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs font-medium text-gray-600 dark:text-gray-400 uppercase">Products</span>
|
||||
</div>
|
||||
<div class="flex items-baseline gap-1">
|
||||
<span class="text-xl font-bold text-gray-700 dark:text-gray-200" x-text="subscription?.products_count || 0"></span>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">
|
||||
/ <span x-text="subscription?.products_limit || '∞'"></span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-2 h-1.5 bg-gray-200 rounded-full dark:bg-gray-600" x-show="subscription?.products_limit">
|
||||
<div class="h-1.5 rounded-full transition-all"
|
||||
:class="getUsageBarColor(subscription?.products_count, subscription?.products_limit)"
|
||||
:style="`width: ${Math.min(100, (subscription?.products_count / subscription?.products_limit) * 100)}%`">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Team Members Usage -->
|
||||
<div class="p-3 bg-gray-50 rounded-lg dark:bg-gray-700">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs font-medium text-gray-600 dark:text-gray-400 uppercase">Team Members</span>
|
||||
</div>
|
||||
<div class="flex items-baseline gap-1">
|
||||
<span class="text-xl font-bold text-gray-700 dark:text-gray-200" x-text="subscription?.team_count || 0"></span>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">
|
||||
/ <span x-text="subscription?.team_members_limit || '∞'"></span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-2 h-1.5 bg-gray-200 rounded-full dark:bg-gray-600" x-show="subscription?.team_members_limit">
|
||||
<div class="h-1.5 rounded-full transition-all"
|
||||
:class="getUsageBarColor(subscription?.team_count, subscription?.team_members_limit)"
|
||||
:style="`width: ${Math.min(100, (subscription?.team_count / subscription?.team_members_limit) * 100)}%`">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No Subscription Notice -->
|
||||
<div class="px-4 py-3 mb-6 bg-yellow-50 border border-yellow-200 rounded-lg dark:bg-yellow-900/20 dark:border-yellow-800" x-show="!subscription && !loading">
|
||||
<div class="flex items-center gap-3">
|
||||
<span x-html="$icon('exclamation', 'w-5 h-5 text-yellow-600 dark:text-yellow-400')"></span>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-yellow-800 dark:text-yellow-200">No Subscription Found</p>
|
||||
<p class="text-sm text-yellow-700 dark:text-yellow-300">This vendor doesn't have a subscription yet.</p>
|
||||
</div>
|
||||
<button
|
||||
@click="createSubscription()"
|
||||
class="ml-auto px-3 py-1.5 text-sm font-medium text-white bg-yellow-600 rounded-lg hover:bg-yellow-700">
|
||||
Create Subscription
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Info Cards -->
|
||||
<div class="grid gap-6 mb-8 md:grid-cols-2">
|
||||
<!-- Basic Information -->
|
||||
|
||||
@@ -86,7 +86,7 @@
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
x-model="vendor.vendor_code"
|
||||
:value="vendor?.vendor_code || ''"
|
||||
disabled
|
||||
class="block w-full mt-1 text-sm bg-gray-100 border-gray-300 rounded-md dark:bg-gray-700 dark:text-gray-400 dark:border-gray-600 cursor-not-allowed"
|
||||
>
|
||||
@@ -171,7 +171,7 @@
|
||||
</span>
|
||||
<input
|
||||
type="email"
|
||||
x-model="vendor.owner_email"
|
||||
:value="vendor?.owner_email || ''"
|
||||
disabled
|
||||
class="block w-full mt-1 text-sm bg-gray-100 border-gray-300 rounded-md dark:bg-gray-700 dark:text-gray-400 dark:border-gray-600 cursor-not-allowed"
|
||||
>
|
||||
|
||||
64
app/templates/vendor/billing.html
vendored
64
app/templates/vendor/billing.html
vendored
@@ -37,6 +37,18 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="showAddonSuccessMessage">
|
||||
<div class="mb-6 p-4 bg-green-100 border border-green-400 text-green-700 rounded-lg flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<span x-html="$icon('check-circle', 'w-5 h-5 mr-2')"></span>
|
||||
<span>Add-on purchased successfully!</span>
|
||||
</div>
|
||||
<button @click="showAddonSuccessMessage = false" class="text-green-700 hover:text-green-900">
|
||||
<span x-html="$icon('x-mark', 'w-5 h-5')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Loading State -->
|
||||
<template x-if="loading">
|
||||
<div class="flex justify-center items-center py-12">
|
||||
@@ -314,18 +326,46 @@
|
||||
x-transition:leave-end="opacity-0"
|
||||
class="fixed inset-0 z-30 flex items-center justify-center overflow-auto bg-black bg-opacity-50"
|
||||
@click.self="showAddonsModal = false">
|
||||
<div class="w-full max-w-2xl mx-4 bg-white dark:bg-gray-800 rounded-lg shadow-xl">
|
||||
<div class="w-full max-w-2xl mx-4 bg-white dark:bg-gray-800 rounded-lg shadow-xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b dark:border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Add-ons</h3>
|
||||
<button @click="showAddonsModal = false" class="text-gray-400 hover:text-gray-600">
|
||||
<span x-html="$icon('x-mark', 'w-6 h-6')"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="p-6 overflow-y-auto">
|
||||
<!-- My Active Add-ons -->
|
||||
<template x-if="myAddons.length > 0">
|
||||
<div class="mb-6">
|
||||
<h4 class="text-sm font-semibold text-gray-600 dark:text-gray-400 mb-3 uppercase tracking-wide">Your Active Add-ons</h4>
|
||||
<div class="space-y-3">
|
||||
<template x-for="addon in myAddons.filter(a => a.status === 'active')" :key="addon.id">
|
||||
<div class="flex items-center justify-between p-4 bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-800 rounded-lg">
|
||||
<div>
|
||||
<h4 class="font-medium text-gray-700 dark:text-gray-200" x-text="addon.addon_name"></h4>
|
||||
<template x-if="addon.domain_name">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400" x-text="addon.domain_name"></p>
|
||||
</template>
|
||||
<p class="text-xs text-gray-400 mt-1">
|
||||
<span x-text="addon.period_end ? `Renews ${formatDate(addon.period_end)}` : 'Active'"></span>
|
||||
</p>
|
||||
</div>
|
||||
<button @click="cancelAddon(addon)"
|
||||
class="px-3 py-1 text-sm font-medium text-red-600 bg-red-100 rounded-lg hover:bg-red-200 dark:bg-red-900/50 dark:text-red-400">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Available Add-ons -->
|
||||
<h4 class="text-sm font-semibold text-gray-600 dark:text-gray-400 mb-3 uppercase tracking-wide">Available Add-ons</h4>
|
||||
<template x-if="addons.length === 0">
|
||||
<p class="text-gray-500 text-center py-8">No add-ons available</p>
|
||||
</template>
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-3">
|
||||
<template x-for="addon in addons" :key="addon.id">
|
||||
<div class="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<div>
|
||||
@@ -336,8 +376,22 @@
|
||||
<span x-text="`/${addon.billing_period}`"></span>
|
||||
</p>
|
||||
</div>
|
||||
<button class="px-4 py-2 text-sm font-medium text-purple-600 bg-purple-100 rounded-lg hover:bg-purple-200">
|
||||
Add
|
||||
<button @click="purchaseAddon(addon)"
|
||||
:disabled="isAddonPurchased(addon.code) || purchasingAddon === addon.code"
|
||||
:class="isAddonPurchased(addon.code) ? 'bg-gray-200 text-gray-500 cursor-not-allowed' : 'bg-purple-100 text-purple-600 hover:bg-purple-200'"
|
||||
class="px-4 py-2 text-sm font-medium rounded-lg transition-colors">
|
||||
<template x-if="purchasingAddon === addon.code">
|
||||
<span class="flex items-center">
|
||||
<svg class="animate-spin -ml-1 mr-2 h-4 w-4" xmlns="http://www.w3.org/2000/svg" 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 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Processing...
|
||||
</span>
|
||||
</template>
|
||||
<template x-if="purchasingAddon !== addon.code">
|
||||
<span x-text="isAddonPurchased(addon.code) ? 'Active' : 'Add'"></span>
|
||||
</template>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user