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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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')
Expand Down
135 changes: 135 additions & 0 deletions Modules/Projects/Services/ProjectBillingService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
<?php

namespace Modules\Projects\Services;

use Illuminate\Support\Facades\DB;
use InvalidArgumentException;
use Modules\Invoices\Enums\InvoiceStatus;
use Modules\Invoices\Models\Invoice;
use Modules\Invoices\Models\InvoiceItem;
use Modules\Projects\Enums\TaskStatus;
use Modules\Projects\Models\Project;
use Modules\Projects\Models\Task;

class ProjectBillingService
{
/**
* Create (or extend) a draft invoice for the project's client with one
* invoice item per billable task.
*
* Only completed tasks that are not yet linked to an invoice item are
* billed. When the client already has a draft invoice, the items are
* appended to it instead of creating a second draft.
*/
public function billTasks(Project $project, array $taskIds): Invoice
{
$tasks = $this->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),
]);
}
}
146 changes: 146 additions & 0 deletions Modules/Projects/Tests/Feature/ProjectBillingTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
<?php

namespace Modules\Projects\Tests\Feature;

use InvalidArgumentException;
use Modules\Clients\Models\Relation;
use Modules\Core\Tests\AbstractCompanyPanelTestCase;
use Modules\Invoices\Enums\InvoiceStatus;
use Modules\Invoices\Models\Invoice;
use Modules\Projects\Enums\TaskStatus;
use Modules\Projects\Models\Project;
use Modules\Projects\Models\Task;
use Modules\Projects\Services\ProjectBillingService;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\Attributes\Test;

class ProjectBillingTest extends AbstractCompanyPanelTestCase
{
private ProjectBillingService $service;

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

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