Part A: Fix duplicate Service creation on provisioning retry - All 4 provisioning services use Service::firstOrCreate() keyed on subscription_id+service_type to prevent duplicates on queue retries - HandleSubscriptionCreated sends notification before provisioning, no longer re-throws on failure - RetryProvisioningCommand simplified to reuse existing Service records Part B: Plans/Pricing page complete redesign - Service type tabs (VPS, Dedicated, Web Hosting, MySQL) - Billing cycle segmented toggle (monthly/quarterly/semi-annual/annual) - Feature icons per service type, Popular/Best Value badges - Stock indicators, effective monthly price calculations Part C: Admin service soft-delete/archive - Service model uses SoftDeletes trait - Admin can archive and restore services - Show archived toggle on services list - Migration adds deleted_at column Docs: Updated TASKS.md, CLAUDE.md, PROJECT_DEVELOPMENT.md, MEMORY.md - Phase 3 marked complete, test counts updated (252 passing) - SupportPal references replaced with standalone ticket system - Frontend design skill background rule added - Closed GitHub issues #3, #6, #7, #8, #9 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
204 lines
6.6 KiB
PHP
204 lines
6.6 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Http\Controllers\Account;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Models\Invoice;
|
|
use App\Services\Billing\BillingServiceFactory;
|
|
use Barryvdh\DomPDF\Facade\Pdf;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\RedirectResponse;
|
|
use Illuminate\Http\Request;
|
|
use Inertia\Inertia;
|
|
use Inertia\Response;
|
|
|
|
class BillingController extends Controller
|
|
{
|
|
public function __construct(
|
|
private BillingServiceFactory $billingFactory,
|
|
) {}
|
|
|
|
public function index(Request $request): Response
|
|
{
|
|
$user = $request->user();
|
|
$stripeService = $this->billingFactory->make('stripe');
|
|
|
|
return Inertia::render('Billing/Index', [
|
|
'paymentMethods' => $stripeService->getPaymentMethods($user),
|
|
'invoices' => $user->invoices()
|
|
->latest()
|
|
->take(20)
|
|
->get(),
|
|
'transactions' => $user->paymentTransactions()
|
|
->latest()
|
|
->take(20)
|
|
->get(),
|
|
'intent' => $user->hasStripeId() ? $user->createSetupIntent() : null,
|
|
'stripeKey' => config('cashier.key'),
|
|
]);
|
|
}
|
|
|
|
public function addPaymentMethod(Request $request): RedirectResponse
|
|
{
|
|
$request->validate([
|
|
'payment_method_id' => ['required', 'string'],
|
|
]);
|
|
|
|
$service = $this->billingFactory->make('stripe');
|
|
$success = $service->addPaymentMethod($request->user(), $request->input('payment_method_id'));
|
|
|
|
if (! $success) {
|
|
return back()->with('error', 'Failed to add payment method.');
|
|
}
|
|
|
|
return back()->with('success', 'Payment method added.');
|
|
}
|
|
|
|
public function removePaymentMethod(Request $request, string $paymentMethodId): RedirectResponse
|
|
{
|
|
$service = $this->billingFactory->make('stripe');
|
|
$service->removePaymentMethod($request->user(), $paymentMethodId);
|
|
|
|
return back()->with('success', 'Payment method removed.');
|
|
}
|
|
|
|
public function setDefaultPaymentMethod(Request $request): RedirectResponse
|
|
{
|
|
$request->validate([
|
|
'payment_method_id' => ['required', 'string'],
|
|
]);
|
|
|
|
$service = $this->billingFactory->make('stripe');
|
|
$service->setDefaultPaymentMethod($request->user(), $request->input('payment_method_id'));
|
|
|
|
return back()->with('success', 'Default payment method updated.');
|
|
}
|
|
|
|
public function paymentMethods(Request $request): Response
|
|
{
|
|
$user = $request->user();
|
|
$paymentMethods = [];
|
|
$defaultPaymentMethod = null;
|
|
|
|
if ($user->hasStripeId()) {
|
|
$methods = $user->paymentMethods();
|
|
$defaultPm = $user->defaultPaymentMethod();
|
|
$defaultPaymentMethod = $defaultPm?->id;
|
|
|
|
foreach ($methods as $method) {
|
|
$paymentMethods[] = [
|
|
'id' => $method->id,
|
|
'brand' => $method->card->brand ?? 'unknown',
|
|
'last_four' => $method->card->last_four ?? '****',
|
|
'exp_month' => $method->card->exp_month,
|
|
'exp_year' => $method->card->exp_year,
|
|
'is_default' => $method->id === $defaultPaymentMethod,
|
|
];
|
|
}
|
|
}
|
|
|
|
// Create setup intent for adding new cards
|
|
if (! $user->hasStripeId()) {
|
|
$user->createAsStripeCustomer();
|
|
}
|
|
|
|
return Inertia::render('Billing/PaymentMethods', [
|
|
'paymentMethods' => $paymentMethods,
|
|
'defaultPaymentMethod' => $defaultPaymentMethod,
|
|
'intent' => $user->createSetupIntent(),
|
|
'stripeKey' => config('cashier.key'),
|
|
]);
|
|
}
|
|
|
|
public function invoices(Request $request): Response
|
|
{
|
|
$invoices = $request->user()
|
|
->invoices()
|
|
->latest()
|
|
->paginate(20);
|
|
|
|
return Inertia::render('Billing/Invoices', [
|
|
'invoices' => $invoices,
|
|
]);
|
|
}
|
|
|
|
public function downloadInvoice(Request $request, Invoice $invoice): \Symfony\Component\HttpFoundation\Response
|
|
{
|
|
if ($invoice->user_id !== $request->user()->id) {
|
|
abort(403);
|
|
}
|
|
|
|
$invoice->load(['user', 'items']);
|
|
|
|
$pdf = Pdf::loadView('pdf.invoice', ['invoice' => $invoice]);
|
|
|
|
return $pdf->download("invoice-{$invoice->number}.pdf");
|
|
}
|
|
|
|
public function transactions(Request $request): Response
|
|
{
|
|
$transactions = $request->user()
|
|
->paymentTransactions()
|
|
->latest()
|
|
->paginate(20);
|
|
|
|
return Inertia::render('Billing/Transactions', [
|
|
'transactions' => $transactions,
|
|
]);
|
|
}
|
|
|
|
public function upcomingRenewals(Request $request): Response
|
|
{
|
|
$user = $request->user();
|
|
|
|
$renewals = $user->subscriptions()
|
|
->select([
|
|
'subscriptions.*',
|
|
'plans.name as plan_name',
|
|
'plans.price as plan_price',
|
|
'plans.billing_cycle as plan_billing_cycle',
|
|
])
|
|
->leftJoin('plans', 'subscriptions.plan_id', '=', 'plans.id')
|
|
->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();
|
|
|
|
if (! $user->hasStripeId()) {
|
|
$user->createAsStripeCustomer();
|
|
}
|
|
|
|
return response()->json([
|
|
'client_secret' => $user->createSetupIntent()->client_secret,
|
|
]);
|
|
}
|
|
}
|