Change validation from nullable|integer to exists:locations,id to properly validate against the locations table.
538 lines
19 KiB
PHP
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.');
|
|
}
|
|
}
|