Files
website/website/app/Http/Controllers/Account/BillingController.php
Claude Dev 45d25d61ba Idempotent provisioning, service soft-delete, Plans page redesign, doc updates
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>
2026-02-10 06:30:57 -05:00

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,
]);
}
}