nyavokevin 8f7019e815 feat(invoice): support group-based invoices without a client
Allow invoices to target either a client or a client group by making
`client_id` nullable and validating that exactly one recipient is set.

Load group relations in invoice data so the frontend can display group
details in invoice views and type definitions.

Make quote acceptance reuse an existing invoice instead of creating a
duplicate, and surface backend status update errors in the quote UI.
2026-04-02 16:08:44 +03:00

204 lines
7.5 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Repositories;
use App\Models\Invoice;
use App\Models\Quote;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use App\Repositories\ClientActivityTimelineRepositoryInterface;
class InvoiceRepository extends BaseRepository implements InvoiceRepositoryInterface
{
public function __construct(
Invoice $model,
protected InvoiceLineRepositoryInterface $invoiceLineRepository,
protected readonly ClientActivityTimelineRepositoryInterface $timelineRepository
) {
parent::__construct($model);
}
public function createFromQuote(int|string $quoteId): Invoice
{
return DB::transaction(function () use ($quoteId) {
// Resolve Quote directly to avoid circular dependency
$quote = Quote::with(['client', 'group', 'lines'])->find($quoteId);
if (!$quote) {
throw new \Exception("Quote not found");
}
$existingInvoice = Invoice::where('source_quote_id', $quote->id)->first();
if ($existingInvoice) {
return $existingInvoice;
}
// Create Invoice
$invoiceData = [
'client_id' => $quote->client_id,
'group_id' => $quote->group_id,
'source_quote_id' => $quote->id,
'status' => 'brouillon', // Start as draft
'invoice_date' => now(),
'currency' => $quote->currency,
'total_ht' => $quote->total_ht,
'total_tva' => $quote->total_tva,
'total_ttc' => $quote->total_ttc,
];
$invoice = parent::create($invoiceData);
// Copy Lines
foreach ($quote->lines as $line) {
$this->invoiceLineRepository->create([
'invoice_id' => $invoice->id,
'product_id' => $line->product_id,
'packaging_id' => $line->packaging_id,
'packages_qty' => $line->packages_qty,
'units_qty' => $line->units_qty,
'description' => $line->description,
'qty_base' => $line->qty_base,
'unit_price' => $line->unit_price,
'unit_price_per_package' => $line->unit_price_per_package,
'tva_rate_id' => $line->tva_rate_id,
'discount_pct' => $line->discount_pct,
'total_ht' => $line->total_ht,
]);
}
// Record history
$this->recordHistory($invoice->id, null, 'brouillon', 'Created from Quote ' . $quote->reference);
try {
if ($invoice->client_id !== null) {
$this->timelineRepository->logActivity([
'client_id' => $invoice->client_id,
'actor_type' => 'user',
'actor_user_id' => auth()->id(),
'event_type' => 'invoice_created',
'entity_type' => 'invoice',
'entity_id' => $invoice->id,
'title' => 'Nouvelle facture créée',
'description' => "Une facture a été créée à partir du devis #{$quote->id}.",
'created_at' => now(),
]);
}
} catch (\Exception $e) {
Log::error("Failed to log invoice creation activity: " . $e->getMessage());
}
return $invoice;
});
}
public function all(array $columns = ['*']): \Illuminate\Support\Collection
{
return $this->model->with(['client', 'group', 'lines.product'])->get($columns);
}
public function create(array $data): Invoice
{
return DB::transaction(function () use ($data) {
try {
// Create the invoice
$invoice = parent::create($data);
// Create the invoice lines
if (isset($data['lines']) && is_array($data['lines'])) {
foreach ($data['lines'] as $lineData) {
$lineData['invoice_id'] = $invoice->id;
$this->invoiceLineRepository->create($lineData);
}
}
// Record initial status history
$this->recordHistory($invoice->id, null, $invoice->status, 'Invoice created');
try {
if ($invoice->client_id !== null) {
$this->timelineRepository->logActivity([
'client_id' => $invoice->client_id,
'actor_type' => 'user',
'actor_user_id' => auth()->id(),
'event_type' => 'invoice_created',
'entity_type' => 'invoice',
'entity_id' => $invoice->id,
'title' => 'Nouvelle facture créée',
'description' => "La facture #{$invoice->id} a été créée.",
'created_at' => now(),
]);
}
} catch (\Exception $e) {
Log::error("Failed to log manual invoice creation activity: " . $e->getMessage());
}
return $invoice;
} catch (\Exception $e) {
Log::error('Error creating invoice with lines: ' . $e->getMessage(), [
'exception' => $e,
'data' => $data,
]);
throw $e;
}
});
}
public function update(int|string $id, array $attributes): bool
{
return DB::transaction(function () use ($id, $attributes) {
try {
$invoice = $this->find($id);
if (!$invoice) {
return false;
}
$oldStatus = $invoice->status;
// Update the invoice
$updated = parent::update($id, $attributes);
if ($updated) {
$newStatus = $attributes['status'] ?? $oldStatus;
// If status changed, record history
if ($oldStatus !== $newStatus) {
$this->recordHistory((int) $id, $oldStatus, $newStatus, 'Invoice status updated');
}
}
return $updated;
} catch (\Exception $e) {
Log::error('Error updating invoice: ' . $e->getMessage(), [
'id' => $id,
'attributes' => $attributes,
'exception' => $e,
]);
throw $e;
}
});
}
public function find(int|string $id, array $columns = ['*']): ?Invoice
{
return $this->model->with(['client', 'group', 'lines.product', 'history.user'])->find($id, $columns);
}
private function recordHistory(int $invoiceId, ?string $oldStatus, string $newStatus, ?string $comment = null): void
{
\App\Models\DocumentStatusHistory::create([
'document_type' => 'invoice',
'document_id' => $invoiceId,
'old_status' => $oldStatus,
'new_status' => $newStatus,
'changed_by' => auth()->id(), // Assuming authenticated user
'comment' => $comment,
'changed_at' => now(),
]);
}
}