Add organization setting employees_can_see_billable_rates

This commit is contained in:
Constantin Graf
2024-10-01 18:14:26 +02:00
committed by Constantin Graf
parent 9d279d4980
commit 51cd919db6
13 changed files with 269 additions and 15 deletions

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Enums\Role;
use App\Http\Requests\V1\Organization\OrganizationUpdateRequest;
use App\Http\Resources\V1\Organization\OrganizationResource;
use App\Models\Organization;
@@ -23,7 +24,9 @@ class OrganizationController extends Controller
{
$this->checkPermission($organization, 'organizations:view');
return new OrganizationResource($organization);
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
return new OrganizationResource($organization, $showBillableRate);
}
/**
@@ -39,6 +42,9 @@ class OrganizationController extends Controller
$organization->name = $request->input('name');
$oldBillableRate = $organization->billable_rate;
if ($request->has('employees_can_see_billable_rates')) {
$organization->employees_can_see_billable_rates = $request->validated('employees_can_see_billable_rates');
}
$organization->billable_rate = $request->getBillableRate();
$organization->save();
@@ -46,6 +52,6 @@ class OrganizationController extends Controller
$billableRateService->updateTimeEntriesBillableRateForOrganization($organization);
}
return new OrganizationResource($organization);
return new OrganizationResource($organization, true);
}
}

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Enums\Role;
use App\Exceptions\Api\EntityStillInUseApiException;
use App\Http\Requests\V1\Project\ProjectIndexRequest;
use App\Http\Requests\V1\Project\ProjectStoreRequest;
@@ -60,7 +61,9 @@ class ProjectController extends Controller
$projects = $projectsQuery->paginate(config('app.pagination_per_page_default'));
return new ProjectCollection($projects);
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
return new ProjectCollection($projects, $showBillableRate);
}
/**
@@ -74,9 +77,12 @@ class ProjectController extends Controller
{
$this->checkPermission($organization, 'projects:view', $project);
// Note: There is currently no need to check if a user is a member of the project,
// since this is only relevant for users with the role "employee" and they can not access this endpoint.
$project->load('organization');
return new ProjectResource($project);
return new ProjectResource($project, true);
}
/**
@@ -101,7 +107,7 @@ class ProjectController extends Controller
$project->organization()->associate($organization);
$project->save();
return new ProjectResource($project);
return new ProjectResource($project, true);
}
/**
@@ -132,7 +138,7 @@ class ProjectController extends Controller
$billableRateService->updateTimeEntriesBillableRateForProject($project);
}
return new ProjectResource($project);
return new ProjectResource($project, true);
}
/**

View File

@@ -31,6 +31,9 @@ class OrganizationUpdateRequest extends FormRequest
'integer',
'min:0',
],
'employees_can_see_billable_rates' => [
'boolean',
],
];
}

View File

@@ -13,6 +13,20 @@ use Illuminate\Http\Request;
*/
class OrganizationResource extends BaseResource
{
private bool $showBillableRate;
/**
* Create a new resource instance.
*
* @return void
*/
public function __construct(Organization $resource, bool $showBillableRate)
{
parent::__construct($resource);
$this->showBillableRate = $showBillableRate;
}
/**
* Transform the resource into an array.
*
@@ -28,7 +42,9 @@ class OrganizationResource extends BaseResource
/** @var bool $color Personal organizations automatically created after registration */
'is_personal' => $this->resource->personal_team,
/** @var int|null $billable_rate Billable rate in cents per hour */
'billable_rate' => $this->resource->billable_rate,
'billable_rate' => $this->showBillableRate ? $this->resource->billable_rate : null,
/** @var bool $employees_can_see_billable_rates Can members of the organization with role "employee" see the billable rates */
'employees_can_see_billable_rates' => $this->resource->employees_can_see_billable_rates,
];
}
}

View File

@@ -5,14 +5,39 @@ declare(strict_types=1);
namespace App\Http\Resources\V1\Project;
use App\Http\Resources\PaginatedResourceCollection;
use App\Models\Project;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection;
use Illuminate\Pagination\LengthAwarePaginator;
class ProjectCollection extends ResourceCollection implements PaginatedResourceCollection
{
private bool $showBillableRates;
/**
* The resource that this resource collects.
*
* @var string
* @param LengthAwarePaginator<Project> $resource
*/
public $collects = ProjectResource::class;
public function __construct($resource, bool $showBillableRates)
{
parent::__construct($resource);
$this->showBillableRates = $showBillableRates;
}
protected function collects(): ?string
{
return null;
}
/**
* Transform the resource collection into an array.
*
* @return array<array<string, string|bool|int|null>>
*/
public function toArray(Request $request): array
{
return $this->collection->map(function (Project $project) use ($request): array {
return (new ProjectResource($project, $this->showBillableRates))
->toArray($request);
})->all();
}
}

