From 24b937767e95d35c5febea5f7f86e33a7c221e7c Mon Sep 17 00:00:00 2001 From: Constantin Graf Date: Wed, 14 Oct 2026 13:29:32 +0200 Subject: [PATCH] Enforce billable rate restriction in controllers and importers instead of model hooks --- app/Http/Controllers/Api/V1/Controller.php | 5 + .../Controllers/Api/V1/MemberController.php | 2 +- .../Api/V1/OrganizationController.php | 6 +- .../Controllers/Api/V1/ProjectController.php | 13 +- .../Api/V1/ProjectMemberController.php | 6 +- .../Api/V1/TimeEntryController.php | 15 ++ app/Models/Member.php | 11 -- app/Models/Organization.php | 20 --- app/Models/Project.php | 20 --- app/Models/ProjectMember.php | 11 -- app/Models/TimeEntry.php | 7 - .../Importers/ClockifyTimeEntriesImporter.php | 4 +- .../Import/Importers/DefaultImporter.php | 26 ++- .../Importers/GenericTimeEntriesImporter.php | 4 +- .../Importers/HarvestTimeEntriesImporter.php | 4 +- .../Import/Importers/SolidtimeImporter.php | 4 +- .../Importers/TogglTimeEntriesImporter.php | 4 +- .../Model/BillableRateRestrictionTest.php | 161 ------------------ 18 files changed, 66 insertions(+), 257 deletions(-) delete mode 100644 tests/Unit/Model/BillableRateRestrictionTest.php diff --git a/app/Http/Controllers/Api/V1/Controller.php b/app/Http/Controllers/Api/V1/Controller.php index da63d507..3cb62ca6 100644 --- a/app/Http/Controllers/Api/V1/Controller.php +++ b/app/Http/Controllers/Api/V1/Controller.php @@ -49,4 +49,9 @@ class Controller extends \App\Http\Controllers\Controller { return app(BillingContract::class)->hasSubscription($organization) || app(BillingContract::class)->hasTrial($organization); } + + protected function canUseBillableRates(Organization $organization): bool + { + return app(BillingContract::class)->canUseBillableRates($organization); + } } diff --git a/app/Http/Controllers/Api/V1/MemberController.php b/app/Http/Controllers/Api/V1/MemberController.php index 56de76f7..c5322d04 100644 --- a/app/Http/Controllers/Api/V1/MemberController.php +++ b/app/Http/Controllers/Api/V1/MemberController.php @@ -82,7 +82,7 @@ class MemberController extends Controller $this->checkPermission($organization, 'members:update', $member); $oldBillableRate = $member->billable_rate; - if ($request->has('billable_rate')) { + if ($request->has('billable_rate') && $this->canUseBillableRates($organization)) { $member->billable_rate = $request->getBillableRate(); } if ($request->has('role') && $member->role !== $request->getRole()->value) { diff --git a/app/Http/Controllers/Api/V1/OrganizationController.php b/app/Http/Controllers/Api/V1/OrganizationController.php index 85e9069f..30d6b8f8 100644 --- a/app/Http/Controllers/Api/V1/OrganizationController.php +++ b/app/Http/Controllers/Api/V1/OrganizationController.php @@ -46,6 +46,7 @@ class OrganizationController extends Controller public function update(Organization $organization, OrganizationUpdateRequest $request, BillableRateService $billableRateService): OrganizationResource { $this->checkPermission($organization, 'organizations:update'); + $canUseBillableRates = $this->canUseBillableRates($organization); if ($request->getName() !== null) { $organization->name = $request->getName(); @@ -53,7 +54,8 @@ class OrganizationController extends Controller if ($request->getCurrency() !== null) { $organization->currency = $request->getCurrency(); } - if ($request->getEmployeesCanSeeBillableRates() !== null) { + // Organizations that can not use billable rates can keep or remove the visibility for employees, but not enable it + if ($request->getEmployeesCanSeeBillableRates() !== null && ($canUseBillableRates || ! $request->getEmployeesCanSeeBillableRates())) { $organization->employees_can_see_billable_rates = $request->getEmployeesCanSeeBillableRates(); } if ($request->getEmployeesCanManageTasks() !== null) { @@ -81,7 +83,7 @@ class OrganizationController extends Controller $organization->breaks_enabled = $request->getBreaksEnabled(); } $oldBillableRate = $organization->billable_rate; - if ($request->has('billable_rate')) { + if ($request->has('billable_rate') && $canUseBillableRates) { $organization->billable_rate = $request->getBillableRate(); } $organization->save(); diff --git a/app/Http/Controllers/Api/V1/ProjectController.php b/app/Http/Controllers/Api/V1/ProjectController.php index d4c0268a..033d592c 100644 --- a/app/Http/Controllers/Api/V1/ProjectController.php +++ b/app/Http/Controllers/Api/V1/ProjectController.php @@ -96,11 +96,12 @@ class ProjectController extends Controller public function store(Organization $organization, ProjectStoreRequest $request): JsonResource { $this->checkPermission($organization, 'projects:create'); + $canUseBillableRates = $this->canUseBillableRates($organization); $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->getBillableRate(); + $project->is_billable = $canUseBillableRates && $request->boolean('is_billable'); + $project->billable_rate = $canUseBillableRates ? $request->getBillableRate() : null; $project->client_id = $request->input('client_id'); $project->is_public = $request->getIsPublic(); if ($this->canAccessPremiumFeatures($organization) && $request->has('estimated_time')) { @@ -122,9 +123,13 @@ class ProjectController extends Controller public function update(Organization $organization, Project $project, ProjectUpdateRequest $request, BillableRateService $billableRateService): JsonResource { $this->checkPermission($organization, 'projects:update', $project); + $canUseBillableRates = $this->canUseBillableRates($organization); $project->name = $request->input('name'); $project->color = $request->input('color'); - $project->is_billable = (bool) $request->input('is_billable'); + // Organizations that can not use billable rates can keep or remove the billable flag, but not set it + if ($canUseBillableRates || ! $request->boolean('is_billable')) { + $project->is_billable = $request->boolean('is_billable'); + } if ($request->has('is_archived')) { $project->archived_at = $request->getIsArchived() ? Carbon::now() : null; } @@ -136,7 +141,7 @@ class ProjectController extends Controller } $oldBillableRate = $project->billable_rate; $clientIdChanged = false; - if ($request->has('billable_rate')) { + if ($request->has('billable_rate') && $canUseBillableRates) { $project->billable_rate = $request->getBillableRate(); } if ($project->client_id !== $request->input('client_id')) { diff --git a/app/Http/Controllers/Api/V1/ProjectMemberController.php b/app/Http/Controllers/Api/V1/ProjectMemberController.php index 03d5c247..00100c2a 100644 --- a/app/Http/Controllers/Api/V1/ProjectMemberController.php +++ b/app/Http/Controllers/Api/V1/ProjectMemberController.php @@ -73,7 +73,7 @@ class ProjectMemberController extends Controller } $projectMember = new ProjectMember; - $projectMember->billable_rate = $request->getBillableRate(); + $projectMember->billable_rate = $this->canUseBillableRates($organization) ? $request->getBillableRate() : null; $projectMember->member()->associate($member); $projectMember->user()->associate($member->user); $projectMember->project()->associate($project); @@ -97,7 +97,9 @@ class ProjectMemberController extends Controller { $this->checkPermission($organization, 'project-members:update', projectMember: $projectMember); $oldBillableRate = $projectMember->billable_rate; - $projectMember->billable_rate = $request->getBillableRate(); + if ($this->canUseBillableRates($organization)) { + $projectMember->billable_rate = $request->getBillableRate(); + } $projectMember->save(); if ($oldBillableRate !== $projectMember->billable_rate) { diff --git a/app/Http/Controllers/Api/V1/TimeEntryController.php b/app/Http/Controllers/Api/V1/TimeEntryController.php index bdd64bca..3499f888 100644 --- a/app/Http/Controllers/Api/V1/TimeEntryController.php +++ b/app/Http/Controllers/Api/V1/TimeEntryController.php @@ -647,6 +647,10 @@ class TimeEntryController extends Controller $timeEntry = new TimeEntry; $timeEntry->fill($request->validated()); + // Organizations that can not use billable rates can not create billable time entries + if (! $this->canUseBillableRates($organization)) { + $timeEntry->billable = false; + } $timeEntry->client()->associate($client); $timeEntry->user_id = $member->user_id; $timeEntry->description = $request->input('description') ?? ''; @@ -714,7 +718,12 @@ class TimeEntryController extends Controller $task = $request->input('task_id') !== null ? Task::findOrFail((string) $request->input('task_id')) : null; } + $wasBillable = $timeEntry->billable; $timeEntry->fill($request->validated()); + // Organizations that can not use billable rates can keep or remove the billable flag, but not set it + if (! $wasBillable && ! $this->canUseBillableRates($organization)) { + $timeEntry->billable = false; + } if ($newMember !== null) { $timeEntry->member()->associate($newMember); $timeEntry->user()->associate($newMember->user); @@ -772,6 +781,7 @@ class TimeEntryController extends Controller ->get(); $changes = $request->validated('changes'); + $canUseBillableRates = $this->canUseBillableRates($organization); if ($request->has('changes.description')) { $changes['description'] = $request->input('changes.description') ?? ''; @@ -837,8 +847,13 @@ class TimeEntryController extends Controller } $oldProject = $timeEntry->project; $oldTask = $timeEntry->task; + $wasBillable = $timeEntry->billable; $timeEntry->fill($changes); + // Organizations that can not use billable rates can keep or remove the billable flag, but not set it + if (! $wasBillable && ! $canUseBillableRates) { + $timeEntry->billable = false; + } if ($newMember !== null) { $timeEntry->member()->associate($newMember); $timeEntry->user_id = $newMember->user_id; diff --git a/app/Models/Member.php b/app/Models/Member.php index 0fbbf29f..7cc3fefc 100644 --- a/app/Models/Member.php +++ b/app/Models/Member.php @@ -6,7 +6,6 @@ namespace App\Models; use App\Models\Concerns\CustomAuditable; use App\Models\Concerns\HasUuids; -use App\Service\BillingContract; use Database\Factories\MemberFactory; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -49,16 +48,6 @@ class Member extends Pivot implements AuditableContract */ protected $table = 'members'; - protected static function booted(): void - { - static::saving(function (Member $member): void { - // Organizations that can not use billable rates can not change the billable rate - if ($member->billable_rate !== $member->getOriginal('billable_rate') && ! app(BillingContract::class)->canUseBillableRates($member->loadMissing('organization')->organization)) { - $member->billable_rate = $member->getOriginal('billable_rate'); - } - }); - } - /** * @return BelongsTo */ diff --git a/app/Models/Organization.php b/app/Models/Organization.php index 21b21a1b..b5f8fc92 100644 --- a/app/Models/Organization.php +++ b/app/Models/Organization.php @@ -11,7 +11,6 @@ use App\Enums\NumberFormat; use App\Enums\TimeFormat; use App\Models\Concerns\CustomAuditable; use App\Models\Concerns\HasUuids; -use App\Service\BillingContract; use Database\Factories\OrganizationFactory; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -97,25 +96,6 @@ class Organization extends Model implements AuditableContract protected $attributes = [ ]; - protected static function booted(): void - { - static::saving(function (Organization $organization): void { - // Organizations that can not use billable rates can keep or remove the visibility for employees, but not - // enable it, and can not change the billable rate - $enablesVisibility = $organization->employees_can_see_billable_rates && ! $organization->getOriginal('employees_can_see_billable_rates'); - if (! $enablesVisibility && $organization->billable_rate === $organization->getOriginal('billable_rate')) { - return; - } - if (app(BillingContract::class)->canUseBillableRates($organization)) { - return; - } - if ($enablesVisibility) { - $organization->employees_can_see_billable_rates = false; - } - $organization->billable_rate = $organization->getOriginal('billable_rate'); - }); - } - public function getAuditOwnerOrganizationId(): ?string { return $this->getKey(); diff --git a/app/Models/Project.php b/app/Models/Project.php index b8f6b187..b33f05a6 100644 --- a/app/Models/Project.php +++ b/app/Models/Project.php @@ -6,7 +6,6 @@ namespace App\Models; use App\Models\Concerns\CustomAuditable; use App\Models\Concerns\HasUuids; -use App\Service\BillingContract; use Database\Factories\ProjectFactory; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Casts\Attribute; @@ -137,25 +136,6 @@ class Project extends Model implements AuditableContract return $this->scopeComputedAttributesGenerate($builder, $attributes); } - protected static function booted(): void - { - static::saving(function (Project $project): void { - // Organizations that can not use billable rates can keep or remove the billable flag, but not set it, - // and can not change the billable rate - $enablesBillable = $project->is_billable && ! $project->getOriginal('is_billable'); - if (! $enablesBillable && $project->billable_rate === $project->getOriginal('billable_rate')) { - return; - } - if (app(BillingContract::class)->canUseBillableRates($project->loadMissing('organization')->organization)) { - return; - } - if ($enablesBillable) { - $project->is_billable = false; - } - $project->billable_rate = $project->getOriginal('billable_rate'); - }); - } - /** * @return BelongsTo */ diff --git a/app/Models/ProjectMember.php b/app/Models/ProjectMember.php index daf16366..1bff0042 100644 --- a/app/Models/ProjectMember.php +++ b/app/Models/ProjectMember.php @@ -7,7 +7,6 @@ namespace App\Models; use App\Models\Concerns\AuditableThroughParent; use App\Models\Concerns\CustomAuditable; use App\Models\Concerns\HasUuids; -use App\Service\BillingContract; use Database\Factories\ProjectMemberFactory; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -49,16 +48,6 @@ class ProjectMember extends Model implements AuditableContract, AuditableThrough 'billable_rate' => 'int', ]; - protected static function booted(): void - { - static::saving(function (ProjectMember $projectMember): void { - // Organizations that can not use billable rates can not change the billable rate - if ($projectMember->billable_rate !== $projectMember->getOriginal('billable_rate') && ! app(BillingContract::class)->canUseBillableRates($projectMember->loadMissing('project.organization')->project->organization)) { - $projectMember->billable_rate = $projectMember->getOriginal('billable_rate'); - } - }); - } - /** * @return BelongsTo */ diff --git a/app/Models/TimeEntry.php b/app/Models/TimeEntry.php index 9cd79203..2928cca5 100644 --- a/app/Models/TimeEntry.php +++ b/app/Models/TimeEntry.php @@ -8,7 +8,6 @@ use App\Enums\TimeEntryType; use App\Models\Concerns\CustomAuditable; use App\Models\Concerns\HasUuids; use App\Service\BillableRateService; -use App\Service\BillingContract; use Carbon\CarbonInterval; use Database\Factories\TimeEntryFactory; use Illuminate\Database\Eloquent\Builder; @@ -141,12 +140,6 @@ class TimeEntry extends Model implements AuditableContract $timeEntry->client_id = null; $timeEntry->tags = []; } - // Organizations that can not use billable rates can keep or remove the billable flag, but not set it - if ($timeEntry->billable && ! $timeEntry->getOriginal('billable') && ! app(BillingContract::class)->canUseBillableRates($timeEntry->loadMissing('organization')->organization)) { - $timeEntry->billable = false; - // Note: The billable rate is computed before saving, non-billable time entries have no billable rate - $timeEntry->billable_rate = null; - } }); } diff --git a/app/Service/Import/Importers/ClockifyTimeEntriesImporter.php b/app/Service/Import/Importers/ClockifyTimeEntriesImporter.php index 2f2514a5..4fb901a8 100644 --- a/app/Service/Import/Importers/ClockifyTimeEntriesImporter.php +++ b/app/Service/Import/Importers/ClockifyTimeEntriesImporter.php @@ -119,7 +119,7 @@ class ClockifyTimeEntriesImporter extends DefaultImporter $timeEntry->task_id = $taskId; $timeEntry->project_id = $projectId; $timeEntry->client_id = $clientId; - $timeEntry->organization()->associate($this->organization); + $timeEntry->organization_id = $this->organization->id; if (strlen($record['Description']) > 5000) { throw new ImportException('Time entry description is too long'); } @@ -128,7 +128,7 @@ class ClockifyTimeEntriesImporter extends DefaultImporter if (! in_array($record['Billable'], ['Yes', 'No'], true)) { throw new ImportException('Invalid billable value'); } - $timeEntry->billable = $record['Billable'] === 'Yes'; + $timeEntry->billable = $this->canUseBillableRates && $record['Billable'] === 'Yes'; } if ($isBreak) { // Breaks can not be billable or belong to a project/task (already skipped above) diff --git a/app/Service/Import/Importers/DefaultImporter.php b/app/Service/Import/Importers/DefaultImporter.php index 484259ee..3f0fe491 100644 --- a/app/Service/Import/Importers/DefaultImporter.php +++ b/app/Service/Import/Importers/DefaultImporter.php @@ -74,9 +74,16 @@ abstract class DefaultImporter implements ImporterContract protected BillableRateService $billableRateService; + /** + * Organizations that can not use billable rates import everything without billable flags and rates + */ + protected bool $canUseBillableRates; + public function init(Organization $organization): void { $this->organization = $organization; + $this->billableRateService = app(BillableRateService::class); + $this->canUseBillableRates = $this->billableRateService->canUseBillableRates($organization); $this->userImportHelper = new ImportDatabaseHelper(User::class, ['email'], true, function (Builder $builder) { /** @var Builder $builder */ return $builder->belongsToOrganization($this->organization); @@ -99,7 +106,11 @@ abstract class DefaultImporter implements ImporterContract 'string', 'in:placeholder', ], - ]); + ], beforeSave: function (Member $member): void { + if (! $this->canUseBillableRates) { + $member->billable_rate = null; + } + }); $this->projectImportHelper = new ImportDatabaseHelper(Project::class, ['name', 'client_id', 'organization_id'], true, function (Builder $builder) { /** @var Builder $builder */ return $builder->where('organization_id', $this->organization->id); @@ -126,8 +137,10 @@ abstract class DefaultImporter implements ImporterContract if ($project->billable_rate === 0) { $project->billable_rate = null; } - // Note: Prevents a query per project for the billable rate restriction (see Project::booted) - $project->setRelation('organization', $this->organization); + if (! $this->canUseBillableRates) { + $project->is_billable = false; + $project->billable_rate = null; + } }); $this->projectMemberImportHelper = new ImportDatabaseHelper(ProjectMember::class, ['project_id', 'member_id'], true, function (Builder $builder): Builder { /** @var Builder $builder */ @@ -142,10 +155,8 @@ abstract class DefaultImporter implements ImporterContract if ($projectMember->billable_rate === 0) { $projectMember->billable_rate = null; } - // Note: Prevents queries per project member for the billable rate restriction (see ProjectMember::booted) - $project = $this->projectImportHelper->getModelById($projectMember->project_id); - if ($project !== null) { - $projectMember->setRelation('project', $project->setRelation('organization', $this->organization)); + if (! $this->canUseBillableRates) { + $projectMember->billable_rate = null; } }); $this->tagImportHelper = new ImportDatabaseHelper(Tag::class, ['name', 'organization_id'], true, function (Builder $builder): Builder { @@ -188,7 +199,6 @@ abstract class DefaultImporter implements ImporterContract $this->timeEntriesCreated = 0; $this->colorService = app(ColorService::class); $this->timezoneService = app(TimezoneService::class); - $this->billableRateService = app(BillableRateService::class); } /** diff --git a/app/Service/Import/Importers/GenericTimeEntriesImporter.php b/app/Service/Import/Importers/GenericTimeEntriesImporter.php index c08a4c06..36faff63 100644 --- a/app/Service/Import/Importers/GenericTimeEntriesImporter.php +++ b/app/Service/Import/Importers/GenericTimeEntriesImporter.php @@ -129,12 +129,12 @@ class GenericTimeEntriesImporter extends DefaultImporter $timeEntry->task_id = $taskId; $timeEntry->project_id = $projectId; $timeEntry->client_id = $clientId; - $timeEntry->organization()->associate($this->organization); + $timeEntry->organization_id = $this->organization->id; $timeEntry->description = $record['description']; if (! in_array($record['billable'], ['true', 'false'], true)) { throw new ImportException('Invalid billable value'); } - $timeEntry->billable = $record['billable'] === 'true'; + $timeEntry->billable = $this->canUseBillableRates && $record['billable'] === 'true'; $timeEntry->tags = $this->getTags($record['tags']); $timeEntry->is_imported = true; try { diff --git a/app/Service/Import/Importers/HarvestTimeEntriesImporter.php b/app/Service/Import/Importers/HarvestTimeEntriesImporter.php index 41abaff5..93c8126c 100644 --- a/app/Service/Import/Importers/HarvestTimeEntriesImporter.php +++ b/app/Service/Import/Importers/HarvestTimeEntriesImporter.php @@ -107,7 +107,7 @@ class HarvestTimeEntriesImporter extends DefaultImporter $timeEntry->task_id = $taskId; $timeEntry->project_id = $projectId; $timeEntry->client_id = $clientId; - $timeEntry->organization()->associate($this->organization); + $timeEntry->organization_id = $this->organization->id; if (strlen($record['Notes']) > 5000) { throw new ImportException('Time entry note is too long'); } @@ -115,7 +115,7 @@ class HarvestTimeEntriesImporter extends DefaultImporter if (! in_array($record['Billable?'], ['Yes', 'No'], true)) { throw new ImportException('Invalid billable value'); } - $timeEntry->billable = $record['Billable?'] === 'Yes'; + $timeEntry->billable = $this->canUseBillableRates && $record['Billable?'] === 'Yes'; $timeEntry->tags = []; $timeEntry->is_imported = true; diff --git a/app/Service/Import/Importers/SolidtimeImporter.php b/app/Service/Import/Importers/SolidtimeImporter.php index 69520903..cddd43da 100644 --- a/app/Service/Import/Importers/SolidtimeImporter.php +++ b/app/Service/Import/Importers/SolidtimeImporter.php @@ -260,7 +260,7 @@ class SolidtimeImporter extends DefaultImporter $timeEntry->task_id = $taskId; $timeEntry->project_id = $projectId; $timeEntry->client_id = $clientId; - $timeEntry->organization()->associate($this->organization); + $timeEntry->organization_id = $this->organization->id; if (strlen($timeEntryRow['description']) > 5000) { throw new ImportException('Time entry description is too long'); } @@ -268,7 +268,7 @@ class SolidtimeImporter extends DefaultImporter if (! in_array($timeEntryRow['billable'], ['true', 'false'], true)) { throw new ImportException('Invalid billable value'); } - $timeEntry->billable = $timeEntryRow['billable'] === 'true'; + $timeEntry->billable = $this->canUseBillableRates && $timeEntryRow['billable'] === 'true'; // The type column does not exist in old exports if (($timeEntryRow['type'] ?? '') !== '') { $type = TimeEntryType::tryFrom($timeEntryRow['type']); diff --git a/app/Service/Import/Importers/TogglTimeEntriesImporter.php b/app/Service/Import/Importers/TogglTimeEntriesImporter.php index 11299313..0a90fce9 100644 --- a/app/Service/Import/Importers/TogglTimeEntriesImporter.php +++ b/app/Service/Import/Importers/TogglTimeEntriesImporter.php @@ -113,12 +113,12 @@ class TogglTimeEntriesImporter extends DefaultImporter $timeEntry->task_id = $taskId; $timeEntry->project_id = $projectId; $timeEntry->client_id = $clientId; - $timeEntry->organization()->associate($this->organization); + $timeEntry->organization_id = $this->organization->id; $timeEntry->description = $record['Description']; if (! in_array($record['Billable'], ['Yes', 'No'], true)) { throw new ImportException('Invalid billable value'); } - $timeEntry->billable = $record['Billable'] === 'Yes'; + $timeEntry->billable = $this->canUseBillableRates && $record['Billable'] === 'Yes'; $timeEntry->tags = $this->getTags($record['Tags']); $timeEntry->is_imported = true; try { diff --git a/tests/Unit/Model/BillableRateRestrictionTest.php b/tests/Unit/Model/BillableRateRestrictionTest.php deleted file mode 100644 index eb8e8268..00000000 --- a/tests/Unit/Model/BillableRateRestrictionTest.php +++ /dev/null @@ -1,161 +0,0 @@ -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); - } -}