diff --git a/app/Http/Controllers/Api/V1/ChartController.php b/app/Http/Controllers/Api/V1/ChartController.php index 167b9ed1..a9c78bbc 100644 --- a/app/Http/Controllers/Api/V1/ChartController.php +++ b/app/Http/Controllers/Api/V1/ChartController.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace App\Http\Controllers\Api\V1; -use App\Enums\Role; use App\Models\Organization; use App\Service\DashboardService; use App\Service\PermissionStore; @@ -159,7 +158,7 @@ class ChartController extends Controller $this->checkPermission($organization, 'charts:view:own'); $user = $this->user(); - $showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates; + $showBillableRate = $this->canSeeBillableRates($organization); if (! $showBillableRate) { throw new AuthorizationException('You do not have permission to view billable rates.'); } diff --git a/app/Http/Controllers/Api/V1/Controller.php b/app/Http/Controllers/Api/V1/Controller.php index da63d507..21f11af5 100644 --- a/app/Http/Controllers/Api/V1/Controller.php +++ b/app/Http/Controllers/Api/V1/Controller.php @@ -4,6 +4,8 @@ declare(strict_types=1); namespace App\Http\Controllers\Api\V1; +use App\Enums\Role; +use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException; use App\Models\Organization; use App\Service\BillingContract; use App\Service\PermissionStore; @@ -49,4 +51,30 @@ 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); + } + + protected function canSeeBillableRates(Organization $organization): bool + { + if (! $this->canUseBillableRates($organization)) { + return false; + } + + return $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates; + } + + /** + * Organizations that can not use billable rates may keep or remove an existing rate, but not set a new one + * + * @throws FeatureIsNotAvailableInFreePlanApiException + */ + protected function checkBillableRateChange(Organization $organization, ?int $oldBillableRate, ?int $newBillableRate): void + { + if ($newBillableRate !== null && $newBillableRate !== $oldBillableRate && ! $this->canUseBillableRates($organization)) { + throw new FeatureIsNotAvailableInFreePlanApiException; + } + } } diff --git a/app/Http/Controllers/Api/V1/MemberController.php b/app/Http/Controllers/Api/V1/MemberController.php index 0b1a1ab6..1122e432 100644 --- a/app/Http/Controllers/Api/V1/MemberController.php +++ b/app/Http/Controllers/Api/V1/MemberController.php @@ -83,6 +83,7 @@ class MemberController extends Controller $this->checkPermission($organization, 'members:update', $member); if ($request->has('billable_rate') && $member->billable_rate !== $request->getBillableRate()) { + $this->checkBillableRateChange($organization, $member->billable_rate, $request->getBillableRate()); $member->billable_rate = $request->getBillableRate(); $billableRateService->updateTimeEntriesBillableRateForMember($member); diff --git a/app/Http/Controllers/Api/V1/OrganizationController.php b/app/Http/Controllers/Api/V1/OrganizationController.php index 8722eb6c..084c1108 100644 --- a/app/Http/Controllers/Api/V1/OrganizationController.php +++ b/app/Http/Controllers/Api/V1/OrganizationController.php @@ -4,8 +4,8 @@ declare(strict_types=1); namespace App\Http\Controllers\Api\V1; -use App\Enums\Role; use App\Events\AfterCreateOrganization; +use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException; use App\Http\Requests\V1\Organization\OrganizationDestroyRequest; use App\Http\Requests\V1\Organization\OrganizationStoreRequest; use App\Http\Requests\V1\Organization\OrganizationUpdateRequest; @@ -32,7 +32,7 @@ class OrganizationController extends Controller { $this->checkPermission($organization, 'organizations:view'); - $showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates; + $showBillableRate = $this->canSeeBillableRates($organization); return new OrganizationResource($organization, $showBillableRate); } @@ -54,7 +54,10 @@ class OrganizationController extends Controller if ($request->getCurrency() !== null) { $organization->currency = $request->getCurrency(); } - if ($request->getEmployeesCanSeeBillableRates() !== null) { + if ($request->getEmployeesCanSeeBillableRates() !== null && $request->getEmployeesCanSeeBillableRates() !== $organization->employees_can_see_billable_rates) { + if (! $this->canUseBillableRates($organization)) { + throw new FeatureIsNotAvailableInFreePlanApiException; + } $organization->employees_can_see_billable_rates = $request->getEmployeesCanSeeBillableRates(); } if ($request->getEmployeesCanManageTasks() !== null) { @@ -83,6 +86,7 @@ class OrganizationController extends Controller } $hasBillableRate = $request->has('billable_rate'); if ($hasBillableRate) { + $this->checkBillableRateChange($organization, $organization->billable_rate, $request->getBillableRate()); $oldBillableRate = $organization->billable_rate; $organization->billable_rate = $request->getBillableRate(); } diff --git a/app/Http/Controllers/Api/V1/ProjectController.php b/app/Http/Controllers/Api/V1/ProjectController.php index 6f884265..4fd02817 100644 --- a/app/Http/Controllers/Api/V1/ProjectController.php +++ b/app/Http/Controllers/Api/V1/ProjectController.php @@ -65,7 +65,7 @@ class ProjectController extends Controller ->orderBy('id') ->paginate(config('app.pagination_per_page_default')); - $showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates; + $showBillableRate = $this->canSeeBillableRates($organization); return new ProjectCollection($projects, $showBillableRate); } @@ -99,6 +99,7 @@ class ProjectController extends Controller public function store(Organization $organization, ProjectStoreRequest $request): JsonResource { $this->checkPermission($organization, 'projects:create'); + $this->checkBillableRateChange($organization, null, $request->getBillableRate()); $project = new Project; $project->name = $request->input('name'); $project->color = $request->input('color'); @@ -125,6 +126,7 @@ class ProjectController extends Controller public function update(Organization $organization, Project $project, ProjectUpdateRequest $request, BillableRateService $billableRateService): JsonResource { $this->checkPermission($organization, 'projects:update', $project); + $this->checkBillableRateChange($organization, $project->billable_rate, $request->getBillableRate()); $project->name = $request->input('name'); $project->color = $request->input('color'); $project->is_billable = (bool) $request->input('is_billable'); @@ -139,14 +141,17 @@ class ProjectController extends Controller } $oldBillableRate = $project->billable_rate; $clientIdChanged = false; - $project->billable_rate = $request->getBillableRate(); + // Organizations that can not use billable rates do not receive the rate of the project, so the incoming value is ignored to keep the existing rate + if ($this->canUseBillableRates($organization)) { + $project->billable_rate = $request->getBillableRate(); + } if ($project->client_id !== $request->input('client_id')) { $project->client_id = $request->input('client_id'); $clientIdChanged = true; } $project->save(); - if ($oldBillableRate !== $request->getBillableRate()) { + if ($oldBillableRate !== $project->billable_rate) { $billableRateService->updateTimeEntriesBillableRateForProject($project); } if ($clientIdChanged) { diff --git a/app/Http/Controllers/Api/V1/ProjectMemberController.php b/app/Http/Controllers/Api/V1/ProjectMemberController.php index cb940f9a..28d34cf0 100644 --- a/app/Http/Controllers/Api/V1/ProjectMemberController.php +++ b/app/Http/Controllers/Api/V1/ProjectMemberController.php @@ -65,6 +65,7 @@ class ProjectMemberController extends Controller public function store(Organization $organization, Project $project, ProjectMemberStoreRequest $request, BillableRateService $billableRateService): JsonResource { $this->checkPermission($organization, 'project-members:create', $project); + $this->checkBillableRateChange($organization, null, $request->getBillableRate()); $member = Member::findOrFail((string) $request->input('member_id')); if ($member->user->is_placeholder) { @@ -98,6 +99,7 @@ class ProjectMemberController extends Controller public function update(Organization $organization, ProjectMember $projectMember, ProjectMemberUpdateRequest $request, BillableRateService $billableRateService): JsonResource { $this->checkPermission($organization, 'project-members:update', projectMember: $projectMember); + $this->checkBillableRateChange($organization, $projectMember->billable_rate, $request->getBillableRate()); $oldBillableRate = $projectMember->billable_rate; $projectMember->billable_rate = $request->getBillableRate(); $projectMember->save(); diff --git a/app/Http/Controllers/Api/V1/TimeEntryController.php b/app/Http/Controllers/Api/V1/TimeEntryController.php index 165546b9..242e0e7b 100644 --- a/app/Http/Controllers/Api/V1/TimeEntryController.php +++ b/app/Http/Controllers/Api/V1/TimeEntryController.php @@ -5,7 +5,6 @@ declare(strict_types=1); namespace App\Http\Controllers\Api\V1; use App\Enums\ExportFormat; -use App\Enums\Role; use App\Enums\TimeEntryType; use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException; use App\Exceptions\Api\OverlappingTimeEntryApiException; @@ -240,7 +239,7 @@ class TimeEntryController extends Controller } $user = $this->user(); $timezone = $user->timezone; - $showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates; + $showBillableRate = $this->canSeeBillableRates($organization); $roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null; $roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null; @@ -386,7 +385,7 @@ class TimeEntryController extends Controller } $canAccessPremiumFeatures = $this->canAccessPremiumFeatures($organization); $user = $this->user(); - $showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates; + $showBillableRate = $this->canSeeBillableRates($organization); $group1Type = $request->getGroup(); $group2Type = $request->getSubGroup(); @@ -441,7 +440,7 @@ class TimeEntryController extends Controller } $debug = $request->getDebug(); $user = $this->user(); - $showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates; + $showBillableRate = $this->canSeeBillableRates($organization); $group = $request->getGroup(); $subGroup = $request->getSubGroup(); diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index a27bacf8..a59173de 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -57,6 +57,7 @@ class HandleInertiaRequests extends Middleware 'has_trial' => $billing->hasTrial($currentOrganization), 'trial_until' => $billing->getTrialUntil($currentOrganization)?->toIso8601ZuluString(), 'is_blocked' => $billing->isBlocked($currentOrganization), + 'can_use_billable_rates' => $billing->canUseBillableRates($currentOrganization), ] : null, 'flash' => [ 'message' => fn () => $request->session()->get('message'), diff --git a/app/Service/BillingContract.php b/app/Service/BillingContract.php index a3c58a3b..f886f4eb 100644 --- a/app/Service/BillingContract.php +++ b/app/Service/BillingContract.php @@ -54,4 +54,13 @@ class BillingContract { return false; } + + /** + * Check if the organization can set and see billable rates + * Without a billing system every organization can use billable rates + */ + public function canUseBillableRates(Organization $organization): bool + { + return true; + } } diff --git a/app/Service/Import/Importers/DefaultImporter.php b/app/Service/Import/Importers/DefaultImporter.php index af6c6081..983e8195 100644 --- a/app/Service/Import/Importers/DefaultImporter.php +++ b/app/Service/Import/Importers/DefaultImporter.php @@ -14,6 +14,7 @@ use App\Models\Tag; use App\Models\Task; use App\Models\User; use App\Service\BillableRateService; +use App\Service\BillingContract; use App\Service\ColorService; use App\Service\Import\ImportDatabaseHelper; use App\Service\TimezoneService; @@ -71,9 +72,15 @@ abstract class DefaultImporter implements ImporterContract protected BillableRateService $billableRateService; + /** + * Organizations that can not use billable rates import projects, members and project members without billable rates + */ + protected bool $canUseBillableRates; + public function init(Organization $organization): void { $this->organization = $organization; + $this->canUseBillableRates = app(BillingContract::class)->canUseBillableRates($organization); $this->userImportHelper = new ImportDatabaseHelper(User::class, ['email'], true, function (Builder $builder) { /** @var Builder $builder */ return $builder->belongsToOrganization($this->organization); @@ -96,7 +103,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); @@ -120,7 +131,7 @@ abstract class DefaultImporter implements ImporterContract 'uuid', ], ], beforeSave: function (Project $project): void { - if ($project->billable_rate === 0) { + if ($project->billable_rate === 0 || ! $this->canUseBillableRates) { $project->billable_rate = null; } }); @@ -134,7 +145,7 @@ abstract class DefaultImporter implements ImporterContract 'max:2147483647', ], ], beforeSave: function (ProjectMember $projectMember): void { - if ($projectMember->billable_rate === 0) { + if ($projectMember->billable_rate === 0 || ! $this->canUseBillableRates) { $projectMember->billable_rate = null; } }); diff --git a/resources/js/Components/Common/Member/MemberEditModal.vue b/resources/js/Components/Common/Member/MemberEditModal.vue index 6dd99555..518ff373 100644 --- a/resources/js/Components/Common/Member/MemberEditModal.vue +++ b/resources/js/Components/Common/Member/MemberEditModal.vue @@ -21,6 +21,7 @@ import { getOrganizationCurrencyString } from '@/utils/money'; import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue'; import { useOrganizationQuery } from '@/utils/useOrganizationQuery'; import { getCurrentOrganizationId } from '@/utils/useUser'; +import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock'; const { updateMember } = useMembersStore(); const { organization } = useOrganizationQuery(getCurrentOrganizationId()!); @@ -71,6 +72,8 @@ function saveWithChecks() { } } +const { locked: billableRatesLocked } = useBillableRatesLock(); + const billableRateSelect = ref('default-rate'); onMounted(() => { @@ -159,7 +162,12 @@ const roleDescription = computed(() => { Billable Rate -
+ +