Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 18 additions & 15 deletions Modules/Quotes/Enums/QuoteStatus.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;.
Expand All @@ -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',
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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')
Expand Down
69 changes: 69 additions & 0 deletions Modules/Quotes/Services/QuoteService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
121 changes: 121 additions & 0 deletions Modules/Quotes/Tests/Feature/QuoteConversionTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
<?php

namespace Modules\Quotes\Tests\Feature;

use Filament\Actions\Testing\TestAction;
use InvalidArgumentException;
use Livewire\Livewire;
use Modules\Clients\Models\Relation;
use Modules\Core\Database\Seeders\PermissionsSeeder;
use Modules\Core\Database\Seeders\RolesSeeder;
use Modules\Core\Enums\Permission;
use Modules\Core\Enums\UserRole;
use Modules\Core\Models\Numbering;
use Modules\Core\Tests\AbstractCompanyPanelTestCase;
use Modules\Invoices\Enums\InvoiceStatus;
use Modules\Invoices\Models\Invoice;
use Modules\Quotes\Enums\QuoteStatus;
use Modules\Quotes\Filament\Company\Resources\Quotes\Pages\ListQuotes;
use Modules\Quotes\Models\Quote;
use Modules\Quotes\Services\QuoteService;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\Attributes\Test;

class QuoteConversionTest extends AbstractCompanyPanelTestCase
{
private QuoteService $service;

protected function setUp(): void
{
parent::setUp();

$this->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));
}
}
3 changes: 3 additions & 0 deletions resources/lang/en/ip.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading