Skip to content

Commit 4d5dfdc

Browse files
committed
Fix ticket billing system to work with actual database schema
Major fixes: - Use 'is_billed' column instead of non-existent 'invoice_id' on time entries - Use 'subtotal', 'tax', 'total' columns instead of 'amount' on invoice items - Add 'name' field (required) to invoice items - Add 'category_id' (required) when creating invoices - Fix getContractAssignment() to use contract relationship instead of non-existent schedule relationship - Fix BillingAuditLog to pass company_id explicitly for queue jobs - Cast invoice due days to int to fix Carbon type error - Include soft-deleted invoices when calculating next invoice number - Consolidate multiple tickets for same client into single draft invoice ProcessPendingTicketBilling.php: - Fix query to use client.contracts instead of contact.contractAssignments - Use 'is_billed' instead of 'invoice_id' for time entry queries TicketTimeEntry.php: - Update scopeInvoiced/scopeUninvoiced to use is_billed column - Remove invoice_id from fillable and casts (column doesn't exist) This enables ticket billing to actually create invoices from billable time entries on closed/resolved tickets.
1 parent dca9df2 commit 4d5dfdc

File tree

3 files changed

+115
-56
lines changed

3 files changed

+115
-56
lines changed

app/Console/Commands/ProcessPendingTicketBilling.php

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -156,21 +156,18 @@ protected function buildPendingTicketsQuery()
156156
->when(config('billing.ticket.include_unbilled_only', true), function ($query) {
157157
// Additional check for time entries or contract rates
158158
$query->where(function ($q) {
159-
// Has billable time entries
159+
// Has billable time entries that haven't been billed
160160
$q->whereHas('timeEntries', function ($teQuery) {
161161
$teQuery->where('billable', true)
162-
->whereNull('invoice_id');
162+
->where('is_billed', false);
163163
})
164-
// OR has contact with active contract and per-ticket rate
165-
->orWhereHas('contact.contractAssignments', function ($caQuery) {
166-
$caQuery->where('per_ticket_rate', '>', 0)
167-
->whereHas('schedule', function ($schedQuery) {
168-
$schedQuery->where('is_active', true)
169-
->whereDate('start_date', '<=', now())
170-
->where(function ($dateQuery) {
171-
$dateQuery->whereNull('end_date')
172-
->orWhereDate('end_date', '>=', now());
173-
});
164+
// OR has client with active contract
165+
->orWhereHas('client.contracts', function ($contractQuery) {
166+
$contractQuery->whereIn('status', ['active', 'signed'])
167+
->whereDate('start_date', '<=', now())
168+
->where(function ($dateQuery) {
169+
$dateQuery->whereNull('end_date')
170+
->orWhereDate('end_date', '>=', now());
174171
});
175172
});
176173
});
@@ -189,12 +186,12 @@ protected function displayTicketTable($tickets): void
189186
foreach ($tickets as $ticket) {
190187
$timeEntries = $ticket->timeEntries()
191188
->where('billable', true)
192-
->whereNull('invoice_id')
189+
->where('is_billed', false)
193190
->count();
194191

195192
$totalHours = $ticket->timeEntries()
196193
->where('billable', true)
197-
->whereNull('invoice_id')
194+
->where('is_billed', false)
198195
->sum('hours_worked');
199196

200197
$rows[] = [

app/Domains/Financial/Services/TicketBillingService.php

Lines changed: 102 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
use App\Domains\Client\Models\Client;
66
use App\Domains\Contract\Models\ContractContactAssignment;
7+
use App\Domains\Financial\Models\Category;
78
use App\Domains\Financial\Models\Invoice;
89
use App\Domains\Financial\Models\InvoiceItem;
910
use 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;

app/Domains/Ticket/Models/TicketTimeEntry.php

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,6 @@ protected static function newFactory()
5252
'rejected_at',
5353
'rejected_by',
5454
'rejection_reason',
55-
'invoice_id',
56-
'invoiced_at',
5755
'metadata',
5856
];
5957

@@ -76,8 +74,6 @@ protected static function newFactory()
7674
'approved_by' => 'integer',
7775
'rejected_at' => 'datetime',
7876
'rejected_by' => 'integer',
79-
'invoice_id' => 'integer',
80-
'invoiced_at' => 'datetime',
8177
'metadata' => 'array',
8278
];
8379

@@ -336,12 +332,12 @@ public function scopeByEntryType($query, string $type)
336332

337333
public function scopeInvoiced($query)
338334
{
339-
return $query->whereNotNull('invoice_id');
335+
return $query->where('is_billed', true);
340336
}
341337

342338
public function scopeUninvoiced($query)
343339
{
344-
return $query->whereNull('invoice_id');
340+
return $query->where('is_billed', false);
345341
}
346342

347343
public function scopeApproved($query)

0 commit comments

Comments
 (0)