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:
@@ -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();
|
||||
|
||||
259
website/resources/ts/Pages/Billing/UpcomingRenewals.vue
Normal file
259
website/resources/ts/Pages/Billing/UpcomingRenewals.vue
Normal 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"
|
||||
>
|
||||
← 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>
|
||||
@@ -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' },
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user