From 86555664c5d1f343278713215cab77df11d188c2 Mon Sep 17 00:00:00 2001 From: Constantin Graf Date: Tue, 4 Jun 2024 14:35:02 +0200 Subject: [PATCH] Added billable flag to projects --- .../Controllers/Api/V1/ProjectController.php | 2 + .../V1/Project/ProjectStoreRequest.php | 4 ++ .../V1/Project/ProjectUpdateRequest.php | 4 ++ app/Models/Project.php | 10 ++++ .../Importers/ClockifyProjectsImporter.php | 1 + .../Import/Importers/DefaultImporter.php | 4 ++ .../Import/Importers/TogglDataImporter.php | 1 + database/factories/ProjectFactory.php | 13 ++++- ...d_is_billable_column_to_projects_table.php | 39 +++++++++++++++ .../Endpoint/Api/V1/ProjectEndpointTest.php | 47 ++++++++++++------- 10 files changed, 106 insertions(+), 19 deletions(-) create mode 100644 database/migrations/2024_05_30_175801_add_is_billable_column_to_projects_table.php diff --git a/app/Http/Controllers/Api/V1/ProjectController.php b/app/Http/Controllers/Api/V1/ProjectController.php index 5e04a9d6..de48efdb 100644 --- a/app/Http/Controllers/Api/V1/ProjectController.php +++ b/app/Http/Controllers/Api/V1/ProjectController.php @@ -85,6 +85,7 @@ class ProjectController extends Controller $project = new Project(); $project->name = $request->input('name'); $project->color = $request->input('color'); + $project->is_billable = (bool) $request->input('is_billable'); $project->billable_rate = $request->input('billable_rate'); $project->client_id = $request->input('client_id'); $project->organization()->associate($organization); @@ -105,6 +106,7 @@ class ProjectController extends Controller $this->checkPermission($organization, 'projects:update', $project); $project->name = $request->input('name'); $project->color = $request->input('color'); + $project->is_billable = (bool) $request->input('is_billable'); $project->billable_rate = $request->input('billable_rate'); $project->client_id = $request->input('client_id'); $project->save(); diff --git a/app/Http/Requests/V1/Project/ProjectStoreRequest.php b/app/Http/Requests/V1/Project/ProjectStoreRequest.php index e8b34136..3446e496 100644 --- a/app/Http/Requests/V1/Project/ProjectStoreRequest.php +++ b/app/Http/Requests/V1/Project/ProjectStoreRequest.php @@ -38,6 +38,10 @@ class ProjectStoreRequest extends FormRequest 'max:255', new ColorRule(), ], + 'is_billable' => [ + 'required', + 'boolean', + ], 'billable_rate' => [ 'nullable', 'integer', diff --git a/app/Http/Requests/V1/Project/ProjectUpdateRequest.php b/app/Http/Requests/V1/Project/ProjectUpdateRequest.php index e421cd9e..29af649f 100644 --- a/app/Http/Requests/V1/Project/ProjectUpdateRequest.php +++ b/app/Http/Requests/V1/Project/ProjectUpdateRequest.php @@ -37,6 +37,10 @@ class ProjectUpdateRequest extends FormRequest 'max:255', new ColorRule(), ], + 'is_billable' => [ + 'required', + 'boolean', + ], 'billable_rate' => [ 'nullable', 'integer', diff --git a/app/Models/Project.php b/app/Models/Project.php index 47daddd4..a4d15e27 100644 --- a/app/Models/Project.php +++ b/app/Models/Project.php @@ -20,6 +20,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany; * @property string $organization_id * @property string $client_id * @property int|null $billable_rate + * @property bool $is_billable * @property-read Organization $organization * @property-read Client|null $client * @property-read Collection $tasks @@ -43,6 +44,15 @@ class Project extends Model 'color' => 'string', ]; + /** + * Set default values for attributes. + * + * @var array + */ + protected $attributes = [ + 'is_billable' => false, + ]; + /** * @return BelongsTo */ diff --git a/app/Service/Import/Importers/ClockifyProjectsImporter.php b/app/Service/Import/Importers/ClockifyProjectsImporter.php index 8830c89b..63938d5e 100644 --- a/app/Service/Import/Importers/ClockifyProjectsImporter.php +++ b/app/Service/Import/Importers/ClockifyProjectsImporter.php @@ -41,6 +41,7 @@ class ClockifyProjectsImporter extends DefaultImporter ], [ 'client_id' => $clientId, 'color' => $this->colorService->getRandomColor(), + 'is_billable' => $record['Billability'] === 'Yes', 'billable_rate' => $billableRateKey !== null && $record[$billableRateKey] !== '' ? (int) (((float) $record[$billableRateKey]) * 100) : null, ]); } diff --git a/app/Service/Import/Importers/DefaultImporter.php b/app/Service/Import/Importers/DefaultImporter.php index bcf85b01..bfe967cd 100644 --- a/app/Service/Import/Importers/DefaultImporter.php +++ b/app/Service/Import/Importers/DefaultImporter.php @@ -96,6 +96,10 @@ abstract class DefaultImporter implements ImporterContract 'required', 'max:255', ], + 'is_billable' => [ + 'required', + 'boolean', + ], 'billable_rate' => [ 'nullable', 'integer', diff --git a/app/Service/Import/Importers/TogglDataImporter.php b/app/Service/Import/Importers/TogglDataImporter.php index b27c805c..71020bc1 100644 --- a/app/Service/Import/Importers/TogglDataImporter.php +++ b/app/Service/Import/Importers/TogglDataImporter.php @@ -121,6 +121,7 @@ class TogglDataImporter extends DefaultImporter ], [ 'client_id' => $clientId, 'color' => $project->color, + 'is_billable' => $project->rate !== null, 'billable_rate' => $project->rate !== null ? (int) ($project->rate * 100) : null, ], (string) $project->id); diff --git a/database/factories/ProjectFactory.php b/database/factories/ProjectFactory.php index 55faa471..31dcd536 100644 --- a/database/factories/ProjectFactory.php +++ b/database/factories/ProjectFactory.php @@ -27,13 +27,24 @@ class ProjectFactory extends Factory return [ 'name' => $this->faker->company(), 'color' => app(ColorService::class)->getRandomColor(), - 'billable_rate' => $this->faker->numberBetween(50, 1000) * 100, + 'is_billable' => false, + 'billable_rate' => null, 'is_public' => false, 'client_id' => null, 'organization_id' => Organization::factory(), ]; } + public function billable(): self + { + return $this->state(function (array $attributes): array { + return [ + 'is_billable' => true, + 'billable_rate' => $this->faker->numberBetween(50, 1000) * 100, + ]; + }); + } + public function forOrganization(Organization $organization): self { return $this->state(function (array $attributes) use ($organization): array { diff --git a/database/migrations/2024_05_30_175801_add_is_billable_column_to_projects_table.php b/database/migrations/2024_05_30_175801_add_is_billable_column_to_projects_table.php new file mode 100644 index 00000000..334d364e --- /dev/null +++ b/database/migrations/2024_05_30_175801_add_is_billable_column_to_projects_table.php @@ -0,0 +1,39 @@ +boolean('is_billable')->default(false); + }); + DB::statement(' + update projects + set is_billable = true + where projects.billable_rate is not null and projects.billable_rate > 0 + '); + Schema::table('projects', function (Blueprint $table) { + $table->boolean('is_billable')->default(null)->change(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('projects', function (Blueprint $table) { + $table->dropColumn('is_billable'); + }); + } +}; diff --git a/tests/Unit/Endpoint/Api/V1/ProjectEndpointTest.php b/tests/Unit/Endpoint/Api/V1/ProjectEndpointTest.php index 75ea7bad..0cc1b62b 100644 --- a/tests/Unit/Endpoint/Api/V1/ProjectEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/ProjectEndpointTest.php @@ -128,6 +128,7 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract $response = $this->postJson(route('api.v1.projects.store', [$data->organization->getKey()]), [ 'name' => $projectFake->name, 'color' => $projectFake->color, + 'is_billable' => $projectFake->is_billable, ]); // Assert @@ -140,21 +141,23 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract $data = $this->createUserWithPermission([ 'projects:create', ]); - $project = Project::factory()->forOrganization($data->organization)->make(); + $projectFake = Project::factory()->forOrganization($data->organization)->make(); Passport::actingAs($data->user); // Act $response = $this->postJson(route('api.v1.projects.store', [$data->organization->getKey()]), [ - 'name' => $project->name, - 'color' => $project->color, + 'name' => $projectFake->name, + 'color' => $projectFake->color, + 'is_billable' => $projectFake->is_billable, ]); // Assert $response->assertStatus(201); $this->assertDatabaseHas(Project::class, [ - 'name' => $project->name, - 'color' => $project->color, - 'organization_id' => $project->organization_id, + 'name' => $projectFake->name, + 'color' => $projectFake->color, + 'organization_id' => $projectFake->organization_id, + 'is_billable' => $projectFake->is_billable, ]); } @@ -165,22 +168,24 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract 'projects:create', ]); $client = Client::factory()->forOrganization($data->organization)->create(); - $project = Project::factory()->forOrganization($data->organization)->make(); + $projectFake = Project::factory()->forOrganization($data->organization)->make(); Passport::actingAs($data->user); // Act $response = $this->postJson(route('api.v1.projects.store', [$data->organization->getKey()]), [ - 'name' => $project->name, - 'color' => $project->color, + 'name' => $projectFake->name, + 'color' => $projectFake->color, + 'is_billable' => $projectFake->is_billable, 'client_id' => $client->getKey(), ]); // Assert $response->assertStatus(201); $this->assertDatabaseHas(Project::class, [ - 'name' => $project->name, - 'color' => $project->color, - 'organization_id' => $project->organization_id, + 'name' => $projectFake->name, + 'color' => $projectFake->color, + 'is_billable' => $projectFake->is_billable, + 'organization_id' => $projectFake->organization_id, 'client_id' => $client->getKey(), ]); } @@ -191,23 +196,25 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract $data = $this->createUserWithPermission([ 'projects:create', ]); - $project = Project::factory()->forOrganization($data->organization)->make(); + $projectFake = Project::factory()->forOrganization($data->organization)->make(); Passport::actingAs($data->user); // Act $response = $this->postJson(route('api.v1.projects.store', [$data->organization->getKey()]), [ - 'name' => $project->name, - 'color' => $project->color, + 'name' => $projectFake->name, + 'color' => $projectFake->color, + 'is_billable' => true, 'billable_rate' => 10001, ]); // Assert $response->assertStatus(201); $this->assertDatabaseHas(Project::class, [ - 'name' => $project->name, - 'color' => $project->color, - 'organization_id' => $project->organization_id, + 'name' => $projectFake->name, + 'color' => $projectFake->color, + 'is_billable' => true, 'billable_rate' => 10001, + 'organization_id' => $projectFake->organization_id, ]); } @@ -226,6 +233,7 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract $response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [ 'name' => $projectFake->name, 'color' => $projectFake->color, + 'is_billable' => $projectFake->is_billable, ]); // Assert @@ -245,6 +253,7 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract $response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [ 'name' => $projectFake->name, 'color' => $projectFake->color, + 'is_billable' => $projectFake->is_billable, ]); // Assert @@ -266,6 +275,7 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract $response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [ 'name' => $projectFake->name, 'color' => $projectFake->color, + 'is_billable' => $projectFake->is_billable, 'client_id' => $client->getKey(), ]); @@ -292,6 +302,7 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract $response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [ 'name' => $projectFake->name, 'color' => $projectFake->color, + 'is_billable' => $projectFake->is_billable, 'billable_rate' => 10002, ]);