mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-08 00:02:15 +01:00
Add organization setting employees_can_see_billable_rates
This commit is contained in:
committed by
Constantin Graf
parent
9d279d4980
commit
51cd919db6
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -31,6 +31,9 @@ class OrganizationUpdateRequest extends FormRequest
|
||||
'integer',
|
||||
'min:0',
|
||||
],
|
||||
'employees_can_see_billable_rates' => [
|
||||
'boolean',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -26,6 +26,7 @@ class OrganizationFactory extends Factory
|
||||
'billable_rate' => null,
|
||||
'user_id' => User::factory(),
|
||||
'personal_team' => true,
|
||||
'employees_can_see_billable_rates' => false,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user