44
55use App \Domains \Client \Models \Client ;
66use App \Domains \Contract \Models \ContractContactAssignment ;
7+ use App \Domains \Financial \Models \Category ;
78use App \Domains \Financial \Models \Invoice ;
89use App \Domains \Financial \Models \InvoiceItem ;
910use App \Domains \Financial \Models \BillingAuditLog ;
@@ -112,18 +113,24 @@ public function billTicket(Ticket $ticket, array $options = []): ?Invoice
112113 $ ticket ->update (['invoice_id ' => $ invoice ->id ]);
113114
114115 // Log audit trail
115- BillingAuditLog::logBillingAction (
116- action: BillingAuditLog::ACTION_INVOICE_GENERATED ,
117- ticketId: $ ticket ->id ,
118- invoiceId: $ invoice ->id ,
119- description: "Invoice # {$ invoice ->number } generated from ticket # {$ ticket ->number }" ,
120- metadata: [
116+ BillingAuditLog::create ([
117+ 'company_id ' => $ ticket ->company_id ,
118+ 'user_id ' => auth ()->id (),
119+ 'action ' => BillingAuditLog::ACTION_INVOICE_GENERATED ,
120+ 'entity_type ' => 'Ticket ' ,
121+ 'entity_id ' => $ ticket ->id ,
122+ 'ticket_id ' => $ ticket ->id ,
123+ 'invoice_id ' => $ invoice ->id ,
124+ 'description ' => "Invoice # {$ invoice ->number } generated from ticket # {$ ticket ->number }" ,
125+ 'metadata ' => [
121126 'strategy ' => $ strategy ,
122- 'amount ' => $ invoice ->amount ,
127+ 'amount ' => $ invoice ->total ?? 0 ,
123128 'client_id ' => $ ticket ->client_id ,
124129 'billable_hours ' => $ invoice ->items ->sum ('quantity ' ),
125- ]
126- );
130+ ],
131+ 'ip_address ' => request ()->ip () ?? '127.0.0.1 ' ,
132+ 'user_agent ' => request ()->userAgent () ?? 'CLI ' ,
133+ ]);
127134
128135 $ this ->log ('Ticket billing completed ' , [
129136 'ticket_id ' => $ ticket ->id ,
@@ -177,7 +184,7 @@ protected function billByTimeEntries(Ticket $ticket, Client $client, array $opti
177184 {
178185 $ timeEntries = $ ticket ->timeEntries ()
179186 ->where ('billable ' , true )
180- ->whereNull ( ' invoice_id ' )
187+ ->where ( ' is_billed ' , false )
181188 ->get ();
182189
183190 if ($ timeEntries ->isEmpty ()) {
@@ -213,19 +220,25 @@ protected function billByTimeEntries(Ticket $ticket, Client $client, array $opti
213220 // Create invoice item
214221 $ description = $ this ->buildTimeEntryDescription ($ ticket , $ timeEntries , $ billableHours );
215222
223+ $ taxRate = $ options ['tax_rate ' ] ?? 0 ;
224+ $ tax = $ amount * ($ taxRate / 100 );
225+
216226 InvoiceItem::create ([
217227 'invoice_id ' => $ invoice ->id ,
218228 'company_id ' => $ invoice ->company_id ,
229+ 'name ' => "Support - Ticket # {$ ticket ->number }" ,
219230 'description ' => $ description ,
220231 'quantity ' => $ billableHours ,
221232 'price ' => $ hourlyRate ,
222- 'amount ' => $ amount ,
223- 'tax_rate ' => $ options ['tax_rate ' ] ?? 0 ,
233+ 'subtotal ' => $ amount ,
234+ 'tax_rate ' => $ taxRate ,
235+ 'tax ' => $ tax ,
236+ 'total ' => $ amount + $ tax ,
224237 ]);
225238
226- // Link time entries to invoice
239+ // Mark time entries as billed
227240 $ timeEntries ->each (function ($ entry ) use ($ invoice ) {
228- $ entry ->update (['invoice_id ' => $ invoice -> id ]);
241+ $ entry ->update (['is_billed ' => true ]);
229242 });
230243
231244 $ this ->updateInvoiceTotals ($ invoice );
@@ -264,14 +277,20 @@ protected function billByTicketRate(Ticket $ticket, Client $client, array $optio
264277 $ description .= "Priority: {$ ticket ->priority }\n" ;
265278 $ description .= "Per-ticket flat rate " ;
266279
280+ $ taxRate = $ options ['tax_rate ' ] ?? 0 ;
281+ $ tax = $ perTicketRate * ($ taxRate / 100 );
282+
267283 InvoiceItem::create ([
268284 'invoice_id ' => $ invoice ->id ,
269285 'company_id ' => $ invoice ->company_id ,
286+ 'name ' => "Support - Ticket # {$ ticket ->number }" ,
270287 'description ' => $ description ,
271288 'quantity ' => 1 ,
272289 'price ' => $ perTicketRate ,
273- 'amount ' => $ perTicketRate ,
274- 'tax_rate ' => $ options ['tax_rate ' ] ?? 0 ,
290+ 'subtotal ' => $ perTicketRate ,
291+ 'tax_rate ' => $ taxRate ,
292+ 'tax ' => $ tax ,
293+ 'total ' => $ perTicketRate + $ tax ,
275294 ]);
276295
277296 $ this ->updateInvoiceTotals ($ invoice );
@@ -286,7 +305,7 @@ protected function billMixed(Ticket $ticket, Client $client, array $options = []
286305 {
287306 $ timeEntries = $ ticket ->timeEntries ()
288307 ->where ('billable ' , true )
289- ->whereNull ( ' invoice_id ' )
308+ ->where ( ' is_billed ' , false )
290309 ->get ();
291310
292311 $ contractAssignment = $ this ->getContractAssignment ($ ticket );
@@ -316,36 +335,47 @@ protected function billMixed(Ticket $ticket, Client $client, array $options = []
316335 'note ' => $ options ['note ' ] ?? "Support ticket # {$ ticket ->number }: {$ ticket ->subject }" ,
317336 ]);
318337
338+ $ taxRate = $ options ['tax_rate ' ] ?? 0 ;
339+
319340 // Add time entry item
320341 if ($ timeAmount > 0 ) {
321342 $ timeDescription = $ this ->buildTimeEntryDescription ($ ticket , $ timeEntries , $ billableHours );
343+ $ timeTax = $ timeAmount * ($ taxRate / 100 );
322344
323345 InvoiceItem::create ([
324346 'invoice_id ' => $ invoice ->id ,
325347 'company_id ' => $ invoice ->company_id ,
348+ 'name ' => "Support Time - Ticket # {$ ticket ->number }" ,
326349 'description ' => $ timeDescription ,
327350 'quantity ' => $ billableHours ,
328351 'price ' => $ hourlyRate ,
329- 'amount ' => $ timeAmount ,
330- 'tax_rate ' => $ options ['tax_rate ' ] ?? 0 ,
352+ 'subtotal ' => $ timeAmount ,
353+ 'tax_rate ' => $ taxRate ,
354+ 'tax ' => $ timeTax ,
355+ 'total ' => $ timeAmount + $ timeTax ,
331356 ]);
332357
333- // Link time entries
358+ // Mark time entries as billed
334359 $ timeEntries ->each (function ($ entry ) use ($ invoice ) {
335- $ entry ->update (['invoice_id ' => $ invoice -> id ]);
360+ $ entry ->update (['is_billed ' => true ]);
336361 });
337362 }
338363
339364 // Add per-ticket rate item
340365 if ($ perTicketRate > 0 ) {
366+ $ perTicketTax = $ perTicketRate * ($ taxRate / 100 );
367+
341368 InvoiceItem::create ([
342369 'invoice_id ' => $ invoice ->id ,
343370 'company_id ' => $ invoice ->company_id ,
371+ 'name ' => "Support Fee - Ticket # {$ ticket ->number }" ,
344372 'description ' => "Support Ticket # {$ ticket ->number } - Flat Rate Fee " ,
345373 'quantity ' => 1 ,
346374 'price ' => $ perTicketRate ,
347- 'amount ' => $ perTicketRate ,
348- 'tax_rate ' => $ options ['tax_rate ' ] ?? 0 ,
375+ 'subtotal ' => $ perTicketRate ,
376+ 'tax_rate ' => $ taxRate ,
377+ 'tax ' => $ perTicketTax ,
378+ 'total ' => $ perTicketRate + $ perTicketTax ,
349379 ]);
350380 }
351381
@@ -355,32 +385,67 @@ protected function billMixed(Ticket $ticket, Client $client, array $options = []
355385 }
356386
357387 /**
358- * Create invoice for ticket billing
388+ * Get or create invoice for billing
389+ *
390+ * This consolidates tickets for the same client into a single draft invoice
359391 */
360392 protected function createInvoice (Client $ client , array $ data = []): Invoice
361393 {
362- $ lastInvoice = Invoice::where ('company_id ' , $ client ->company_id )
394+ // Check if we should consolidate into existing draft invoice
395+ $ consolidate = $ data ['consolidate ' ] ?? config ('billing.ticket.consolidate_invoices ' , true );
396+
397+ if ($ consolidate ) {
398+ // Look for an existing draft invoice for this client created today (with lock to prevent race conditions)
399+ $ existingInvoice = Invoice::where ('company_id ' , $ client ->company_id )
400+ ->where ('client_id ' , $ client ->id )
401+ ->where ('status ' , Invoice::STATUS_DRAFT )
402+ ->whereDate ('date ' , now ()->toDateString ())
403+ ->lockForUpdate ()
404+ ->first ();
405+
406+ if ($ existingInvoice ) {
407+ return $ existingInvoice ;
408+ }
409+ }
410+
411+ // Use a lock to prevent duplicate invoice numbers (include soft-deleted)
412+ $ lastInvoice = Invoice::withTrashed ()
413+ ->where ('company_id ' , $ client ->company_id )
414+ ->lockForUpdate ()
363415 ->orderBy ('number ' , 'desc ' )
364416 ->first ();
365417
366418 $ dueDate = now ()->addDays (
367- $ data ['due_days ' ] ?? config ('billing.ticket.invoice_due_days ' , 30 )
419+ ( int ) ( $ data ['due_days ' ] ?? config ('billing.ticket.invoice_due_days ' , 30 ) )
368420 );
369421
370422 $ status = config ('billing.ticket.require_approval ' , true )
371423 ? Invoice::STATUS_DRAFT
372424 : Invoice::STATUS_SENT ;
373425
426+ // Get or create a default category for ticket billing
427+ $ categoryId = $ data ['category_id ' ] ?? Category::where ('company_id ' , $ client ->company_id )
428+ ->where ('name ' , 'like ' , '%Support% ' )
429+ ->orWhere ('name ' , 'like ' , '%Service% ' )
430+ ->value ('id ' );
431+
432+ // Fallback to first category if none found
433+ if (!$ categoryId ) {
434+ $ categoryId = Category::where ('company_id ' , $ client ->company_id )->value ('id ' )
435+ ?? Category::first ()?->id;
436+ }
437+
374438 $ invoice = Invoice::create ([
375439 'company_id ' => $ client ->company_id ,
376440 'client_id ' => $ client ->id ,
441+ 'category_id ' => $ categoryId ,
377442 'prefix ' => $ data ['prefix ' ] ?? 'INV ' ,
378443 'number ' => $ lastInvoice ? $ lastInvoice ->number + 1 : 1001 ,
379444 'date ' => $ data ['date ' ] ?? now (),
380445 'due_date ' => $ dueDate ,
381446 'status ' => $ status ,
382447 'currency_code ' => $ client ->currency_code ?? 'USD ' ,
383- 'note ' => $ data ['note ' ] ?? null ,
448+ 'note ' => $ data ['note ' ] ?? ' Support Services ' ,
384449 'url_key ' => \Illuminate \Support \Str::random (32 ),
385450 ]);
386451
@@ -443,8 +508,9 @@ protected function getContractAssignment(Ticket $ticket): ?ContractContactAssign
443508 }
444509
445510 return ContractContactAssignment::where ('contact_id ' , $ ticket ->contact_id )
446- ->whereHas ('schedule ' , function ($ query ) {
447- $ query ->where ('is_active ' , true )
511+ ->where ('status ' , 'active ' )
512+ ->whereHas ('contract ' , function ($ query ) {
513+ $ query ->whereIn ('status ' , ['active ' , 'signed ' ])
448514 ->whereDate ('start_date ' , '<= ' , now ())
449515 ->where (function ($ q ) {
450516 $ q ->whereNull ('end_date ' )
@@ -459,14 +525,14 @@ protected function getContractAssignment(Ticket $ticket): ?ContractContactAssign
459525 */
460526 protected function updateInvoiceTotals (Invoice $ invoice ): void
461527 {
462- $ subtotal = $ invoice ->items ()->sum ('amount ' );
463- $ tax = $ invoice ->items ()->sum (DB :: raw ( ' amount * (tax_rate / 100) ' ) );
464- $ total = $ subtotal + $ tax ;
528+ $ subtotal = $ invoice ->items ()->sum ('subtotal ' );
529+ $ tax = $ invoice ->items ()->sum (' tax ' );
530+ $ total = $ invoice -> items ()-> sum ( ' total ' ) ;
465531
466532 $ invoice ->update ([
467533 'subtotal ' => $ subtotal ,
468534 'tax ' => $ tax ,
469- 'amount ' => $ total ,
535+ 'total ' => $ total ,
470536 ]);
471537 }
472538
@@ -476,7 +542,7 @@ protected function updateInvoiceTotals(Invoice $invoice): void
476542 public function previewBilling (Ticket $ ticket ): array
477543 {
478544 $ strategy = $ this ->determineBillingStrategy ($ ticket , []);
479- $ timeEntries = $ ticket ->timeEntries ()->where ('billable ' , true )->whereNull ( ' invoice_id ' )->get ();
545+ $ timeEntries = $ ticket ->timeEntries ()->where ('billable ' , true )->where ( ' is_billed ' , false )->get ();
480546 $ contractAssignment = $ this ->getContractAssignment ($ ticket );
481547
482548 $ preview = [
@@ -629,7 +695,7 @@ public function canBillTicket(Ticket $ticket): bool
629695 }
630696
631697 // Check if there's something to bill
632- $ hasTimeEntries = $ ticket ->timeEntries ()->where ('billable ' , true )->whereNull ( ' invoice_id ' )->exists ();
698+ $ hasTimeEntries = $ ticket ->timeEntries ()->where ('billable ' , true )->where ( ' is_billed ' , false )->exists ();
633699 $ contractAssignment = $ this ->getContractAssignment ($ ticket );
634700 $ hasPerTicketRate = $ contractAssignment && $ contractAssignment ->per_ticket_rate > 0 ;
635701
@@ -703,7 +769,7 @@ public function shouldBillTicket(Ticket $ticket): array
703769
704770 // Check prepaid hours (time-based billing)
705771 if ($ contractAssignment ->max_support_hours_per_month > 0 ) {
706- $ timeEntries = $ ticket ->timeEntries ()->where ('billable ' , true )->whereNull ( ' invoice_id ' )->get ();
772+ $ timeEntries = $ ticket ->timeEntries ()->where ('billable ' , true )->where ( ' is_billed ' , false )->get ();
707773 $ ticketHours = $ timeEntries ->sum ('hours_worked ' );
708774
709775 $ hoursUsed = $ contractAssignment ->current_month_support_hours ?? 0 ;
0 commit comments