Files
hub/app/Http/Controllers/Seller/Crm/InvoiceController.php
kelly e26da88f22 fix: correct location_id validation in InvoiceController
Change validation from nullable|integer to exists:locations,id
to properly validate against the locations table.
2025-12-17 16:49:13 -07:00

538 lines
19 KiB
PHP

<?php
namespace App\Http\Controllers\Seller\Crm;
use App\Http\Controllers\Controller;
use App\Mail\InvoiceMail;
use App\Models\Business;
use App\Models\Crm\CrmDeal;
use App\Models\Crm\CrmInvoice;
use App\Models\Crm\CrmInvoiceItem;
use App\Models\Crm\CrmInvoicePayment;
use App\Models\Crm\CrmQuote;
use Barryvdh\DomPDF\Facade\Pdf;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Mail;
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.recordedBy']);
return view('seller.crm.invoices.show', compact('invoice', 'business'));
}
/**
* Create invoice form
*/
public function create(Request $request, Business $business)
{
// Get all approved buyer businesses as potential customers (matching quotes)
$accounts = Business::where('type', 'buyer')
->where('status', 'approved')
->with('locations:id,business_id,name,is_primary')
->orderBy('name')
->select(['id', 'name', 'slug'])
->get();
// Get open deals for linking
$deals = CrmDeal::forBusiness($business->id)->open()->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', 'account_id', 'location_id')
->with(['contact:id,first_name,last_name', 'items.product'])
->limit(50)
->get();
// Transform quotes for Alpine.js (avoid complex closures in Blade @json)
$quotesForJs = $quotes->map(fn ($q) => [
'id' => $q->id,
'account_id' => $q->account_id,
'contact_id' => $q->contact_id,
'location_id' => $q->location_id,
'items' => $q->items->map(fn ($i) => [
'product_id' => $i->product_id,
'description' => $i->description,
'quantity' => $i->quantity,
'unit_price' => $i->unit_price,
'discount_percent' => $i->discount_percent ?? 0,
])->values(),
])->values();
// Pre-fill from URL parameters
$selectedAccount = null;
$selectedLocation = null;
$selectedContact = null;
$locationContacts = collect();
if ($request->filled('account_id')) {
$selectedAccount = $accounts->firstWhere('id', $request->account_id);
}
if ($request->filled('location_id') && $selectedAccount) {
$selectedLocation = $selectedAccount->locations->firstWhere('id', $request->location_id);
}
// Pre-fill from quote if provided
$quote = null;
if ($request->filled('quote_id')) {
$quote = $quotes->firstWhere('id', $request->quote_id);
if ($quote) {
$selectedAccount = $accounts->firstWhere('id', $quote->account_id);
}
}
return view('seller.crm.invoices.create', compact(
'accounts',
'deals',
'quotes',
'quotesForJs',
'business',
'selectedAccount',
'selectedLocation',
'selectedContact',
'locationContacts',
'quote'
));
}
/**
* 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',
'location_id' => 'nullable|exists:locations,id',
'quote_id' => 'nullable|exists:crm_quotes,id',
'deal_id' => 'nullable|exists:crm_deals,id',
'due_date' => 'required|date|after_or_equal:today',
'tax_rate' => 'nullable|numeric|min:0|max:100',
'discount_type' => 'nullable|in:fixed,percentage',
'discount_value' => 'nullable|numeric|min:0',
'notes' => 'nullable|string|max:2000',
'payment_terms' => 'nullable|string|max:1000',
'items' => 'required|array|min:1',
'items.*.product_id' => 'nullable|exists:products,id',
'items.*.description' => 'required|string|max:500',
'items.*.quantity' => 'required|numeric|min:0.01',
'items.*.unit_price' => 'required|numeric|min:0',
'items.*.discount_percent' => 'nullable|numeric|min:0|max:100',
]);
// SECURITY: Verify contact belongs to the account if account is provided
$contact = \App\Models\Contact::findOrFail($validated['contact_id']);
if (! empty($validated['account_id']) && $contact->business_id !== (int) $validated['account_id']) {
return back()->withErrors(['contact_id' => 'Contact must belong to the selected account.']);
}
// 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();
}
// SECURITY: Verify deal belongs to business if provided
if (! empty($validated['deal_id'])) {
CrmDeal::where('id', $validated['deal_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'],
'location_id' => $validated['location_id'] ?? null,
'quote_id' => $validated['quote_id'] ?? null,
'deal_id' => $validated['deal_id'] ?? null,
'created_by' => $request->user()->id,
'invoice_number' => $invoiceNumber,
'title' => $validated['title'],
'status' => CrmInvoice::STATUS_DRAFT,
'invoice_date' => now(),
'due_date' => $validated['due_date'],
'tax_rate' => $validated['tax_rate'] ?? 0,
'discount_type' => $validated['discount_type'],
'discount_value' => $validated['discount_value'] ?? 0,
'notes' => $validated['notes'],
'terms' => $validated['payment_terms'],
'currency' => 'USD',
]);
// Create line items
foreach ($validated['items'] as $index => $item) {
CrmInvoiceItem::create([
'invoice_id' => $invoice->id,
'product_id' => $item['product_id'] ?? null,
'description' => $item['description'],
'quantity' => $item['quantity'],
'unit_price' => $item['unit_price'],
'discount_percent' => $item['discount_percent'] ?? 0,
'position' => $index,
]);
}
$invoice->calculateTotals();
return redirect()->route('seller.business.crm.invoices.show', [$business, $invoice])
->with('success', 'Invoice created successfully.');
}
/**
* Edit invoice form
*/
public function edit(Request $request, Business $business, CrmInvoice $invoice)
{
if ($invoice->business_id !== $business->id) {
abort(404);
}
if (! $invoice->canBeEdited()) {
return redirect()->route('seller.business.crm.invoices.show', [$business, $invoice])
->withErrors(['error' => 'This invoice cannot be edited.']);
}
$invoice->load(['contact', 'account', 'items.product']);
// Get all approved buyer businesses
$accounts = Business::where('type', 'buyer')
->where('status', 'approved')
->with('locations:id,business_id,name,is_primary')
->orderBy('name')
->select(['id', 'name', 'slug'])
->get();
// Get open deals for linking
$deals = CrmDeal::forBusiness($business->id)->open()->get();
// No quotes dropdown in edit - already linked
$quotes = collect();
$selectedAccount = $invoice->account;
$selectedLocation = $invoice->location ?? null;
$selectedContact = $invoice->contact;
$locationContacts = collect();
return view('seller.crm.invoices.edit', compact(
'invoice',
'accounts',
'deals',
'quotes',
'business',
'selectedAccount',
'selectedLocation',
'selectedContact',
'locationContacts'
));
}
/**
* Update invoice
*/
public function update(Request $request, Business $business, CrmInvoice $invoice)
{
if ($invoice->business_id !== $business->id) {
abort(404);
}
if (! $invoice->canBeEdited()) {
return back()->withErrors(['error' => 'This invoice cannot be edited.']);
}
$validated = $request->validate([
'title' => 'required|string|max:255',
'contact_id' => 'required|exists:contacts,id',
'account_id' => 'nullable|exists:businesses,id',
'location_id' => 'nullable|exists:locations,id',
'deal_id' => 'nullable|exists:crm_deals,id',
'due_date' => 'required|date',
'tax_rate' => 'nullable|numeric|min:0|max:100',
'discount_type' => 'nullable|in:fixed,percentage',
'discount_value' => 'nullable|numeric|min:0',
'notes' => 'nullable|string|max:2000',
'payment_terms' => 'nullable|string|max:1000',
'items' => 'required|array|min:1',
'items.*.product_id' => 'nullable|exists:products,id',
'items.*.description' => 'required|string|max:500',
'items.*.quantity' => 'required|numeric|min:0.01',
'items.*.unit_price' => 'required|numeric|min:0',
'items.*.discount_percent' => 'nullable|numeric|min:0|max:100',
]);
// SECURITY: Verify contact belongs to the account if account is provided
$contact = \App\Models\Contact::findOrFail($validated['contact_id']);
if (! empty($validated['account_id']) && $contact->business_id !== (int) $validated['account_id']) {
return back()->withErrors(['contact_id' => 'Contact must belong to the selected account.']);
}
// SECURITY: Verify deal belongs to business if provided
if (! empty($validated['deal_id'])) {
CrmDeal::where('id', $validated['deal_id'])
->where('business_id', $business->id)
->firstOrFail();
}
$invoice->update([
'contact_id' => $validated['contact_id'],
'account_id' => $validated['account_id'],
'location_id' => $validated['location_id'] ?? null,
'deal_id' => $validated['deal_id'] ?? null,
'title' => $validated['title'],
'due_date' => $validated['due_date'],
'tax_rate' => $validated['tax_rate'] ?? 0,
'discount_type' => $validated['discount_type'],
'discount_value' => $validated['discount_value'] ?? 0,
'notes' => $validated['notes'],
'terms' => $validated['payment_terms'],
]);
// Delete existing items and recreate
$invoice->items()->delete();
foreach ($validated['items'] as $index => $item) {
CrmInvoiceItem::create([
'invoice_id' => $invoice->id,
'product_id' => $item['product_id'] ?? null,
'description' => $item['description'],
'quantity' => $item['quantity'],
'unit_price' => $item['unit_price'],
'discount_percent' => $item['discount_percent'] ?? 0,
'position' => $index,
]);
}
$invoice->calculateTotals();
return redirect()->route('seller.business.crm.invoices.show', [$business, $invoice])
->with('success', 'Invoice updated 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.']);
}
$validated = $request->validate([
'to' => 'required|email',
'cc' => 'nullable|string',
'message' => 'nullable|string|max:2000',
]);
// Generate PDF
$invoice->load(['contact', 'account', 'location', 'deal', 'quote', 'order', 'items.product.brand', 'creator']);
$pdf = Pdf::loadView('pdfs.crm-invoice', [
'invoice' => $invoice,
'business' => $business,
]);
// Send email
$ccEmails = [];
if (! empty($validated['cc'])) {
$ccEmails = array_filter(array_map('trim', explode(',', $validated['cc'])));
}
Mail::to($validated['to'])
->cc($ccEmails)
->send(new InvoiceMail($invoice, $business, $validated['message'] ?? null, $pdf->output()));
// Update status
$invoice->send($request->user());
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);
}
$invoice->load(['contact', 'account', 'location', 'deal', 'quote', 'order', 'items.product.brand', 'creator']);
$pdf = Pdf::loadView('pdfs.crm-invoice', [
'invoice' => $invoice,
'business' => $business,
]);
return $pdf->download($invoice->invoice_number.'.pdf');
}
/**
* View invoice PDF inline
*/
public function pdf(Request $request, Business $business, CrmInvoice $invoice)
{
if ($invoice->business_id !== $business->id) {
abort(404);
}
$invoice->load(['contact', 'account', 'location', 'deal', 'quote', 'order', 'items.product.brand', 'creator']);
$pdf = Pdf::loadView('pdfs.crm-invoice', [
'invoice' => $invoice,
'business' => $business,
]);
return $pdf->stream($invoice->invoice_number.'.pdf');
}
/**
* 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.');
}
}