add billable rate restriction for free organizations

This commit is contained in:
Gregor Vostrak
2026-10-13 14:59:12 +02:00
parent 9e6013eaf1
commit ba2b37f264
55 changed files with 1207 additions and 103 deletions

View File

@@ -0,0 +1,511 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Endpoint\Api\V1;
use App\Enums\Role;
use App\Http\Controllers\Api\V1\Controller;
use App\Models\Member;
use App\Models\Project;
use App\Models\ProjectMember;
use App\Models\TimeEntry;
use App\Models\User;
use Illuminate\Support\Carbon;
use Laravel\Passport\Passport;
use PHPUnit\Framework\Attributes\UsesClass;
/**
* Note: The test data is created before the organization loses access to billable rates,
* otherwise the billable flags and rates of the test data would already be reverted on creation.
*/
#[UsesClass(Controller::class)]
class BillableRateRestrictionEndpointTest extends ApiEndpointTestAbstract
{
public function test_project_store_ignores_billable_flag_and_rate_if_organization_can_not_use_billable_rates(): void
{
// Arrange
$data = $this->createUserWithPermission([
'projects:create',
]);
$this->actAsOrganizationWithoutBillableRates();
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.projects.store', [$data->organization->getKey()]), [
'name' => 'Project',
'color' => '#ef5350',
'client_id' => null,
'is_billable' => true,
'billable_rate' => 10000,
]);
// Assert
$response->assertCreated();
$response->assertJsonPath('data.is_billable', false);
$response->assertJsonPath('data.billable_rate', null);
$project = Project::query()->findOrFail($response->json('data.id'));
$this->assertFalse($project->is_billable);
$this->assertNull($project->billable_rate);
}
public function test_project_store_sets_billable_flag_and_rate_if_organization_can_use_billable_rates(): void
{
// Arrange
$data = $this->createUserWithPermission([
'projects:create',
]);
$this->actAsOrganizationWithSubscription();
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.projects.store', [$data->organization->getKey()]), [
'name' => 'Project',
'color' => '#ef5350',
'client_id' => null,
'is_billable' => true,
'billable_rate' => 10000,
]);
// Assert
$response->assertCreated();
$response->assertJsonPath('data.is_billable', true);
$response->assertJsonPath('data.billable_rate', 10000);
}
public function test_project_update_keeps_or_removes_existing_billable_flag_but_does_not_set_new_one_if_organization_can_not_use_billable_rates(): void
{
// Arrange
$data = $this->createUserWithPermission([
'projects:update',
]);
$billableProject = Project::factory()->forOrganization($data->organization)->create(['is_billable' => true]);
$removedBillableProject = Project::factory()->forOrganization($data->organization)->create(['is_billable' => true]);
$nonBillableProject = Project::factory()->forOrganization($data->organization)->create(['is_billable' => false]);
$this->actAsOrganizationWithoutBillableRates();
Passport::actingAs($data->user);
// Act
$responseKeep = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $billableProject->getKey()]), [
'name' => 'Still billable',
'color' => $billableProject->color,
'client_id' => null,
'is_billable' => true,
]);
$responseRemove = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $removedBillableProject->getKey()]), [
'name' => 'No longer billable',
'color' => $removedBillableProject->color,
'client_id' => null,
'is_billable' => false,
]);
$responseSet = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $nonBillableProject->getKey()]), [
'name' => 'Not billable',
'color' => $nonBillableProject->color,
'client_id' => null,
'is_billable' => true,
]);
// Assert
$responseKeep->assertOk();
$responseKeep->assertJsonPath('data.is_billable', true);
$responseRemove->assertOk();
$responseRemove->assertJsonPath('data.is_billable', false);
$responseSet->assertOk();
$responseSet->assertJsonPath('data.is_billable', false);
$this->assertTrue($billableProject->refresh()->is_billable);
$this->assertFalse($removedBillableProject->refresh()->is_billable);
$nonBillableProject->refresh();
$this->assertFalse($nonBillableProject->is_billable);
$this->assertSame('Not billable', $nonBillableProject->name);
}
public function test_project_update_can_enable_billable_flag_if_organization_can_use_billable_rates(): void
{
// Arrange
$data = $this->createUserWithPermission([
'projects:update',
]);
$project = Project::factory()->forOrganization($data->organization)->create(['is_billable' => false]);
$this->actAsOrganizationWithSubscription();
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
'name' => $project->name,
'color' => $project->color,
'client_id' => null,
'is_billable' => true,
]);
// Assert
$response->assertOk();
$this->assertTrue($project->refresh()->is_billable);
}
public function test_project_update_keeps_existing_billable_rate_and_time_entry_rates_if_organization_can_not_use_billable_rates(): void
{
// Arrange
$data = $this->createUserWithPermission([
'projects:update',
]);
$project = Project::factory()->forOrganization($data->organization)->create([
'billable_rate' => 10000,
'is_billable' => true,
]);
$timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->forProject($project)->create([
'billable' => true,
'billable_rate' => 10000,
]);
$this->actAsOrganizationWithoutBillableRates();
Passport::actingAs($data->user);
// Act
$responseChange = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
'name' => 'Changed rate',
'color' => $project->color,
'client_id' => null,
'is_billable' => true,
'billable_rate' => 20000,
]);
// Note: Organizations that can not use billable rates do not receive the rate of the project, so clients send `null` back
$responseNull = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
'name' => 'Hidden rate',
'color' => $project->color,
'client_id' => null,
'is_billable' => true,
'billable_rate' => null,
]);
$responseOmitted = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
'name' => 'Omitted rate',
'color' => $project->color,
'client_id' => null,
'is_billable' => true,
]);
// Assert
$responseChange->assertOk();
$responseNull->assertOk();
$responseOmitted->assertOk();
$project->refresh();
$this->assertSame('Omitted rate', $project->name);
$this->assertSame(10000, $project->billable_rate);
$this->assertSame(10000, $timeEntry->refresh()->billable_rate);
}
public function test_organization_update_ignores_billable_rate_and_employee_visibility_if_organization_can_not_use_billable_rates(): void
{
// Arrange
$data = $this->createUserWithPermission([
'organizations:update',
]);
$data->organization->billable_rate = 10000;
$data->organization->employees_can_see_billable_rates = false;
$data->organization->save();
$timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->create([
'billable' => true,
'billable_rate' => 10000,
]);
$this->actAsOrganizationWithoutBillableRates();
Passport::actingAs($data->user);
// Act
$responseRate = $this->putJson(route('api.v1.organizations.update', [$data->organization->getKey()]), [
'name' => 'Organization',
'billable_rate' => 20000,
]);
$responseRemoveRate = $this->putJson(route('api.v1.organizations.update', [$data->organization->getKey()]), [
'name' => 'Organization',
'billable_rate' => null,
]);
$responseVisibility = $this->putJson(route('api.v1.organizations.update', [$data->organization->getKey()]), [
'name' => 'Renamed organization',
'employees_can_see_billable_rates' => true,
]);
// Assert
$responseRate->assertOk();
$responseRemoveRate->assertOk();
$responseVisibility->assertOk();
$responseVisibility->assertJsonPath('data.employees_can_see_billable_rates', false);
$data->organization->refresh();
$this->assertSame('Renamed organization', $data->organization->name);
$this->assertSame(10000, $data->organization->billable_rate);
$this->assertFalse($data->organization->employees_can_see_billable_rates);
$this->assertSame(10000, $timeEntry->refresh()->billable_rate);
}
public function test_organization_update_can_remove_employee_visibility_if_organization_can_not_use_billable_rates(): void
{
// Arrange
$data = $this->createUserWithPermission([
'organizations:update',
]);
$data->organization->employees_can_see_billable_rates = true;
$data->organization->save();
$this->actAsOrganizationWithoutBillableRates();
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.organizations.update', [$data->organization->getKey()]), [
'name' => 'Organization',
'employees_can_see_billable_rates' => false,
]);
// Assert
$response->assertOk();
$this->assertFalse($data->organization->refresh()->employees_can_see_billable_rates);
}
public function test_member_update_ignores_billable_rate_but_updates_role_if_organization_can_not_use_billable_rates(): void
{
// Arrange
$data = $this->createUserWithPermission([
'members:update',
]);
$member = Member::factory()->forOrganization($data->organization)->forUser(User::factory()->create())->role(Role::Employee)->create([
'billable_rate' => 10000,
]);
$timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($member)->create([
'billable' => true,
'billable_rate' => 10000,
]);
$this->actAsOrganizationWithoutBillableRates();
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.members.update', [$data->organization->getKey(), $member->getKey()]), [
'billable_rate' => 20000,
'role' => Role::Manager->value,
]);
// Assert
$response->assertOk();
$member->refresh();
$this->assertSame(10000, $member->billable_rate);
$this->assertSame(Role::Manager->value, $member->role);
$this->assertSame(10000, $timeEntry->refresh()->billable_rate);
}
public function test_project_member_store_and_update_ignore_billable_rate_if_organization_can_not_use_billable_rates(): void
{
// Arrange
$data = $this->createUserWithPermission([
'project-members:create',
'project-members:update',
]);
$project = Project::factory()->forOrganization($data->organization)->create();
$otherMember = Member::factory()->forOrganization($data->organization)->forUser(User::factory()->create())->create();
$projectMember = ProjectMember::factory()->forProject($project)->forMember($data->member)->create([
'billable_rate' => 10000,
]);
$this->actAsOrganizationWithoutBillableRates();
Passport::actingAs($data->user);
// Act
$responseStore = $this->postJson(route('api.v1.project-members.store', [$data->organization->getKey(), $project->getKey()]), [
'member_id' => $otherMember->getKey(),
'billable_rate' => 10000,
]);
$responseUpdate = $this->putJson(route('api.v1.project-members.update', [$data->organization->getKey(), $projectMember->getKey()]), [
'billable_rate' => 20000,
]);
// Assert
$responseStore->assertCreated();
$responseStore->assertJsonPath('data.billable_rate', null);
$responseUpdate->assertOk();
$responseUpdate->assertJsonPath('data.billable_rate', 10000);
$this->assertSame(10000, $projectMember->refresh()->billable_rate);
$this->assertDatabaseHas(ProjectMember::class, [
'member_id' => $otherMember->getKey(),
'billable_rate' => null,
]);
}
public function test_organization_exposes_billable_rate_eligibility_for_api_clients(): void
{
// Arrange
$data = $this->createUserWithPermission([
'organizations:view',
]);
$this->actAsOrganizationWithoutBillableRates();
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.organizations.show', [$data->organization->getKey()]));
// Assert
$response->assertOk();
$response->assertJsonPath('data.can_use_billable_rates', false);
}
public function test_aggregate_endpoint_hides_cost_if_organization_can_not_use_billable_rates(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:view:all',
]);
$start = Carbon::now()->timezone($data->user->timezone)->subDays(2);
TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration($start, 3600)->create([
'billable' => true,
'billable_rate' => 10000,
]);
$this->actAsOrganizationWithoutBillableRates();
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.time-entries.aggregate', [
$data->organization->getKey(),
]));
// Assert
$response->assertSuccessful();
$response->assertJsonPath('data.seconds', 3600);
$response->assertJsonPath('data.cost', null);
}
public function test_total_weekly_billable_amount_is_forbidden_if_organization_can_not_use_billable_rates(): void
{
// Arrange
$data = $this->createUserWithPermission([
'charts:view:own',
]);
$this->actAsOrganizationWithoutBillableRates();
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.charts.total-weekly-billable-amount', [$data->organization->getKey()]));
// Assert
$response->assertForbidden();
}
public function test_time_entry_store_creates_non_billable_time_entry_if_organization_can_not_use_billable_rates(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:create:own',
]);
$data->organization->billable_rate = 10000;
$data->organization->save();
$this->actAsOrganizationWithoutBillableRates();
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [
'billable' => true,
'start' => Carbon::now()->subHour()->toIso8601ZuluString(),
'end' => Carbon::now()->toIso8601ZuluString(),
'member_id' => $data->member->getKey(),
]);
// Assert
$response->assertCreated();
$response->assertJsonPath('data.billable', false);
$timeEntry = TimeEntry::query()->findOrFail($response->json('data.id'));
$this->assertFalse($timeEntry->billable);
$this->assertNull($timeEntry->billable_rate);
}
public function test_time_entry_store_creates_billable_time_entry_if_organization_can_use_billable_rates(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:create:own',
]);
$this->actAsOrganizationWithSubscription();
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [
'billable' => true,
'start' => Carbon::now()->subHour()->toIso8601ZuluString(),
'end' => Carbon::now()->toIso8601ZuluString(),
'member_id' => $data->member->getKey(),
]);
// Assert
$response->assertCreated();
$response->assertJsonPath('data.billable', true);
}
public function test_time_entry_update_keeps_or_removes_existing_billable_flag_but_does_not_set_new_one_if_organization_can_not_use_billable_rates(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:update:own',
]);
$billableTimeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->billable()
->startWithDuration(Carbon::now()->subHours(7), 3600)->create();
$removedBillableTimeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->billable()
->startWithDuration(Carbon::now()->subHours(5), 3600)->create();
$nonBillableTimeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->notBillable()
->startWithDuration(Carbon::now()->subHours(3), 3600)->create();
$this->actAsOrganizationWithoutBillableRates();
Passport::actingAs($data->user);
// Act
$responseKeep = $this->putJson(route('api.v1.time-entries.update', [$data->organization->getKey(), $billableTimeEntry->getKey()]), [
'billable' => true,
'description' => 'Still billable',
]);
$responseRemove = $this->putJson(route('api.v1.time-entries.update', [$data->organization->getKey(), $removedBillableTimeEntry->getKey()]), [
'billable' => false,
]);
$responseSet = $this->putJson(route('api.v1.time-entries.update', [$data->organization->getKey(), $nonBillableTimeEntry->getKey()]), [
'billable' => true,
'description' => 'Not billable',
]);
// Assert
$responseKeep->assertOk();
$responseKeep->assertJsonPath('data.billable', true);
$responseRemove->assertOk();
$responseRemove->assertJsonPath('data.billable', false);
$responseSet->assertOk();
$responseSet->assertJsonPath('data.billable', false);
$this->assertTrue($billableTimeEntry->refresh()->billable);
$this->assertSame('Still billable', $billableTimeEntry->description);
$this->assertFalse($removedBillableTimeEntry->refresh()->billable);
$nonBillableTimeEntry->refresh();
$this->assertFalse($nonBillableTimeEntry->billable);
$this->assertSame('Not billable', $nonBillableTimeEntry->description);
}
public function test_time_entry_update_multiple_keeps_existing_billable_flags_but_does_not_set_new_ones_if_organization_can_not_use_billable_rates(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:update:own',
]);
$data->organization->billable_rate = 10000;
$data->organization->save();
$billableTimeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->billable()->create();
$nonBillableTimeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->notBillable()->create();
$this->actAsOrganizationWithoutBillableRates();
Passport::actingAs($data->user);
// Act
$response = $this->patchJson(route('api.v1.time-entries.update-multiple', [$data->organization->getKey()]), [
'ids' => [$billableTimeEntry->getKey(), $nonBillableTimeEntry->getKey()],
'changes' => [
'billable' => true,
'description' => 'Mass update',
],
]);
// Assert
$response->assertOk();
$response->assertExactJson([
'success' => [$billableTimeEntry->getKey(), $nonBillableTimeEntry->getKey()],
'error' => [],
]);
$billableTimeEntry->refresh();
$this->assertTrue($billableTimeEntry->billable);
$this->assertSame('Mass update', $billableTimeEntry->description);
$nonBillableTimeEntry->refresh();
$this->assertFalse($nonBillableTimeEntry->billable);
$this->assertNull($nonBillableTimeEntry->billable_rate);
$this->assertSame('Mass update', $nonBillableTimeEntry->description);
}
}

