Compare commits

..

1 Commits

Author SHA1 Message Date
Gregor Vostrak
e793b81b35 add billable rate restriction interfaces for billing extension 2026-10-06 17:20:33 +02:00
38 changed files with 882 additions and 211 deletions

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Enums\Role;
use App\Models\Organization;
use App\Service\DashboardService;
use App\Service\PermissionStore;
@@ -159,7 +158,7 @@ class ChartController extends Controller
$this->checkPermission($organization, 'charts:view:own');
$user = $this->user();
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
$showBillableRate = $this->canSeeBillableRates($organization);
if (! $showBillableRate) {
throw new AuthorizationException('You do not have permission to view billable rates.');
}

View File

@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Enums\Role;
use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException;
use App\Models\Organization;
use App\Service\BillingContract;
use App\Service\PermissionStore;
@@ -49,4 +51,30 @@ class Controller extends \App\Http\Controllers\Controller
{
return app(BillingContract::class)->hasSubscription($organization) || app(BillingContract::class)->hasTrial($organization);
}
protected function canUseBillableRates(Organization $organization): bool
{
return app(BillingContract::class)->canUseBillableRates($organization);
}
protected function canSeeBillableRates(Organization $organization): bool
{
if (! $this->canUseBillableRates($organization)) {
return false;
}
return $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
}
/**
* Organizations that can not use billable rates may keep or remove an existing rate, but not set a new one
*
* @throws FeatureIsNotAvailableInFreePlanApiException
*/
protected function checkBillableRateChange(Organization $organization, ?int $oldBillableRate, ?int $newBillableRate): void
{
if ($newBillableRate !== null && $newBillableRate !== $oldBillableRate && ! $this->canUseBillableRates($organization)) {
throw new FeatureIsNotAvailableInFreePlanApiException;
}
}
}

View File

@@ -83,6 +83,7 @@ class MemberController extends Controller
$this->checkPermission($organization, 'members:update', $member);
if ($request->has('billable_rate') && $member->billable_rate !== $request->getBillableRate()) {
$this->checkBillableRateChange($organization, $member->billable_rate, $request->getBillableRate());
$member->billable_rate = $request->getBillableRate();
$billableRateService->updateTimeEntriesBillableRateForMember($member);

View File

@@ -4,8 +4,8 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Enums\Role;
use App\Events\AfterCreateOrganization;
use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException;
use App\Http\Requests\V1\Organization\OrganizationDestroyRequest;
use App\Http\Requests\V1\Organization\OrganizationStoreRequest;
use App\Http\Requests\V1\Organization\OrganizationUpdateRequest;
@@ -32,7 +32,7 @@ class OrganizationController extends Controller
{
$this->checkPermission($organization, 'organizations:view');
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
$showBillableRate = $this->canSeeBillableRates($organization);
return new OrganizationResource($organization, $showBillableRate);
}
@@ -54,7 +54,10 @@ class OrganizationController extends Controller
if ($request->getCurrency() !== null) {
$organization->currency = $request->getCurrency();
}
if ($request->getEmployeesCanSeeBillableRates() !== null) {
if ($request->getEmployeesCanSeeBillableRates() !== null && $request->getEmployeesCanSeeBillableRates() !== $organization->employees_can_see_billable_rates) {
if (! $this->canUseBillableRates($organization)) {
throw new FeatureIsNotAvailableInFreePlanApiException;
}
$organization->employees_can_see_billable_rates = $request->getEmployeesCanSeeBillableRates();
}
if ($request->getEmployeesCanManageTasks() !== null) {
@@ -83,6 +86,7 @@ class OrganizationController extends Controller
}
$hasBillableRate = $request->has('billable_rate');
if ($hasBillableRate) {
$this->checkBillableRateChange($organization, $organization->billable_rate, $request->getBillableRate());
$oldBillableRate = $organization->billable_rate;
$organization->billable_rate = $request->getBillableRate();
}

View File

@@ -65,7 +65,7 @@ class ProjectController extends Controller
->orderBy('id')
->paginate(config('app.pagination_per_page_default'));
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
$showBillableRate = $this->canSeeBillableRates($organization);
return new ProjectCollection($projects, $showBillableRate);
}
@@ -99,6 +99,7 @@ class ProjectController extends Controller
public function store(Organization $organization, ProjectStoreRequest $request): JsonResource
{
$this->checkPermission($organization, 'projects:create');
$this->checkBillableRateChange($organization, null, $request->getBillableRate());
$project = new Project;
$project->name = $request->input('name');
$project->color = $request->input('color');
@@ -125,6 +126,7 @@ class ProjectController extends Controller
public function update(Organization $organization, Project $project, ProjectUpdateRequest $request, BillableRateService $billableRateService): JsonResource
{
$this->checkPermission($organization, 'projects:update', $project);
$this->checkBillableRateChange($organization, $project->billable_rate, $request->getBillableRate());
$project->name = $request->input('name');
$project->color = $request->input('color');
$project->is_billable = (bool) $request->input('is_billable');
@@ -139,14 +141,17 @@ class ProjectController extends Controller
}
$oldBillableRate = $project->billable_rate;
$clientIdChanged = false;
$project->billable_rate = $request->getBillableRate();
// Organizations that can not use billable rates do not receive the rate of the project, so the incoming value is ignored to keep the existing rate
if ($this->canUseBillableRates($organization)) {
$project->billable_rate = $request->getBillableRate();
}
if ($project->client_id !== $request->input('client_id')) {
$project->client_id = $request->input('client_id');
$clientIdChanged = true;
}
$project->save();
if ($oldBillableRate !== $request->getBillableRate()) {
if ($oldBillableRate !== $project->billable_rate) {
$billableRateService->updateTimeEntriesBillableRateForProject($project);
}
if ($clientIdChanged) {

View File

@@ -65,6 +65,7 @@ class ProjectMemberController extends Controller
public function store(Organization $organization, Project $project, ProjectMemberStoreRequest $request, BillableRateService $billableRateService): JsonResource
{
$this->checkPermission($organization, 'project-members:create', $project);
$this->checkBillableRateChange($organization, null, $request->getBillableRate());
$member = Member::findOrFail((string) $request->input('member_id'));
if ($member->user->is_placeholder) {
@@ -98,6 +99,7 @@ class ProjectMemberController extends Controller
public function update(Organization $organization, ProjectMember $projectMember, ProjectMemberUpdateRequest $request, BillableRateService $billableRateService): JsonResource
{
$this->checkPermission($organization, 'project-members:update', projectMember: $projectMember);
$this->checkBillableRateChange($organization, $projectMember->billable_rate, $request->getBillableRate());
$oldBillableRate = $projectMember->billable_rate;
$projectMember->billable_rate = $request->getBillableRate();
$projectMember->save();

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Enums\ExportFormat;
use App\Enums\Role;
use App\Enums\TimeEntryType;
use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException;
use App\Exceptions\Api\OverlappingTimeEntryApiException;
@@ -240,7 +239,7 @@ class TimeEntryController extends Controller
}
$user = $this->user();
$timezone = $user->timezone;
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
$showBillableRate = $this->canSeeBillableRates($organization);
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
@@ -386,7 +385,7 @@ class TimeEntryController extends Controller
}
$canAccessPremiumFeatures = $this->canAccessPremiumFeatures($organization);
$user = $this->user();
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
$showBillableRate = $this->canSeeBillableRates($organization);
$group1Type = $request->getGroup();
$group2Type = $request->getSubGroup();
@@ -441,7 +440,7 @@ class TimeEntryController extends Controller
}
$debug = $request->getDebug();
$user = $this->user();
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
$showBillableRate = $this->canSeeBillableRates($organization);
$group = $request->getGroup();
$subGroup = $request->getSubGroup();

View File

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

View File

@@ -54,4 +54,13 @@ class BillingContract
{
return false;
}
/**
* Check if the organization can set and see billable rates
* Without a billing system every organization can use billable rates
*/
public function canUseBillableRates(Organization $organization): bool
{
return true;
}
}

View File

@@ -14,6 +14,7 @@ use App\Models\Tag;
use App\Models\Task;
use App\Models\User;
use App\Service\BillableRateService;
use App\Service\BillingContract;
use App\Service\ColorService;
use App\Service\Import\ImportDatabaseHelper;
use App\Service\TimezoneService;
@@ -71,9 +72,15 @@ abstract class DefaultImporter implements ImporterContract
protected BillableRateService $billableRateService;
/**
* Organizations that can not use billable rates import projects, members and project members without billable rates
*/
protected bool $canUseBillableRates;
public function init(Organization $organization): void
{
$this->organization = $organization;
$this->canUseBillableRates = app(BillingContract::class)->canUseBillableRates($organization);
$this->userImportHelper = new ImportDatabaseHelper(User::class, ['email'], true, function (Builder $builder) {
/** @var Builder<User> $builder */
return $builder->belongsToOrganization($this->organization);
@@ -96,7 +103,11 @@ abstract class DefaultImporter implements ImporterContract
'string',
'in:placeholder',
],
]);
], beforeSave: function (Member $member): void {
if (! $this->canUseBillableRates) {
$member->billable_rate = null;
}
});
$this->projectImportHelper = new ImportDatabaseHelper(Project::class, ['name', 'client_id', 'organization_id'], true, function (Builder $builder) {
/** @var Builder<Project> $builder */
return $builder->where('organization_id', $this->organization->id);
@@ -120,7 +131,7 @@ abstract class DefaultImporter implements ImporterContract
'uuid',
],
], beforeSave: function (Project $project): void {
if ($project->billable_rate === 0) {
if ($project->billable_rate === 0 || ! $this->canUseBillableRates) {
$project->billable_rate = null;
}
});
@@ -134,7 +145,7 @@ abstract class DefaultImporter implements ImporterContract
'max:2147483647',
],
], beforeSave: function (ProjectMember $projectMember): void {
if ($projectMember->billable_rate === 0) {
if ($projectMember->billable_rate === 0 || ! $this->canUseBillableRates) {
$projectMember->billable_rate = null;
}
});

