Add upcoming renewals page with days-until-renewal indicators

Shows subscriptions nearing renewal with color-coded urgency chips,
auto-renew status, and quick manage links.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Claude Dev
2026-02-09 20:37:15 -05:00
parent edf428215f
commit dd558d5dcc
5 changed files with 329 additions and 0 deletions

View File

@@ -142,6 +142,39 @@ class BillingController extends Controller
]);
}
public function upcomingRenewals(Request $request): Response
{
$user = $request->user();
$renewals = $user->subscriptions()
->with('plan')
->whereIn('stripe_status', ['active', 'trialing'])
->whereNotNull('current_period_end')
->orderBy('current_period_end')
->get()
->map(function ($subscription) use ($user) {
$service = $user->services()
->where('subscription_id', $subscription->id)
->first();
return [
'id' => $subscription->id,
'plan_name' => $subscription->plan?->name ?? $subscription->type,
'plan_price' => $subscription->plan?->price,
'billing_cycle' => $subscription->plan?->billing_cycle,
'renewal_date' => $subscription->current_period_end,
'status' => $subscription->stripe_status,
'auto_renew' => $service?->auto_renew ?? true,
'service_id' => $service?->id,
'days_until_renewal' => now()->diffInDays($subscription->current_period_end, false),
];
});
return Inertia::render('Billing/UpcomingRenewals', [
'renewals' => $renewals,
]);
}
public function setupIntent(Request $request): JsonResponse
{
$user = $request->user();

View File

@@ -0,0 +1,259 @@
<script lang="ts" setup>
import { computed } from 'vue'
import { Link } from '@inertiajs/vue3'
import AccountLayout from '@/Layouts/AccountLayout.vue'
import { resolveSubscriptionStatusColor, formatPrice } from '@/utils/resolvers'
import type { UpcomingRenewal } from '@/types'
interface Props {
renewals: UpcomingRenewal[]
}
defineOptions({ layout: AccountLayout })
const props = defineProps<Props>()
const nextRenewalDate = computed<string | null>(() => {
if (props.renewals.length === 0) return null
const sorted = [...props.renewals].sort(
(a, b) => new Date(a.renewal_date).getTime() - new Date(b.renewal_date).getTime(),
)
return sorted[0].renewal_date
})
const totalMonthlyCost = computed<number>(() => {
return props.renewals.reduce((sum, r) => {
if (!r.plan_price) return sum
const price = parseFloat(r.plan_price)
if (r.billing_cycle === 'yearly' || r.billing_cycle === 'annual') {
return sum + price / 12
}
if (r.billing_cycle === 'quarterly') {
return sum + price / 3
}
return sum + price
}, 0)
})
function resolveRenewalUrgencyColor(days: number): string {
if (days < 0) return 'error'
if (days < 7) return 'error'
if (days <= 14) return 'warning'
return 'success'
}
function formatRenewalDate(dateString: string): string {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
}
</script>
<template>
<div>
<div class="mb-4">
<Link
href="/billing"
class="text-primary text-body-2 text-decoration-none"
>
&larr; Back to Billing
</Link>
</div>
<div class="text-h4 font-weight-bold mb-6">
Upcoming Renewals
</div>
<!-- Summary Cards -->
<VRow
v-if="renewals.length > 0"
class="mb-6"
>
<VCol
cols="12"
sm="4"
>
<VCard>
<VCardText class="d-flex align-center ga-3">
<VAvatar
color="primary"
variant="tonal"
rounded
>
<VIcon icon="tabler-calendar-repeat" />
</VAvatar>
<div>
<div class="text-body-2 text-medium-emphasis">
Total Upcoming
</div>
<div class="text-h5 font-weight-bold">
{{ renewals.length }}
</div>
</div>
</VCardText>
</VCard>
</VCol>
<VCol
cols="12"
sm="4"
>
<VCard>
<VCardText class="d-flex align-center ga-3">
<VAvatar
color="warning"
variant="tonal"
rounded
>
<VIcon icon="tabler-clock" />
</VAvatar>
<div>
<div class="text-body-2 text-medium-emphasis">
Next Renewal
</div>
<div class="text-h6 font-weight-bold">
{{ nextRenewalDate ? formatRenewalDate(nextRenewalDate) : 'N/A' }}
</div>
</div>
</VCardText>
</VCard>
</VCol>
<VCol
cols="12"
sm="4"
>
<VCard>
<VCardText class="d-flex align-center ga-3">
<VAvatar
color="success"
variant="tonal"
rounded
>
<VIcon icon="tabler-currency-dollar" />
</VAvatar>
<div>
<div class="text-body-2 text-medium-emphasis">
Est. Monthly Cost
</div>
<div class="text-h5 font-weight-bold">
${{ totalMonthlyCost.toFixed(2) }}
</div>
</div>
</VCardText>
</VCard>
</VCol>
</VRow>
<!-- Renewals Table -->
<VCard>
<VCardText
v-if="renewals.length === 0"
class="text-center py-10"
>
<VIcon
icon="tabler-calendar-off"
size="48"
class="text-disabled mb-4"
/>
<div class="text-h6 text-medium-emphasis mb-2">
No Upcoming Renewals
</div>
<div class="text-body-2 text-disabled">
You don't have any active subscriptions with upcoming renewals.
</div>
</VCardText>
<template v-else>
<VTable>
<thead>
<tr>
<th>Plan</th>
<th>Price</th>
<th>Renewal Date</th>
<th>Days Until Renewal</th>
<th>Status</th>
<th>Auto Renew</th>
<th class="text-end" />
</tr>
</thead>
<tbody>
<tr
v-for="renewal in renewals"
:key="renewal.id"
>
<td class="font-weight-medium">
<Link
:href="`/subscriptions/${renewal.id}`"
class="text-primary text-decoration-none"
>
{{ renewal.plan_name }}
</Link>
</td>
<td>
<template v-if="renewal.plan_price">
{{ formatPrice(renewal.plan_price, renewal.billing_cycle ?? undefined) }}
</template>
<span
v-else
class="text-disabled"
>--</span>
</td>
<td>{{ formatRenewalDate(renewal.renewal_date) }}</td>
<td>
<VChip
:color="resolveRenewalUrgencyColor(renewal.days_until_renewal)"
size="small"
>
<template v-if="renewal.days_until_renewal < 0">
{{ Math.abs(renewal.days_until_renewal) }}d overdue
</template>
<template v-else-if="renewal.days_until_renewal === 0">
Today
</template>
<template v-else>
{{ renewal.days_until_renewal }}d
</template>
</VChip>
</td>
<td>
<VChip
:color="resolveSubscriptionStatusColor(renewal.status)"
size="small"
class="text-capitalize"
>
{{ renewal.status }}
</VChip>
</td>
<td>
<VChip
:color="renewal.auto_renew ? 'success' : 'secondary'"
size="small"
variant="tonal"
>
{{ renewal.auto_renew ? 'On' : 'Off' }}
</VChip>
</td>
<td class="text-end">
<Link
:href="`/subscriptions/${renewal.id}`"
class="text-decoration-none"
>
<VBtn
color="primary"
variant="tonal"
size="small"
>
Manage
</VBtn>
</Link>
</td>
</tr>
</tbody>
</VTable>
</template>
</VCard>
</div>
</template>

View File

@@ -9,6 +9,7 @@ export const accountNavItems: VerticalNavItems = [
{ heading: 'Billing' },
{ title: 'Billing', to: '/billing', icon: 'tabler-credit-card' },
{ title: 'Payment Methods', to: '/billing/payment-methods', icon: 'tabler-wallet' },
{ title: 'Renewals', to: '/billing/renewals', icon: 'tabler-calendar-repeat' },
{ title: 'Plans', to: '/plans', icon: 'tabler-package' },
{ heading: 'Support' },

View File

@@ -205,4 +205,39 @@ export interface TicketReply {
}
}
export interface AuditLog {
id: number
user_id: number | null
admin_id: number | null
action: string
resource_type: string | null
resource_id: number | null
ip_address: string | null
user_agent: string | null
changes: Record<string, unknown> | null
created_at: string
user?: {
id: number
name: string
email: string
}
admin?: {
id: number
name: string
email: string
}
}
export interface UpcomingRenewal {
id: number
plan_name: string
plan_price: string | null
billing_cycle: string | null
renewal_date: string
status: string
auto_renew: boolean
service_id: number | null
days_until_renewal: number
}
export type StatusColor = 'success' | 'error' | 'warning' | 'info' | 'secondary'

View File

@@ -53,6 +53,7 @@ Route::get('/billing/payment-methods', [BillingController::class, 'paymentMethod
Route::get('/billing/invoices', [BillingController::class, 'invoices'])->name('account.billing.invoices');
Route::get('/billing/invoices/{invoice}/download', [BillingController::class, 'downloadInvoice'])->name('account.billing.invoices.download');
Route::get('/billing/transactions', [BillingController::class, 'transactions'])->name('account.billing.transactions');
Route::get('/billing/renewals', [BillingController::class, 'upcomingRenewals'])->name('account.billing.renewals');
Route::post('/billing/setup-intent', [BillingController::class, 'setupIntent'])->name('account.billing.setup-intent');
// Support Tickets