View File

@@ -1426,6 +1426,33 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
]);
}
public function test_update_endpoint_keeps_billable_rate_if_it_is_not_part_of_the_request(): void
{
// Arrange
$data = $this->createUserWithPermission([
'projects:update',
]);
$project = Project::factory()->forOrganization($data->organization)->create([
'billable_rate' => 10000,
]);
$this->assertBillableRateServiceIsUnused();
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
'name' => 'Renamed',
'color' => $project->color,
'client_id' => null,
'is_billable' => $project->is_billable,
]);
// Assert
$response->assertStatus(200);
$project->refresh();
$this->assertSame('Renamed', $project->name);
$this->assertSame(10000, $project->billable_rate);
}
public function test_update_endpoint_can_archive_a_project(): void
{
// Arrange

View File

@@ -36,6 +36,7 @@ class HandleInertiaRequestsMiddlewareTest extends MiddlewareTestAbstract
$mock->shouldReceive('getTrialUntil')->andReturn(null);
$mock->shouldReceive('isBlocked')->andReturn(false);
$mock->shouldReceive('getGoalLimit')->andReturn(1);
$mock->shouldReceive('canUseBillableRates')->andReturn(true);
});
Passport::actingAs($user->user);
@@ -49,6 +50,7 @@ class HandleInertiaRequestsMiddlewareTest extends MiddlewareTestAbstract
->where('billing.trial_until', null)
->where('billing.is_blocked', false)
->where('billing.goal_limit', 1)
->where('billing.can_use_billable_rates', true)
);
}
@@ -64,6 +66,7 @@ class HandleInertiaRequestsMiddlewareTest extends MiddlewareTestAbstract
$mock->shouldReceive('getTrialUntil')->andReturn($trialUntil);
$mock->shouldReceive('isBlocked')->andReturn(false);
$mock->shouldReceive('getGoalLimit')->andReturn(null);
$mock->shouldReceive('canUseBillableRates')->andReturn(true);
});
Passport::actingAs($user->user);
@@ -77,6 +80,7 @@ class HandleInertiaRequestsMiddlewareTest extends MiddlewareTestAbstract
->where('billing.trial_until', $trialUntil->toIso8601ZuluString())
->where('billing.is_blocked', false)
->where('billing.goal_limit', null)
->where('billing.can_use_billable_rates', true)
);
}
}

