add billable rate restriction for free organizations

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

View File

@@ -4,8 +4,8 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Enums\Role;
use App\Models\Organization;
use App\Service\BillableRateService;
use App\Service\DashboardService;
use App\Service\PermissionStore;
use Illuminate\Auth\Access\AuthorizationException;
@@ -154,12 +154,12 @@ class ChartController extends Controller
*
* @response array{value: int, currency: string}
*/
public function totalWeeklyBillableAmount(Organization $organization, DashboardService $dashboardService): JsonResponse
public function totalWeeklyBillableAmount(Organization $organization, DashboardService $dashboardService, BillableRateService $billableRateService): JsonResponse
{
$this->checkPermission($organization, 'charts:view:own');
$user = $this->user();
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
$showBillableRate = $billableRateService->canSeeBillableRates($organization, $this->member($organization));
if (! $showBillableRate) {
throw new AuthorizationException('You do not have permission to view billable rates.');
}

View File

@@ -81,10 +81,9 @@ class MemberController extends Controller
{
$this->checkPermission($organization, 'members:update', $member);
if ($request->has('billable_rate') && $member->billable_rate !== $request->getBillableRate()) {
$oldBillableRate = $member->billable_rate;
if ($request->has('billable_rate')) {
$member->billable_rate = $request->getBillableRate();
$billableRateService->updateTimeEntriesBillableRateForMember($member);
}
if ($request->has('role') && $member->role !== $request->getRole()->value) {
$newRole = $request->getRole();
@@ -93,6 +92,10 @@ class MemberController extends Controller
}
$member->save();
if ($oldBillableRate !== $member->billable_rate) {
$billableRateService->updateTimeEntriesBillableRateForMember($member);
}
return new MemberResource($member);
}

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Enums\Role;
use App\Events\AfterCreateOrganization;
use App\Http\Requests\V1\Organization\OrganizationDestroyRequest;
use App\Http\Requests\V1\Organization\OrganizationStoreRequest;
@@ -28,11 +27,11 @@ class OrganizationController extends Controller
*
* @throws AuthorizationException
*/
public function show(Organization $organization): OrganizationResource
public function show(Organization $organization, BillableRateService $billableRateService): OrganizationResource
{
$this->checkPermission($organization, 'organizations:view');
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
$showBillableRate = $billableRateService->canSeeBillableRates($organization, $this->member($organization));
return new OrganizationResource($organization, $showBillableRate);
}
@@ -81,14 +80,13 @@ class OrganizationController extends Controller
if ($request->getBreaksEnabled() !== null) {
$organization->breaks_enabled = $request->getBreaksEnabled();
}
$hasBillableRate = $request->has('billable_rate');
if ($hasBillableRate) {
$oldBillableRate = $organization->billable_rate;
$oldBillableRate = $organization->billable_rate;
if ($request->has('billable_rate')) {
$organization->billable_rate = $request->getBillableRate();
}
$organization->save();
if ($hasBillableRate && $oldBillableRate !== $request->getBillableRate()) {
if ($oldBillableRate !== $organization->billable_rate) {
$billableRateService->updateTimeEntriesBillableRateForOrganization($organization);
}

View File

@@ -4,7 +4,6 @@ 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;
@@ -39,7 +38,7 @@ class ProjectController extends Controller
*
* @operationId getProjects
*/
public function index(Organization $organization, ProjectIndexRequest $request): ProjectCollection
public function index(Organization $organization, ProjectIndexRequest $request, BillableRateService $billableRateService): ProjectCollection
{
$this->checkPermission($organization, 'projects:view');
$canViewAllProjects = $this->hasPermission($organization, 'projects:view:all');
@@ -63,7 +62,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 = $billableRateService->canSeeBillableRates($organization, $this->member($organization));
return new ProjectCollection($projects, $showBillableRate);
}
@@ -137,14 +136,16 @@ class ProjectController extends Controller
}
$oldBillableRate = $project->billable_rate;
$clientIdChanged = false;
$project->billable_rate = $request->getBillableRate();
if ($request->has('billable_rate')) {
$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) {

View File

@@ -79,7 +79,7 @@ class ProjectMemberController extends Controller
$projectMember->project()->associate($project);
$projectMember->save();
if ($request->getBillableRate() !== null) {
if ($projectMember->billable_rate !== null) {
$billableRateService->updateTimeEntriesBillableRateForProjectMember($projectMember);
}
@@ -100,7 +100,7 @@ class ProjectMemberController extends Controller
$projectMember->billable_rate = $request->getBillableRate();
$projectMember->save();
if ($oldBillableRate !== $request->getBillableRate()) {
if ($oldBillableRate !== $projectMember->billable_rate) {
$billableRateService->updateTimeEntriesBillableRateForProjectMember($projectMember);
}

View File

@@ -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;
@@ -29,6 +28,7 @@ use App\Models\Organization;
use App\Models\Project;
use App\Models\Task;
use App\Models\TimeEntry;
use App\Service\BillableRateService;
use App\Service\LocalizationService;
use App\Service\ReportExport\TimeEntriesDetailedCsvExport;
use App\Service\ReportExport\TimeEntriesDetailedExport;
@@ -227,7 +227,7 @@ class TimeEntryController extends Controller
*
* @operationId exportTimeEntries
*/
public function indexExport(Organization $organization, TimeEntryIndexExportRequest $request, TimeEntryAggregationService $timeEntryAggregationService): JsonResponse
public function indexExport(Organization $organization, TimeEntryIndexExportRequest $request, TimeEntryAggregationService $timeEntryAggregationService, BillableRateService $billableRateService): JsonResponse
{
$member = $this->member($organization);
/** @var Member|null $memberFilter */
@@ -245,7 +245,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 = $billableRateService->canSeeBillableRates($organization, $this->member($organization));
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
@@ -384,7 +384,7 @@ class TimeEntryController extends Controller
*
* @throws AuthorizationException
*/
public function aggregate(Organization $organization, TimeEntryAggregateRequest $request, TimeEntryAggregationService $timeEntryAggregationService): array
public function aggregate(Organization $organization, TimeEntryAggregateRequest $request, TimeEntryAggregationService $timeEntryAggregationService, BillableRateService $billableRateService): array
{
$member = $this->member($organization);
/** @var Member|null $memberFilter */
@@ -396,7 +396,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 = $billableRateService->canSeeBillableRates($organization, $this->member($organization));
$group1Type = $request->getGroup();
$group2Type = $request->getSubGroup();
@@ -434,7 +434,7 @@ class TimeEntryController extends Controller
* @throws NoOutputFileInResponse
* @throws FeatureIsNotAvailableInFreePlanApiException
*/
public function aggregateExport(Organization $organization, TimeEntryAggregateExportRequest $request, TimeEntryAggregationService $timeEntryAggregationService): JsonResponse
public function aggregateExport(Organization $organization, TimeEntryAggregateExportRequest $request, TimeEntryAggregationService $timeEntryAggregationService, BillableRateService $billableRateService): JsonResponse
{
$member = $this->member($organization);
/** @var Member|null $memberFilter */
@@ -451,7 +451,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 = $billableRateService->canSeeBillableRates($organization, $this->member($organization));
$group = $request->getGroup();
$subGroup = $request->getSubGroup();

View File

@@ -60,6 +60,7 @@ class HandleInertiaRequests extends Middleware
'trial_until' => $billing->getTrialUntil($currentOrganization)?->toIso8601ZuluString(),
'is_blocked' => $billing->isBlocked($currentOrganization),
'goal_limit' => $billing->getGoalLimit($currentOrganization),
'can_use_billable_rates' => $billing->canUseBillableRates($currentOrganization),
] : null,
'flash' => [
'message' => fn () => $request->session()->get('message'),

View File

@@ -68,7 +68,7 @@ class ProjectUpdateRequest extends BaseFormRequest
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(),
],
// Billable rate in cents per hour (example: 8000 means 80.00 in the organization's currency)
// Billable rate in cents per hour (example: 8000 means 80.00 in the organization's currency), the current rate is kept if the field is omitted
'billable_rate' => array_merge([
'nullable',
],

View File

@@ -11,6 +11,7 @@ use App\Enums\NumberFormat;
use App\Enums\TimeFormat;
use App\Http\Resources\V1\BaseResource;
use App\Models\Organization;
use App\Service\BillingContract;
use App\Service\CurrencyService;
use Illuminate\Http\Request;
@@ -51,6 +52,8 @@ class OrganizationResource extends BaseResource
'is_personal' => $this->resource->personal_team,
/** Billable rate in cents per hour */
'billable_rate' => $this->showBillableRate ? $this->resource->billable_rate : null,
/** Whether the organization's plan allows billable flags and rates */
'can_use_billable_rates' => app(BillingContract::class)->canUseBillableRates($this->resource),
/** Can members of the organization with role "employee" see the billable rates */
'employees_can_see_billable_rates' => $this->resource->employees_can_see_billable_rates,
/** Can members of the organization with role "employee" manage tasks in public projects and projects they are assigned to */

View File

@@ -6,6 +6,7 @@ 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;
@@ -48,6 +49,16 @@ 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<User, $this>
*/

View File

@@ -11,6 +11,7 @@ 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;
@@ -96,6 +97,25 @@ 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();

View File

@@ -6,6 +6,7 @@ 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;
@@ -136,6 +137,25 @@ 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<Organization, $this>
*/

View File

@@ -7,6 +7,7 @@ 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;
@@ -48,6 +49,16 @@ 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<Project, $this>
*/

View File

@@ -8,6 +8,7 @@ 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;
@@ -140,6 +141,12 @@ 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;
}
});
}

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Service;
use App\Enums\Role;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Project;
@@ -13,6 +14,24 @@ use Illuminate\Database\Eloquent\Builder;
class BillableRateService
{
public function __construct(
private BillingContract $billing,
) {}
public function canUseBillableRates(Organization $organization): bool
{
return $this->billing->canUseBillableRates($organization);
}
public function canSeeBillableRates(Organization $organization, Member $member): bool
{
if (! $this->canUseBillableRates($organization)) {
return false;
}
return $member->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
}
public function updateTimeEntriesBillableRateForProjectMember(ProjectMember $projectMember): void
{
TimeEntry::query()

View File

@@ -63,4 +63,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;
}
}

View File

@@ -119,7 +119,7 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
$timeEntry->task_id = $taskId;
$timeEntry->project_id = $projectId;
$timeEntry->client_id = $clientId;
$timeEntry->organization_id = $this->organization->id;
$timeEntry->organization()->associate($this->organization);
if (strlen($record['Description']) > 5000) {
throw new ImportException('Time entry description is too long');
}

View File

@@ -126,6 +126,8 @@ 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);
});
$this->projectMemberImportHelper = new ImportDatabaseHelper(ProjectMember::class, ['project_id', 'member_id'], true, function (Builder $builder): Builder {
/** @var Builder<ProjectMember> $builder */
@@ -140,6 +142,11 @@ 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));
}
});
$this->tagImportHelper = new ImportDatabaseHelper(Tag::class, ['name', 'organization_id'], true, function (Builder $builder): Builder {
/** @var Builder<Tag> $builder */

View File

@@ -129,7 +129,7 @@ class GenericTimeEntriesImporter extends DefaultImporter
$timeEntry->task_id = $taskId;
$timeEntry->project_id = $projectId;
$timeEntry->client_id = $clientId;
$timeEntry->organization_id = $this->organization->id;
$timeEntry->organization()->associate($this->organization);
$timeEntry->description = $record['description'];
if (! in_array($record['billable'], ['true', 'false'], true)) {
throw new ImportException('Invalid billable value');

View File

@@ -107,7 +107,7 @@ class HarvestTimeEntriesImporter extends DefaultImporter
$timeEntry->task_id = $taskId;
$timeEntry->project_id = $projectId;
$timeEntry->client_id = $clientId;
$timeEntry->organization_id = $this->organization->id;
$timeEntry->organization()->associate($this->organization);
if (strlen($record['Notes']) > 5000) {
throw new ImportException('Time entry note is too long');
}

View File

@@ -241,7 +241,7 @@ class SolidtimeImporter extends DefaultImporter
$timeEntry->task_id = $taskId;
$timeEntry->project_id = $projectId;
$timeEntry->client_id = $clientId;
$timeEntry->organization_id = $this->organization->id;
$timeEntry->organization()->associate($this->organization);
if (strlen($timeEntryRow['description']) > 5000) {
throw new ImportException('Time entry description is too long');
}

View File

@@ -113,7 +113,7 @@ class TogglTimeEntriesImporter extends DefaultImporter
$timeEntry->task_id = $taskId;
$timeEntry->project_id = $projectId;
$timeEntry->client_id = $clientId;
$timeEntry->organization_id = $this->organization->id;
$timeEntry->organization()->associate($this->organization);
$timeEntry->description = $record['Description'];
if (! in_array($record['Billable'], ['Yes', 'No'], true)) {
throw new ImportException('Invalid billable value');

View File

@@ -884,7 +884,7 @@ test('test that sorting members by billable rate works', async ({ page, ctx }) =
await expect(table).toBeVisible();
// First click = desc (highest first), null rates last
const billableHeader = table.getByText('Billable Rate').first();
const billableHeader = table.getByRole('button', { name: /Billable Rate/ });
await billableHeader.click();
await expect(billableHeader.locator('svg')).toBeVisible();
let names = await getTableRowNames(table);

View File

@@ -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<MemberBillableKey>('default-rate');
onMounted(() => {
@@ -159,7 +162,12 @@ const roleDescription = computed(() => {
<FieldLabel :icon="BillableIcon" for="billableRateType"
>Billable Rate</FieldLabel
>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<BillableRateInput
v-if="billableRatesLocked"
v-model="displayedRate"
:currency="getOrganizationCurrencyString()"
name="memberBillableRate" />
<div v-else class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<Select v-model="billableRateSelect">
<SelectTrigger id="billableRateType">
<SelectValue />

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
import type { Member, Organization } from '@/packages/api/src';
import { api } from '@/packages/api/src';
import { CheckCircleIcon, UserCircleIcon } from '@heroicons/vue/24/outline';
@@ -25,6 +26,7 @@ import MemberEditModal from '@/Components/Common/Member/MemberEditModal.vue';
import MemberMergeModal from '@/Components/Common/Member/MemberMergeModal.vue';
import MemberMakePlaceholderModal from '@/Components/Common/Member/MemberMakePlaceholderModal.vue';
import MemberDeleteModal from '@/Components/Common/Member/MemberDeleteModal.vue';
import UpgradeLockedBadge from '@/packages/ui/src/UpgradeLockedBadge.vue';
import { capitalizeFirstLetter } from '../../../utils/format';
import { formatCents } from '../../../packages/ui/src/utils/money';
import {
@@ -71,6 +73,8 @@ async function invitePlaceholder(id: string) {
const userHasValidMailAddress = computed(() => {
return !props.member.email.endsWith('@solidtime-import.test');
});
const { locked: billableRatesLocked } = useBillableRatesLock();
</script>
<template>
@@ -89,8 +93,9 @@ const userHasValidMailAddress = computed(() => {
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-primary">
{{ capitalizeFirstLetter(member.role) }}
</div>
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-primary">
<span v-if="member.billable_rate">
<div class="whitespace-nowrap px-3 flex items-center text-sm text-text-primary">
<UpgradeLockedBadge v-if="billableRatesLocked" />
<span v-else-if="member.billable_rate">
{{
formatCents(
member.billable_rate,

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
import ProjectMoreOptionsDropdown from '@/Components/Common/Project/ProjectMoreOptionsDropdown.vue';
import type { Project } from '@/packages/api/src';
import { computed, ref, inject, type ComputedRef } from 'vue';
@@ -15,6 +16,7 @@ import { useTasksQuery } from '@/utils/useTasksQuery';
import { useProjectsStore } from '@/utils/useProjects';
import TableRow from '@/Components/TableRow.vue';
import ProjectEditModal from '@/Components/Common/Project/ProjectEditModal.vue';
import UpgradeLockedBadge from '@/packages/ui/src/UpgradeLockedBadge.vue';
import { formatCents } from '@/packages/ui/src/utils/money';
import { getOrganizationCurrencyString } from '@/utils/money';
import EstimatedTimeProgress from '@/packages/ui/src/EstimatedTimeProgress.vue';
@@ -78,6 +80,8 @@ const billableRateInfo = computed(() => {
});
const showEditProjectModal = ref(false);
const { locked: billableRatesLocked } = useBillableRatesLock();
</script>
<template>
@@ -100,13 +104,15 @@ const showEditProjectModal = ref(false);
</span>
<span class="text-text-secondary"> {{ projectTasksCount }} Tasks </span>
</div>
<div class="whitespace-nowrap min-w-0 px-3 py-4 text-sm text-text-primary">
<div
class="whitespace-nowrap min-w-0 px-3 py-4 flex items-center text-sm text-text-primary">
<div v-if="project.client_id" class="overflow-ellipsis overflow-hidden">
{{ client?.name }}
</div>
<div v-else class="text-text-tertiary">No client</div>
</div>
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-primary">
<div
class="whitespace-nowrap px-3 py-4 flex items-center text-sm text-text-primary">
<div v-if="project.spent_time">
{{
formatHumanReadableDuration(
@@ -130,8 +136,9 @@ const showEditProjectModal = ref(false);
</div>
<div
v-if="showBillableRate"
class="whitespace-nowrap px-3 py-4 text-sm text-text-primary">
<span v-if="billableRateInfo">{{ billableRateInfo }}</span>
class="whitespace-nowrap px-3 flex items-center text-sm text-text-primary">
<UpgradeLockedBadge v-if="billableRatesLocked" />
<span v-else-if="billableRateInfo">{{ billableRateInfo }}</span>
<span v-else class="text-text-tertiary">--</span>
</div>
<div

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
import type { ProjectMember } from '@/packages/api/src';
import { computed, ref, inject, type ComputedRef } from 'vue';
import TableRow from '@/Components/TableRow.vue';
@@ -10,6 +11,7 @@ import { capitalizeFirstLetter } from '@/utils/format';
import ProjectMemberEditModal from '@/Components/Common/ProjectMember/ProjectMemberEditModal.vue';
import { getOrganizationCurrencyString } from '@/utils/money';
import type { Organization } from '@/packages/api/src';
import UpgradeLockedBadge from '@/packages/ui/src/UpgradeLockedBadge.vue';
const props = defineProps<{
projectMember: ProjectMember;
@@ -33,6 +35,8 @@ const member = computed(() => {
return members.value.find((member) => member.id === props.projectMember.member_id);
});
const showEditModal = ref(false);
const { locked: billableRatesLocked } = useBillableRatesLock();
</script>
<template>
@@ -47,18 +51,21 @@ const showEditModal = ref(false);
{{ member?.name }}
</span>
</div>
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary">
{{
projectMember.billable_rate
? formatCents(
projectMember.billable_rate,
getOrganizationCurrencyString(),
organization?.currency_format,
organization?.currency_symbol,
organization?.number_format
)
: '--'
}}
<div class="whitespace-nowrap px-3 flex items-center text-sm text-text-secondary">
<UpgradeLockedBadge v-if="billableRatesLocked" />
<span v-else>
{{
projectMember.billable_rate
? formatCents(
projectMember.billable_rate,
getOrganizationCurrencyString(),
organization?.currency_format,
organization?.currency_symbol,
organization?.number_format
)
: '--'
}}
</span>
</div>
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary">
{{ capitalizeFirstLetter(member?.role ?? '') }}

View File

@@ -1,10 +1,11 @@
<script setup lang="ts">
import { useBreaksEnabled } from '@/packages/ui/src/utils/useBreaksEnabled';
import { CheckCircleIcon, TagIcon, UserGroupIcon } from '@heroicons/vue/20/solid';
import { CheckCircleIcon, LockClosedIcon, TagIcon, UserGroupIcon } from '@heroicons/vue/20/solid';
import { FolderIcon } from '@heroicons/vue/16/solid';
import { Check, Coffee } from '@lucide/vue';
import { RadioGroupIndicator, RadioGroupItem, RadioGroupRoot, type AcceptableValue } from 'reka-ui';
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
import ReportingRoundingControls from '@/Components/Common/Reporting/ReportingRoundingControls.vue';
import TaskMultiselectDropdown from '@/Components/Common/Task/TaskMultiselectDropdown.vue';
import ClientMultiselectDropdown from '@/Components/Common/Client/ClientMultiselectDropdown.vue';
@@ -12,6 +13,7 @@ import MemberMultiselectDropdown from '@/Components/Common/Member/MemberMultisel
import ReportingFilterBadge from '@/Components/Common/Reporting/ReportingFilterBadge.vue';
import ProjectMultiselectDropdown from '@/Components/Common/Project/ProjectMultiselectDropdown.vue';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/packages/ui/src';
import { Button } from '@/packages/ui/src/Buttons';
import MainContainer from '@/packages/ui/src/MainContainer.vue';
import DateRangePicker from '@/packages/ui/src/Input/DateRangePicker.vue';
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
@@ -35,6 +37,8 @@ const roundingMinutes = defineModel<number>('roundingMinutes', { required: true
const startDate = defineModel<string>('startDate', { required: true });
const endDate = defineModel<string>('endDate', { required: true });
const { locked: billableRatesLocked, requestUpgrade } = useBillableRatesLock();
const emit = defineEmits<{
submit: [];
}>();
@@ -141,7 +145,17 @@ async function createTag(name: string) {
</template>
</TagDropdown>
<Select v-model="billable" @update:model-value="emit('submit')">
<Button
v-if="billableRatesLocked"
variant="outline"
size="sm"
data-testid="billable_filter_locked"
@click="requestUpgrade">
<BillableIcon class="h-4 text-text-quaternary" />
<span class="text-text-secondary">Billable</span>
<LockClosedIcon class="!w-3 text-text-tertiary" />
</Button>
<Select v-else v-model="billable" @update:model-value="emit('submit')">
<SelectTrigger
size="sm"
variant="outline"

View File

@@ -1,4 +1,6 @@
<script setup lang="ts">
import UpgradeLockedValue from '@/packages/ui/src/UpgradeLockedValue.vue';
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
import {
ChartBarIcon,
ArrowDownTrayIcon,
@@ -295,6 +297,8 @@ const tableData = computed(() => {
};
});
});
const { locked: billableRatesLocked } = useBillableRatesLock();
</script>
<template>
@@ -450,17 +454,20 @@ const tableData = computed(() => {
<div
v-if="showBillableRate"
class="justify-end pr-6 flex items-center font-medium">
{{
aggregatedTableTimeEntries.cost
? formatCents(
aggregatedTableTimeEntries.cost,
getOrganizationCurrencyString(),
organization?.currency_format,
organization?.currency_symbol,
organization?.number_format
)
: '--'
}}
<UpgradeLockedValue v-if="billableRatesLocked" />
<template v-else>
{{
aggregatedTableTimeEntries.cost
? formatCents(
aggregatedTableTimeEntries.cost,
getOrganizationCurrencyString(),
organization?.currency_format,
organization?.currency_symbol,
organization?.number_format
)
: '--'
}}
</template>
</div>
</div>
</template>

View File

@@ -2,6 +2,8 @@
import { formatReportingDuration } from '@/packages/ui/src/utils/time';
import { formatCents } from '@/packages/ui/src/utils/money';
import GroupedItemsCountButton from '@/packages/ui/src/GroupedItemsCountButton.vue';
import UpgradeLockedValue from '@/packages/ui/src/UpgradeLockedValue.vue';
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
import { ref, inject, type ComputedRef } from 'vue';
import { twMerge } from 'tailwind-merge';
import type { Organization } from '@/packages/api/src';
@@ -27,6 +29,8 @@ const props = defineProps<{
const expanded = ref(false);
const organization = inject<ComputedRef<Organization>>('organization');
const { locked: billableRatesLocked } = useBillableRatesLock();
</script>
<template>
@@ -53,17 +57,20 @@ const organization = inject<ComputedRef<Organization>>('organization');
}}
</div>
<div v-if="showCost" class="justify-end pr-6 flex items-center">
{{
entry.cost
? formatCents(
entry.cost,
props.currency,
organization?.currency_format,
organization?.currency_symbol,
organization?.number_format
)
: '--'
}}
<UpgradeLockedValue v-if="billableRatesLocked" />
<template v-else>
{{
entry.cost
? formatCents(
entry.cost,
props.currency,
organization?.currency_format,
organization?.currency_symbol,
organization?.number_format
)
: '--'
}}
</template>
</div>
</div>
<div

View File

@@ -9,7 +9,7 @@ defineProps<{
<div class="rounded-lg bg-card-background border-card-border shadow-card border px-3.5 py-2.5">
<dt class="font-medium text-sm text-text-secondary">{{ title }}</dt>
<dd class="text-xl text-text-primary pt-1 font-medium">
{{ value ?? '--' }}
<slot>{{ value ?? '--' }}</slot>
</dd>
</div>
</template>

View File

@@ -1,4 +1,6 @@
<script setup lang="ts">
import UpgradeLockedValue from '@/packages/ui/src/UpgradeLockedValue.vue';
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
import { use } from 'echarts/core';
import { CanvasRenderer } from 'echarts/renderers';
import { BarChart } from 'echarts/charts';
@@ -101,6 +103,8 @@ const { data: totalWeeklyBillableTime } = useQuery({
staleTime: 1000 * 30, // 30 seconds
});
const { locked: billableRatesLocked } = useBillableRatesLock();
const { data: totalWeeklyBillableAmount } = useQuery({
queryKey: ['totalWeeklyBillableAmount', organizationId],
queryFn: () => {
@@ -110,7 +114,7 @@ const { data: totalWeeklyBillableAmount } = useQuery({
},
});
},
enabled: computed(() => !!organizationId.value),
enabled: computed(() => !!organizationId.value && !billableRatesLocked.value),
staleTime: 1000 * 30, // 30 seconds
});
@@ -270,7 +274,11 @@ const option = computed(() => {
)
: '--'
" />
<StatCard v-if="billableRatesLocked" title="Billable Amount">
<UpgradeLockedValue />
</StatCard>
<StatCard
v-else
title="Billable Amount"
:value="
totalWeeklyBillableAmount

View File

@@ -1,4 +1,6 @@
<script setup lang="ts">
import UpgradeLockedValue from '@/packages/ui/src/UpgradeLockedValue.vue';
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
import ReportingRow from '@/Components/Common/Reporting/ReportingRow.vue';
import ReportingGroupBySelect from '@/Components/Common/Reporting/ReportingGroupBySelect.vue';
import {
@@ -126,6 +128,8 @@ const showBillableRate = computed(() => {
getCurrentRole() !== 'employee' || organization?.value?.employees_can_see_billable_rates
);
});
const { locked: billableRatesLocked } = useBillableRatesLock();
</script>
<template>
@@ -190,17 +194,20 @@ const showBillableRate = computed(() => {
<div
v-if="showBillableRate"
class="justify-end pr-6 flex items-center font-medium">
{{
aggregatedTableTimeEntries.cost
? formatCents(
aggregatedTableTimeEntries.cost,
getOrganizationCurrencyString(),
organization?.currency_format,
organization?.currency_symbol,
organization?.number_format
)
: '--'
}}
<UpgradeLockedValue v-if="billableRatesLocked" />
<template v-else>
{{
aggregatedTableTimeEntries.cost
? formatCents(
aggregatedTableTimeEntries.cost,
getOrganizationCurrencyString(),
organization?.currency_format,
organization?.currency_symbol,
organization?.number_format
)
: '--'
}}
</template>
</div>
</div>
</template>

View File

@@ -38,7 +38,9 @@ import {
canViewReport,
canViewTags,
} from '@/utils/permissions';
import { isBillingActivated, isInvoicingActivated } from '@/utils/billing';
import { canUseBillableRates, isBillingActivated, isInvoicingActivated } from '@/utils/billing';
import UpgradeModal from '@/packages/ui/src/UpgradeModal.vue';
import { billableRatesLockKey } from '@/packages/ui/src/utils/useBillableRatesLock';
import { upgradeCtaKey } from '@/packages/ui/src/UpgradeModal/useUpgradeCta';
import type { User } from '@/types/models';
import { ArrowsRightLeftIcon } from '@heroicons/vue/16/solid';
@@ -91,6 +93,14 @@ const { organization, isLoading: isOrganizationLoading } = useOrganizationQuery(
provide('organization', organization);
const showBillableRatesUpgradeModal = ref(false);
provide(billableRatesLockKey, {
locked: computed(() => !canUseBillableRates()),
requestUpgrade: () => {
showBillableRatesUpgradeModal.value = true;
},
});
provide(upgradeCtaKey, {
canUpgrade: computed(() => isBillingActivated() && canManageBilling()),
goToBilling: () => router.visit('/billing'),
@@ -392,4 +402,5 @@ const page = usePage<{
<NotificationContainer></NotificationContainer>
<UserTimezoneMismatchModal></UserTimezoneMismatchModal>
<CommandPaletteProvider></CommandPaletteProvider>
<UpgradeModal v-model:show="showBillableRatesUpgradeModal" feature="billable-rates" />
</template>

View File

@@ -1,4 +1,6 @@
<script setup lang="ts">
import UpgradeLockedBadge from '@/packages/ui/src/UpgradeLockedBadge.vue';
import { canUseBillableRates } from '@/utils/billing';
import MainContainer from '@/packages/ui/src/MainContainer.vue';
import AppLayout from '@/Layouts/AppLayout.vue';
import { FolderIcon, PlusIcon } from '@heroicons/vue/20/solid';
@@ -80,6 +82,9 @@ const shownTasks = computed(() => {
return task.project_id === projectId && task.is_done;
});
});
// Note: The page sits above the app layout that provides the lock, so it can not inject it
const billableRatesLocked = computed(() => !canUseBillableRates());
</script>
<template>
@@ -117,11 +122,17 @@ const shownTasks = computed(() => {
</li>
</ol>
<div class="px-4 space-x-1">
<Badge v-if="project?.billable_rate">
<UpgradeLockedBadge v-if="billableRatesLocked"
>Billable Rates</UpgradeLockedBadge
>
<Badge v-else-if="project?.billable_rate">
{{ billableRateFormatted }}
/ h
</Badge>
<Badge v-if="project?.is_billable && !project?.billable_rate">
<Badge
v-if="
project?.is_billable && !project?.billable_rate && !billableRatesLocked
">
Default Rate
</Badge>
<Badge v-if="!project?.is_billable"> Non-Billable </Badge>

View File

@@ -10,10 +10,12 @@ import { storeToRefs } from 'pinia';
import OrganizationBillableRateModal from '@/Components/Common/Organization/OrganizationBillableRateModal.vue';
import { getOrganizationCurrencyString } from '@/utils/money';
import { Checkbox } from '@/packages/ui/src';
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
const store = useOrganizationStore();
const { fetchOrganization, updateOrganization } = store;
const { organization } = storeToRefs(store);
const { locked: billableRatesLocked, requestUpgrade } = useBillableRatesLock();
const saving = ref(false);
const organizationBody = ref<UpdateOrganizationBody>({
name: '',
@@ -75,9 +77,8 @@ function checkForConfirmationModal() {
<Checkbox
v-if="organization"
id="organizationShowBillableRatesToEmployees"
v-model:checked="
organizationBody.employees_can_see_billable_rates
"></Checkbox>
v-model:checked="organizationBody.employees_can_see_billable_rates"
:disabled="billableRatesLocked"></Checkbox>
<FieldLabel for="organizationShowBillableRatesToEmployees"
>Show Billable Rates to Employees</FieldLabel
>
@@ -85,7 +86,10 @@ function checkForConfirmationModal() {
</div>
</template>
<template #actions>
<PrimaryButton @click="checkForConfirmationModal">Save</PrimaryButton>
<PrimaryButton v-if="billableRatesLocked" @click="requestUpgrade"
>Upgrade</PrimaryButton
>
<PrimaryButton v-else @click="checkForConfirmationModal">Save</PrimaryButton>
</template>
</FormSection>
</template>

View File

@@ -326,6 +326,7 @@ const IntervalFormat = z.enum([
const TimeFormat = z.enum(['12-hours', '24-hours']);
const OrganizationResource = z
.object({
can_use_billable_rates: z.boolean().optional(),
id: z.string(),
name: z.string(),
is_personal: z.boolean(),

View File

@@ -8,6 +8,8 @@ import {
NumberFieldIncrement,
NumberFieldInput,
} from '..';
import UpgradeLockedField from './UpgradeLockedField.vue';
import { useBillableRatesLock } from '../utils/useBillableRatesLock';
const props = defineProps<{
name: string;
@@ -20,6 +22,8 @@ const model = defineModel<number | null>({
default: null,
});
const { locked } = useBillableRatesLock();
const billableRateInput = ref<HTMLInputElement | null>(null);
useFocus(billableRateInput, { initialValue: props.focus });
@@ -30,7 +34,9 @@ function formatValue(modelValue: number | null) {
<template>
<div class="relative">
<UpgradeLockedField v-if="locked" />
<NumberField
v-else
:id="name"
ref="billableRateInput"
:model-value="formatValue(model)"

View File

@@ -2,6 +2,7 @@
import { computed } from 'vue';
import { twMerge } from 'tailwind-merge';
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
import {
Tooltip,
TooltipContent,
@@ -10,7 +11,13 @@ import {
} from '@/packages/ui/src/tooltip';
const active = defineModel<boolean>({ default: false });
const emit = defineEmits(['changed']);
const { locked: billableRatesLocked, requestUpgrade } = useBillableRatesLock();
function toggleBillable() {
// Organizations with locked billable rates can remove the billable flag, but not set it
if (billableRatesLocked.value && !active.value) {
requestUpgrade();
return;
}
active.value = !active.value;
emit('changed', active.value);
}
@@ -26,7 +33,12 @@ const props = withDefaults(
}
);
const tooltipLabel = computed(() => (active.value ? 'Billable' : 'Non Billable'));
const tooltipLabel = computed(() => {
if (billableRatesLocked.value && !active.value) {
return 'Billable rates require an upgrade';
}
return active.value ? 'Billable' : 'Non Billable';
});
const iconColorClasses = computed(() => {
if (active.value) {

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
import { LockClosedIcon } from '@heroicons/vue/20/solid';
import { useBillableRatesLock } from '../utils/useBillableRatesLock';
const { requestUpgrade } = useBillableRatesLock();
</script>
<template>
<button
type="button"
data-testid="billable_rates_locked"
class="flex h-9 w-full items-center justify-between gap-2 rounded-md border border-input-border bg-input-background px-3 text-left text-sm text-text-tertiary shadow-sm transition hover:border-border-tertiary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
@click.prevent.stop="requestUpgrade">
<span class="flex min-w-0 items-center gap-2">
<LockClosedIcon class="w-3.5 shrink-0 text-icon-default" />
<span class="truncate"><slot>Billable rates</slot></span>
</span>
<span
class="shrink-0 rounded border border-border-secondary bg-secondary px-2 py-0.5 text-xs font-semibold text-text-secondary">
Upgrade
</span>
</button>
</template>

View File

@@ -10,21 +10,27 @@ import {
} from '@/packages/ui/src/tooltip';
import { computed, onMounted, ref, watch } from 'vue';
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
import { useBillableRatesLock } from '../utils/useBillableRatesLock';
const props = defineProps<{
currency: string;
organizationBillableRate: number | null;
}>();
const { locked: billableRatesLocked } = useBillableRatesLock();
type RateType = 'default-rate' | 'custom-rate';
const billableDefault = ref<'billable' | 'non-billable'>('non-billable');
const rateType = ref<RateType>('default-rate');
const originallyBillable = ref(false);
const billableFlagLocked = computed(() => billableRatesLocked.value && !originallyBillable.value);
const billableRate = defineModel<number | null>('billableRate');
const isBillable = defineModel<boolean>('isBillable');
onMounted(() => {
originallyBillable.value = isBillable.value === true;
if (isBillable.value === true) {
billableDefault.value = 'billable';
rateType.value = billableRate.value ? 'custom-rate' : 'default-rate';
@@ -66,6 +72,9 @@ const displayedRate = computed({
});
const billableDescription = computed(() => {
if (billableFlagLocked.value) {
return 'Your organization’s plan does not allow enabling billable projects.';
}
if (billableDefault.value === 'non-billable') {
return 'New time entries for this project will not be marked billable by default.';
}
@@ -84,14 +93,19 @@ const emit = defineEmits(['submit']);
</SelectTrigger>
<SelectContent>
<SelectItem value="non-billable">Non-billable</SelectItem>
<SelectItem value="billable">Billable</SelectItem>
<SelectItem value="billable" :disabled="billableFlagLocked">Billable</SelectItem>
</SelectContent>
</Select>
<FieldDescription>{{ billableDescription }}</FieldDescription>
</Field>
<Field>
<FieldLabel :icon="BillableIcon" for="billableRateType">Billable Rate</FieldLabel>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<BillableRateInput
v-if="billableRatesLocked"
v-model="displayedRate"
:currency="currency"
name="billableRate" />
<div v-else class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<Select v-model="rateType">
<SelectTrigger id="billableRateType">
<SelectValue />

View File

@@ -16,12 +16,14 @@ import type {
} from '@/packages/api/src';
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '..';
import { Button } from '@/packages/ui/src/Buttons';
import TimeRangeFields from '@/packages/ui/src/TimeEntry/TimeRangeFields.vue';
import type { Tag, Task } from '@/packages/api/src';
const show = defineModel('show', { default: false });
const { locked: billableRatesLocked } = useBillableRatesLock();
const saving = ref(false);
const props = defineProps<{
@@ -189,7 +191,9 @@ const billableProxy = computed({
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="true">Billable</SelectItem>
<SelectItem value="true" :disabled="billableRatesLocked"
>Billable</SelectItem
>
<SelectItem value="false">Non Billable</SelectItem>
</SelectContent>
</Select>

View File

@@ -17,6 +17,7 @@ import type {
} from '@/packages/api/src';
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '..';
import { Button } from '@/packages/ui/src/Buttons';
import DatePicker from '@/packages/ui/src/Input/DatePicker.vue';
@@ -64,6 +65,7 @@ watch(show, (value) => {
});
const editableTimeEntry = ref<TimeEntry | null>(null);
const { locked: billableRatesLocked } = useBillableRatesLock();
watch(
() => props.timeEntry,
@@ -134,6 +136,11 @@ async function deleteEntry() {
}
}
// Organizations with locked billable rates can not make a non-billable time entry billable
const billableOptionLocked = computed(
() => billableRatesLocked.value && !props.timeEntry?.billable
);
const billableProxy = computed({
get: () =>
editableTimeEntry.value ? (editableTimeEntry.value.billable ? 'true' : 'false') : 'false',
@@ -245,7 +252,9 @@ const typeProxy = computed({
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="true">Billable</SelectItem>
<SelectItem value="true" :disabled="billableOptionLocked"
>Billable</SelectItem
>
<SelectItem value="false">Non Billable</SelectItem>
</SelectContent>
</Select>

View File

@@ -20,9 +20,12 @@ import { XMarkIcon } from '@heroicons/vue/16/solid';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '..';
import { Button } from '@/packages/ui/src/Buttons';
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
import type { Tag, Task } from '@/packages/api/src';
const show = defineModel('show', { default: false });
// Organizations with locked billable rates can remove the billable flag, but not set it
const { locked: billableRatesLocked } = useBillableRatesLock();
const saving = ref(false);
const props = defineProps<{
@@ -263,7 +266,9 @@ const showBreakWarning = computed(
<SelectItem value="do-not-update">
Keep current billable status
</SelectItem>
<SelectItem value="billable">Billable</SelectItem>
<SelectItem value="billable" :disabled="billableRatesLocked"
>Billable</SelectItem
>
<SelectItem value="non-billable">Non Billable</SelectItem>
</SelectContent>
</Select>

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
import { LockClosedIcon } from '@heroicons/vue/20/solid';
import { useBillableRatesLock } from './utils/useBillableRatesLock';
const { requestUpgrade } = useBillableRatesLock();
</script>
<template>
<button
type="button"
data-testid="billable_rates_locked_badge"
class="inline-flex items-center space-x-1 rounded border border-border-secondary bg-secondary px-2 py-1 hover:border-border-tertiary hover:bg-tertiary"
@click.prevent.stop="requestUpgrade">
<LockClosedIcon class="w-3 text-text-tertiary"></LockClosedIcon>
<span class="text-xs font-semibold text-text-secondary"><slot>Upgrade</slot></span>
</button>
</template>

View File

@@ -0,0 +1,22 @@
<script setup lang="ts">
import { LockClosedIcon } from '@heroicons/vue/20/solid';
import { Button } from './Buttons';
import { useBillableRatesLock } from './utils/useBillableRatesLock';
const { requestUpgrade } = useBillableRatesLock();
</script>
<template>
<span class="inline-flex items-center gap-1">
--
<Button
variant="ghost"
size="icon"
class="h-6 w-6 text-text-tertiary hover:text-text-primary"
aria-label="Upgrade to unlock billable rates"
data-testid="billable_rates_locked_button"
@click.prevent.stop="requestUpgrade">
<LockClosedIcon />
</Button>
</span>
</template>

View File

@@ -21,6 +21,8 @@ export type { CalendarSettings } from './FullCalendar/calendarSettings';
export type { ActivityPeriod } from './FullCalendar/activityTypes';
export { cn } from './utils/cn';
export { useCssVariable } from './utils/useCssVariable';
export { billableRatesLockKey, useBillableRatesLock } from './utils/useBillableRatesLock';
export type { BillableRatesLock } from './utils/useBillableRatesLock';
export { upgradeCtaKey, useUpgradeCta } from './UpgradeModal/useUpgradeCta';
export type { UpgradeCta, UpgradeFeature } from './UpgradeModal/useUpgradeCta';
@@ -165,6 +167,8 @@ import TabBar from './TabBar/TabBar.vue';
import TabBarItem from './TabBar/TabBarItem.vue';
import TimezoneMismatchModal from './TimezoneMismatchModal.vue';
import UpgradeBadge from './UpgradeBadge.vue';
import UpgradeLockedBadge from './UpgradeLockedBadge.vue';
import UpgradeLockedValue from './UpgradeLockedValue.vue';
import UpgradeModal from './UpgradeModal.vue';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './tooltip/index';
@@ -310,5 +314,7 @@ export {
TooltipProvider,
TooltipTrigger,
UpgradeBadge,
UpgradeLockedBadge,
UpgradeLockedValue,
UpgradeModal,
};

View File

@@ -0,0 +1,22 @@
import { computed, inject, type ComputedRef, type InjectionKey } from 'vue';
export interface BillableRatesLock {
locked: ComputedRef<boolean>;
requestUpgrade: () => void;
}
export const billableRatesLockKey: InjectionKey<BillableRatesLock> = Symbol('billableRatesLock');
/**
* Whether billable rates are locked behind a plan upgrade for the current organization.
*
* The app layout provides the lock state and opens the upgrade dialog. Without a
* provider (e.g. public report views or other consumers of this package) billable
* rates count as unlocked.
*/
export function useBillableRatesLock(): BillableRatesLock {
return inject(billableRatesLockKey, {
locked: computed(() => false),
requestUpgrade: () => {},
});
}

View File

@@ -98,3 +98,13 @@ export function isAllowedToPerformPremiumAction() {
(isBillingActivated() && isInTrial())
);
}
export function canUseBillableRates() {
const page = usePage<{
billing: {
can_use_billable_rates?: boolean;
} | null;
}>();
return !isBillingActivated() || page.props.billing?.can_use_billable_rates !== false;
}

View File

@@ -90,6 +90,7 @@ abstract class TestCase extends BaseTestCase
$mock->shouldReceive('getTrialUntil')->andReturn(null);
$mock->shouldReceive('getGoalLimit')->andReturn(null);
$mock->shouldReceive('isBlocked')->andReturn(false);
$mock->shouldReceive('canUseBillableRates')->andReturn(true);
});
}
@@ -102,6 +103,18 @@ abstract class TestCase extends BaseTestCase
// Core has no goal limit, the limit of the free plan is implemented in the billing extension
$mock->shouldReceive('getGoalLimit')->andReturn(null);
$mock->shouldReceive('isBlocked')->andReturn(false);
$mock->shouldReceive('canUseBillableRates')->andReturn(true);
});
}
protected function actAsOrganizationWithoutBillableRates(): void
{
$this->mock(BillingContract::class, function (MockInterface $mock): void {
$mock->shouldReceive('hasSubscription')->andReturn(false);
$mock->shouldReceive('hasTrial')->andReturn(false);
$mock->shouldReceive('getTrialUntil')->andReturn(null);
$mock->shouldReceive('isBlocked')->andReturn(false);
$mock->shouldReceive('canUseBillableRates')->andReturn(false);
});
}
}

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;
@@ -102,6 +104,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