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:
@@ -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"
|
||||
|
||||
169
website/resources/js/Pages/Billing/Index.vue
Normal file
169
website/resources/js/Pages/Billing/Index.vue
Normal 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">•••• {{ 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>
|
||||
81
website/resources/js/Pages/Billing/Invoices.vue
Normal file
81
website/resources/js/Pages/Billing/Invoices.vue
Normal 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">← 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>
|
||||
80
website/resources/js/Pages/Billing/Transactions.vue
Normal file
80
website/resources/js/Pages/Billing/Transactions.vue
Normal 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">← 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>
|
||||
191
website/resources/js/Pages/Checkout/Show.vue
Normal file
191
website/resources/js/Pages/Checkout/Show.vue
Normal 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">← 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>
|
||||
@@ -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>
|
||||
|
||||
77
website/resources/js/Pages/Plans/Index.vue
Normal file
77
website/resources/js/Pages/Plans/Index.vue
Normal 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>
|
||||
59
website/resources/js/Pages/Plans/Show.vue
Normal file
59
website/resources/js/Pages/Plans/Show.vue
Normal 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">← 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>
|
||||
78
website/resources/js/Pages/Subscriptions/Index.vue
Normal file
78
website/resources/js/Pages/Subscriptions/Index.vue
Normal 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' }} ·
|
||||
<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>
|
||||
163
website/resources/js/Pages/Subscriptions/Show.vue
Normal file
163
website/resources/js/Pages/Subscriptions/Show.vue
Normal 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">← 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>
|
||||
Reference in New Issue
Block a user