View File

@@ -0,0 +1,161 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Model;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Project;
use App\Models\ProjectMember;
use App\Models\TimeEntry;
use App\Models\User;
use App\Service\BillingContract;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Mockery\MockInterface;
use PHPUnit\Framework\Attributes\CoversClass;
use Tests\TestCaseWithDatabase;
#[CoversClass(Organization::class)]
#[CoversClass(Project::class)]
#[CoversClass(ProjectMember::class)]
#[CoversClass(Member::class)]
#[CoversClass(TimeEntry::class)]
class BillableRateRestrictionTest extends TestCaseWithDatabase
{
use RefreshDatabase;
public function test_new_time_entry_is_not_billable_if_organization_can_not_use_billable_rates(): void
{
// Arrange
$organization = Organization::factory()->create();
$member = Member::factory()->forOrganization($organization)->forUser(User::factory()->create())->create();
$this->actAsOrganizationWithoutBillableRates();
// Act
$timeEntry = TimeEntry::factory()->forOrganization($organization)->forMember($member)->billable()->create();
// Assert
$this->assertFalse($timeEntry->refresh()->billable);
}
public function test_existing_time_entry_can_keep_and_remove_billable_flag_but_not_get_it_if_organization_can_not_use_billable_rates(): void
{
// Arrange
$organization = Organization::factory()->create();
$member = Member::factory()->forOrganization($organization)->forUser(User::factory()->create())->create();
$billableTimeEntry = TimeEntry::factory()->forOrganization($organization)->forMember($member)->billable()->create();
$removedBillableTimeEntry = TimeEntry::factory()->forOrganization($organization)->forMember($member)->billable()->create();
$nonBillableTimeEntry = TimeEntry::factory()->forOrganization($organization)->forMember($member)->notBillable()->create();
$this->actAsOrganizationWithoutBillableRates();
// Act
$billableTimeEntry->description = 'Changed';
$billableTimeEntry->save();
$removedBillableTimeEntry->billable = false;
$removedBillableTimeEntry->save();
$nonBillableTimeEntry->billable = true;
$nonBillableTimeEntry->save();
// Assert
$this->assertTrue($billableTimeEntry->refresh()->billable);
$this->assertFalse($removedBillableTimeEntry->refresh()->billable);
$this->assertFalse($nonBillableTimeEntry->refresh()->billable);
}
public function test_time_entry_can_get_billable_flag_if_organization_can_use_billable_rates(): void
{
// Arrange
$organization = Organization::factory()->create();
$member = Member::factory()->forOrganization($organization)->forUser(User::factory()->create())->create();
$timeEntry = TimeEntry::factory()->forOrganization($organization)->forMember($member)->notBillable()->create();
$this->actAsOrganizationWithSubscription();
// Act
$timeEntry->billable = true;
$timeEntry->save();
// Assert
$this->assertTrue($timeEntry->refresh()->billable);
}
public function test_billing_is_only_asked_if_a_restricted_change_is_saved(): void
{
// Arrange
$organization = Organization::factory()->billableRate(10000)->create();
$member = Member::factory()->forOrganization($organization)->forUser(User::factory()->create())->billableRate(10000)->create();
$project = Project::factory()->forOrganization($organization)->billable(10000)->create();
$projectMember = ProjectMember::factory()->forProject($project)->forMember($member)->create(['billable_rate' => 10000]);
$timeEntry = TimeEntry::factory()->forOrganization($organization)->forMember($member)->billable()->create();
$this->mock(BillingContract::class, function (MockInterface $mock): void {
$mock->shouldNotReceive('canUseBillableRates');
});
// Act
$organization->name = 'Changed';
$organization->save();
$member->billable_rate = 10000;
$member->save();
$project->name = 'Changed';
$project->is_billable = true;
$project->save();
$projectMember->billable_rate = 10000;
$projectMember->save();
$timeEntry->description = 'Changed';
$timeEntry->billable = true;
$timeEntry->save();
$timeEntry->billable = false;
$timeEntry->save();
TimeEntry::factory()->forOrganization($organization)->forMember($member)->notBillable()->create();
// Assert
$this->assertFalse($timeEntry->refresh()->billable);
}
public function test_billable_rates_can_not_be_set_or_changed_if_organization_can_not_use_billable_rates(): void
{
// Arrange
$organization = Organization::factory()->billableRate(10000)->create();
$member = Member::factory()->forOrganization($organization)->forUser(User::factory()->create())->billableRate(10000)->create();
$project = Project::factory()->forOrganization($organization)->billable(10000)->create();
$projectMember = ProjectMember::factory()->forProject($project)->forMember($member)->create(['billable_rate' => 10000]);
$this->actAsOrganizationWithoutBillableRates();
// Act
$organization->billable_rate = 20000;
$organization->save();
$member->billable_rate = null;
$member->save();
$project->billable_rate = 20000;
$project->save();
$projectMember->billable_rate = null;
$projectMember->save();
$newProject = Project::factory()->forOrganization($organization)->billable(10000)->create();
$newProjectMember = ProjectMember::factory()->forProject($newProject)->forMember($member)->create(['billable_rate' => 10000]);
// Assert
$this->assertSame(10000, $organization->refresh()->billable_rate);
$this->assertSame(10000, $member->refresh()->billable_rate);
$this->assertSame(10000, $project->refresh()->billable_rate);
$this->assertTrue($project->is_billable);
$this->assertSame(10000, $projectMember->refresh()->billable_rate);
$newProject->refresh();
$this->assertNull($newProject->billable_rate);
$this->assertFalse($newProject->is_billable);
$this->assertNull($newProjectMember->refresh()->billable_rate);
}
public function test_employees_can_not_be_allowed_to_see_billable_rates_if_organization_can_not_use_billable_rates(): void
{
// Arrange
$organization = Organization::factory()->create(['employees_can_see_billable_rates' => false]);
$this->actAsOrganizationWithoutBillableRates();
// Act
$organization->employees_can_see_billable_rates = true;
$organization->save();
// Assert
$this->assertFalse($organization->refresh()->employees_can_see_billable_rates);
}
}

