Skip to content

Commit a95c38e

Browse files
authored
Merge pull request #578 from underdogg-forks/feature/268-bill-project-tasks
[IP-268]: bill project tasks
2 parents 43f94c4 + dbe62cd commit a95c38e

4 files changed

Lines changed: 314 additions & 0 deletions

File tree

Modules/Projects/Filament/Company/Resources/Projects/Tables/ProjectsTable.php

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,16 @@
88
use Filament\Actions\DeleteAction;
99
use Filament\Actions\DeleteBulkAction;
1010
use Filament\Actions\EditAction;
11+
use Filament\Forms\Components\CheckboxList;
12+
use Filament\Notifications\Notification;
1113
use Filament\Tables\Columns\TextColumn;
1214
use Filament\Tables\Table;
15+
use InvalidArgumentException;
1316
use Modules\Core\Enums\Permission;
1417
use Modules\Core\Helpers\EnumHelper;
1518
use Modules\Projects\Enums\ProjectStatus;
1619
use Modules\Projects\Models\Project;
20+
use Modules\Projects\Services\ProjectBillingService;
1721
use Modules\Projects\Services\ProjectService;
1822

1923
class ProjectsTable
@@ -60,6 +64,31 @@ public static function configure(Table $table): Table
6064
app(ProjectService::class)->updateProject($record, $data);
6165
})
6266
->modalWidth('full'),
67+
Action::make('bill_tasks')
68+
->label(trans('ip.bill_tasks'))
69+
->icon('heroicon-o-banknotes')
70+
->visible(fn () => auth()->user()?->can(Permission::CREATE_INVOICES->value))
71+
->schema(fn (Project $record) => [
72+
CheckboxList::make('task_ids')
73+
->label(trans('ip.billable_tasks'))
74+
->options(app(ProjectBillingService::class)->billableTaskOptions($record))
75+
->required(),
76+
])
77+
->action(function (Project $record, array $data): void {
78+
try {
79+
app(ProjectBillingService::class)->billTasks($record, $data['task_ids'] ?? []);
80+
81+
Notification::make()
82+
->title(trans('ip.tasks_billed_to_draft_invoice'))
83+
->success()
84+
->send();
85+
} catch (InvalidArgumentException $e) {
86+
Notification::make()
87+
->title($e->getMessage())
88+
->danger()
89+
->send();
90+
}
91+
}),
6392
Action::make('duplicate')
6493
->label(trans('ip.duplicate'))
6594
->icon('heroicon-o-document-duplicate')
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
<?php
2+
3+
namespace Modules\Projects\Services;
4+
5+
use Illuminate\Support\Facades\DB;
6+
use InvalidArgumentException;
7+
use Modules\Invoices\Enums\InvoiceStatus;
8+
use Modules\Invoices\Models\Invoice;
9+
use Modules\Invoices\Models\InvoiceItem;
10+
use Modules\Projects\Enums\TaskStatus;
11+
use Modules\Projects\Models\Project;
12+
use Modules\Projects\Models\Task;
13+
14+
class ProjectBillingService
15+
{
16+
/**
17+
* Create (or extend) a draft invoice for the project's client with one
18+
* invoice item per billable task.
19+
*
20+
* Only completed tasks that are not yet linked to an invoice item are
21+
* billed. When the client already has a draft invoice, the items are
22+
* appended to it instead of creating a second draft.
23+
*/
24+
public function billTasks(Project $project, array $taskIds): Invoice
25+
{
26+
$tasks = $this->billableTasks($project, $taskIds);
27+
28+
if ($tasks->isEmpty()) {
29+
throw new InvalidArgumentException(trans('ip.no_billable_tasks_selected'));
30+
}
31+
32+
return DB::transaction(function () use ($project, $tasks) {
33+
$invoice = $this->findOrCreateDraftInvoice($project);
34+
35+
foreach ($tasks as $task) {
36+
$price = (float) ($task->task_price ?? 0);
37+
38+
$invoice->invoiceItems()->create([
39+
'task_id' => $task->id,
40+
'item_name' => $task->task_name,
41+
'description' => $task->description,
42+
'quantity' => 1,
43+
'price' => $price,
44+
'discount' => 0,
45+
'subtotal' => $price,
46+
'tax_rate_id' => $task->tax_rate_id,
47+
]);
48+
}
49+
50+
$this->refreshTotals($invoice);
51+
52+
return $invoice->refresh();
53+
});
54+
}
55+
56+
/**
57+
* The project's completed tasks from the given selection that are not
58+
* already on an invoice.
59+
*/
60+
public function billableTasks(Project $project, array $taskIds)
61+
{
62+
$alreadyBilled = InvoiceItem::query()
63+
->whereIn('task_id', $taskIds)
64+
->pluck('task_id')
65+
->all();
66+
67+
return $project->tasks()
68+
->whereIn('id', $taskIds)
69+
->where('task_status', TaskStatus::COMPLETED->value)
70+
->whereNotIn('id', $alreadyBilled)
71+
->get();
72+
}
73+
74+
/**
75+
* Completed, unbilled tasks of the project — the options offered in the
76+
* Bill Tasks selection modal.
77+
*/
78+
public function billableTaskOptions(Project $project): array
79+
{
80+
$billedTaskIds = InvoiceItem::query()
81+
->whereNotNull('task_id')
82+
->pluck('task_id')
83+
->all();
84+
85+
return $project->tasks()
86+
->where('task_status', TaskStatus::COMPLETED->value)
87+
->whereNotIn('id', $billedTaskIds)
88+
->pluck('task_name', 'id')
89+
->toArray();
90+
}
91+
92+
protected function findOrCreateDraftInvoice(Project $project): Invoice
93+
{
94+
$draft = Invoice::query()
95+
->where('customer_id', $project->customer_id)
96+
->where('invoice_status', InvoiceStatus::DRAFT->value)
97+
->latest('id')
98+
->first();
99+
100+
if ($draft) {
101+
return $draft;
102+
}
103+
104+
/*
105+
* Drafts may carry a null invoice number — numbering is assigned
106+
* when the invoice leaves draft.
107+
*/
108+
return Invoice::query()->create([
109+
'company_id' => $project->company_id,
110+
'customer_id' => $project->customer_id,
111+
'user_id' => auth()->id(),
112+
'invoice_number' => null,
113+
'invoice_status' => InvoiceStatus::DRAFT->value,
114+
'invoice_sign' => '1',
115+
'invoiced_at' => now(),
116+
'invoice_due_at' => now()->addDays(30),
117+
'invoice_discount_amount' => 0,
118+
'invoice_discount_percent' => 0,
119+
'item_tax_total' => 0,
120+
'invoice_item_subtotal' => 0,
121+
'invoice_tax_total' => 0,
122+
'invoice_total' => 0,
123+
]);
124+
}
125+
126+
protected function refreshTotals(Invoice $invoice): void
127+
{
128+
$subtotal = (float) $invoice->invoiceItems()->sum('subtotal');
129+
130+
$invoice->update([
131+
'invoice_item_subtotal' => $subtotal,
132+
'invoice_total' => $subtotal + (float) ($invoice->invoice_tax_total ?? 0),
133+
]);
134+
}
135+
}
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
<?php
2+
3+
namespace Modules\Projects\Tests\Feature;
4+
5+
use InvalidArgumentException;
6+
use Modules\Clients\Models\Relation;
7+
use Modules\Core\Tests\AbstractCompanyPanelTestCase;
8+
use Modules\Invoices\Enums\InvoiceStatus;
9+
use Modules\Invoices\Models\Invoice;
10+
use Modules\Projects\Enums\TaskStatus;
11+
use Modules\Projects\Models\Project;
12+
use Modules\Projects\Models\Task;
13+
use Modules\Projects\Services\ProjectBillingService;
14+
use PHPUnit\Framework\Attributes\Group;
15+
use PHPUnit\Framework\Attributes\Test;
16+
17+
class ProjectBillingTest extends AbstractCompanyPanelTestCase
18+
{
19+
private ProjectBillingService $service;
20+
21+
protected function setUp(): void
22+
{
23+
parent::setUp();
24+
25+
$this->actingAs($this->user);
26+
$this->service = app(ProjectBillingService::class);
27+
}
28+
29+
#[Test]
30+
#[Group('crud')]
31+
public function it_bills_completed_tasks_to_a_new_draft_invoice(): void
32+
{
33+
/* Arrange */
34+
$project = $this->createProject();
35+
$taskA = $this->createTask($project, ['task_name' => 'Design', 'task_price' => 100]);
36+
$taskB = $this->createTask($project, ['task_name' => 'Build', 'task_price' => 250.5]);
37+
38+
/* Act */
39+
$invoice = $this->service->billTasks($project, [$taskA->id, $taskB->id]);
40+
41+
/* Assert */
42+
$this->assertSame(InvoiceStatus::DRAFT, $invoice->invoice_status);
43+
$this->assertSame($project->customer_id, $invoice->customer_id);
44+
$this->assertNull($invoice->invoice_number);
45+
$this->assertCount(2, $invoice->invoiceItems);
46+
$this->assertEqualsWithDelta(350.5, (float) $invoice->invoice_item_subtotal, 0.001);
47+
48+
$this->assertDatabaseHas('invoice_items', [
49+
'invoice_id' => $invoice->id,
50+
'task_id' => $taskA->id,
51+
'item_name' => 'Design',
52+
]);
53+
}
54+
55+
#[Test]
56+
#[Group('crud')]
57+
public function it_appends_tasks_to_an_existing_draft_invoice(): void
58+
{
59+
/* Arrange */
60+
$project = $this->createProject();
61+
$taskA = $this->createTask($project, ['task_price' => 100]);
62+
$taskB = $this->createTask($project, ['task_price' => 50]);
63+
64+
$first = $this->service->billTasks($project, [$taskA->id]);
65+
66+
/* Act */
67+
$second = $this->service->billTasks($project, [$taskB->id]);
68+
69+
/* Assert */
70+
$this->assertSame($first->id, $second->id);
71+
$this->assertCount(2, $second->invoiceItems);
72+
$this->assertSame(1, Invoice::query()->count());
73+
}
74+
75+
#[Test]
76+
#[Group('crud')]
77+
public function it_does_not_bill_the_same_task_twice(): void
78+
{
79+
/* Arrange */
80+
$project = $this->createProject();
81+
$task = $this->createTask($project, ['task_price' => 100]);
82+
83+
$this->service->billTasks($project, [$task->id]);
84+
85+
/* Assert */
86+
$this->expectException(InvalidArgumentException::class);
87+
88+
/* Act */
89+
$this->service->billTasks($project, [$task->id]);
90+
}
91+
92+
#[Test]
93+
#[Group('crud')]
94+
public function it_rejects_tasks_that_are_not_completed(): void
95+
{
96+
/* Arrange */
97+
$project = $this->createProject();
98+
$task = $this->createTask($project, ['task_status' => TaskStatus::IN_PROGRESS->value]);
99+
100+
/* Assert */
101+
$this->expectException(InvalidArgumentException::class);
102+
103+
/* Act */
104+
$this->service->billTasks($project, [$task->id]);
105+
}
106+
107+
#[Test]
108+
#[Group('crud')]
109+
public function it_only_offers_completed_unbilled_tasks_as_options(): void
110+
{
111+
/* Arrange */
112+
$project = $this->createProject();
113+
$completed = $this->createTask($project, ['task_name' => 'Done']);
114+
$open = $this->createTask($project, [
115+
'task_name' => 'Open',
116+
'task_status' => TaskStatus::OPEN->value,
117+
]);
118+
$billed = $this->createTask($project, ['task_name' => 'Billed']);
119+
$this->service->billTasks($project, [$billed->id]);
120+
121+
/* Act */
122+
$options = $this->service->billableTaskOptions($project);
123+
124+
/* Assert */
125+
$this->assertSame([$completed->id => 'Done'], $options);
126+
}
127+
128+
private function createProject(): Project
129+
{
130+
$customer = Relation::factory()->for($this->company)->customer()->create();
131+
132+
return Project::factory()->for($this->company)->create([
133+
'customer_id' => $customer->id,
134+
]);
135+
}
136+
137+
private function createTask(Project $project, array $attributes = []): Task
138+
{
139+
return Task::factory()->for($this->company)->create(array_merge([
140+
'customer_id' => $project->customer_id,
141+
'project_id' => $project->id,
142+
'task_status' => TaskStatus::COMPLETED->value,
143+
'task_price' => 100,
144+
], $attributes));
145+
}
146+
}

resources/lang/en/ip.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,10 @@
141141
'default_list_limit' => 'Number of Items in Lists',
142142
'default_notes' => 'Default Notes',
143143
'default_payment_method' => 'Default Payment Method',
144+
'bill_tasks' => 'Bill Tasks',
145+
'billable_tasks' => 'Billable Tasks',
146+
'tasks_billed_to_draft_invoice' => 'The selected tasks were added to a draft invoice.',
147+
'no_billable_tasks_selected' => 'None of the selected tasks can be billed — only completed tasks that are not already invoiced qualify.',
144148
'default_pdf_template' => 'Default PDF Template',
145149
'default_public_template' => 'Default Public Template',
146150
'default_terms' => 'Default Terms',

0 commit comments

Comments
 (0)