View File

@@ -13,6 +13,15 @@ use Illuminate\Http\Request;
*/
class ProjectResource extends BaseResource
{
private bool $showBillableRate;
public function __construct(Project $resource, bool $showBillableRate)
{
parent::__construct($resource);
$this->showBillableRate = $showBillableRate;
}
/**
* Transform the resource into an array.
*
@@ -32,7 +41,7 @@ class ProjectResource extends BaseResource
/** @var bool $is_archived Whether the client is archived */
'is_archived' => $this->resource->is_archived,
/** @var int|null $billable_rate Billable rate in cents per hour */
'billable_rate' => $this->resource->billable_rate,
'billable_rate' => $this->showBillableRate ? $this->resource->billable_rate : null,
/** @var bool $is_billable Project time entries billable default */
'is_billable' => $this->resource->is_billable,
/** @var int|null $estimated_time Estimated time in seconds */

View File

@@ -29,6 +29,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
* @property string $currency
* @property int|null $billable_rate
* @property string $user_id
* @property bool $employees_can_see_billable_rates
* @property User $owner
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
@@ -58,6 +59,7 @@ class Organization extends JetstreamTeam implements AuditableContract
'name' => 'string',
'personal_team' => 'boolean',
'currency' => 'string',
'employees_can_see_billable_rates' => 'boolean',
];
/**

View File

@@ -26,6 +26,7 @@ class OrganizationFactory extends Factory
'billable_rate' => null,
'user_id' => User::factory(),
'personal_team' => true,
'employees_can_see_billable_rates' => false,
];
}

View File

@@ -11,6 +11,7 @@ use App\Models\Project;
use App\Models\ProjectMember;
use App\Service\ColorService;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Carbon;
/**
* @extends Factory<Project>
@@ -46,12 +47,21 @@ class ProjectFactory extends Factory
});
}
public function billable(): self
public function billable(?int $billableRate = null): self
{
return $this->state(function (array $attributes): array {
return $this->state(function (array $attributes) use ($billableRate): array {
return [
'is_billable' => true,
'billable_rate' => $this->faker->numberBetween(50, 1000) * 100,
'billable_rate' => $billableRate === null ? $this->faker->numberBetween(50, 1000) * 100 : $billableRate,
];
});
}
public function createdAt(Carbon $createdAt): self
{
return $this->state(function (array $attributes) use ($createdAt): array {
return [
'created_at' => $createdAt,
];
});
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('organizations', function (Blueprint $table): void {
$table->boolean('employees_can_see_billable_rates')->default(false);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('organizations', function (Blueprint $table): void {
$table->dropColumn('employees_can_see_billable_rates');
});
}
};

View File

@@ -53,6 +53,32 @@ abstract class TestCaseWithDatabase extends TestCase
];
}
public function createUserWithRole(Role $role): object
{
$owner = User::factory()->create();
$organization = Organization::factory()->withOwner($owner)->create();
$ownerMember = Member::factory()->forUser($owner)->forOrganization($organization)->role(Role::Owner)->create();
$owner->currentOrganization()->associate($organization);
$owner->save();
if ($role === Role::Owner) {
$user = $owner;
$member = $ownerMember;
} else {
$user = User::factory()->create();
$member = Member::factory()->forUser($user)->forOrganization($organization)->role($role)->create();
$user->currentOrganization()->associate($organization);
}
return (object) [
'user' => $user,
'organization' => $organization,
'member' => $member,
'owner' => $owner,
'ownerMember' => $ownerMember,
];
}
protected function enableQueryLog(): void
{
DB::flushQueryLog();

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Tests\Unit\Endpoint\Api\V1;
use App\Enums\Role;
use App\Http\Controllers\Api\V1\OrganizationController;
use App\Models\Organization;
use App\Service\BillableRateService;
@@ -58,6 +59,40 @@ class OrganizationEndpointTest extends ApiEndpointTestAbstract
$response->assertJsonPath('data.id', $data->organization->getKey());
}
public function test_show_endpoint_shows_billable_rate_for_members_with_role_employee_if_organization_allows_it(): void
{
// Arrange
$data = $this->createUserWithRole(Role::Employee);
$data->organization->employees_can_see_billable_rates = true;
$data->organization->billable_rate = 100;
$data->organization->save();
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.organizations.show', [$data->organization->getKey()]));
// Assert
$response->assertStatus(200);
$response->assertJsonPath('data.billable_rate', 100);
}
public function test_show_endpoint_does_not_show_billable_rate_for_members_with_role_employee_if_organization_does_not_allow_it(): void
{
// Arrange
$data = $this->createUserWithRole(Role::Employee);
$data->organization->employees_can_see_billable_rates = false;
$data->organization->billable_rate = 100;
$data->organization->save();
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.organizations.show', [$data->organization->getKey()]));
// Assert
$response->assertStatus(200);
$response->assertJsonPath('data.billable_rate', null);
}
public function test_update_endpoint_fails_if_user_has_no_permission_to_update_organizations(): void
{
// Arrange
@@ -123,6 +158,32 @@ class OrganizationEndpointTest extends ApiEndpointTestAbstract
]);
}
public function test_update_endpoint_can_update_the_setting_employees_can_see_billable_rates(): void
{
// Arrange
$data = $this->createUserWithPermission([
'organizations:update',
]);
$this->assertBillableRateServiceIsUnused();
$data->organization->employees_can_see_billable_rates = false;
$data->organization->save();
$organizationFake = Organization::factory()->make();
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.organizations.update', [$data->organization->getKey()]), [
'name' => $organizationFake->name,
'employees_can_see_billable_rates' => true,
]);
// Assert
$response->assertStatus(200);
$this->assertDatabaseHas(Organization::class, [
'name' => $organizationFake->name,
'employees_can_see_billable_rates' => true,
]);
}
public function test_update_endpoint_can_update_billable_rate_of_organization_and_update_time_entries(): void
{
// Arrange

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Tests\Unit\Endpoint\Api\V1;
use App\Enums\Role;
use App\Http\Controllers\Api\V1\ProjectController;
use App\Models\Client;
use App\Models\Organization;
@@ -159,6 +160,64 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
$response->assertJsonCount(4, 'data');
}
public function test_index_endpoint_sets_billable_rate_to_null_if_member_is_employee_and_organization_does_not_allow_employees_to_see_billable_rates(): void
{
// Arrange
$data = $this->createUserWithRole(Role::Employee);
$organization = $data->organization;
$organization->employees_can_see_billable_rates = false;
$organization->save();
$privateProjects = Project::factory()->forOrganization($data->organization)->isPrivate()->billable(111)->createMany(2);
$publicProjects = Project::factory()->forOrganization($data->organization)->isPublic()->billable(112)->createMany(2);
$privateProjectsWithMembership = Project::factory()->forOrganization($data->organization)->addMember($data->member)->billable(113)->isPrivate()->createMany(2);
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.projects.index', [$organization->getKey()]));
// Assert
$response->assertStatus(200);
$response->assertJsonCount(4, 'data');
$response->assertJson(fn (AssertableJson $json) => $json
->has('data')
->has('links')
->has('meta')
->where('data.0.billable_rate', null)
->where('data.1.billable_rate', null)
->where('data.2.billable_rate', null)
->where('data.3.billable_rate', null)
);
}
public function test_index_endpoint_does_not_set_billable_rate_to_null_if_member_is_employee_and_organization_allows_employees_to_see_billable_rates(): void
{
// Arrange
$data = $this->createUserWithRole(Role::Employee);
$organization = $data->organization;
$organization->employees_can_see_billable_rates = true;
$organization->save();
$privateProjects = Project::factory()->forOrganization($data->organization)->isPrivate()->billable(111)->createdAt(now()->subMinutes(4))->createMany(2);
$publicProjects = Project::factory()->forOrganization($data->organization)->isPublic()->billable(112)->createdAt(now()->subMinutes(3))->createMany(2);
$privateProjectsWithMembership = Project::factory()->forOrganization($data->organization)->addMember($data->member)->billable(113)->isPrivate()->createdAt(now()->subMinutes(2))->createMany(2);
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.projects.index', [$organization->getKey()]));
// Assert
$response->assertStatus(200);
$response->assertJsonCount(4, 'data');
$response->assertJson(fn (AssertableJson $json) => $json
->has('data')
->has('links')
->has('meta')
->where('data.0.billable_rate', 112)
->where('data.1.billable_rate', 112)
->where('data.2.billable_rate', 113)
->where('data.3.billable_rate', 113)
);
}
public function test_show_endpoint_fails_if_user_is_not_part_of_project_organization(): void
{
// Arrange