View File

@@ -53,47 +53,6 @@ function todayAt(hour: number, minute: number = 0): string {
return d.toISOString().replace(/\.\d{3}Z$/, 'Z');
}
// Cross-day tests anchor to Wednesday and Thursday of the current week instead of today and
// tomorrow. E2E users start the week on Monday, so both days are always in the visible week,
// whichever day the suite runs on.
function wednesdayAt(hour: number, minute: number = 0): Date {
const now = new Date();
const daysSinceMonday = (now.getDay() + 6) % 7;
return new Date(
now.getFullYear(),
now.getMonth(),
now.getDate() - daysSinceMonday + 2,
hour,
minute,
0,
0
);
}
function thursdayAt(hour: number, minute: number = 0): Date {
const d = wednesdayAt(hour, minute);
d.setDate(d.getDate() + 1);
return d;
}
function toApiTimestamp(date: Date): string {
return date.toISOString().replace(/\.\d{3}Z$/, 'Z');
}
// Local calendar date (YYYY-MM-DD), matching FullCalendar's data-date attributes
function toDateStr(date: Date): string {
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${date.getFullYear()}-${month}-${day}`;
}
async function columnHeaderCenterX(page: Page, date: Date): Promise<number> {
const header = page.locator(`.fc-col-header-cell[data-date="${toDateStr(date)}"]`);
await expect(header).toBeVisible();
const box = await header.boundingBox();
return box!.x + box!.width / 2;
}
/**
* These tests verify that changing the project on a time entry via the calendar
* updates the billable status to match the new project's is_billable setting.
@@ -1003,9 +962,14 @@ test.describe('Drag-to-Move Events', () => {
page,
ctx,
}) => {
// Create entry: Thursday 00:30 → Thursday 01:30 (1 hour, near midnight)
const start = toApiTimestamp(thursdayAt(0, 30));
const end = toApiTimestamp(thursdayAt(1, 30));
const now = new Date();
const dayOfWeek = now.getDay();
// Need today to have a previous day visible in the week view (skip Sunday with Monday week start)
test.skip(dayOfWeek === 1, 'Skipping on Monday — previous day not visible in week view');
// Create entry: today 00:30 → today 01:30 (1 hour, near midnight)
const start = todayAt(0, 30);
const end = todayAt(1, 30);
await createTimeEntryWithTimestampsViaApi(ctx, {
description: 'Drag up past midnight test',
start,
@@ -1014,10 +978,11 @@ test.describe('Drag-to-Move Events', () => {
await goToCalendar(page);
await scrollCalendarToTime(page, '00:00:00');
const thursdayCol = page.locator(
`.fc-timegrid-col[data-date="${toDateStr(thursdayAt(0))}"]`
);
const event = thursdayCol
const todayStr = new Date(now.getFullYear(), now.getMonth(), now.getDate())
.toISOString()
.split('T')[0];
const todayCol = page.locator(`.fc-timegrid-col[data-date="${todayStr}"]`);
const event = todayCol
.locator('.fc-event')
.filter({ hasText: 'Drag up past midnight test' });
await expect(event).toBeVisible({ timeout: 10000 });
@@ -1053,7 +1018,10 @@ test.describe('Drag-to-Move Events', () => {
expect(newDurationMs).toBe(3600000);
// The event should have moved to the previous day
expect(toDateStr(newStart)).toBe(toDateStr(wednesdayAt(0)));
const yesterdayStr = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1)
.toISOString()
.split('T')[0];
expect(newStart.toISOString().split('T')[0]).toBe(yesterdayStr);
});
});
@@ -1293,8 +1261,8 @@ test.describe('Resize Events', () => {
});
test('resize bottom edge across day boundary changes end date', async ({ page, ctx }) => {
const start = toApiTimestamp(wednesdayAt(10));
const end = toApiTimestamp(wednesdayAt(11));
const start = todayAt(10);
const end = todayAt(11);
await createTimeEntryWithTimestampsViaApi(ctx, {
description: 'Cross resize end test',
start,
@@ -1303,8 +1271,26 @@ test.describe('Resize Events', () => {
await goToCalendar(page);
await scrollCalendarToTime(page, '09:00:00');
// Resize into Thursday's column (for end resize, end must be > start)
const targetX = await columnHeaderCenterX(page, thursdayAt(0));
// Find a column AFTER today (for end resize, end must be > start)
const headers = page.locator('.fc-col-header-cell');
const headerCount = await headers.count();
let targetX: number | undefined;
let todayIndex = -1;
for (let i = 0; i < headerCount; i++) {
const header = headers.nth(i);
const isToday = await header.evaluate((el) => el.classList.contains('fc-day-today'));
if (isToday) {
todayIndex = i;
break;
}
}
// Pick first column after today, or skip if today is last
for (let i = todayIndex + 1; i < headerCount; i++) {
const box = await headers.nth(i).boundingBox();
targetX = box!.x + box!.width / 2;
break;
}
test.skip(targetX === undefined, 'No column after today to resize to');
const event = page
.locator('.fc-event')
@@ -1332,7 +1318,7 @@ test.describe('Resize Events', () => {
// First drag down vertically to engage resize (like test 3.1)
await page.mouse.move(centerX, bottomY + slotHeight * 4, { steps: 15 });
// Then move horizontally to a later day column
await page.mouse.move(targetX, bottomY + slotHeight * 4, { steps: 10 });
await page.mouse.move(targetX!, bottomY + slotHeight * 4, { steps: 10 });
await page.mouse.up();
})(),
]);
@@ -1354,8 +1340,8 @@ test.describe('Resize Events', () => {
page,
ctx,
}) => {
const start = toApiTimestamp(wednesdayAt(10));
const end = toApiTimestamp(wednesdayAt(14));
const start = todayAt(10);
const end = todayAt(14);
await createTimeEntryWithTimestampsViaApi(ctx, {
description: 'Cross resize end test',
start,
@@ -1364,8 +1350,30 @@ test.describe('Resize Events', () => {
await goToCalendar(page);
await scrollCalendarToTime(page, '09:00:00');
// Resize into Thursday's column (later day needed for end-edge resize)
const targetX = await columnHeaderCenterX(page, thursdayAt(0));
// Find a non-today column header that is AFTER today (later day needed for end-edge resize)
const headers = page.locator('.fc-col-header-cell');
const headerCount = await headers.count();
let targetX: number | undefined;
let foundToday = false;
for (let i = 0; i < headerCount; i++) {
const header = headers.nth(i);
const isToday = await header.evaluate((el) => el.classList.contains('fc-day-today'));
if (isToday) {
foundToday = true;
continue;
}
if (foundToday) {
const box = await header.boundingBox();
targetX = box!.x + box!.width / 2;
break;
}
}
// If today is the last column, use the one before today instead won't work for end resize,
// so skip this test in that edge case
if (targetX === undefined) {
test.skip();
return;
}
const event = page
.locator('.fc-event')
@@ -1390,7 +1398,7 @@ test.describe('Resize Events', () => {
await page.waitForTimeout(100);
await page.mouse.down();
// Move to a different day column at same Y position
await page.mouse.move(targetX, bottomY - 3, { steps: 15 });
await page.mouse.move(targetX!, bottomY - 3, { steps: 15 });
await page.mouse.up();
})(),
]);
@@ -1456,9 +1464,22 @@ test.describe('Resize Events', () => {
});
test('multi-day event end resize on last day works correctly', async ({ page, ctx }) => {
// Create entry spanning Wednesday evening → Thursday morning
const start = toApiTimestamp(wednesdayAt(20));
const end = toApiTimestamp(thursdayAt(10));
// Create entry spanning today evening → tomorrow morning
const start = todayAt(20);
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const tomorrowStr = `${tomorrow.getFullYear()}-${String(tomorrow.getMonth() + 1).padStart(2, '0')}-${String(tomorrow.getDate()).padStart(2, '0')}`;
const end = new Date(
tomorrow.getFullYear(),
tomorrow.getMonth(),
tomorrow.getDate(),
10,
0,
0,
0
)
.toISOString()
.replace(/\.\d{3}Z$/, 'Z');
await createTimeEntryWithTimestampsViaApi(ctx, {
description: 'Multi-day end resize',
@@ -1467,14 +1488,14 @@ test.describe('Resize Events', () => {
});
await goToCalendar(page);
const thursdayCol = page.locator(
`.fc-timegrid-col[data-date="${toDateStr(thursdayAt(0))}"]`
);
// Check if tomorrow column is visible
const tomorrowCol = page.locator(`.fc-timegrid-col[data-date="${tomorrowStr}"]`);
test.skip((await tomorrowCol.count()) === 0, 'Tomorrow not visible in current view');
await scrollCalendarToTime(page, '09:00:00');
// Find the event segment on Thursday's column
const event = thursdayCol
// Find the event segment on tomorrow's column
const event = tomorrowCol
.locator('.fc-event')
.filter({ hasText: 'Multi-day end resize' })
.first();
@@ -1520,9 +1541,22 @@ test.describe('Resize Events', () => {
page,
ctx,
}) => {
// Create entry spanning Wednesday → Thursday
const start = toApiTimestamp(wednesdayAt(10));
const end = toApiTimestamp(thursdayAt(14));
// Create entry spanning today → tomorrow
const start = todayAt(10);
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const tomorrowStr = `${tomorrow.getFullYear()}-${String(tomorrow.getMonth() + 1).padStart(2, '0')}-${String(tomorrow.getDate()).padStart(2, '0')}`;
const end = new Date(
tomorrow.getFullYear(),
tomorrow.getMonth(),
tomorrow.getDate(),
14,
0,
0,
0
)
.toISOString()
.replace(/\.\d{3}Z$/, 'Z');
await createTimeEntryWithTimestampsViaApi(ctx, {
description: 'Backward end resize multi',
@@ -1531,16 +1565,29 @@ test.describe('Resize Events', () => {
});
await goToCalendar(page);
const thursdayCol = page.locator(
`.fc-timegrid-col[data-date="${toDateStr(thursdayAt(0))}"]`
);
// Check if tomorrow column is visible
const tomorrowCol = page.locator(`.fc-timegrid-col[data-date="${tomorrowStr}"]`);
test.skip((await tomorrowCol.count()) === 0, 'Tomorrow not visible in current view');
await scrollCalendarToTime(page, '12:00:00');
const wednesdayX = await columnHeaderCenterX(page, wednesdayAt(0));
// Find today's column header to get its X center
const headers = page.locator('.fc-col-header-cell');
const headerCount = await headers.count();
let todayX: number | undefined;
for (let i = 0; i < headerCount; i++) {
const header = headers.nth(i);
const isToday = await header.evaluate((el) => el.classList.contains('fc-day-today'));
if (isToday) {
const box = await header.boundingBox();
todayX = box!.x + box!.width / 2;
break;
}
}
test.skip(todayX === undefined, 'Could not find today column header');
// Find event segment on Thursday's column and resize end backward to Wednesday
const event = thursdayCol
// Find event segment on tomorrow's column and resize end backward to today
const event = tomorrowCol
.locator('.fc-event')
.filter({ hasText: 'Backward end resize multi' })
.first();
@@ -1562,9 +1609,9 @@ test.describe('Resize Events', () => {
await page.mouse.move(centerX, bottomY - 3);
await page.waitForTimeout(100);
await page.mouse.down();
// Drag down a bit first, then move to Wednesday's column at a Y after the start
// Drag down a bit first, then move to today's column at a Y after the start
await page.mouse.move(centerX, bottomY + slotHeight, { steps: 5 });
await page.mouse.move(wednesdayX, bottomY + slotHeight, { steps: 10 });
await page.mouse.move(todayX!, bottomY + slotHeight, { steps: 10 });
await page.mouse.up();
})(),
]);
@@ -1580,8 +1627,8 @@ test.describe('Resize Events', () => {
});
test('resize end to earlier column prevents end before start', async ({ page, ctx }) => {
const start = toApiTimestamp(thursdayAt(10));
const end = toApiTimestamp(thursdayAt(14));
const start = todayAt(10);
const end = todayAt(14);
await createTimeEntryWithTimestampsViaApi(ctx, {
description: 'End before start test',
start,
@@ -1590,8 +1637,25 @@ test.describe('Resize Events', () => {
await goToCalendar(page);
await scrollCalendarToTime(page, '09:00:00');
// Target Wednesday's column, the day before the entry
const targetX = await columnHeaderCenterX(page, wednesdayAt(0));
// Find a column BEFORE today
const headers = page.locator('.fc-col-header-cell');
const headerCount = await headers.count();
let targetX: number | undefined;
let todayIndex = -1;
for (let i = 0; i < headerCount; i++) {
const header = headers.nth(i);
const isToday = await header.evaluate((el) => el.classList.contains('fc-day-today'));
if (isToday) {
todayIndex = i;
break;
}
}
for (let i = todayIndex - 1; i >= 0; i--) {
const box = await headers.nth(i).boundingBox();
targetX = box!.x + box!.width / 2;
break;
}
test.skip(targetX === undefined, 'No column before today to test');
const event = page
.locator('.fc-event')
@@ -1619,7 +1683,7 @@ test.describe('Resize Events', () => {
await page.waitForTimeout(100);
await page.mouse.down();
// Move to earlier column at a Y position near the top of the grid (before start time)
await page.mouse.move(targetX, eventBox!.y - slotHeight * 4, { steps: 15 });
await page.mouse.move(targetX!, eventBox!.y - slotHeight * 4, { steps: 15 });
await page.mouse.up();
// Wait for any potential API call
@@ -1668,21 +1732,30 @@ test.describe('Click-Drag Selection to Create', () => {
test('drag-to-create spanning two days opens create modal with correct cross-day times', async ({
page,
}) => {
const now = new Date();
const dayOfWeek = now.getDay();
// Need today and tomorrow both visible (skip Saturday with Monday week start)
test.skip(dayOfWeek === 6, 'Skipping on Saturday — tomorrow not visible in week view');
await goToCalendar(page);
await expect(page.locator('.fc')).toBeVisible();
// Use mid-day times so both start and end slots are visible in the viewport
await scrollCalendarToTime(page, '10:00:00');
// Find Wednesday's and Thursday's columns
const wednesdayStr = toDateStr(wednesdayAt(0));
const thursdayStr = toDateStr(thursdayAt(0));
// Find today's and tomorrow's columns
const todayStr = new Date(now.getFullYear(), now.getMonth(), now.getDate())
.toISOString()
.split('T')[0];
const tomorrowStr = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1)
.toISOString()
.split('T')[0];
const wednesdayCol = page.locator(`.fc-timegrid-col[data-date="${wednesdayStr}"]`);
const thursdayCol = page.locator(`.fc-timegrid-col[data-date="${thursdayStr}"]`);
await expect(wednesdayCol).toBeVisible();
await expect(thursdayCol).toBeVisible();
const todayCol = page.locator(`.fc-timegrid-col[data-date="${todayStr}"]`);
const tomorrowCol = page.locator(`.fc-timegrid-col[data-date="${tomorrowStr}"]`);
await expect(todayCol).toBeVisible();
await expect(tomorrowCol).toBeVisible();
// Find the 11:00 slot (start) and 13:00 slot (end on Thursday)
// Find the 11:00 slot (start) and 13:00 slot (end on tomorrow)
const startSlot = page.locator('.fc-timegrid-slot-lane[data-time="11:00:00"]').first();
const endSlot = page.locator('.fc-timegrid-slot-lane[data-time="13:00:00"]').first();
await expect(startSlot).toBeVisible();
@@ -1690,18 +1763,18 @@ test.describe('Click-Drag Selection to Create', () => {
const startSlotBox = await startSlot.boundingBox();
const endSlotBox = await endSlot.boundingBox();
const wednesdayColBox = await wednesdayCol.boundingBox();
const thursdayColBox = await thursdayCol.boundingBox();
const todayColBox = await todayCol.boundingBox();
const tomorrowColBox = await tomorrowCol.boundingBox();
// Start drag at 11:00 on Wednesday's column
const startX = wednesdayColBox!.x + wednesdayColBox!.width / 2;
// Start drag at 11:00 on today's column
const startX = todayColBox!.x + todayColBox!.width / 2;
const startY = startSlotBox!.y + 2;
// End drag at 13:00 on Thursday's column
const endX = thursdayColBox!.x + thursdayColBox!.width / 2;
// End drag at 13:00 on tomorrow's column
const endX = tomorrowColBox!.x + tomorrowColBox!.width / 2;
const endY = endSlotBox!.y + 2;
// Drag from Wednesday to Thursday, moving down first, then across
// Drag from today to tomorrow — move down first, then across
const slotHeight = await getSlotHeight(page);
await page.mouse.move(startX, startY);
await page.mouse.down();
@@ -1719,9 +1792,9 @@ test.describe('Click-Drag Selection to Create', () => {
await expect(dialog.getByText('Start')).toBeVisible();
await expect(dialog.getByText('End')).toBeVisible();
// Start date should be Wednesday, end date should be Thursday
await expect(dialog.getByText(wednesdayStr)).toBeVisible();
await expect(dialog.getByText(thursdayStr)).toBeVisible();
// Start date should be today, end date should be tomorrow
await expect(dialog.getByText(todayStr)).toBeVisible();
await expect(dialog.getByText(tomorrowStr)).toBeVisible();
});
});
@@ -1803,9 +1876,17 @@ test.describe('Timezone & Localization', () => {
test.describe('Multi-Day Events', () => {
test('event spanning 2 days renders and is visible', async ({ page, ctx }) => {
// Create entry that spans from Wednesday 22:00 to Thursday 02:00
const start = toApiTimestamp(wednesdayAt(22));
const end = toApiTimestamp(thursdayAt(2));
// Create entry that spans from today 22:00 to tomorrow 02:00
const now = new Date();
const dayOfWeek = now.getDay();
// If today is Saturday (6), the entry would span to next week and may not be visible
test.skip(dayOfWeek === 6, 'Skipping on Saturday — multi-day would span to next week');
const startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 22, 0, 0);
const endDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 2, 0, 0);
const start = startDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
const end = endDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
await createTimeEntryWithTimestampsViaApi(ctx, {
description: 'Multi day entry',
@@ -1820,8 +1901,14 @@ test.describe('Multi-Day Events', () => {
});
test('multi-day event can be edited via click', async ({ page, ctx }) => {
const start = toApiTimestamp(wednesdayAt(22));
const end = toApiTimestamp(thursdayAt(2));
const now = new Date();
test.skip(now.getDay() === 6, 'Skip on Saturday');
const startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 22, 0, 0);
const endDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 2, 0, 0);
const start = startDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
const end = endDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
await createTimeEntryWithTimestampsViaApi(ctx, {
description: 'Multi day edit test',
@@ -1840,8 +1927,14 @@ test.describe('Multi-Day Events', () => {
});
test('multi-day event context menu works', async ({ page, ctx }) => {
const start = toApiTimestamp(wednesdayAt(22));
const end = toApiTimestamp(thursdayAt(2));
const now = new Date();
test.skip(now.getDay() === 6, 'Skip on Saturday');
const startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 22, 0, 0);
const endDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 2, 0, 0);
const start = startDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
const end = endDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
await createTimeEntryWithTimestampsViaApi(ctx, {
description: 'Multi day ctx test',
@@ -1861,11 +1954,16 @@ test.describe('Multi-Day Events', () => {
page,
ctx,
}) => {
// Create entry: Wednesday 22:00 → Thursday 02:00 (4 hours, spanning 2 days)
const startDate = wednesdayAt(22);
const endDate = thursdayAt(2);
const start = toApiTimestamp(startDate);
const end = toApiTimestamp(endDate);
const now = new Date();
const dayOfWeek = now.getDay();
// Need today and tomorrow both visible (skip Saturday)
test.skip(dayOfWeek === 6, 'Skipping on Saturday — multi-day would span to next week');
// Create entry: today 22:00 → tomorrow 02:00 (4 hours, spanning 2 days)
const startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 22, 0, 0);
const endDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 2, 0, 0);
const start = startDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
const end = endDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
await createTimeEntryWithTimestampsViaApi(ctx, {
description: 'Multi day drag test',
@@ -1875,9 +1973,12 @@ test.describe('Multi-Day Events', () => {
await goToCalendar(page);
await scrollCalendarToTime(page, '00:00:00');
// Find the clipped segment on Thursday's column (00:00-02:00)
const thursdayCol = page.locator(`.fc-timegrid-col[data-date="${toDateStr(endDate)}"]`);
const event = thursdayCol.locator('.fc-event').filter({ hasText: 'Multi day drag test' });
// Find the clipped segment on tomorrow's column (00:00-02:00)
const tomorrowStr = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1)
.toISOString()
.split('T')[0];
const tomorrowCol = page.locator(`.fc-timegrid-col[data-date="${tomorrowStr}"]`);
const event = tomorrowCol.locator('.fc-event').filter({ hasText: 'Multi day drag test' });
await expect(event).toBeVisible({ timeout: 10000 });
const eventBox = await event.boundingBox();
@@ -1912,19 +2013,27 @@ test.describe('Multi-Day Events', () => {
// Duration must be preserved (4 hours)
expect(Math.abs(newDurationMs - origDurationMs)).toBeLessThan(60000);
// The start should still be on Wednesday (not jumped to Thursday)
expect(toDateStr(newStart)).toBe(toDateStr(startDate));
// The start should still be on today (not jumped to tomorrow)
const todayStr = new Date(now.getFullYear(), now.getMonth(), now.getDate())
.toISOString()
.split('T')[0];
expect(newStart.toISOString().split('T')[0]).toBe(todayStr);
});
test('dragging clipped segment of multi-day event upward shifts event earlier', async ({
page,
ctx,
}) => {
// Create entry: Wednesday 22:00 → Thursday 02:00 (4 hours, spanning 2 days)
const startDate = wednesdayAt(22);
const endDate = thursdayAt(2);
const start = toApiTimestamp(startDate);
const end = toApiTimestamp(endDate);
const now = new Date();
const dayOfWeek = now.getDay();
// Need today and tomorrow both visible (skip Saturday)
test.skip(dayOfWeek === 6, 'Skipping on Saturday — multi-day would span to next week');
// Create entry: today 22:00 → tomorrow 02:00 (4 hours, spanning 2 days)
const startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 22, 0, 0);
const endDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 2, 0, 0);
const start = startDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
const end = endDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
await createTimeEntryWithTimestampsViaApi(ctx, {
description: 'Multi day drag up test',
@@ -1934,9 +2043,12 @@ test.describe('Multi-Day Events', () => {
await goToCalendar(page);
await scrollCalendarToTime(page, '00:00:00');
// Find the clipped segment on Thursday's column (00:00-02:00)
const thursdayCol = page.locator(`.fc-timegrid-col[data-date="${toDateStr(endDate)}"]`);
const event = thursdayCol
// Find the clipped segment on tomorrow's column (00:00-02:00)
const tomorrowStr = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1)
.toISOString()
.split('T')[0];
const tomorrowCol = page.locator(`.fc-timegrid-col[data-date="${tomorrowStr}"]`);
const event = tomorrowCol
.locator('.fc-event')
.filter({ hasText: 'Multi day drag up test' });
await expect(event).toBeVisible({ timeout: 10000 });

View File

@@ -21,6 +21,7 @@ import { getOrganizationCurrencyString } from '@/utils/money';
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
import { useOrganizationQuery } from '@/utils/useOrganizationQuery';
import { getCurrentOrganizationId } from '@/utils/useUser';
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
const { updateMember } = useMembersStore();
const { organization } = useOrganizationQuery(getCurrentOrganizationId()!);
@@ -71,6 +72,8 @@ function saveWithChecks() {
}
}
const { locked: billableRatesLocked } = useBillableRatesLock();
const billableRateSelect = ref<MemberBillableKey>('default-rate');
onMounted(() => {
@@ -159,7 +162,12 @@ const roleDescription = computed(() => {
<FieldLabel :icon="BillableIcon" for="billableRateType"
>Billable Rate</FieldLabel
>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<BillableRateInput
v-if="billableRatesLocked"
v-model="displayedRate"
:currency="getOrganizationCurrencyString()"
name="memberBillableRate" />
<div v-else class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<Select v-model="billableRateSelect">
<SelectTrigger id="billableRateType">
<SelectValue />

View File

@@ -1,4 +1,6 @@
<script setup lang="ts">
import UpgradeLockedBadge from '@/packages/ui/src/UpgradeLockedBadge.vue';
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
import TableHeading from '@/Components/Common/TableHeading.vue';
import SortableTableHeaderCell from '@/Components/Common/SortableTableHeaderCell.vue';
import type { SortColumn, SortDirection } from '@/Components/Common/Member/MemberTable.vue';
@@ -12,6 +14,8 @@ const props = defineProps<{
defineEmits<{
sort: [column: SortColumn];
}>();
const { locked: billableRatesLocked } = useBillableRatesLock();
</script>
<template>
@@ -33,7 +37,10 @@ defineEmits<{
column="billable_rate"
v-bind="props"
@sort="$emit('sort', $event)">
Billable Rate
<span class="inline-flex items-center gap-2 whitespace-nowrap">
Billable Rate
<UpgradeLockedBadge v-if="billableRatesLocked" />
</span>
</SortableTableHeaderCell>
<SortableTableHeaderCell column="status" v-bind="props" @sort="$emit('sort', $event)">
Status

View File

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

View File

@@ -23,8 +23,10 @@ import ProjectVisibilitySelect from '@/packages/ui/src/Project/ProjectVisibility
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import { useOrganizationQuery } from '@/utils/useOrganizationQuery';
import { getCurrentOrganizationId } from '@/utils/useUser';
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
const { updateProject } = useProjectsStore();
const { locked: billableRatesLocked } = useBillableRatesLock();
const { clients } = useClientsQuery();
const { organization } = useOrganizationQuery(getCurrentOrganizationId()!);
const show = defineModel('show', { default: false });
@@ -48,6 +50,11 @@ const project = ref<CreateProjectBody>({
is_public: props.originalProject.is_public,
});
// Locked organizations do not receive the rate of the project, so sending it back would remove the existing rate
function getUpdateBody() {
const { billable_rate: billableRate, ...body } = project.value;
return billableRatesLocked.value ? body : { ...body, billable_rate: billableRate };
}
async function submit() {
if (props.originalProject.billable_rate !== project.value.billable_rate) {
// make sure that the alert modal is not immediately submitted when user presses enter
@@ -56,7 +63,7 @@ async function submit() {
}, 0);
return;
}
await updateProject(props.originalProject.id, project.value);
await updateProject(props.originalProject.id, getUpdateBody());
show.value = false;
}
@@ -72,7 +79,7 @@ const currentClientName = computed(() => {
});
async function submitBillableRate() {
await updateProject(props.originalProject.id, project.value);
await updateProject(props.originalProject.id, getUpdateBody());
show.value = false;
showBillableRateModal.value = false;
}

View File

@@ -10,21 +10,13 @@ import Pagination from '@/packages/ui/src/Pagination.vue';
import LoadingSpinner from '@/packages/ui/src/LoadingSpinner.vue';
export type SortColumn =
| 'name'
| 'tasks'
| 'client_name'
| 'spent_time'
| 'progress'
| 'billable_rate'
| 'status'
| 'visibility';
'name' | 'client_name' | 'spent_time' | 'progress' | 'billable_rate' | 'status' | 'visibility';
export type { SortDirection } from '@/utils/useSortableTable';
import { canCreateProjects } from '@/utils/permissions';
import type { CreateProjectBody, Project, Client, CreateClientBody } from '@/packages/api/src';
import { useProjectsStore } from '@/utils/useProjects';
import { useClientsStore } from '@/utils/useClients';
import { useClientsQuery } from '@/utils/useClientsQuery';
import { useTasksQuery } from '@/utils/useTasksQuery';
import { getOrganizationCurrencyString } from '@/utils/money';
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import { useOrganizationQuery } from '@/utils/useOrganizationQuery';
@@ -64,16 +56,6 @@ const clientNameMap = computed(() => {
return map;
});
const { tasks } = useTasksQuery();
const taskCountMap = computed(() => {
const map = new Map<string, number>();
tasks.value.forEach((task) => {
map.set(task.project_id, (map.get(task.project_id) ?? 0) + 1);
});
return map;
});
// Define column accessors for sorting.
// Numeric columns use sortDescFirst so that the first click (chevron down) sorts highest-first,
// while text columns default to ascending (A-Z) on first click (chevron down).
@@ -82,11 +64,6 @@ const columns = computed<SortableColumnDef<Project, SortColumn>[]>(() => [
id: 'name',
accessorFn: (row: Project) => row.name.toLowerCase(),
},
{
id: 'tasks',
sortDescFirst: true,
accessorFn: (row: Project) => taskCountMap.value.get(row.id) ?? 0,
},
{
id: 'client_name',
accessorFn: (row: Project) => {
@@ -184,7 +161,7 @@ async function createClient(client: CreateClientBody): Promise<Client | undefine
}
const gridTemplate = computed(() => {
return `grid-template-columns: minmax(300px, 1fr) minmax(100px, auto) minmax(150px, auto) minmax(140px, auto) minmax(130px, auto) ${props.showBillableRate ? 'minmax(130px, auto)' : ''} minmax(120px, auto) minmax(120px, auto) 80px;`;
return `grid-template-columns: minmax(300px, 1fr) minmax(150px, auto) minmax(140px, auto) minmax(130px, auto) ${props.showBillableRate ? 'minmax(130px, auto)' : ''} minmax(120px, auto) minmax(120px, auto) 80px;`;
});
</script>

View File

@@ -1,4 +1,6 @@
<script setup lang="ts">
import UpgradeLockedBadge from '@/packages/ui/src/UpgradeLockedBadge.vue';
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
import { computed } from 'vue';
import TableHeading from '@/Components/Common/TableHeading.vue';
import SortableTableHeaderCell from '@/Components/Common/SortableTableHeaderCell.vue';
@@ -25,6 +27,8 @@ const sortState = computed(() => ({
function handleSort(column: SortColumn) {
emit('sort', column);
}
const { locked: billableRatesLocked } = useBillableRatesLock();
</script>
<template>
@@ -36,9 +40,6 @@ function handleSort(column: SortColumn) {
@sort="handleSort">
Name
</SortableTableHeaderCell>
<SortableTableHeaderCell column="tasks" v-bind="sortState" @sort="handleSort">
Tasks
</SortableTableHeaderCell>
<SortableTableHeaderCell column="client_name" v-bind="sortState" @sort="handleSort">
Client
</SortableTableHeaderCell>
@@ -53,7 +54,10 @@ function handleSort(column: SortColumn) {
column="billable_rate"
v-bind="sortState"
@sort="handleSort">
Billable Rate
<span class="inline-flex items-center gap-2 whitespace-nowrap">
Billable Rate
<UpgradeLockedBadge v-if="billableRatesLocked" />
</span>
</SortableTableHeaderCell>
<SortableTableHeaderCell column="status" v-bind="sortState" @sort="handleSort">
Status

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
import ProjectMoreOptionsDropdown from '@/Components/Common/Project/ProjectMoreOptionsDropdown.vue';
import type { Project } from '@/packages/api/src';
import { computed, ref, inject, type ComputedRef } from 'vue';
@@ -15,6 +16,7 @@ import { useTasksQuery } from '@/utils/useTasksQuery';
import { useProjectsStore } from '@/utils/useProjects';
import TableRow from '@/Components/TableRow.vue';
import ProjectEditModal from '@/Components/Common/Project/ProjectEditModal.vue';
import UpgradeLockedBadge from '@/packages/ui/src/UpgradeLockedBadge.vue';
import { formatCents } from '@/packages/ui/src/utils/money';
import { getOrganizationCurrencyString } from '@/utils/money';
import EstimatedTimeProgress from '@/packages/ui/src/EstimatedTimeProgress.vue';
@@ -52,8 +54,11 @@ function deleteProject() {
}
function archiveProject() {
// Locked organizations do not receive the rate of the project, so sending it back would remove the existing rate
const { billable_rate: billableRate, ...project } = props.project;
useProjectsStore().updateProject(props.project.id, {
...props.project,
...project,
...(billableRatesLocked.value ? {} : { billable_rate: billableRate }),
is_archived: !props.project.is_archived,
});
}
@@ -78,6 +83,8 @@ const billableRateInfo = computed(() => {
});
const showEditProjectModal = ref(false);
const { locked: billableRatesLocked } = useBillableRatesLock();
</script>
<template>
@@ -88,19 +95,17 @@ const showEditProjectModal = ref(false);
<ContextMenuTrigger as-child>
<TableRow :href="route('projects.show', { project: project.id })">
<div
class="whitespace-nowrap min-w-0 flex items-center space-x-3 py-4 pr-3 text-sm font-medium text-text-primary pl-2 sm:pl-4 lg:pl-6">
class="whitespace-nowrap min-w-0 flex items-center space-x-5 py-4 pr-3 text-sm font-medium text-text-primary pl-2 sm:pl-4 lg:pl-6">
<div
:style="{ backgroundColor: project.color }"
class="w-2.5 h-2.5 ml-1 rounded-full"></div>
:style="{
backgroundColor: project.color,
boxShadow: `var(--tw-ring-inset) 0 0 0 calc(4px + var(--tw-ring-offset-width)) ${project.color}30`,
}"
class="w-3 h-3 ml-1 rounded-full"></div>
<span class="overflow-ellipsis overflow-hidden">
{{ project.name }}
</span>
</div>
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-primary">
<span v-if="projectTasksCount">
{{ projectTasksCount }} {{ projectTasksCount === 1 ? 'Task' : 'Tasks' }}
</span>
<span v-else class="text-text-tertiary">--</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 v-if="project.client_id" class="overflow-ellipsis overflow-hidden">
@@ -131,7 +136,8 @@ const showEditProjectModal = ref(false);
<div
v-if="showBillableRate"
class="whitespace-nowrap px-3 py-4 text-sm text-text-primary">
<span v-if="billableRateInfo">{{ billableRateInfo }}</span>
<UpgradeLockedBadge v-if="billableRatesLocked" />
<span v-else-if="billableRateInfo">{{ billableRateInfo }}</span>
<span v-else class="text-text-tertiary">--</span>
</div>
<div

View File

@@ -1,5 +1,9 @@
<script setup lang="ts">
import UpgradeLockedBadge from '@/packages/ui/src/UpgradeLockedBadge.vue';
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
import TableHeading from '@/Components/Common/TableHeading.vue';
const { locked: billableRatesLocked } = useBillableRatesLock();
</script>
<template>
@@ -7,7 +11,10 @@ import TableHeading from '@/Components/Common/TableHeading.vue';
<div class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
Name
</div>
<div class="px-3 py-1.5 text-left text-text-tertiary">Billable Rate</div>
<div class="px-3 py-1.5 text-left text-text-tertiary flex items-center gap-2">
Billable Rate
<UpgradeLockedBadge v-if="billableRatesLocked" />
</div>
<div class="px-3 py-1.5 text-left text-text-tertiary">Role</div>
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
<span class="sr-only">Edit</span>

View File

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

View File

@@ -1,4 +1,6 @@
<script setup lang="ts">
import UpgradeLockedBadge from '@/packages/ui/src/UpgradeLockedBadge.vue';
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
import {
ChartBarIcon,
ArrowDownTrayIcon,
@@ -290,6 +292,8 @@ const tableData = computed(() => {
};
});
});
const { locked: billableRatesLocked } = useBillableRatesLock();
</script>
<template>
@@ -416,7 +420,12 @@ const tableData = computed(() => {
<div class="text-right" :class="!showBillableRate ? 'pr-6' : ''">
Duration
</div>
<div v-if="showBillableRate" class="text-right pr-6">Cost</div>
<div
v-if="showBillableRate"
class="flex items-center justify-end gap-2 pr-6">
<UpgradeLockedBadge v-if="billableRatesLocked" />
Cost
</div>
</div>
<template
v-if="

View File

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

View File

@@ -1,4 +1,6 @@
<script setup lang="ts">
import UpgradeLockedBadge from '@/packages/ui/src/UpgradeLockedBadge.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">
<UpgradeLockedBadge />
</StatCard>
<StatCard
v-else
title="Billable Amount"
:value="
totalWeeklyBillableAmount

View File

@@ -1,4 +1,6 @@
<script setup lang="ts">
import UpgradeLockedBadge from '@/packages/ui/src/UpgradeLockedBadge.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>
@@ -151,7 +155,10 @@ const showBillableRate = computed(() => {
class="contents [&>*]:border-card-background-separator [&>*]:border-b [&>*]:pb-1.5 [&>*]:pt-1 text-text-tertiary text-sm">
<div class="pl-6">Name</div>
<div class="text-right" :class="!showBillableRate ? 'pr-6' : ''">Duration</div>
<div v-if="showBillableRate" class="text-right pr-6">Cost</div>
<div v-if="showBillableRate" class="flex items-center justify-end gap-2 pr-6">
<UpgradeLockedBadge v-if="billableRatesLocked" />
Cost
</div>
</div>
<div

View File

@@ -23,7 +23,7 @@ import { PanelLeft } from '@lucide/vue';
import NavigationSidebarItem from '@/Components/NavigationSidebarItem.vue';
import UserSettingsIcon from '@/Components/UserSettingsIcon.vue';
import MainContainer from '@/packages/ui/src/MainContainer.vue';
import { nextTick, onMounted, provide, ref } from 'vue';
import { computed, nextTick, onMounted, provide, ref } from 'vue';
import NotificationContainer from '@/Components/NotificationContainer.vue';
import { initializeStores } from '@/utils/init';
import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
@@ -37,7 +37,9 @@ import {
canViewReport,
canViewTags,
} from '@/utils/permissions';
import { isBillingActivated, isInvoicingActivated } from '@/utils/billing';
import { canUseBillableRates, isBillingActivated, isInvoicingActivated } from '@/utils/billing';
import UpgradeModal from '@/Components/Common/UpgradeModal.vue';
import { billableRatesLockKey } from '@/packages/ui/src/utils/useBillableRatesLock';
import type { User } from '@/types/models';
import { ArrowsRightLeftIcon } from '@heroicons/vue/16/solid';
import { fetchToken, isTokenValid } from '@/utils/session';
@@ -88,6 +90,14 @@ const { organization, isLoading: isOrganizationLoading } = useOrganizationQuery(
provide('organization', organization);
const showBillableRatesUpgradeModal = ref(false);
provide(billableRatesLockKey, {
locked: computed(() => !canUseBillableRates()),
requestUpgrade: () => {
showBillableRatesUpgradeModal.value = true;
},
});
onMounted(async () => {
useTheme();
// make sure that the initial requests are only loaded once, this can be removed once we move away from inertia
@@ -377,4 +387,7 @@ const page = usePage<{
<NotificationContainer></NotificationContainer>
<UserTimezoneMismatchModal></UserTimezoneMismatchModal>
<CommandPaletteProvider></CommandPaletteProvider>
<UpgradeModal v-model:show="showBillableRatesUpgradeModal">
<strong>Billable Rates</strong> are only available in solidtime Professional.
</UpgradeModal>
</template>

View File

@@ -1,4 +1,6 @@
<script setup lang="ts">
import UpgradeLockedBadge from '@/packages/ui/src/UpgradeLockedBadge.vue';
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
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,8 @@ const shownTasks = computed(() => {
return task.project_id === projectId && task.is_done;
});
});
const { locked: billableRatesLocked } = useBillableRatesLock();
</script>
<template>
@@ -117,11 +121,17 @@ const shownTasks = computed(() => {
</li>
</ol>
<div class="px-4 space-x-1">
<Badge v-if="project?.billable_rate">
<UpgradeLockedBadge v-if="billableRatesLocked"
>Billable Rates</UpgradeLockedBadge
>
<Badge v-else-if="project?.billable_rate">
{{ billableRateFormatted }}
/ h
</Badge>
<Badge v-if="project?.is_billable && !project?.billable_rate">
<Badge
v-if="
project?.is_billable && !project?.billable_rate && !billableRatesLocked
">
Default Rate
</Badge>
<Badge v-if="!project?.is_billable"> Non-Billable </Badge>

View File

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

View File

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

View File

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

View File

@@ -10,12 +10,15 @@ 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');
@@ -91,7 +94,12 @@ const emit = defineEmits(['submit']);
</Field>
<Field>
<FieldLabel :icon="BillableIcon" for="billableRateType">Billable Rate</FieldLabel>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<BillableRateInput
v-if="billableRatesLocked"
v-model="displayedRate"
:currency="currency"
name="billableRate" />
<div v-else class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<Select v-model="rateType">
<SelectTrigger id="billableRateType">
<SelectValue />

View File

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

View File

@@ -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';
import Badge from './Badge.vue';
import Button from './Buttons/Button.vue';

View File

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

View File

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

View File

@@ -89,6 +89,7 @@ abstract class TestCase extends BaseTestCase
$mock->shouldReceive('hasTrial')->andReturn(false);
$mock->shouldReceive('getTrialUntil')->andReturn(null);
$mock->shouldReceive('isBlocked')->andReturn(false);
$mock->shouldReceive('canUseBillableRates')->andReturn(true);
});
}
@@ -99,6 +100,18 @@ abstract class TestCase extends BaseTestCase
$mock->shouldReceive('hasTrial')->andReturn(false);
$mock->shouldReceive('getTrialUntil')->andReturn(null);
$mock->shouldReceive('isBlocked')->andReturn(false);
$mock->shouldReceive('canUseBillableRates')->andReturn(true);
});
}
protected function actAsOrganizationWithoutBillableRates(): void
{
$this->mock(BillingContract::class, function (MockInterface $mock): void {
$mock->shouldReceive('hasSubscription')->andReturn(false);
$mock->shouldReceive('hasTrial')->andReturn(false);
$mock->shouldReceive('getTrialUntil')->andReturn(null);
$mock->shouldReceive('isBlocked')->andReturn(false);
$mock->shouldReceive('canUseBillableRates')->andReturn(false);
});
}
}

View File

@@ -0,0 +1,286 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Endpoint\Api\V1;
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;
#[UsesClass(Controller::class)]
class BillableRateRestrictionEndpointTest extends ApiEndpointTestAbstract
{
public function test_project_store_fails_with_billable_rate_if_organization_can_not_use_billable_rates(): void
{
// Arrange
$this->actAsOrganizationWithoutBillableRates();
$data = $this->createUserWithPermission([
'projects:create',
]);
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->assertStatus(400);
$response->assertJsonPath('error', true);
$response->assertJsonPath('key', 'feature_is_not_available_in_free_plan');
$this->assertDatabaseMissing(Project::class, [
'name' => 'Project',
]);
}
public function test_project_store_without_billable_rate_works_if_organization_can_not_use_billable_rates(): void
{
// Arrange
$this->actAsOrganizationWithoutBillableRates();
$data = $this->createUserWithPermission([
'projects:create',
]);
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' => null,
]);
// Assert
$response->assertStatus(201);
$this->assertDatabaseHas(Project::class, [
'name' => 'Project',
'is_billable' => true,
'billable_rate' => null,
]);
}
public function test_project_update_keeps_existing_billable_rate_if_organization_can_not_use_billable_rates(): void
{
// Arrange
$this->actAsOrganizationWithoutBillableRates();
$data = $this->createUserWithPermission([
'projects:update',
]);
$project = Project::factory()->forOrganization($data->organization)->create([
'billable_rate' => 10000,
]);
Passport::actingAs($data->user);
// Act
$responseKeep = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
'name' => 'RenamedKeep',
'color' => $project->color,
'client_id' => null,
'is_billable' => true,
'billable_rate' => 10000,
]);
$responseChange = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
'name' => 'RenamedChange',
'color' => $project->color,
'client_id' => null,
'is_billable' => true,
'billable_rate' => 20000,
]);
$responseRemove = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
'name' => 'RenamedRemove',
'color' => $project->color,
'client_id' => null,
'is_billable' => true,
'billable_rate' => null,
]);
// Assert
$responseKeep->assertStatus(200);
$responseKeep->assertJsonPath('data.name', 'RenamedKeep');
$responseChange->assertStatus(400);
$responseChange->assertJsonPath('key', 'feature_is_not_available_in_free_plan');
$responseRemove->assertStatus(200);
$project->refresh();
$this->assertSame('RenamedRemove', $project->name);
$this->assertSame(10000, $project->billable_rate);
}
public function test_project_update_without_billable_rate_does_not_change_project_rate_or_time_entries_if_organization_can_not_use_billable_rates(): void
{
// Arrange
$this->actAsOrganizationWithoutBillableRates();
$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,
]);
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' => true,
'is_archived' => true,
]);
// Assert
$response->assertStatus(200);
$project->refresh();
$this->assertSame('Renamed', $project->name);
$this->assertTrue($project->is_archived);
$this->assertSame(10000, $project->billable_rate);
$this->assertSame(10000, $timeEntry->refresh()->billable_rate);
}
public function test_organization_update_fails_to_set_billable_rate_or_employee_visibility_if_organization_can_not_use_billable_rates(): void
{
// Arrange
$this->actAsOrganizationWithoutBillableRates();
$data = $this->createUserWithPermission([
'organizations:update',
]);
$data->organization->employees_can_see_billable_rates = false;
$data->organization->save();
Passport::actingAs($data->user);
// Act
$responseRate = $this->putJson(route('api.v1.organizations.update', [$data->organization->getKey()]), [
'name' => 'Organization',
'billable_rate' => 10000,
]);
$responseVisibility = $this->putJson(route('api.v1.organizations.update', [$data->organization->getKey()]), [
'name' => 'Organization',
'employees_can_see_billable_rates' => true,
]);
$responseUnchanged = $this->putJson(route('api.v1.organizations.update', [$data->organization->getKey()]), [
'name' => 'Organization',
'billable_rate' => null,
'employees_can_see_billable_rates' => false,
]);
// Assert
$responseRate->assertStatus(400);
$responseVisibility->assertStatus(400);
$responseUnchanged->assertStatus(200);
$data->organization->refresh();
$this->assertNull($data->organization->billable_rate);
$this->assertFalse($data->organization->employees_can_see_billable_rates);
}
public function test_member_update_fails_to_set_billable_rate_if_organization_can_not_use_billable_rates(): void
{
// Arrange
$this->actAsOrganizationWithoutBillableRates();
$data = $this->createUserWithPermission([
'members:update',
]);
$member = Member::factory()->forOrganization($data->organization)->forUser(User::factory()->create())->create([
'billable_rate' => null,
]);
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.members.update', [$data->organization->getKey(), $member->getKey()]), [
'billable_rate' => 10000,
]);
// Assert
$response->assertStatus(400);
$response->assertJsonPath('key', 'feature_is_not_available_in_free_plan');
$this->assertNull($member->refresh()->billable_rate);
}
public function test_project_member_store_and_update_fail_to_set_billable_rate_if_organization_can_not_use_billable_rates(): void
{
// Arrange
$this->actAsOrganizationWithoutBillableRates();
$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' => null,
]);
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' => 10000,
]);
// Assert
$responseStore->assertStatus(400);
$responseUpdate->assertStatus(400);
$this->assertNull($projectMember->refresh()->billable_rate);
$this->assertDatabaseMissing(ProjectMember::class, [
'member_id' => $otherMember->getKey(),
]);
}
public function test_aggregate_endpoint_hides_cost_if_organization_can_not_use_billable_rates(): void
{
// Arrange
$this->actAsOrganizationWithoutBillableRates();
$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,
]);
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
$this->actAsOrganizationWithoutBillableRates();
$data = $this->createUserWithPermission([
'charts:view:own',
]);
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.charts.total-weekly-billable-amount', [$data->organization->getKey()]));
// Assert
$response->assertForbidden();
}
}

View File

@@ -35,6 +35,7 @@ class HandleInertiaRequestsMiddlewareTest extends MiddlewareTestAbstract
$mock->shouldReceive('hasTrial')->andReturn(false);
$mock->shouldReceive('getTrialUntil')->andReturn(null);
$mock->shouldReceive('isBlocked')->andReturn(false);
$mock->shouldReceive('canUseBillableRates')->andReturn(true);
});
Passport::actingAs($user->user);
@@ -47,6 +48,7 @@ class HandleInertiaRequestsMiddlewareTest extends MiddlewareTestAbstract
->where('billing.has_trial', false)
->where('billing.trial_until', null)
->where('billing.is_blocked', false)
->where('billing.can_use_billable_rates', true)
);
}
@@ -61,6 +63,7 @@ class HandleInertiaRequestsMiddlewareTest extends MiddlewareTestAbstract
$mock->shouldReceive('hasTrial')->andReturn(true);
$mock->shouldReceive('getTrialUntil')->andReturn($trialUntil);
$mock->shouldReceive('isBlocked')->andReturn(false);
$mock->shouldReceive('canUseBillableRates')->andReturn(true);
});
Passport::actingAs($user->user);
@@ -73,6 +76,7 @@ class HandleInertiaRequestsMiddlewareTest extends MiddlewareTestAbstract
->where('billing.has_trial', true)
->where('billing.trial_until', $trialUntil->toIso8601ZuluString())
->where('billing.is_blocked', false)
->where('billing.can_use_billable_rates', true)
);
}
}

View File

@@ -8,6 +8,8 @@ use App\Enums\TimeEntryType;
use App\Jobs\RecalculateSpentTimeForProject;
use App\Jobs\RecalculateSpentTimeForTask;
use App\Models\Organization;
use App\Models\Project;
use App\Models\ProjectMember;
use App\Models\TimeEntry;
use App\Service\Import\Importers\DefaultImporter;
use App\Service\Import\Importers\ImportException;
@@ -102,6 +104,34 @@ class SolidtimeImporterTest extends ImporterTestAbstract
Queue::assertPushed(RecalculateSpentTimeForTask::class, 1);
}
public function test_import_drops_billable_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')->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->assertNull($timeEntry->billable_rate);
}
}
public function test_import_of_test_file_with_type_column_imports_breaks(): void
{
// Arrange