Files
hub/app/Http/Controllers/Seller/Crm/InvoiceController.php
kelly 496ca61489 feat: dashboard redesign and sidebar consolidation
- Redesign dashboard as daily briefing format with action-first layout
- Consolidate sidebar menu structure (Dashboard as single link)
- Fix CRM form styling to use consistent UI patterns
- Add PWA icons and push notification groundwork
- Update SuiteMenuResolver for cleaner navigation
2025-12-14 03:41:31 -07:00

285 lines
9.4 KiB
PHP

<?php
namespace App\Http\Controllers\Seller\Crm;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\Crm\CrmInvoice;
use App\Models\Crm\CrmInvoiceItem;
use App\Models\Crm\CrmInvoicePayment;
use App\Models\Crm\CrmQuote;
use Illuminate\Http\Request;
class InvoiceController extends Controller
{
/**
* List invoices
*/
public function index(Request $request, Business $business)
{
$query = CrmInvoice::forBusiness($business->id)
->with(['contact', 'account', 'creator'])
->withCount('items');
// Filters
if ($request->filled('status')) {
$query->where('status', $request->status);
}
if ($request->filled('overdue')) {
$query->overdue();
}
if ($request->filled('search')) {
$query->where(function ($q) use ($request) {
$q->where('invoice_number', 'ILIKE', "%{$request->search}%")
->orWhere('title', 'ILIKE', "%{$request->search}%");
});
}
$invoices = $query->orderByDesc('created_at')->paginate(25);
// Stats - single efficient query with conditional aggregation
$invoiceStats = CrmInvoice::forBusiness($business->id)
->selectRaw("
SUM(CASE WHEN status IN ('sent', 'viewed', 'partial') THEN balance_due ELSE 0 END) as outstanding,
SUM(CASE WHEN status IN ('sent', 'viewed', 'partial') AND due_date < CURRENT_DATE THEN balance_due ELSE 0 END) as overdue
")
->first();
$paidThisMonth = CrmInvoicePayment::whereHas('invoice', fn ($q) => $q->where('business_id', $business->id))
->whereMonth('payment_date', now()->month)
->whereYear('payment_date', now()->year)
->sum('amount');
$stats = [
'outstanding' => $invoiceStats->outstanding ?? 0,
'overdue' => $invoiceStats->overdue ?? 0,
'paid_this_month' => $paidThisMonth,
];
return view('seller.crm.invoices.index', compact('invoices', 'stats', 'business'));
}
/**
* Show invoice details
*/
public function show(Request $request, Business $business, CrmInvoice $invoice)
{
if ($invoice->business_id !== $business->id) {
abort(404);
}
$invoice->load(['contact', 'account', 'quote', 'creator', 'items.product', 'payments']);
return view('seller.crm.invoices.show', compact('invoice', 'business'));
}
/**
* Create invoice form
*/
public function create(Request $request, Business $business)
{
// Limit contacts for dropdown - most recent 100
$contacts = \App\Models\Contact::where('business_id', $business->id)
->select('id', 'first_name', 'last_name', 'email', 'company_name')
->orderByDesc('updated_at')
->limit(100)
->get();
// Limit quotes to accepted without invoices
$quotes = CrmQuote::forBusiness($business->id)
->where('status', CrmQuote::STATUS_ACCEPTED)
->whereDoesntHave('invoice')
->select('id', 'quote_number', 'title', 'total', 'contact_id')
->with('contact:id,first_name,last_name')
->limit(50)
->get();
return view('seller.crm.invoices.create', compact('contacts', 'quotes', 'business'));
}
/**
* Store new invoice
*/
public function store(Request $request, Business $business)
{
$validated = $request->validate([
'title' => 'required|string|max:255',
'contact_id' => 'required|exists:contacts,id',
'account_id' => 'nullable|exists:businesses,id',
'quote_id' => 'nullable|exists:crm_quotes,id',
'due_date' => 'required|date|after_or_equal:today',
'tax_rate' => 'nullable|numeric|min:0|max:100',
'notes' => 'nullable|string|max:2000',
'payment_terms' => 'nullable|string|max:1000',
'items' => 'required|array|min:1',
'items.*.description' => 'required|string|max:500',
'items.*.quantity' => 'required|numeric|min:0.01',
'items.*.unit_price' => 'required|numeric|min:0',
]);
// SECURITY: Verify contact belongs to business
\App\Models\Contact::where('id', $validated['contact_id'])
->where('business_id', $business->id)
->firstOrFail();
// SECURITY: Verify quote belongs to business if provided
if (! empty($validated['quote_id'])) {
CrmQuote::where('id', $validated['quote_id'])
->where('business_id', $business->id)
->firstOrFail();
}
$invoiceNumber = CrmInvoice::generateInvoiceNumber($business->id);
$invoice = CrmInvoice::create([
'business_id' => $business->id,
'contact_id' => $validated['contact_id'],
'account_id' => $validated['account_id'],
'quote_id' => $validated['quote_id'],
'created_by' => $request->user()->id,
'invoice_number' => $invoiceNumber,
'title' => $validated['title'],
'status' => CrmInvoice::STATUS_DRAFT,
'issue_date' => now(),
'due_date' => $validated['due_date'],
'tax_rate' => $validated['tax_rate'] ?? 0,
'notes' => $validated['notes'],
'payment_terms' => $validated['payment_terms'],
'currency' => 'USD',
]);
// Create line items
foreach ($validated['items'] as $index => $item) {
CrmInvoiceItem::create([
'invoice_id' => $invoice->id,
'description' => $item['description'],
'quantity' => $item['quantity'],
'unit_price' => $item['unit_price'],
'sort_order' => $index,
]);
}
$invoice->calculateTotals();
return redirect()->route('seller.business.crm.invoices.show', [$business, $invoice])
->with('success', 'Invoice created successfully.');
}
/**
* Send invoice to contact
*/
public function send(Request $request, Business $business, CrmInvoice $invoice)
{
if ($invoice->business_id !== $business->id) {
abort(404);
}
if (! $invoice->canBeSent()) {
return back()->withErrors(['error' => 'This invoice cannot be sent.']);
}
$invoice->send($request->user());
// TODO: Send email notification to contact
return back()->with('success', 'Invoice sent successfully.');
}
/**
* Record a payment
*/
public function recordPayment(Request $request, Business $business, CrmInvoice $invoice)
{
if ($invoice->business_id !== $business->id) {
abort(404);
}
$validated = $request->validate([
'amount' => 'required|numeric|min:0.01|max:'.$invoice->amount_due,
'payment_date' => 'required|date',
'payment_method' => 'required|string|in:cash,check,wire,ach,credit_card,other',
'reference' => 'nullable|string|max:255',
'notes' => 'nullable|string|max:500',
]);
CrmInvoicePayment::create([
'invoice_id' => $invoice->id,
'amount' => $validated['amount'],
'payment_date' => $validated['payment_date'],
'payment_method' => $validated['payment_method'],
'reference' => $validated['reference'],
'notes' => $validated['notes'],
'recorded_by' => $request->user()->id,
]);
// Recalculate totals
$invoice->calculateTotals();
// Auto-mark as paid if fully paid
if ($invoice->amount_due <= 0) {
$invoice->update([
'status' => CrmInvoice::STATUS_PAID,
'paid_at' => now(),
]);
}
return back()->with('success', 'Payment recorded.');
}
/**
* Mark invoice as void
*/
public function void(Request $request, Business $business, CrmInvoice $invoice)
{
if ($invoice->business_id !== $business->id) {
abort(404);
}
if ($invoice->status === CrmInvoice::STATUS_PAID) {
return back()->withErrors(['error' => 'Paid invoices cannot be voided.']);
}
$invoice->update([
'status' => CrmInvoice::STATUS_VOID,
'voided_at' => now(),
'voided_by' => $request->user()->id,
]);
return back()->with('success', 'Invoice voided.');
}
/**
* Download invoice PDF
*/
public function download(Request $request, Business $business, CrmInvoice $invoice)
{
if ($invoice->business_id !== $business->id) {
abort(404);
}
// TODO: Generate PDF
return back()->with('info', 'PDF generation coming soon.');
}
/**
* Delete invoice
*/
public function destroy(Request $request, Business $business, CrmInvoice $invoice)
{
if ($invoice->business_id !== $business->id) {
abort(404);
}
if ($invoice->status !== CrmInvoice::STATUS_DRAFT) {
return back()->withErrors(['error' => 'Only draft invoices can be deleted.']);
}
$invoice->delete();
return redirect()->route('seller.business.crm.invoices.index', $business)
->with('success', 'Invoice deleted.');
}
}