mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-10-13 16:23:18 +01:00
add billable rate restriction for free organizations
This commit is contained in:
@@ -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.');
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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>
|
||||
*/
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
*/
|
||||
|
||||
@@ -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>
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ?? '') }}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
23
resources/js/packages/ui/src/Input/UpgradeLockedField.vue
Normal file
23
resources/js/packages/ui/src/Input/UpgradeLockedField.vue
Normal 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>
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
17
resources/js/packages/ui/src/UpgradeLockedBadge.vue
Normal file
17
resources/js/packages/ui/src/UpgradeLockedBadge.vue
Normal 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>
|
||||
22
resources/js/packages/ui/src/UpgradeLockedValue.vue
Normal file
22
resources/js/packages/ui/src/UpgradeLockedValue.vue
Normal 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>
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
22
resources/js/packages/ui/src/utils/useBillableRatesLock.ts
Normal file
22
resources/js/packages/ui/src/utils/useBillableRatesLock.ts
Normal 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: () => {},
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
161
tests/Unit/Model/BillableRateRestrictionTest.php
Normal file
161
tests/Unit/Model/BillableRateRestrictionTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user