Implement Phase 2: Billing & Subscriptions

Add complete billing system with Stripe and PayPal gateway support,
checkout flow with coupon validation, subscription management
(cancel/resume/swap), payment method management, invoice and
transaction history, webhook handlers, dunning/suspension system
with scheduled processing, and 29 new tests (53 total passing).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Claude Dev
2026-02-09 07:18:48 -05:00
parent 5988c6d064
commit b1e080d70c
40 changed files with 3018 additions and 1 deletions

View File

@@ -24,6 +24,24 @@ const domains = computed(() => page.props.domains);
>
Dashboard
</Link>
<Link
href="/subscriptions"
class="px-3 py-2 rounded-md text-sm font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-100"
>
Subscriptions
</Link>
<Link
href="/billing"
class="px-3 py-2 rounded-md text-sm font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-100"
>
Billing
</Link>
<Link
href="/plans"
class="px-3 py-2 rounded-md text-sm font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-100"
>
Plans
</Link>
<Link
href="/profile"
class="px-3 py-2 rounded-md text-sm font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-100"

View File

@@ -0,0 +1,169 @@
<script setup>
import { ref } from 'vue';
import { useForm, Link } from '@inertiajs/vue3';
import AppLayout from '@/Layouts/AppLayout.vue';
defineOptions({ layout: AppLayout });
defineProps({
paymentMethods: Array,
invoices: Array,
transactions: Array,
intent: Object,
stripeKey: String,
});
const addForm = useForm({
payment_method_id: '',
});
const defaultForm = useForm({
payment_method_id: '',
});
const setDefault = (id) => {
defaultForm.payment_method_id = id;
defaultForm.post('/billing/payment-methods/default');
};
const removeMethod = (id) => {
if (confirm('Are you sure you want to remove this payment method?')) {
useForm({}).delete(`/billing/payment-methods/${id}`);
}
};
</script>
<template>
<div>
<h1 class="text-2xl font-bold text-gray-900 mb-6">Billing</h1>
<div class="space-y-8">
<!-- Payment Methods -->
<div class="bg-white rounded-lg border border-gray-200 shadow-sm p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-gray-900">Payment Methods</h2>
</div>
<div v-if="paymentMethods.length === 0" class="text-sm text-gray-500">
No payment methods on file.
</div>
<div v-else class="space-y-3">
<div
v-for="pm in paymentMethods"
:key="pm.id"
class="flex items-center justify-between p-3 border rounded-md"
:class="pm.is_default ? 'border-blue-300 bg-blue-50' : 'border-gray-200'"
>
<div class="flex items-center gap-3">
<span class="text-sm font-medium capitalize">{{ pm.brand }}</span>
<span class="text-sm text-gray-500">&bull;&bull;&bull;&bull; {{ pm.last_four }}</span>
<span class="text-sm text-gray-400">{{ pm.exp_month }}/{{ pm.exp_year }}</span>
<span v-if="pm.is_default" class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800">Default</span>
</div>
<div class="flex items-center gap-2">
<button
v-if="!pm.is_default"
@click="setDefault(pm.id)"
:disabled="defaultForm.processing"
class="text-sm text-blue-600 hover:text-blue-500"
>
Make Default
</button>
<button
@click="removeMethod(pm.id)"
class="text-sm text-red-600 hover:text-red-500"
>
Remove
</button>
</div>
</div>
</div>
</div>
<!-- Recent Invoices -->
<div class="bg-white rounded-lg border border-gray-200 shadow-sm p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-gray-900">Recent Invoices</h2>
<Link href="/billing/invoices" class="text-sm text-blue-600 hover:text-blue-500">View All</Link>
</div>
<div v-if="invoices.length === 0" class="text-sm text-gray-500">No invoices yet.</div>
<table v-else class="w-full text-sm">
<thead>
<tr class="border-b border-gray-200">
<th class="text-left py-2 text-gray-500 font-medium">Number</th>
<th class="text-left py-2 text-gray-500 font-medium">Date</th>
<th class="text-left py-2 text-gray-500 font-medium">Status</th>
<th class="text-right py-2 text-gray-500 font-medium">Amount</th>
<th class="text-right py-2 text-gray-500 font-medium"></th>
</tr>
</thead>
<tbody>
<tr v-for="invoice in invoices" :key="invoice.id" class="border-b border-gray-100">
<td class="py-2 text-gray-900">{{ invoice.number }}</td>
<td class="py-2 text-gray-600">{{ new Date(invoice.created_at).toLocaleDateString() }}</td>
<td class="py-2">
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium capitalize"
:class="{
'bg-green-100 text-green-800': invoice.status === 'paid',
'bg-yellow-100 text-yellow-800': invoice.status === 'pending',
'bg-red-100 text-red-800': invoice.status === 'overdue',
}"
>
{{ invoice.status }}
</span>
</td>
<td class="py-2 text-right text-gray-900">${{ parseFloat(invoice.total).toFixed(2) }}</td>
<td class="py-2 text-right">
<a :href="`/billing/invoices/${invoice.id}/download`" class="text-blue-600 hover:text-blue-500">Download</a>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Recent Transactions -->
<div class="bg-white rounded-lg border border-gray-200 shadow-sm p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-gray-900">Recent Transactions</h2>
<Link href="/billing/transactions" class="text-sm text-blue-600 hover:text-blue-500">View All</Link>
</div>
<div v-if="transactions.length === 0" class="text-sm text-gray-500">No transactions yet.</div>
<table v-else class="w-full text-sm">
<thead>
<tr class="border-b border-gray-200">
<th class="text-left py-2 text-gray-500 font-medium">Date</th>
<th class="text-left py-2 text-gray-500 font-medium">Gateway</th>
<th class="text-left py-2 text-gray-500 font-medium">Status</th>
<th class="text-left py-2 text-gray-500 font-medium">Description</th>
<th class="text-right py-2 text-gray-500 font-medium">Amount</th>
</tr>
</thead>
<tbody>
<tr v-for="tx in transactions" :key="tx.id" class="border-b border-gray-100">
<td class="py-2 text-gray-600">{{ new Date(tx.created_at).toLocaleDateString() }}</td>
<td class="py-2 text-gray-600 capitalize">{{ tx.gateway }}</td>
<td class="py-2">
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium capitalize"
:class="{
'bg-green-100 text-green-800': tx.status === 'succeeded',
'bg-red-100 text-red-800': tx.status === 'failed',
'bg-yellow-100 text-yellow-800': tx.status === 'pending',
}"
>
{{ tx.status }}
</span>
</td>
<td class="py-2 text-gray-600">{{ tx.description }}</td>
<td class="py-2 text-right text-gray-900">${{ parseFloat(tx.amount).toFixed(2) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,81 @@
<script setup>
import { Link } from '@inertiajs/vue3';
import AppLayout from '@/Layouts/AppLayout.vue';
defineOptions({ layout: AppLayout });
defineProps({
invoices: Object,
});
</script>
<template>
<div>
<div class="mb-4">
<Link href="/billing" class="text-sm text-blue-600 hover:text-blue-500">&larr; Back to Billing</Link>
</div>
<h1 class="text-2xl font-bold text-gray-900 mb-6">Invoices</h1>
<div class="bg-white rounded-lg border border-gray-200 shadow-sm overflow-hidden">
<div v-if="!invoices.data || invoices.data.length === 0" class="p-6 text-sm text-gray-500 text-center">
No invoices found.
</div>
<table v-else class="w-full text-sm">
<thead class="bg-gray-50">
<tr>
<th class="text-left px-6 py-3 text-gray-500 font-medium">Number</th>
<th class="text-left px-6 py-3 text-gray-500 font-medium">Date</th>
<th class="text-left px-6 py-3 text-gray-500 font-medium">Gateway</th>
<th class="text-left px-6 py-3 text-gray-500 font-medium">Status</th>
<th class="text-right px-6 py-3 text-gray-500 font-medium">Amount</th>
<th class="text-right px-6 py-3 text-gray-500 font-medium"></th>
</tr>
</thead>
<tbody>
<tr v-for="invoice in invoices.data" :key="invoice.id" class="border-t border-gray-100">
<td class="px-6 py-3 text-gray-900">{{ invoice.number }}</td>
<td class="px-6 py-3 text-gray-600">{{ new Date(invoice.created_at).toLocaleDateString() }}</td>
<td class="px-6 py-3 text-gray-600 capitalize">{{ invoice.gateway }}</td>
<td class="px-6 py-3">
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium capitalize"
:class="{
'bg-green-100 text-green-800': invoice.status === 'paid',
'bg-yellow-100 text-yellow-800': invoice.status === 'pending',
'bg-red-100 text-red-800': invoice.status === 'overdue',
}"
>
{{ invoice.status }}
</span>
</td>
<td class="px-6 py-3 text-right text-gray-900">${{ parseFloat(invoice.total).toFixed(2) }}</td>
<td class="px-6 py-3 text-right">
<a :href="`/billing/invoices/${invoice.id}/download`" class="text-blue-600 hover:text-blue-500">Download</a>
</td>
</tr>
</tbody>
</table>
<!-- Pagination -->
<div v-if="invoices.links && invoices.last_page > 1" class="px-6 py-3 border-t border-gray-200 flex items-center justify-between">
<div class="text-sm text-gray-500">
Showing {{ invoices.from }} to {{ invoices.to }} of {{ invoices.total }}
</div>
<div class="flex gap-1">
<Link
v-for="link in invoices.links"
:key="link.label"
:href="link.url || '#'"
:class="[
'px-3 py-1 text-sm rounded',
link.active ? 'bg-blue-600 text-white' : 'text-gray-600 hover:bg-gray-100',
!link.url && 'opacity-50 pointer-events-none',
]"
v-html="link.label"
/>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,80 @@
<script setup>
import { Link } from '@inertiajs/vue3';
import AppLayout from '@/Layouts/AppLayout.vue';
defineOptions({ layout: AppLayout });
defineProps({
transactions: Object,
});
</script>
<template>
<div>
<div class="mb-4">
<Link href="/billing" class="text-sm text-blue-600 hover:text-blue-500">&larr; Back to Billing</Link>
</div>
<h1 class="text-2xl font-bold text-gray-900 mb-6">Transactions</h1>
<div class="bg-white rounded-lg border border-gray-200 shadow-sm overflow-hidden">
<div v-if="!transactions.data || transactions.data.length === 0" class="p-6 text-sm text-gray-500 text-center">
No transactions found.
</div>
<table v-else class="w-full text-sm">
<thead class="bg-gray-50">
<tr>
<th class="text-left px-6 py-3 text-gray-500 font-medium">Date</th>
<th class="text-left px-6 py-3 text-gray-500 font-medium">Gateway</th>
<th class="text-left px-6 py-3 text-gray-500 font-medium">Method</th>
<th class="text-left px-6 py-3 text-gray-500 font-medium">Status</th>
<th class="text-left px-6 py-3 text-gray-500 font-medium">Description</th>
<th class="text-right px-6 py-3 text-gray-500 font-medium">Amount</th>
</tr>
</thead>
<tbody>
<tr v-for="tx in transactions.data" :key="tx.id" class="border-t border-gray-100">
<td class="px-6 py-3 text-gray-600">{{ new Date(tx.created_at).toLocaleDateString() }}</td>
<td class="px-6 py-3 text-gray-600 capitalize">{{ tx.gateway }}</td>
<td class="px-6 py-3 text-gray-600 capitalize">{{ tx.payment_method }}</td>
<td class="px-6 py-3">
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium capitalize"
:class="{
'bg-green-100 text-green-800': tx.status === 'succeeded',
'bg-red-100 text-red-800': tx.status === 'failed',
'bg-yellow-100 text-yellow-800': tx.status === 'pending',
'bg-gray-100 text-gray-800': tx.status === 'refunded',
}"
>
{{ tx.status }}
</span>
</td>
<td class="px-6 py-3 text-gray-600">{{ tx.description }}</td>
<td class="px-6 py-3 text-right text-gray-900">${{ parseFloat(tx.amount).toFixed(2) }}</td>
</tr>
</tbody>
</table>
<!-- Pagination -->
<div v-if="transactions.links && transactions.last_page > 1" class="px-6 py-3 border-t border-gray-200 flex items-center justify-between">
<div class="text-sm text-gray-500">
Showing {{ transactions.from }} to {{ transactions.to }} of {{ transactions.total }}
</div>
<div class="flex gap-1">
<Link
v-for="link in transactions.links"
:key="link.label"
:href="link.url || '#'"
:class="[
'px-3 py-1 text-sm rounded',
link.active ? 'bg-blue-600 text-white' : 'text-gray-600 hover:bg-gray-100',
!link.url && 'opacity-50 pointer-events-none',
]"
v-html="link.label"
/>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,191 @@
<script setup>
import { ref, computed } from 'vue';
import { useForm, Link } from '@inertiajs/vue3';
import AppLayout from '@/Layouts/AppLayout.vue';
defineOptions({ layout: AppLayout });
const props = defineProps({
plan: Object,
paymentMethods: Array,
intent: Object,
stripeKey: String,
});
const selectedGateway = ref('stripe');
const selectedPaymentMethod = ref(props.paymentMethods?.[0]?.id || '');
const couponCode = ref('');
const couponApplied = ref(false);
const couponDiscount = ref(0);
const couponError = ref('');
const total = computed(() => {
const price = parseFloat(props.plan.price);
return Math.max(0, price - couponDiscount.value).toFixed(2);
});
const form = useForm({
gateway: 'stripe',
payment_method_id: props.paymentMethods?.[0]?.id || '',
coupon_code: '',
});
const applyCoupon = async () => {
couponError.value = '';
couponApplied.value = false;
try {
const response = await fetch('/checkout/apply-coupon', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content,
'Accept': 'application/json',
},
body: JSON.stringify({
code: couponCode.value,
plan_id: props.plan.id,
}),
});
const data = await response.json();
if (data.valid) {
couponApplied.value = true;
couponDiscount.value = data.discount;
} else {
couponError.value = data.message || 'Invalid coupon.';
}
} catch {
couponError.value = 'Failed to validate coupon.';
}
};
const submit = () => {
form.gateway = selectedGateway.value;
form.payment_method_id = selectedPaymentMethod.value;
form.coupon_code = couponApplied.value ? couponCode.value : '';
form.post(`/checkout/${props.plan.id}`);
};
</script>
<template>
<div>
<div class="mb-4">
<Link href="/plans" class="text-sm text-blue-600 hover:text-blue-500">&larr; Back to Plans</Link>
</div>
<h1 class="text-2xl font-bold text-gray-900 mb-6">Checkout</h1>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<!-- Order Summary -->
<div class="lg:col-span-1 order-2 lg:order-1">
<div class="bg-white rounded-lg border border-gray-200 shadow-sm p-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4">Order Summary</h2>
<div class="space-y-3">
<div class="flex justify-between text-sm">
<span class="text-gray-600">{{ plan.name }}</span>
<span class="text-gray-900">${{ parseFloat(plan.price).toFixed(2) }}</span>
</div>
<div class="flex justify-between text-sm text-gray-500">
<span>Billing Cycle</span>
<span class="capitalize">{{ plan.billing_cycle }}</span>
</div>
<div v-if="couponApplied" class="flex justify-between text-sm text-green-600">
<span>Discount</span>
<span>-${{ couponDiscount.toFixed(2) }}</span>
</div>
<hr class="border-gray-200">
<div class="flex justify-between font-semibold">
<span>Total</span>
<span>${{ total }}/{{ plan.billing_cycle }}</span>
</div>
</div>
</div>
</div>
<!-- Checkout Form -->
<div class="lg:col-span-2 order-1 lg:order-2">
<form @submit.prevent="submit" class="space-y-6">
<!-- Payment Gateway -->
<div class="bg-white rounded-lg border border-gray-200 shadow-sm p-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4">Payment Method</h2>
<div class="space-y-3">
<label class="flex items-center p-3 border rounded-md cursor-pointer" :class="selectedGateway === 'stripe' ? 'border-blue-500 bg-blue-50' : 'border-gray-200'">
<input v-model="selectedGateway" type="radio" value="stripe" class="mr-3">
<span class="text-sm font-medium">Credit / Debit Card (Stripe)</span>
</label>
<label class="flex items-center p-3 border rounded-md cursor-pointer" :class="selectedGateway === 'paypal' ? 'border-blue-500 bg-blue-50' : 'border-gray-200'">
<input v-model="selectedGateway" type="radio" value="paypal" class="mr-3">
<span class="text-sm font-medium">PayPal</span>
</label>
</div>
<!-- Saved Payment Methods (Stripe) -->
<div v-if="selectedGateway === 'stripe' && paymentMethods.length > 0" class="mt-4">
<label class="block text-sm font-medium text-gray-700 mb-2">Select Card</label>
<select v-model="selectedPaymentMethod" class="w-full rounded-md border-gray-300 text-sm">
<option v-for="pm in paymentMethods" :key="pm.id" :value="pm.id">
{{ pm.brand }} ending in {{ pm.last_four }} ({{ pm.exp_month }}/{{ pm.exp_year }})
<template v-if="pm.is_default"> - Default</template>
</option>
</select>
</div>
<div v-if="selectedGateway === 'stripe' && paymentMethods.length === 0" class="mt-4">
<p class="text-sm text-gray-500">
You have no saved payment methods.
<Link href="/billing" class="text-blue-600 hover:text-blue-500">Add one first</Link>.
</p>
</div>
</div>
<!-- Coupon -->
<div class="bg-white rounded-lg border border-gray-200 shadow-sm p-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4">Coupon Code</h2>
<div class="flex gap-3">
<input
v-model="couponCode"
type="text"
placeholder="Enter coupon code"
class="flex-1 rounded-md border-gray-300 text-sm"
:disabled="couponApplied"
>
<button
type="button"
@click="applyCoupon"
:disabled="!couponCode || couponApplied"
class="px-4 py-2 bg-gray-100 text-sm font-medium text-gray-700 rounded-md hover:bg-gray-200 disabled:opacity-50"
>
{{ couponApplied ? 'Applied' : 'Apply' }}
</button>
</div>
<p v-if="couponError" class="mt-2 text-sm text-red-600">{{ couponError }}</p>
<p v-if="couponApplied" class="mt-2 text-sm text-green-600">Coupon applied successfully!</p>
</div>
<!-- Errors -->
<div v-if="form.errors && Object.keys(form.errors).length" class="rounded-md bg-red-50 p-4">
<ul class="list-disc list-inside text-sm text-red-600">
<li v-for="(error, field) in form.errors" :key="field">{{ error }}</li>
</ul>
</div>
<!-- Submit -->
<button
type="submit"
:disabled="form.processing || (selectedGateway === 'stripe' && !selectedPaymentMethod)"
class="w-full px-6 py-3 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700 disabled:opacity-50"
>
<span v-if="form.processing">Processing...</span>
<span v-else>Subscribe for ${{ total }}/{{ plan.billing_cycle }}</span>
</button>
</form>
</div>
</div>
</div>
</template>

