diff --git a/Modules/Projects/Filament/Company/Resources/Projects/Tables/ProjectsTable.php b/Modules/Projects/Filament/Company/Resources/Projects/Tables/ProjectsTable.php index c3f636d30..4c9a28d82 100644 --- a/Modules/Projects/Filament/Company/Resources/Projects/Tables/ProjectsTable.php +++ b/Modules/Projects/Filament/Company/Resources/Projects/Tables/ProjectsTable.php @@ -8,12 +8,16 @@ use Filament\Actions\DeleteAction; use Filament\Actions\DeleteBulkAction; use Filament\Actions\EditAction; +use Filament\Forms\Components\CheckboxList; +use Filament\Notifications\Notification; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; +use InvalidArgumentException; use Modules\Core\Enums\Permission; use Modules\Core\Helpers\EnumHelper; use Modules\Projects\Enums\ProjectStatus; use Modules\Projects\Models\Project; +use Modules\Projects\Services\ProjectBillingService; use Modules\Projects\Services\ProjectService; class ProjectsTable @@ -60,6 +64,31 @@ public static function configure(Table $table): Table app(ProjectService::class)->updateProject($record, $data); }) ->modalWidth('full'), + Action::make('bill_tasks') + ->label(trans('ip.bill_tasks')) + ->icon('heroicon-o-banknotes') + ->visible(fn () => auth()->user()?->can(Permission::CREATE_INVOICES->value)) + ->schema(fn (Project $record) => [ + CheckboxList::make('task_ids') + ->label(trans('ip.billable_tasks')) + ->options(app(ProjectBillingService::class)->billableTaskOptions($record)) + ->required(), + ]) + ->action(function (Project $record, array $data): void { + try { + app(ProjectBillingService::class)->billTasks($record, $data['task_ids'] ?? []); + + Notification::make() + ->title(trans('ip.tasks_billed_to_draft_invoice')) + ->success() + ->send(); + } catch (InvalidArgumentException $e) { + Notification::make() + ->title($e->getMessage()) + ->danger() + ->send(); + } + }), Action::make('duplicate') ->label(trans('ip.duplicate')) ->icon('heroicon-o-document-duplicate') diff --git a/Modules/Projects/Services/ProjectBillingService.php b/Modules/Projects/Services/ProjectBillingService.php new file mode 100644 index 000000000..bec604edf --- /dev/null +++ b/Modules/Projects/Services/ProjectBillingService.php @@ -0,0 +1,135 @@ +billableTasks($project, $taskIds); + + if ($tasks->isEmpty()) { + throw new InvalidArgumentException(trans('ip.no_billable_tasks_selected')); + } + + return DB::transaction(function () use ($project, $tasks) { + $invoice = $this->findOrCreateDraftInvoice($project); + + foreach ($tasks as $task) { + $price = (float) ($task->task_price ?? 0); + + $invoice->invoiceItems()->create([ + 'task_id' => $task->id, + 'item_name' => $task->task_name, + 'description' => $task->description, + 'quantity' => 1, + 'price' => $price, + 'discount' => 0, + 'subtotal' => $price, + 'tax_rate_id' => $task->tax_rate_id, + ]); + } + + $this->refreshTotals($invoice); + + return $invoice->refresh(); + }); + } + + /** + * The project's completed tasks from the given selection that are not + * already on an invoice. + */ + public function billableTasks(Project $project, array $taskIds) + { + $alreadyBilled = InvoiceItem::query() + ->whereIn('task_id', $taskIds) + ->pluck('task_id') + ->all(); + + return $project->tasks() + ->whereIn('id', $taskIds) + ->where('task_status', TaskStatus::COMPLETED->value) + ->whereNotIn('id', $alreadyBilled) + ->get(); + } + + /** + * Completed, unbilled tasks of the project — the options offered in the + * Bill Tasks selection modal. + */ + public function billableTaskOptions(Project $project): array + { + $billedTaskIds = InvoiceItem::query() + ->whereNotNull('task_id') + ->pluck('task_id') + ->all(); + + return $project->tasks() + ->where('task_status', TaskStatus::COMPLETED->value) + ->whereNotIn('id', $billedTaskIds) + ->pluck('task_name', 'id') + ->toArray(); + } + + protected function findOrCreateDraftInvoice(Project $project): Invoice + { + $draft = Invoice::query() + ->where('customer_id', $project->customer_id) + ->where('invoice_status', InvoiceStatus::DRAFT->value) + ->latest('id') + ->first(); + + if ($draft) { + return $draft; + } + + /* + * Drafts may carry a null invoice number — numbering is assigned + * when the invoice leaves draft. + */ + return Invoice::query()->create([ + 'company_id' => $project->company_id, + 'customer_id' => $project->customer_id, + 'user_id' => auth()->id(), + 'invoice_number' => null, + 'invoice_status' => InvoiceStatus::DRAFT->value, + 'invoice_sign' => '1', + 'invoiced_at' => now(), + 'invoice_due_at' => now()->addDays(30), + 'invoice_discount_amount' => 0, + 'invoice_discount_percent' => 0, + 'item_tax_total' => 0, + 'invoice_item_subtotal' => 0, + 'invoice_tax_total' => 0, + 'invoice_total' => 0, + ]); + } + + protected function refreshTotals(Invoice $invoice): void + { + $subtotal = (float) $invoice->invoiceItems()->sum('subtotal'); + + $invoice->update([ + 'invoice_item_subtotal' => $subtotal, + 'invoice_total' => $subtotal + (float) ($invoice->invoice_tax_total ?? 0), + ]); + } +} diff --git a/Modules/Projects/Tests/Feature/ProjectBillingTest.php b/Modules/Projects/Tests/Feature/ProjectBillingTest.php new file mode 100644 index 000000000..f8c9b83a6 --- /dev/null +++ b/Modules/Projects/Tests/Feature/ProjectBillingTest.php @@ -0,0 +1,146 @@ +actingAs($this->user); + $this->service = app(ProjectBillingService::class); + } + + #[Test] + #[Group('crud')] + public function it_bills_completed_tasks_to_a_new_draft_invoice(): void + { + /* Arrange */ + $project = $this->createProject(); + $taskA = $this->createTask($project, ['task_name' => 'Design', 'task_price' => 100]); + $taskB = $this->createTask($project, ['task_name' => 'Build', 'task_price' => 250.5]); + + /* Act */ + $invoice = $this->service->billTasks($project, [$taskA->id, $taskB->id]); + + /* Assert */ + $this->assertSame(InvoiceStatus::DRAFT, $invoice->invoice_status); + $this->assertSame($project->customer_id, $invoice->customer_id); + $this->assertNull($invoice->invoice_number); + $this->assertCount(2, $invoice->invoiceItems); + $this->assertEqualsWithDelta(350.5, (float) $invoice->invoice_item_subtotal, 0.001); + + $this->assertDatabaseHas('invoice_items', [ + 'invoice_id' => $invoice->id, + 'task_id' => $taskA->id, + 'item_name' => 'Design', + ]); + } + + #[Test] + #[Group('crud')] + public function it_appends_tasks_to_an_existing_draft_invoice(): void + { + /* Arrange */ + $project = $this->createProject(); + $taskA = $this->createTask($project, ['task_price' => 100]); + $taskB = $this->createTask($project, ['task_price' => 50]); + + $first = $this->service->billTasks($project, [$taskA->id]); + + /* Act */ + $second = $this->service->billTasks($project, [$taskB->id]); + + /* Assert */ + $this->assertSame($first->id, $second->id); + $this->assertCount(2, $second->invoiceItems); + $this->assertSame(1, Invoice::query()->count()); + } + + #[Test] + #[Group('crud')] + public function it_does_not_bill_the_same_task_twice(): void + { + /* Arrange */ + $project = $this->createProject(); + $task = $this->createTask($project, ['task_price' => 100]); + + $this->service->billTasks($project, [$task->id]); + + /* Assert */ + $this->expectException(InvalidArgumentException::class); + + /* Act */ + $this->service->billTasks($project, [$task->id]); + } + + #[Test] + #[Group('crud')] + public function it_rejects_tasks_that_are_not_completed(): void + { + /* Arrange */ + $project = $this->createProject(); + $task = $this->createTask($project, ['task_status' => TaskStatus::IN_PROGRESS->value]); + + /* Assert */ + $this->expectException(InvalidArgumentException::class); + + /* Act */ + $this->service->billTasks($project, [$task->id]); + } + + #[Test] + #[Group('crud')] + public function it_only_offers_completed_unbilled_tasks_as_options(): void + { + /* Arrange */ + $project = $this->createProject(); + $completed = $this->createTask($project, ['task_name' => 'Done']); + $open = $this->createTask($project, [ + 'task_name' => 'Open', + 'task_status' => TaskStatus::OPEN->value, + ]); + $billed = $this->createTask($project, ['task_name' => 'Billed']); + $this->service->billTasks($project, [$billed->id]); + + /* Act */ + $options = $this->service->billableTaskOptions($project); + + /* Assert */ + $this->assertSame([$completed->id => 'Done'], $options); + } + + private function createProject(): Project + { + $customer = Relation::factory()->for($this->company)->customer()->create(); + + return Project::factory()->for($this->company)->create([ + 'customer_id' => $customer->id, + ]); + } + + private function createTask(Project $project, array $attributes = []): Task + { + return Task::factory()->for($this->company)->create(array_merge([ + 'customer_id' => $project->customer_id, + 'project_id' => $project->id, + 'task_status' => TaskStatus::COMPLETED->value, + 'task_price' => 100, + ], $attributes)); + } +} diff --git a/resources/lang/en/ip.php b/resources/lang/en/ip.php index e60cf5855..8be994ade 100644 --- a/resources/lang/en/ip.php +++ b/resources/lang/en/ip.php @@ -135,6 +135,10 @@ 'default_list_limit' => 'Number of Items in Lists', 'default_notes' => 'Default Notes', 'default_payment_method' => 'Default Payment Method', + 'bill_tasks' => 'Bill Tasks', + 'billable_tasks' => 'Billable Tasks', + 'tasks_billed_to_draft_invoice' => 'The selected tasks were added to a draft invoice.', + 'no_billable_tasks_selected' => 'None of the selected tasks can be billed — only completed tasks that are not already invoiced qualify.', 'default_pdf_template' => 'Default PDF Template', 'default_public_template' => 'Default Public Template', 'default_terms' => 'Default Terms',