- Add impersonation stop route on account subdomain so impersonated users can exit (#11) - Replace non-concurrency-safe invoice number generation (count+1, rand) with date+random string - Wrap admin dashboard stats queries in Cache::remember with 5-minute TTL - Add database indexes on invoices.status, orders.status, audit_logs.action, audit_logs.created_at - Fix last_four to last4 matching Stripe's actual card property name Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
249 lines
8.1 KiB
PHP
249 lines
8.1 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Http\Controllers\Admin;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Http\Requests\Admin\StoreInvoiceRequest;
|
|
use App\Http\Requests\Admin\UpdateInvoiceRequest;
|
|
use App\Models\AuditLog;
|
|
use App\Models\Invoice;
|
|
use App\Models\User;
|
|
use App\Notifications\InvoiceNotification;
|
|
use Barryvdh\DomPDF\Facade\Pdf;
|
|
use Illuminate\Http\RedirectResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Str;
|
|
use Inertia\Inertia;
|
|
use Inertia\Response;
|
|
|
|
class InvoiceController extends Controller
|
|
{
|
|
public function index(Request $request): Response
|
|
{
|
|
$query = Invoice::query()
|
|
->with('user:id,name,email');
|
|
|
|
// Search by invoice number or customer name
|
|
if ($search = $request->input('search')) {
|
|
$query->where(function ($q) use ($search): void {
|
|
$q->where('number', 'like', "%{$search}%")
|
|
->orWhereHas('user', function ($uq) use ($search): void {
|
|
$uq->where('name', 'like', "%{$search}%")
|
|
->orWhere('email', 'like', "%{$search}%");
|
|
});
|
|
});
|
|
}
|
|
|
|
// Filter by status
|
|
if ($status = $request->input('status')) {
|
|
$query->where('status', $status);
|
|
}
|
|
|
|
$invoices = $query->latest()->paginate(15)->withQueryString();
|
|
|
|
return Inertia::render('Admin/Invoices/Index', [
|
|
'invoices' => $invoices,
|
|
'filters' => [
|
|
'search' => $request->input('search', ''),
|
|
'status' => $request->input('status', ''),
|
|
],
|
|
]);
|
|
}
|
|
|
|
public function create(): Response
|
|
{
|
|
$customers = User::query()
|
|
->select('id', 'name', 'email')
|
|
->orderBy('name')
|
|
->get();
|
|
|
|
return Inertia::render('Admin/Invoices/Create', [
|
|
'customers' => $customers,
|
|
]);
|
|
}
|
|
|
|
public function store(StoreInvoiceRequest $request): RedirectResponse
|
|
{
|
|
$validated = $request->validated();
|
|
|
|
$invoice = DB::transaction(function () use ($validated, $request): Invoice {
|
|
// Calculate total from line items
|
|
$total = collect($validated['items'])->sum(function (array $item): float {
|
|
return (float) $item['unit_price'] * (int) $item['quantity'];
|
|
});
|
|
|
|
// Generate unique invoice number
|
|
$number = 'INV-'.now()->format('Ymd').'-'.strtoupper(Str::random(6));
|
|
|
|
$status = ($validated['send_immediately'] ?? false) ? 'pending' : 'draft';
|
|
|
|
$invoice = Invoice::create([
|
|
'user_id' => $validated['customer_id'],
|
|
'gateway' => 'manual',
|
|
'number' => $number,
|
|
'total' => $total,
|
|
'tax' => 0,
|
|
'currency' => 'USD',
|
|
'status' => $status,
|
|
'due_date' => $validated['due_date'],
|
|
'notes' => $validated['notes'] ?? null,
|
|
]);
|
|
|
|
// Create line items
|
|
foreach ($validated['items'] as $item) {
|
|
$invoice->items()->create([
|
|
'description' => $item['description'],
|
|
'quantity' => $item['quantity'],
|
|
'amount' => $item['unit_price'],
|
|
]);
|
|
}
|
|
|
|
AuditLog::create([
|
|
'user_id' => $validated['customer_id'],
|
|
'admin_id' => $request->user()?->id,
|
|
'action' => 'create_invoice',
|
|
'resource_type' => 'invoice',
|
|
'resource_id' => $invoice->id,
|
|
'ip_address' => $request->ip(),
|
|
'user_agent' => $request->userAgent(),
|
|
]);
|
|
|
|
return $invoice;
|
|
});
|
|
|
|
// Send email if requested
|
|
if ($validated['send_immediately'] ?? false) {
|
|
$invoice->load('user');
|
|
$invoice->user?->notify(new InvoiceNotification($invoice));
|
|
}
|
|
|
|
return redirect()->route('invoices.show', $invoice)
|
|
->with('success', "Invoice {$invoice->number} has been created.");
|
|
}
|
|
|
|
public function show(Invoice $invoice): Response
|
|
{
|
|
$invoice->load([
|
|
'user:id,name,email,status',
|
|
'items',
|
|
'paymentTransactions',
|
|
]);
|
|
|
|
return Inertia::render('Admin/Invoices/Show', [
|
|
'invoice' => $invoice,
|
|
]);
|
|
}
|
|
|
|
public function edit(Invoice $invoice): Response|RedirectResponse
|
|
{
|
|
if (! in_array($invoice->status, ['draft', 'pending'])) {
|
|
return redirect()->route('invoices.show', $invoice)
|
|
->with('error', 'Only draft or pending invoices can be edited.');
|
|
}
|
|
|
|
$invoice->load(['user:id,name,email', 'items']);
|
|
|
|
return Inertia::render('Admin/Invoices/Edit', [
|
|
'invoice' => $invoice,
|
|
]);
|
|
}
|
|
|
|
public function update(UpdateInvoiceRequest $request, Invoice $invoice): RedirectResponse
|
|
{
|
|
if (! in_array($invoice->status, ['draft', 'pending'])) {
|
|
return redirect()->route('invoices.show', $invoice)
|
|
->with('error', 'Only draft or pending invoices can be edited.');
|
|
}
|
|
|
|
$validated = $request->validated();
|
|
|
|
DB::transaction(function () use ($invoice, $validated, $request): void {
|
|
// Delete existing items and recreate
|
|
$invoice->items()->delete();
|
|
|
|
$total = 0.0;
|
|
foreach ($validated['items'] as $item) {
|
|
$lineTotal = (float) $item['unit_price'] * (int) $item['quantity'];
|
|
$total += $lineTotal;
|
|
|
|
$invoice->items()->create([
|
|
'description' => $item['description'],
|
|
'quantity' => $item['quantity'],
|
|
'amount' => $item['unit_price'],
|
|
]);
|
|
}
|
|
|
|
$invoice->update([
|
|
'total' => $total,
|
|
'due_date' => $validated['due_date'],
|
|
'notes' => $validated['notes'] ?? null,
|
|
]);
|
|
|
|
AuditLog::create([
|
|
'user_id' => $invoice->user_id,
|
|
'admin_id' => $request->user()?->id,
|
|
'action' => 'update_invoice',
|
|
'resource_type' => 'invoice',
|
|
'resource_id' => $invoice->id,
|
|
'ip_address' => $request->ip(),
|
|
'user_agent' => $request->userAgent(),
|
|
]);
|
|
});
|
|
|
|
return redirect()->route('invoices.show', $invoice)
|
|
->with('success', "Invoice {$invoice->number} has been updated.");
|
|
}
|
|
|
|
public function resend(Invoice $invoice): RedirectResponse
|
|
{
|
|
$invoice->load('user');
|
|
|
|
if (! $invoice->user) {
|
|
return redirect()->back()->with('error', 'Cannot resend: no customer associated with this invoice.');
|
|
}
|
|
|
|
$invoice->user->notify(new InvoiceNotification($invoice));
|
|
|
|
AuditLog::create([
|
|
'user_id' => $invoice->user_id,
|
|
'admin_id' => auth()->id(),
|
|
'action' => 'resend_invoice',
|
|
'resource_type' => 'invoice',
|
|
'resource_id' => $invoice->id,
|
|
'ip_address' => request()->ip(),
|
|
'user_agent' => request()->userAgent(),
|
|
]);
|
|
|
|
return redirect()->back()->with('success', "Invoice {$invoice->number} email has been queued for delivery.");
|
|
}
|
|
|
|
public function download(Invoice $invoice): \Symfony\Component\HttpFoundation\Response
|
|
{
|
|
$invoice->load(['user', 'items']);
|
|
|
|
$pdf = Pdf::loadView('pdf.invoice', ['invoice' => $invoice]);
|
|
|
|
return $pdf->download("invoice-{$invoice->number}.pdf");
|
|
}
|
|
|
|
public function void(Invoice $invoice): RedirectResponse
|
|
{
|
|
$invoice->update(['status' => 'void']);
|
|
|
|
AuditLog::create([
|
|
'user_id' => $invoice->user_id,
|
|
'admin_id' => auth()->id(),
|
|
'action' => 'void_invoice',
|
|
'resource_type' => 'invoice',
|
|
'resource_id' => $invoice->id,
|
|
'ip_address' => request()->ip(),
|
|
'user_agent' => request()->userAgent(),
|
|
]);
|
|
|
|
return redirect()->back()->with('success', "Invoice {$invoice->number} has been voided.");
|
|
}
|
|
}
|