View File

@@ -1,4 +1,5 @@
<script setup>
import { Link } from '@inertiajs/vue3';
import AppLayout from '@/Layouts/AppLayout.vue';
defineOptions({ layout: AppLayout });
@@ -27,7 +28,10 @@ defineProps({
<div class="bg-white rounded-lg border border-gray-200 shadow-sm p-6">
<h3 class="text-sm font-medium text-gray-500">Quick Actions</h3>
<div class="mt-3 space-y-2">
<a href="/profile" class="block text-sm text-blue-600 hover:text-blue-500">Edit Profile</a>
<Link href="/plans" class="block text-sm text-blue-600 hover:text-blue-500">Browse Plans</Link>
<Link href="/subscriptions" class="block text-sm text-blue-600 hover:text-blue-500">My Subscriptions</Link>
<Link href="/billing" class="block text-sm text-blue-600 hover:text-blue-500">Billing & Payments</Link>
<Link href="/profile" class="block text-sm text-blue-600 hover:text-blue-500">Edit Profile</Link>
</div>
</div>
</div>

View File

@@ -0,0 +1,77 @@
<script setup>
import { Link } from '@inertiajs/vue3';
import AppLayout from '@/Layouts/AppLayout.vue';
defineOptions({ layout: AppLayout });
defineProps({
plansByType: Object,
});
const formatPrice = (price, cycle) => {
const amount = parseFloat(price).toFixed(2);
return `$${amount}/${cycle}`;
};
const serviceTypeLabels = {
vps: 'VPS Servers',
dedicated: 'Dedicated Servers',
hosting: 'Web Hosting',
game: 'Game Servers',
};
</script>
<template>
<div>
<h1 class="text-2xl font-bold text-gray-900 mb-6">Plans & Pricing</h1>
<div v-for="(plans, type) in plansByType" :key="type" class="mb-10">
<h2 class="text-xl font-semibold text-gray-800 mb-4">
{{ serviceTypeLabels[type] || type }}
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div
v-for="plan in plans"
:key="plan.id"
class="bg-white rounded-lg border border-gray-200 shadow-sm p-6 flex flex-col"
>
<h3 class="text-lg font-semibold text-gray-900">{{ plan.name }}</h3>
<p v-if="plan.description" class="mt-1 text-sm text-gray-500">{{ plan.description }}</p>
<div class="mt-4">
<span class="text-3xl font-bold text-gray-900">
{{ formatPrice(plan.price, plan.billing_cycle) }}
</span>
</div>
<ul v-if="plan.features" class="mt-4 space-y-2 flex-1">
<li v-for="(value, feature) in plan.features" :key="feature" class="flex items-start text-sm text-gray-600">
<svg class="h-5 w-5 text-green-500 mr-2 shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
<span><strong>{{ feature }}:</strong> {{ value }}</span>
</li>
</ul>
<div class="mt-6">
<span v-if="plan.stock_quantity !== null && plan.stock_quantity <= 0" class="block text-center text-sm font-medium text-red-600">
Out of Stock
</span>
<Link
v-else
:href="`/checkout/${plan.id}`"
class="block w-full text-center px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700"
>
Order Now
</Link>
</div>
</div>
</div>
</div>
<div v-if="!plansByType || Object.keys(plansByType).length === 0" class="text-center py-12">
<p class="text-gray-500">No plans are currently available.</p>
</div>
</div>
</template>

View File

@@ -0,0 +1,59 @@
<script setup>
import { Link } from '@inertiajs/vue3';
import AppLayout from '@/Layouts/AppLayout.vue';
defineOptions({ layout: AppLayout });
defineProps({
plan: Object,
});
const formatPrice = (price, cycle) => {
const amount = parseFloat(price).toFixed(2);
return `$${amount}/${cycle}`;
};
</script>
<template>
<div>
<div class="mb-4">
<Link href="/plans" class="text-sm text-blue-600 hover:text-blue-500">&larr; Back to Plans</Link>
</div>
<div class="bg-white rounded-lg border border-gray-200 shadow-sm p-8 max-w-2xl">
<h1 class="text-2xl font-bold text-gray-900">{{ plan.name }}</h1>
<p v-if="plan.description" class="mt-2 text-gray-500">{{ plan.description }}</p>
<div class="mt-6">
<span class="text-4xl font-bold text-gray-900">
{{ formatPrice(plan.price, plan.billing_cycle) }}
</span>
</div>
<div v-if="plan.features" class="mt-8">
<h2 class="text-lg font-semibold text-gray-900 mb-3">Features</h2>
<ul class="space-y-2">
<li v-for="(value, feature) in plan.features" :key="feature" class="flex items-start text-sm text-gray-600">
<svg class="h-5 w-5 text-green-500 mr-2 shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
<span><strong>{{ feature }}:</strong> {{ value }}</span>
</li>
</ul>
</div>
<div class="mt-8">
<span v-if="plan.stock_quantity !== null && plan.stock_quantity <= 0" class="block text-center text-sm font-medium text-red-600">
This plan is currently out of stock.
</span>
<Link
v-else
:href="`/checkout/${plan.id}`"
class="inline-block px-6 py-3 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700"
>
Order Now
</Link>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,78 @@
<script setup>
import { Link } from '@inertiajs/vue3';
import AppLayout from '@/Layouts/AppLayout.vue';
defineOptions({ layout: AppLayout });
defineProps({
subscriptions: Array,
});
const statusColors = {
active: 'bg-green-100 text-green-800',
canceled: 'bg-red-100 text-red-800',
past_due: 'bg-yellow-100 text-yellow-800',
trialing: 'bg-blue-100 text-blue-800',
incomplete: 'bg-gray-100 text-gray-800',
};
</script>
<template>
<div>
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-gray-900">Subscriptions</h1>
<Link href="/plans" class="px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700">
Browse Plans
</Link>
</div>
<div v-if="subscriptions.length === 0" class="bg-white rounded-lg border border-gray-200 shadow-sm p-12 text-center">
<p class="text-gray-500 mb-4">You don't have any subscriptions yet.</p>
<Link href="/plans" class="text-blue-600 hover:text-blue-500 text-sm font-medium">Browse Available Plans</Link>
</div>
<div v-else class="space-y-4">
<div
v-for="subscription in subscriptions"
:key="subscription.id"
class="bg-white rounded-lg border border-gray-200 shadow-sm p-6"
>
<div class="flex items-center justify-between">
<div>
<h3 class="text-lg font-semibold text-gray-900">
{{ subscription.plan?.name || subscription.type }}
</h3>
<p class="text-sm text-gray-500 mt-1">
{{ subscription.gateway || 'stripe' }} &middot;
<span v-if="subscription.current_period_end">
Renews {{ new Date(subscription.current_period_end).toLocaleDateString() }}
</span>
</p>
</div>
<div class="flex items-center gap-3">
<span
:class="statusColors[subscription.stripe_status] || 'bg-gray-100 text-gray-800'"
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium capitalize"
>
{{ subscription.stripe_status }}
</span>
<Link
:href="`/subscriptions/${subscription.id}`"
class="text-sm text-blue-600 hover:text-blue-500 font-medium"
>
Manage
</Link>
</div>
</div>
<div v-if="subscription.plan" class="mt-3 text-sm text-gray-600">
${{ parseFloat(subscription.plan.price).toFixed(2) }}/{{ subscription.plan.billing_cycle }}
</div>
<div v-if="subscription.ends_at" class="mt-2 text-sm text-red-600">
Cancels on {{ new Date(subscription.ends_at).toLocaleDateString() }}
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,163 @@
<script setup>
import { ref } from 'vue';
import { useForm, Link } from '@inertiajs/vue3';
import AppLayout from '@/Layouts/AppLayout.vue';
defineOptions({ layout: AppLayout });
const props = defineProps({
subscription: Object,
availablePlans: Array,
});
const cancelImmediately = ref(false);
const cancelForm = useForm({
immediately: false,
});
const swapForm = useForm({
plan_id: '',
});
const statusColors = {
active: 'bg-green-100 text-green-800',
canceled: 'bg-red-100 text-red-800',
past_due: 'bg-yellow-100 text-yellow-800',
trialing: 'bg-blue-100 text-blue-800',
incomplete: 'bg-gray-100 text-gray-800',
};
const cancelSubscription = () => {
cancelForm.immediately = cancelImmediately.value;
cancelForm.post(`/subscriptions/${props.subscription.id}/cancel`);
};
const resumeSubscription = () => {
useForm({}).post(`/subscriptions/${props.subscription.id}/resume`);
};
const swapPlan = () => {
swapForm.post(`/subscriptions/${props.subscription.id}/swap`);
};
</script>
<template>
<div>
<div class="mb-4">
<Link href="/subscriptions" class="text-sm text-blue-600 hover:text-blue-500">&larr; Back to Subscriptions</Link>
</div>
<h1 class="text-2xl font-bold text-gray-900 mb-6">Subscription Details</h1>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Subscription Info -->
<div class="lg:col-span-2 space-y-6">
<div class="bg-white rounded-lg border border-gray-200 shadow-sm p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-gray-900">
{{ subscription.plan?.name || subscription.type }}
</h2>
<span
:class="statusColors[subscription.stripe_status] || 'bg-gray-100 text-gray-800'"
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium capitalize"
>
{{ subscription.stripe_status }}
</span>
</div>
<dl class="grid grid-cols-2 gap-4 text-sm">
<div>
<dt class="text-gray-500">Gateway</dt>
<dd class="mt-1 text-gray-900 capitalize">{{ subscription.gateway || 'stripe' }}</dd>
</div>
<div v-if="subscription.plan">
<dt class="text-gray-500">Price</dt>
<dd class="mt-1 text-gray-900">${{ parseFloat(subscription.plan.price).toFixed(2) }}/{{ subscription.plan.billing_cycle }}</dd>
</div>
<div v-if="subscription.current_period_start">
<dt class="text-gray-500">Current Period Start</dt>
<dd class="mt-1 text-gray-900">{{ new Date(subscription.current_period_start).toLocaleDateString() }}</dd>
</div>
<div v-if="subscription.current_period_end">
<dt class="text-gray-500">Current Period End</dt>
<dd class="mt-1 text-gray-900">{{ new Date(subscription.current_period_end).toLocaleDateString() }}</dd>
</div>
<div v-if="subscription.ends_at">
<dt class="text-gray-500">Cancels On</dt>
<dd class="mt-1 text-red-600">{{ new Date(subscription.ends_at).toLocaleDateString() }}</dd>
</div>
<div>
<dt class="text-gray-500">Created</dt>
<dd class="mt-1 text-gray-900">{{ new Date(subscription.created_at).toLocaleDateString() }}</dd>
</div>
</dl>
</div>
<!-- Change Plan -->
<div v-if="availablePlans.length > 0 && subscription.stripe_status === 'active'" class="bg-white rounded-lg border border-gray-200 shadow-sm p-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4">Change Plan</h2>
<form @submit.prevent="swapPlan" class="space-y-4">
<div class="space-y-2">
<label v-for="plan in availablePlans" :key="plan.id"
class="flex items-center justify-between p-3 border rounded-md cursor-pointer"
:class="swapForm.plan_id == plan.id ? 'border-blue-500 bg-blue-50' : 'border-gray-200'"
>
<div class="flex items-center">
<input v-model="swapForm.plan_id" type="radio" :value="plan.id" class="mr-3">
<span class="text-sm font-medium">{{ plan.name }}</span>
</div>
<span class="text-sm text-gray-600">${{ parseFloat(plan.price).toFixed(2) }}/{{ plan.billing_cycle }}</span>
</label>
</div>
<button
type="submit"
:disabled="!swapForm.plan_id || swapForm.processing"
class="px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700 disabled:opacity-50"
>
{{ swapForm.processing ? 'Changing...' : 'Change Plan' }}
</button>
</form>
</div>
</div>
<!-- Actions Sidebar -->
<div class="space-y-6">
<!-- Cancel -->
<div v-if="subscription.stripe_status === 'active' && !subscription.ends_at" class="bg-white rounded-lg border border-gray-200 shadow-sm p-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4">Cancel Subscription</h2>
<div class="space-y-3">
<label class="flex items-center text-sm">
<input v-model="cancelImmediately" type="checkbox" class="mr-2">
Cancel immediately (no grace period)
</label>
<button
@click="cancelSubscription"
:disabled="cancelForm.processing"
class="w-full px-4 py-2 bg-red-600 text-white text-sm font-medium rounded-md hover:bg-red-700 disabled:opacity-50"
>
{{ cancelForm.processing ? 'Cancelling...' : 'Cancel Subscription' }}
</button>
</div>
</div>
<!-- Resume -->
<div v-if="subscription.ends_at && subscription.stripe_status !== 'canceled'" class="bg-white rounded-lg border border-gray-200 shadow-sm p-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4">Resume Subscription</h2>
<p class="text-sm text-gray-500 mb-3">Your subscription is set to cancel. You can resume it before it expires.</p>
<button
@click="resumeSubscription"
class="w-full px-4 py-2 bg-green-600 text-white text-sm font-medium rounded-md hover:bg-green-700"
>
Resume Subscription
</button>
</div>
</div>
</div>
</div>
</template>