View File

@@ -8,6 +8,8 @@ use App\Enums\TimeEntryType;
use App\Jobs\RecalculateSpentTimeForProject;
use App\Jobs\RecalculateSpentTimeForTask;
use App\Models\Organization;
use App\Models\Project;
use App\Models\ProjectMember;
use App\Models\TimeEntry;
use App\Service\Import\Importers\DefaultImporter;
use App\Service\Import\Importers\ImportException;
@@ -104,6 +106,35 @@ class SolidtimeImporterTest extends ImporterTestAbstract
Queue::assertPushed(RecalculateSpentTimeForTask::class, 1);
}
public function test_import_drops_billable_flags_and_rates_if_organization_can_not_use_billable_rates(): void
{
// Arrange
$this->actAsOrganizationWithoutBillableRates();
$zipPath = $this->createTestZip('solidtime_import_test_1');
$timezone = 'Europe/Vienna';
$organization = Organization::factory()->create();
$importer = new SolidtimeImporter;
$importer->init($organization);
$data = file_get_contents($zipPath);
Queue::fake([
RecalculateSpentTimeForProject::class,
RecalculateSpentTimeForTask::class,
]);
// Act
$importer->importData($data, $timezone);
// Assert
$this->assertSame(3, Project::query()->whereBelongsTo($organization, 'organization')->whereNull('billable_rate')->where('is_billable', '=', false)->count());
$this->assertSame(1, ProjectMember::query()->whereBelongsToOrganization($organization)->whereNull('billable_rate')->count());
$timeEntries = TimeEntry::query()->whereBelongsTo($organization, 'organization')->get();
$this->assertCount(2, $timeEntries);
foreach ($timeEntries as $timeEntry) {
$this->assertFalse($timeEntry->billable);
$this->assertNull($timeEntry->billable_rate);
}
}
public function test_import_of_test_file_with_type_column_imports_breaks(): void
{
// Arrange