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
|
public function setupIntent(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$user = $request->user();
|
$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' },
|
{ heading: 'Billing' },
|
||||||
{ title: 'Billing', to: '/billing', icon: 'tabler-credit-card' },
|
{ title: 'Billing', to: '/billing', icon: 'tabler-credit-card' },
|
||||||
{ title: 'Payment Methods', to: '/billing/payment-methods', icon: 'tabler-wallet' },
|
{ 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' },
|
{ title: 'Plans', to: '/plans', icon: 'tabler-package' },
|
||||||
|
|
||||||
{ heading: 'Support' },
|
{ 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'
|
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', [BillingController::class, 'invoices'])->name('account.billing.invoices');
|
||||||
Route::get('/billing/invoices/{invoice}/download', [BillingController::class, 'downloadInvoice'])->name('account.billing.invoices.download');
|
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/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');
|
Route::post('/billing/setup-intent', [BillingController::class, 'setupIntent'])->name('account.billing.setup-intent');
|
||||||
|
|
||||||
// Support Tickets
|
// Support Tickets
|
||||||
|
|||||||
Reference in New Issue
Block a user