From 801d46f31178e49dd4fe5caeef2832b3373641a4 Mon Sep 17 00:00:00 2001 From: Niels Drost Date: Fri, 3 Jul 2026 20:25:40 +0200 Subject: [PATCH 1/2] feat(#160): convert a quote to a draft invoice QuoteService::convertQuoteToInvoice() copies the client, items, totals, and summary fields onto a new unnumbered draft invoice inside a transaction, then marks the quote Converted (new QuoteStatus case). The convert_to_invoice table action is wired to the service, requires confirmation, and hides once a quote is converted. Co-Authored-By: Claude Fable 5 --- Modules/Quotes/Enums/QuoteStatus.php | 33 +++++---- .../Resources/Quotes/Tables/QuotesTable.php | 21 +++++- Modules/Quotes/Services/QuoteService.php | 69 +++++++++++++++++++ resources/lang/en/ip.php | 3 + 4 files changed, 109 insertions(+), 17 deletions(-) diff --git a/Modules/Quotes/Enums/QuoteStatus.php b/Modules/Quotes/Enums/QuoteStatus.php index 0f2ae63c8..8d84b769d 100644 --- a/Modules/Quotes/Enums/QuoteStatus.php +++ b/Modules/Quotes/Enums/QuoteStatus.php @@ -6,11 +6,12 @@ enum QuoteStatus: string implements LabeledEnum { - case DRAFT = 'draft'; - case SENT = 'sent'; - case VIEWED = 'viewed'; - case APPROVED = 'approved'; - case REJECTED = 'rejected'; + case DRAFT = 'draft'; + case SENT = 'sent'; + case VIEWED = 'viewed'; + case APPROVED = 'approved'; + case REJECTED = 'rejected'; + case CONVERTED = 'converted'; /** * case DRAFT = 1;. @@ -33,22 +34,24 @@ public static function values(): array public function label(): string { return match ($this) { - self::DRAFT => trans('ip.quote_status_draft'), - self::SENT => trans('ip.quote_status_sent'), - self::VIEWED => trans('ip.quote_status_viewed'), - self::APPROVED => trans('ip.quote_status_approved'), - self::REJECTED => trans('ip.quote_status_rejected'), + self::DRAFT => trans('ip.quote_status_draft'), + self::SENT => trans('ip.quote_status_sent'), + self::VIEWED => trans('ip.quote_status_viewed'), + self::APPROVED => trans('ip.quote_status_approved'), + self::REJECTED => trans('ip.quote_status_rejected'), + self::CONVERTED => trans('ip.quote_status_converted'), }; } public function color(): string { return match ($this) { - self::DRAFT => 'gray', - self::SENT => 'green', - self::VIEWED => 'info', - self::APPROVED => 'success', - self::REJECTED => 'danger', + self::DRAFT => 'gray', + self::SENT => 'green', + self::VIEWED => 'info', + self::APPROVED => 'success', + self::REJECTED => 'danger', + self::CONVERTED => 'warning', }; } } diff --git a/Modules/Quotes/Filament/Company/Resources/Quotes/Tables/QuotesTable.php b/Modules/Quotes/Filament/Company/Resources/Quotes/Tables/QuotesTable.php index 899d23525..de1a6c06c 100644 --- a/Modules/Quotes/Filament/Company/Resources/Quotes/Tables/QuotesTable.php +++ b/Modules/Quotes/Filament/Company/Resources/Quotes/Tables/QuotesTable.php @@ -10,6 +10,7 @@ use Filament\Actions\EditAction; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; +use InvalidArgumentException; use Modules\Core\Enums\Permission; use Modules\Core\Helpers\EnumHelper; use Modules\Core\Support\DateHelpers; @@ -118,8 +119,24 @@ public static function configure(Table $table): Table Action::make('convert_to_invoice') ->label(trans('ip.convert_to_invoice')) ->icon('heroicon-o-arrow-right-circle') - ->visible(fn () => auth()->user()?->can(Permission::CONVERT_TO_INVOICE_QUOTES->value)) - ->action(function (Quote $record): void {}), + ->visible(fn (Quote $record) => $record->quote_status !== QuoteStatus::CONVERTED + && auth()->user()?->can(Permission::CONVERT_TO_INVOICE_QUOTES->value)) + ->requiresConfirmation() + ->action(function (Quote $record): void { + try { + app(QuoteService::class)->convertQuoteToInvoice($record); + + \Filament\Notifications\Notification::make() + ->title(trans('ip.quote_converted_to_invoice')) + ->success() + ->send(); + } catch (InvalidArgumentException $e) { + \Filament\Notifications\Notification::make() + ->title($e->getMessage()) + ->danger() + ->send(); + } + }), Action::make('archive') ->label(trans('ip.archive')) ->icon('heroicon-o-archive-box') diff --git a/Modules/Quotes/Services/QuoteService.php b/Modules/Quotes/Services/QuoteService.php index b7fca18ae..dd84c8d29 100644 --- a/Modules/Quotes/Services/QuoteService.php +++ b/Modules/Quotes/Services/QuoteService.php @@ -6,7 +6,10 @@ use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; +use InvalidArgumentException; use Modules\Core\Services\BaseService; +use Modules\Invoices\Enums\InvoiceStatus; +use Modules\Invoices\Models\Invoice; use Modules\Quotes\Enums\QuoteStatus; use Modules\Quotes\Models\Quote; use Throwable; @@ -188,6 +191,72 @@ public function deleteQuote(Quote $quote): Quote return $quote; } + /** + * Convert an accepted quote into a draft invoice. + * + * Copies the client, all quote items, and the summary fields onto a new + * unnumbered draft invoice, then marks the quote as Converted. Numbering + * is assigned when the invoice leaves draft. + * + * @throws InvalidArgumentException when the quote was already converted + */ + public function convertQuoteToInvoice(Quote $quote): Invoice + { + if ($quote->quote_status === QuoteStatus::CONVERTED) { + throw new InvalidArgumentException(trans('ip.quote_already_converted')); + } + + return DB::transaction(function () use ($quote) { + $invoice = Invoice::query()->create([ + 'company_id' => $quote->company_id, + 'customer_id' => $quote->prospect_id, + 'numbering_id' => null, + 'user_id' => auth()->id() ?? $quote->user_id, + 'invoice_number' => null, + 'invoice_status' => InvoiceStatus::DRAFT->value, + 'invoice_sign' => '1', + 'invoiced_at' => Carbon::today(), + 'invoice_due_at' => Carbon::today()->addDays(30), + 'invoice_discount_amount' => $quote->quote_discount_amount ?? 0, + 'invoice_discount_percent' => $quote->quote_discount_percent ?? 0, + 'item_tax_total' => $quote->item_tax_total ?? 0, + 'invoice_item_subtotal' => $quote->quote_item_subtotal ?? 0, + 'invoice_tax_total' => $quote->quote_tax_total ?? 0, + 'invoice_total' => $quote->quote_total ?? 0, + 'url_key' => Str::random(32), + 'summary' => $quote->summary, + 'terms' => $quote->terms, + 'footer' => $quote->footer, + ]); + + foreach ($quote->quoteItems as $item) { + $invoice->invoiceItems()->create([ + 'company_id' => $item->company_id, + 'product_id' => $item->product_id, + 'product_unit_id' => $item->product_unit_id, + 'task_id' => $item->task_id, + 'added_at' => Carbon::today()->toDateString(), + 'item_name' => $item->item_name, + 'quantity' => $item->quantity, + 'price' => $item->price, + 'discount' => $item->discount, + 'subtotal' => $item->subtotal, + 'tax_1' => $item->tax_1, + 'tax_2' => $item->tax_2, + 'tax_total' => $item->tax_total, + 'total' => $item->total, + 'tax_rate_id' => $item->tax_rate_id, + 'tax_rate_2_id' => $item->tax_rate_2_id, + 'description' => $item->description, + ]); + } + + $quote->update(['quote_status' => QuoteStatus::CONVERTED->value]); + + return $invoice; + }); + } + private function calculateItemTaxTotal(array $data): float { return collect($data['quoteItems'] ?? [])->sum(fn ($item) => $item['tax_total'] ?? 0); diff --git a/resources/lang/en/ip.php b/resources/lang/en/ip.php index e60cf5855..62e697f46 100644 --- a/resources/lang/en/ip.php +++ b/resources/lang/en/ip.php @@ -567,9 +567,12 @@ 'quote_status_draft' => 'Draft', 'quote_status_email_body' => 'The client %1$s has %2$s the quote %3$s.' . "\n\n" . 'Link to Quote: %4$s', 'quote_status_email_subject' => 'Client %1$s %2$s quote %3$s', + 'quote_status_converted' => 'Converted', 'quote_status_rejected' => 'Rejected', 'quote_status_sent' => 'Sent', 'quote_status_viewed' => 'Viewed', + 'quote_converted_to_invoice' => 'The quote was converted to a draft invoice.', + 'quote_already_converted' => 'This quote has already been converted to an invoice.', 'quote_tax' => 'Quote Tax', 'quote_template' => 'Quote Template', 'quote_templates' => 'Quote Templates', From f535c3ae9370a8492b4b72342028b1bbd1a5c163 Mon Sep 17 00:00:00 2001 From: Niels Drost Date: Fri, 3 Jul 2026 20:25:40 +0200 Subject: [PATCH 2/2] test(#160): cover quote-to-invoice conversion Co-Authored-By: Claude Fable 5 --- .../Tests/Feature/QuoteConversionTest.php | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 Modules/Quotes/Tests/Feature/QuoteConversionTest.php diff --git a/Modules/Quotes/Tests/Feature/QuoteConversionTest.php b/Modules/Quotes/Tests/Feature/QuoteConversionTest.php new file mode 100644 index 000000000..47a332aa9 --- /dev/null +++ b/Modules/Quotes/Tests/Feature/QuoteConversionTest.php @@ -0,0 +1,121 @@ +actingAs($this->user); + $this->service = app(QuoteService::class); + } + + #[Test] + #[Group('crud')] + public function it_converts_a_quote_to_a_draft_invoice(): void + { + /* Arrange */ + $quote = $this->createQuote(); + $quote->quoteItems()->create([ + 'company_id' => $this->company->id, + 'item_name' => 'Consulting', + 'quantity' => 2, + 'price' => 150, + 'discount' => 0, + 'subtotal' => 300, + ]); + + /* Act */ + $invoice = $this->service->convertQuoteToInvoice($quote); + + /* Assert */ + $this->assertSame(InvoiceStatus::DRAFT, $invoice->invoice_status); + $this->assertSame($quote->prospect_id, $invoice->customer_id); + $this->assertNull($invoice->invoice_number); + $this->assertEqualsWithDelta((float) $quote->quote_total, (float) $invoice->invoice_total, 0.001); + + $this->assertCount(1, $invoice->invoiceItems); + $this->assertSame('Consulting', $invoice->invoiceItems->first()->item_name); + + $this->assertSame(QuoteStatus::CONVERTED, $quote->refresh()->quote_status); + } + + #[Test] + #[Group('crud')] + public function it_refuses_to_convert_a_quote_twice(): void + { + /* Arrange */ + $quote = $this->createQuote(); + $this->service->convertQuoteToInvoice($quote); + + /* Assert */ + $this->expectException(InvalidArgumentException::class); + + /* Act */ + $this->service->convertQuoteToInvoice($quote->refresh()); + + /* Assert — no second invoice was created */ + $this->assertSame(1, Invoice::query()->count()); + } + + #[Test] + #[Group('crud')] + public function it_hides_the_convert_action_for_converted_quotes(): void + { + /* Arrange */ + (new PermissionsSeeder())->run(); + (new RolesSeeder())->run(); + $this->user->assignRole(UserRole::CUSTOMER_ADMIN->value); + $this->user->givePermissionTo(Permission::CONVERT_TO_INVOICE_QUOTES->value); + + $open = $this->createQuote(); + $converted = $this->createQuote(['quote_status' => QuoteStatus::CONVERTED->value]); + + /* Act */ + $component = Livewire::actingAs($this->user)->test(ListQuotes::class); + + /* Assert */ + $component + ->assertSuccessful() + ->assertActionVisible(TestAction::make('convert_to_invoice')->table($open)) + ->assertActionHidden(TestAction::make('convert_to_invoice')->table($converted)); + } + + private function createQuote(array $attributes = []): Quote + { + $prospect = Relation::factory()->for($this->company)->prospect()->create(); + $documentGroup = Numbering::factory()->for($this->company)->create(); + + return Quote::factory()->for($this->company)->create(array_merge([ + 'quote_number' => 'Q-' . fake()->unique()->numberBetween(1000, 99999), + 'prospect_id' => $prospect->getKey(), + 'numbering_id' => $documentGroup->getKey(), + 'user_id' => $this->user->id, + 'quote_status' => QuoteStatus::APPROVED->value, + 'quoted_at' => '2025-05-10', + ], $attributes)); + } +}