Compare commits

...

9 Commits

Author SHA1 Message Date
Gregor Vostrak
77161e3b7e load billable rate limits extension branches for billing and invoicing 2026-10-13 15:05:13 +02:00
Gregor Vostrak
d9941e338f add billable rate restriction for free organizations 2026-10-13 14:59:12 +02:00
Gregor Vostrak
2564f74d9a move upgrade modal to ui package and add trial status and cancel trial ui 2026-10-13 14:58:12 +02:00
Constantin Graf
0c58896b00 Run OSS Scanner image on Octane/FrankenPHP and bundle Gotenberg
Production runs as long-lived Octane workers in FrankenPHP worker mode, so state leaking between requests
(static properties, singletons, ...) is a real risk that php artisan serve cannot reproduce. Base the scanner image
on the production FrankenPHP image, add start-octane.sh (single worker, production Caddyfile) and describe this
class of issue in the threat model.

Bundle Gotenberg (Chromium only) so the PDF export tests pass offline and PDF rendering can be exercised.
2026-10-09 18:10:44 +02:00
Constantin Graf
6301b0f4a1 Clarify that super admins are trusted in OSS Scanner threat model 2026-10-09 18:10:44 +02:00
Constantin Graf
4c993c8eb2 Add OSS Scanner build environment and threat model 2026-10-09 18:10:44 +02:00
Constantin Graf
ae6937d012 Updated auditing extension to v0.0.5 2026-10-09 15:09:41 +02:00
Constantin Graf
1256fa61bd Updated invoicing extension to v0.0.10 2026-10-09 14:42:41 +02:00
Constantin Graf
d6f80e23fb Run core PHPUnit tests with extensions enabled in CI 2026-10-09 14:42:41 +02:00
91 changed files with 2521 additions and 370 deletions

View File

@@ -5,7 +5,7 @@ permissions:
jobs: jobs:
phpunit-extensions: phpunit-extensions:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 15 timeout-minutes: 25
strategy: strategy:
matrix: matrix:
postgres_version: [ 15, 16, 17 ] postgres_version: [ 15, 16, 17 ]
@@ -132,5 +132,8 @@ jobs:
php artisan key:generate php artisan key:generate
php artisan passport:keys php artisan passport:keys
- name: "Run PHPUnit" - name: "Run PHPUnit (extensions)"
run: php artisan test extensions/Billing/tests extensions/Services/tests extensions/Invoicing/tests --stop-on-failure run: php artisan test extensions/Billing/tests extensions/Services/tests extensions/Invoicing/tests --stop-on-failure
- name: "Run PHPUnit (core)"
run: php artisan test --testsuite=Unit,Feature --stop-on-failure

86
.oss-scanner/Dockerfile Normal file
View File

@@ -0,0 +1,86 @@
# Build environment for Anthropic's OSS Scanner (https://github.com/anthropics/oss-scanner).
# The scanner builds this image with the repository root as the build context and then audits it without network
# access, so everything needed to run the app and its test suite (PHP + Composer deps, Node deps + built frontend,
# a local PostgreSQL and a local Gotenberg for PDF rendering) is installed here.
#
# The base image is the FrankenPHP image the production image (docker/prod/Dockerfile) is built on: in production the
# app runs as long-lived Laravel Octane workers in FrankenPHP worker mode, not as one PHP process per request.
#
# Inside the finished image:
# .oss-scanner/start-postgres.sh start the local PostgreSQL server (required for tests and the app)
# .oss-scanner/start-gotenberg.sh start the local Gotenberg server (required for PDF exports)
# php artisan test run the PHPUnit suite (needs PostgreSQL and Gotenberg running)
# .oss-scanner/start-octane.sh run the app like production on http://127.0.0.1:8000 (starts everything above)
ARG FRANKENPHP_VERSION=1.11
ARG PHP_VERSION=8.3
FROM node:20-trixie-slim AS node
FROM gotenberg/gotenberg:8 AS gotenberg
FROM dunglas/frankenphp:${FRANKENPHP_VERSION}-php${PHP_VERSION}
ENV DEBIAN_FRONTEND=noninteractive \
COMPOSER_ALLOW_SUPERUSER=1 \
COMPOSER_NO_INTERACTION=1 \
OCTANE_SERVER=frankenphp \
TZ=UTC
COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer
COPY --from=node /usr/local/bin/node /usr/local/bin/node
COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules
RUN ln -s ../lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \
&& ln -s ../lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx
# PostgreSQL 17 (Debian trixie default) is one of the versions solidtime is tested against in CI.
# chromium, qpdf and exiftool are what Gotenberg needs for HTML to PDF rendering.
RUN apt-get update \
&& apt-get install -y --no-install-recommends git unzip curl ca-certificates postgresql postgresql-client \
chromium fonts-liberation qpdf libimage-exiftool-perl \
&& install-php-extensions pdo_pgsql pgsql intl gd zip bcmath exif pcntl sockets soap apcu \
&& rm -rf /var/lib/apt/lists/* \
&& echo "memory_limit=2G" > "$PHP_INI_DIR/conf.d/99-oss-scanner.ini"
# Gotenberg 8 (the PDF renderer, same image as in CI). solidtime only uses its Chromium HTML to PDF route, so LibreOffice
# and pdftk are not installed: Gotenberg only checks at startup that their binaries exist, start-gotenberg.sh disables
# the LibreOffice routes and the Chromium route does not use pdftk.
COPY --from=gotenberg /usr/bin/gotenberg /usr/local/bin/gotenberg
COPY --from=gotenberg /usr/bin/pdfcpu /usr/local/bin/pdfcpu
COPY --from=gotenberg /opt/gotenberg /opt/gotenberg
ENV CHROMIUM_BIN_PATH=/usr/bin/chromium \
CHROMIUM_HYPHEN_DATA_DIR_PATH=/opt/gotenberg/chromium-hyphen-data \
GOTENBERG_VERSIONS_DIR_PATH=/opt/gotenberg/versions \
QPDF_BIN_PATH=/usr/bin/qpdf \
EXIFTOOL_BIN_PATH=/usr/bin/exiftool \
PDFCPU_BIN_PATH=/usr/local/bin/pdfcpu \
LIBREOFFICE_BIN_PATH=/bin/false \
UNOCONVERTER_BIN_PATH=/bin/false \
PDFTK_BIN_PATH=/bin/false
# Database matching .env.ci: user root / password root, database laravel.
RUN pg_ctlcluster 17 main start \
&& runuser -u postgres -- psql -c "CREATE ROLE root WITH LOGIN SUPERUSER PASSWORD 'root';" \
&& runuser -u postgres -- createdb -O root laravel \
&& pg_ctlcluster 17 main stop
# scanner contract: the checkout lives inside the image, at /src
COPY . /src
WORKDIR /src
RUN composer install --prefer-dist \
&& npm ci \
&& npm run build
RUN cp .env.ci .env \
&& php artisan key:generate \
&& php artisan passport:keys --force \
&& php artisan octane:install --server=frankenphp --no-interaction \
&& chmod +x .oss-scanner/*.sh
# Run the test suite so the image is known to work, but do not fail the build on test failures.
RUN .oss-scanner/start-postgres.sh \
&& .oss-scanner/start-gotenberg.sh \
&& (php artisan test || echo "WARNING: PHPUnit reported failures") \
&& kill "$(cat /tmp/gotenberg.pid)" \
&& pg_ctlcluster 17 main stop

View File

@@ -0,0 +1,18 @@
# Used instead of the root .dockerignore when building .oss-scanner/Dockerfile. The root one is tailored to the
# production image and excludes tests, phpunit.xml etc., which the scanner needs.
node_modules
extensions/*/node_modules
vendor
.env
public/build
public/hot
storage/*.key
storage/logs/*
coverage
test-results
playwright-report
.phpunit.cache
.phpunit.result.cache
auth.json
.DS_Store
.idea

16
.oss-scanner/start-gotenberg.sh Executable file
View File

@@ -0,0 +1,16 @@
#!/usr/bin/env bash
# Start the local Gotenberg server (PDF rendering) on 127.0.0.1:3000, matching GOTENBERG_URL in .env.ci.
# Only the Chromium routes are needed by solidtime, see .oss-scanner/Dockerfile. Stop: kill "$(cat /tmp/gotenberg.pid)"
set -euo pipefail
if ! curl -fs http://127.0.0.1:3000/health >/dev/null; then
nohup gotenberg \
--api-port=3000 \
--libreoffice-disable-routes \
--libreoffice-auto-start=false \
--log-level=warn \
>/tmp/gotenberg.log 2>&1 &
echo $! >/tmp/gotenberg.pid
until curl -fs http://127.0.0.1:3000/health >/dev/null; do sleep 0.5; done
fi
echo "Gotenberg is running on http://127.0.0.1:3000 (log: /tmp/gotenberg.log)"

37
.oss-scanner/start-octane.sh Executable file
View File

@@ -0,0 +1,37 @@
#!/usr/bin/env bash
# Run the app the way production does: Laravel Octane on FrankenPHP in worker mode, with the production Caddyfile.
# A worker boots the app once and then serves many requests, so state kept in memory (static properties, singletons,
# changed config, ...) survives from one request to the next. With a single worker, consecutive requests always hit the
# same worker, so such leaks between requests (e.g. between users of different organizations) reproduce reliably.
#
# Usage: .oss-scanner/start-octane.sh (OCTANE_WORKERS and OCTANE_MAX_REQUESTS can be overridden)
# Stop: php artisan octane:stop
# After changing PHP code, run `php artisan octane:reload`: workers keep the code they booted with.
set -euo pipefail
cd "$(dirname "$0")/.."
.oss-scanner/start-postgres.sh
.oss-scanner/start-gotenberg.sh
php artisan migrate --force
if [ "$(PGPASSWORD=root psql -h 127.0.0.1 -U root -d laravel -tAc 'SELECT count(*) FROM users')" = "0" ]; then
php artisan db:seed --force
fi
php artisan optimize:clear >/dev/null
nohup php artisan octane:frankenphp \
--host=127.0.0.1 \
--port=8000 \
--workers="${OCTANE_WORKERS:-1}" \
--max-requests="${OCTANE_MAX_REQUESTS:-10000}" \
--caddyfile=docker/prod/deployment/octane/FrankenPHP/Caddyfile \
>/tmp/octane.log 2>&1 &
until curl -fs -o /dev/null http://127.0.0.1:8000/login; do
if ! kill -0 $! 2>/dev/null; then
cat /tmp/octane.log
exit 1
fi
sleep 0.5
done
echo "solidtime is running on http://127.0.0.1:8000 with Octane/FrankenPHP (log: /tmp/octane.log)"

7
.oss-scanner/start-postgres.sh Executable file
View File

@@ -0,0 +1,7 @@
#!/usr/bin/env bash
# Start the local PostgreSQL server used by the test suite and the app (see .oss-scanner/Dockerfile).
set -euo pipefail
pg_ctlcluster 17 main start 2>/dev/null || true
until pg_isready -h 127.0.0.1 -p 5432 -q; do sleep 0.5; done
echo "PostgreSQL is running on 127.0.0.1:5432 (user root, password root, database laravel)"

View File

@@ -0,0 +1,122 @@
# Threat model
## What this project does
solidtime is an open-source, multi-tenant time tracking web application (Laravel backend, Vue 3 + Inertia frontend,
PostgreSQL). It runs as a hosted SaaS (solidtime.io) and is self-hosted by many organisations. Users belong to one or
more **organizations**; inside an organization each member has a role: `owner`, `admin`, `manager`, `employee` or
`placeholder` (an imported, non-login member). What each role may do is defined in `app/Service/PermissionStore.php`.
The most important security property is **isolation**: a user must never read or modify data of an organization they
are not a member of, and within an organization a member must not exceed the permissions of their role (e.g. an
employee must not see other members' time entries, billable rates, or manage members, unless the organization settings
explicitly allow it).
## Trust boundaries
- **Super admins are fully trusted.** They are the instance operators, configured via the `SUPER_ADMINS` env
variable, and have access to the Filament admin panel (`app/Filament`), which can view and change data of every
organization and impersonate users. Anything a super admin can do through the panel (including XSS, SQL injection,
SSRF or file access that is only reachable from the panel) is not a vulnerability.
- What **is** in scope: a user who is not a super admin reaching the admin panel, or any of its actions, at all.
- Operators of a self-hosted instance (shell, database, environment, filesystem access) are trusted.
- Everyone else, including organization owners and admins when acting outside their own organization, is untrusted.
## Runtime: long-lived Octane workers
In production (the hosted SaaS and the official Docker image, `docker/prod/`) the app does **not** run as one PHP
process per request. It runs on Laravel Octane with FrankenPHP in worker mode: each worker boots the application once
and then serves many requests from different users and organizations. Anything kept in memory survives from one request
to the next unless Octane resets it (`config/octane.php` lists what is reset). This includes static properties and
static caches, container bindings registered with `singleton()` instead of `scoped()` (see
`app/Providers/AppServiceProvider.php`), objects captured by those singletons, runtime `config()` / locale / timezone
changes, macros and event listeners registered during a request, and state in third-party packages.
Request A leaving state behind that request B (another user, possibly of another organization) then sees or is
affected by is in scope, rated by its impact like any other issue (see severity below). The PHPUnit suite cannot
show this class of bug, because it boots a fresh application for every test. It has to be reproduced over HTTP against
the Octane server, see "How to exercise it".
## Where untrusted input enters
All authenticated users, including employees of any organization and anyone who self-registers (registration is open
by default), are untrusted.
- **JSON API** `routes/api.php` (`/api/v1/...`), authenticated via Passport (session cookie or personal access token).
Most routes are scoped by `{organization}` and authorised in the controllers / form requests.
- **Public, unauthenticated** endpoints: `GET /api/v1/public/reports` (shared reports, accessed by a secret), login,
registration, password reset, email verification, organization invitation acceptance (`routes/web.php`).
- **Web / Inertia routes** `routes/web.php` and Fortify/Jetstream actions in `app/Actions`.
- **Imports** (`app/Service/Import/Importers`): user-uploaded CSV and ZIP files from Toggl, Clockify, Harvest,
generic CSV and solidtime's own export format. ZIP handling is in `ZipImportHelper.php`.
- **Exports / reports** (`app/Service/Export`, `app/Service/ReportExport`): CSV/XLSX/ODS and PDF. PDFs are rendered by
sending HTML to a Gotenberg (headless Chromium) service, so user-controlled content in that HTML matters.
- **OAuth** (Passport) authorization and token endpoints.
- **Filament admin panel** (`app/Filament`): only its access control is in scope (see Trust boundaries).
## Components that matter most / least
Most important: organization scoping and role checks in the API controllers, form requests (`app/Http/Requests`),
`PermissionStore`, public report sharing, invitations and member management (role changes, ownership transfer, member
merge), authentication flows (Fortify, 2FA, email change, API tokens), import parsing.
Less important / out of scope:
- `extensions/` is empty in this repository (proprietary modules are not part of the open-source code).
- `docker/`, `k8s/`, `e2e/`, `playwright/`, `docs/` and developer tooling.
- Third-party dependencies in `vendor/` and `node_modules/`, unless solidtime uses them in an unsafe way.
## How to exercise it
- `.oss-scanner/start-postgres.sh` starts the local PostgreSQL server (user `root`, password `root`, db `laravel`).
- `.oss-scanner/start-gotenberg.sh` starts the local Gotenberg server (PDF rendering via headless Chromium) on
http://127.0.0.1:3000, so the PDF export code path, including what Chromium does with the rendered HTML, can be
exercised.
- `php artisan test` runs the PHPUnit suite (start PostgreSQL and Gotenberg first); all tests are expected to pass. Endpoint tests in `tests/Unit/Endpoint/Api/V1/` show how to create users,
organizations and members with factories and call the API with a given role; they are the quickest way to write a
reproducer. Example: `php artisan test --filter=TimeEntryEndpointTest`.
- To run the app like production: `.oss-scanner/start-octane.sh` (http://127.0.0.1:8000). It starts PostgreSQL and
Gotenberg, migrates (and seeds an empty database, see `database/seeders/DatabaseSeeder.php` for the users) and runs
Octane/FrankenPHP with the production Caddyfile and a **single worker**, so consecutive requests always hit the same
worker and leaks between requests reproduce reliably. Workers keep the code they booted with: after changing PHP
code run `php artisan octane:reload`. Stop it with `php artisan octane:stop`. Note that the test suite and the app
share the same database, so `php artisan test` wipes the app's data.
- A reproducer for a leak between requests is a script that sends request A (e.g. as a member of organization X) and
then request B (as a user of organization Y) to the running Octane server and shows that B observes A's state. API
requests can be authenticated with a personal access token, e.g. created with
`php artisan tinker --execute="echo App\Models\User::where('email', '...')->first()->createToken('t')->accessToken;"`.
- There is no network: mail delivery is not available (mail uses the `array` driver in tests), and remote resources
referenced by PDF templates (e.g. fonts from fonts.bunny.net) fail to load, so PDFs fall back to local fonts.
## How we rate severity
- **Critical**: unauthenticated access to other users' data or accounts; authentication bypass; remote code execution;
SQL injection reachable by any registered user; reading or writing data of an organization the attacker is not a
member of, including through state leaking between requests in an Octane worker.
- **High**: privilege escalation within an organization (e.g. employee to admin/owner, or performing admin-only
actions); access to data the role must not see (other members' time entries, billable rates, member emails) when
the organization settings do not allow it; stored XSS that executes in another user's session; SSRF via PDF
rendering or imports; account takeover requiring user interaction.
- **Medium**: information disclosure with limited impact, CSRF on state-changing endpoints, issues requiring an
unusual but realistic configuration, denial of service by a single authenticated request (e.g. pathological
import file).
- **Low**: everything else with real security impact.
## Anything to leave alone
Please do not report (see also `SECURITY.md`):
- Theoretical findings without a working reproducer.
- Missing or weak security headers in isolation; TLS / mail DNS configuration.
- Self-XSS; CSRF on non-state-changing endpoints (logout, theme).
- CSV / spreadsheet formula injection in exports.
- Owners or admins acting destructively within their own organization.
- Anything requiring direct DB, shell or filesystem access on a self-hosted instance.
- Anything that requires being a super admin, including issues inside the Filament admin panel.
- Missing OAuth scope enforcement (not implemented yet).
- Rate-limit tuning and generic DoS through volume of requests.
## Reports and patches
Please include the affected endpoint or code path, the attacker's role and the victim, a PHPUnit test (in the style of
`tests/Unit/Endpoint/Api/V1/`) that reproduces the issue (for leaks between requests: a script against
`.oss-scanner/start-octane.sh` instead), and a minimal patch that follows the existing patterns
(authorisation in form requests/controllers via `PermissionStore`).

View File

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

View File

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

View File

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

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1; namespace App\Http\Controllers\Api\V1;
use App\Enums\Role;
use App\Exceptions\Api\EntityStillInUseApiException; use App\Exceptions\Api\EntityStillInUseApiException;
use App\Http\Requests\V1\Project\ProjectIndexRequest; use App\Http\Requests\V1\Project\ProjectIndexRequest;
use App\Http\Requests\V1\Project\ProjectStoreRequest; use App\Http\Requests\V1\Project\ProjectStoreRequest;
@@ -39,7 +38,7 @@ class ProjectController extends Controller
* *
* @operationId getProjects * @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'); $this->checkPermission($organization, 'projects:view');
$canViewAllProjects = $this->hasPermission($organization, 'projects:view:all'); $canViewAllProjects = $this->hasPermission($organization, 'projects:view:all');
@@ -63,7 +62,7 @@ class ProjectController extends Controller
->orderBy('id') ->orderBy('id')
->paginate(config('app.pagination_per_page_default')); ->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); return new ProjectCollection($projects, $showBillableRate);
} }
@@ -137,14 +136,16 @@ class ProjectController extends Controller
} }
$oldBillableRate = $project->billable_rate; $oldBillableRate = $project->billable_rate;
$clientIdChanged = false; $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')) { if ($project->client_id !== $request->input('client_id')) {
$project->client_id = $request->input('client_id'); $project->client_id = $request->input('client_id');
$clientIdChanged = true; $clientIdChanged = true;
} }
$project->save(); $project->save();
if ($oldBillableRate !== $request->getBillableRate()) { if ($oldBillableRate !== $project->billable_rate) {
$billableRateService->updateTimeEntriesBillableRateForProject($project); $billableRateService->updateTimeEntriesBillableRateForProject($project);
} }
if ($clientIdChanged) { if ($clientIdChanged) {

View File

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

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1; namespace App\Http\Controllers\Api\V1;
use App\Enums\ExportFormat; use App\Enums\ExportFormat;
use App\Enums\Role;
use App\Enums\TimeEntryType; use App\Enums\TimeEntryType;
use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException; use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException;
use App\Exceptions\Api\OverlappingTimeEntryApiException; use App\Exceptions\Api\OverlappingTimeEntryApiException;
@@ -29,6 +28,7 @@ use App\Models\Organization;
use App\Models\Project; use App\Models\Project;
use App\Models\Task; use App\Models\Task;
use App\Models\TimeEntry; use App\Models\TimeEntry;
use App\Service\BillableRateService;
use App\Service\LocalizationService; use App\Service\LocalizationService;
use App\Service\ReportExport\TimeEntriesDetailedCsvExport; use App\Service\ReportExport\TimeEntriesDetailedCsvExport;
use App\Service\ReportExport\TimeEntriesDetailedExport; use App\Service\ReportExport\TimeEntriesDetailedExport;
@@ -227,7 +227,7 @@ class TimeEntryController extends Controller
* *
* @operationId exportTimeEntries * @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); $member = $this->member($organization);
/** @var Member|null $memberFilter */ /** @var Member|null $memberFilter */
@@ -245,7 +245,7 @@ class TimeEntryController extends Controller
} }
$user = $this->user(); $user = $this->user();
$timezone = $user->timezone; $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; $roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null; $roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
@@ -384,7 +384,7 @@ class TimeEntryController extends Controller
* *
* @throws AuthorizationException * @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); $member = $this->member($organization);
/** @var Member|null $memberFilter */ /** @var Member|null $memberFilter */
@@ -396,7 +396,7 @@ class TimeEntryController extends Controller
} }
$canAccessPremiumFeatures = $this->canAccessPremiumFeatures($organization); $canAccessPremiumFeatures = $this->canAccessPremiumFeatures($organization);
$user = $this->user(); $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(); $group1Type = $request->getGroup();
$group2Type = $request->getSubGroup(); $group2Type = $request->getSubGroup();
@@ -434,7 +434,7 @@ class TimeEntryController extends Controller
* @throws NoOutputFileInResponse * @throws NoOutputFileInResponse
* @throws FeatureIsNotAvailableInFreePlanApiException * @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); $member = $this->member($organization);
/** @var Member|null $memberFilter */ /** @var Member|null $memberFilter */
@@ -451,7 +451,7 @@ class TimeEntryController extends Controller
} }
$debug = $request->getDebug(); $debug = $request->getDebug();
$user = $this->user(); $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(); $group = $request->getGroup();
$subGroup = $request->getSubGroup(); $subGroup = $request->getSubGroup();

View File

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

View File

@@ -68,7 +68,7 @@ class ProjectUpdateRequest extends BaseFormRequest
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(), })->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([ 'billable_rate' => array_merge([
'nullable', 'nullable',
], ],

View File

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

View File

@@ -6,6 +6,7 @@ namespace App\Models;
use App\Models\Concerns\CustomAuditable; use App\Models\Concerns\CustomAuditable;
use App\Models\Concerns\HasUuids; use App\Models\Concerns\HasUuids;
use App\Service\BillingContract;
use Database\Factories\MemberFactory; use Database\Factories\MemberFactory;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -48,6 +49,16 @@ class Member extends Pivot implements AuditableContract
*/ */
protected $table = 'members'; 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> * @return BelongsTo<User, $this>
*/ */

View File

@@ -11,6 +11,7 @@ use App\Enums\NumberFormat;
use App\Enums\TimeFormat; use App\Enums\TimeFormat;
use App\Models\Concerns\CustomAuditable; use App\Models\Concerns\CustomAuditable;
use App\Models\Concerns\HasUuids; use App\Models\Concerns\HasUuids;
use App\Service\BillingContract;
use Database\Factories\OrganizationFactory; use Database\Factories\OrganizationFactory;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -96,6 +97,25 @@ class Organization extends Model implements AuditableContract
protected $attributes = [ 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 public function getAuditOwnerOrganizationId(): ?string
{ {
return $this->getKey(); return $this->getKey();

View File

@@ -6,6 +6,7 @@ namespace App\Models;
use App\Models\Concerns\CustomAuditable; use App\Models\Concerns\CustomAuditable;
use App\Models\Concerns\HasUuids; use App\Models\Concerns\HasUuids;
use App\Service\BillingContract;
use Database\Factories\ProjectFactory; use Database\Factories\ProjectFactory;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Casts\Attribute;
@@ -136,6 +137,25 @@ class Project extends Model implements AuditableContract
return $this->scopeComputedAttributesGenerate($builder, $attributes); 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> * @return BelongsTo<Organization, $this>
*/ */

View File

@@ -7,6 +7,7 @@ namespace App\Models;
use App\Models\Concerns\AuditableThroughParent; use App\Models\Concerns\AuditableThroughParent;
use App\Models\Concerns\CustomAuditable; use App\Models\Concerns\CustomAuditable;
use App\Models\Concerns\HasUuids; use App\Models\Concerns\HasUuids;
use App\Service\BillingContract;
use Database\Factories\ProjectMemberFactory; use Database\Factories\ProjectMemberFactory;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -48,6 +49,16 @@ class ProjectMember extends Model implements AuditableContract, AuditableThrough
'billable_rate' => 'int', '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> * @return BelongsTo<Project, $this>
*/ */

View File

@@ -8,6 +8,7 @@ use App\Enums\TimeEntryType;
use App\Models\Concerns\CustomAuditable; use App\Models\Concerns\CustomAuditable;
use App\Models\Concerns\HasUuids; use App\Models\Concerns\HasUuids;
use App\Service\BillableRateService; use App\Service\BillableRateService;
use App\Service\BillingContract;
use Carbon\CarbonInterval; use Carbon\CarbonInterval;
use Database\Factories\TimeEntryFactory; use Database\Factories\TimeEntryFactory;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
@@ -140,6 +141,12 @@ class TimeEntry extends Model implements AuditableContract
$timeEntry->client_id = null; $timeEntry->client_id = null;
$timeEntry->tags = []; $timeEntry->tags = [];
} }
// Organizations that can not use billable rates can keep or remove the billable flag, but not set it
if ($timeEntry->billable && ! $timeEntry->getOriginal('billable') && ! app(BillingContract::class)->canUseBillableRates($timeEntry->loadMissing('organization')->organization)) {
$timeEntry->billable = false;
// Note: The billable rate is computed before saving, non-billable time entries have no billable rate
$timeEntry->billable_rate = null;
}
}); });
} }

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Service; namespace App\Service;
use App\Enums\Role;
use App\Models\Member; use App\Models\Member;
use App\Models\Organization; use App\Models\Organization;
use App\Models\Project; use App\Models\Project;
@@ -13,6 +14,24 @@ use Illuminate\Database\Eloquent\Builder;
class BillableRateService 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 public function updateTimeEntriesBillableRateForProjectMember(ProjectMember $projectMember): void
{ {
TimeEntry::query() TimeEntry::query()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -884,7 +884,7 @@ test('test that sorting members by billable rate works', async ({ page, ctx }) =
await expect(table).toBeVisible(); await expect(table).toBeVisible();
// First click = desc (highest first), null rates last // 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 billableHeader.click();
await expect(billableHeader.locator('svg')).toBeVisible(); await expect(billableHeader.locator('svg')).toBeVisible();
let names = await getTableRowNames(table); let names = await getTableRowNames(table);

14
e2e/utils/billing.ts Normal file
View File

@@ -0,0 +1,14 @@
import { expect } from '@playwright/test';
import { PLAYWRIGHT_BASE_URL } from '../../playwright/config';
import type { TestContext } from './api';
/**
* End the trial of the test organization, only works with the Billing extension. Reload the page
* afterwards, the page props still contain the trial otherwise.
*/
export async function cancelTrialViaApi(ctx: TestContext): Promise<void> {
const response = await ctx.request.post(
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/trial/cancel`
);
expect(response.status()).toBe(204);
}

View File

@@ -1,7 +1,7 @@
{ {
"Billing": { "Billing": {
"repository": "solidtime-io/extension-billing", "repository": "solidtime-io/extension-billing",
"ref": "v0.0.9" "ref": "feature/billable_rate_limits"
}, },
"Services": { "Services": {
"repository": "solidtime-io/extension-services", "repository": "solidtime-io/extension-services",
@@ -9,10 +9,10 @@
}, },
"Invoicing": { "Invoicing": {
"repository": "solidtime-io/extension-invoicing", "repository": "solidtime-io/extension-invoicing",
"ref": "v0.0.9" "ref": "feature/upgrade_modal_ui_package"
}, },
"Auditing": { "Auditing": {
"repository": "solidtime-io/extension-auditing", "repository": "solidtime-io/extension-auditing",
"ref": "v0.0.4" "ref": "v0.0.5"
} }
} }

View File

@@ -1,5 +1,5 @@
import { defineConfig, devices } from '@playwright/test'; import { defineConfig, devices } from '@playwright/test';
import { existsSync, readFileSync } from 'node:fs'; import { enabledExtensions } from './playwright/config';
/** /**
* Read environment variables from file. * Read environment variables from file.
@@ -7,23 +7,11 @@ import { existsSync, readFileSync } from 'node:fs';
*/ */
// require('dotenv').config(); // require('dotenv').config();
/** /*
* Extensions ship their e2e specs in extensions/<Name>/tests/e2e. Only the specs of extensions * Extensions ship their e2e specs in extensions/<Name>/tests/e2e. Only the specs of extensions
* that are enabled in modules_statuses.json (php artisan module:enable <Name>) are collected, * that are enabled in modules_statuses.json (php artisan module:enable <Name>) are collected,
* a checked out but disabled extension would fail its specs otherwise. * a checked out but disabled extension would fail its specs otherwise.
*/ *
function enabledExtensions(): string[] {
const statusesFile = './modules_statuses.json';
if (!existsSync(statusesFile)) {
return [];
}
const statuses: Record<string, boolean> = JSON.parse(readFileSync(statusesFile, 'utf-8'));
return Object.entries(statuses)
.filter(([, enabled]) => enabled)
.map(([name]) => name);
}
/*
* Every test root gets its own testDir (instead of testDir: '.') so playwright does not walk the * Every test root gets its own testDir (instead of testDir: '.') so playwright does not walk the
* whole repository, including node_modules, vendor and nested worktrees, to find the specs. * whole repository, including node_modules, vendor and nested worktrees, to find the specs.
*/ */

View File

@@ -1,3 +1,23 @@
import { existsSync, readFileSync } from 'node:fs';
export const PLAYWRIGHT_BASE_URL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://solidtime.test'; export const PLAYWRIGHT_BASE_URL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://solidtime.test';
export const MAILPIT_BASE_URL = process.env.MAILPIT_BASE_URL ?? 'http://mailpit:8025'; export const MAILPIT_BASE_URL = process.env.MAILPIT_BASE_URL ?? 'http://mailpit:8025';
export const TEST_USER_PASSWORD = 'amazingpassword123'; export const TEST_USER_PASSWORD = 'amazingpassword123';
/**
* Extensions that are enabled in modules_statuses.json (php artisan module:enable <Name>)
*/
export function enabledExtensions(): string[] {
const statusesFile = './modules_statuses.json';
if (!existsSync(statusesFile)) {
return [];
}
const statuses: Record<string, boolean> = JSON.parse(readFileSync(statusesFile, 'utf-8'));
return Object.entries(statuses)
.filter(([, enabled]) => enabled)
.map(([name]) => name);
}
export function isExtensionEnabled(name: string): boolean {
return enabledExtensions().includes(name);
}

View File

@@ -1,137 +1,21 @@
<script setup lang="ts"> <script setup lang="ts">
import MainContainer from '@/packages/ui/src/MainContainer.vue'; import MainContainer from '@/packages/ui/src/MainContainer.vue';
import { CheckBadgeIcon, XMarkIcon, XCircleIcon } from '@heroicons/vue/16/solid'; import { XMarkIcon, XCircleIcon } from '@heroicons/vue/16/solid';
import { Link } from '@inertiajs/vue3'; import { Link } from '@inertiajs/vue3';
import { computed } from 'vue'; import { computed } from 'vue';
import { import { isBillingActivated, isBlocked } from '@/utils/billing';
daysLeftInTrial,
isBillingActivated,
isBlocked,
isFreePlan,
isInTrial,
} from '@/utils/billing';
import { useSessionStorage } from '@vueuse/core'; import { useSessionStorage } from '@vueuse/core';
import { getCurrentOrganizationId } from '@/utils/useUser'; import { getCurrentOrganizationId } from '@/utils/useUser';
import { canManageBilling } from '@/utils/permissions'; import { canManageBilling } from '@/utils/permissions';
const hideTrialBanner = useSessionStorage('showTrialBanner-' + getCurrentOrganizationId(), false);
const showTrialBanner = computed(() => isInTrial() && !hideTrialBanner.value);
const hideBlockedBanner = useSessionStorage( const hideBlockedBanner = useSessionStorage(
'showBlockedBanner-' + getCurrentOrganizationId(), 'showBlockedBanner-' + getCurrentOrganizationId(),
false false
); );
const showBlockedBanner = computed(() => isBlocked() && !hideBlockedBanner.value); const showBlockedBanner = computed(() => isBlocked() && !hideBlockedBanner.value);
const hideFreeUpgradeBanner = useSessionStorage(
'showFreeUpgradeBanner-' + getCurrentOrganizationId(),
false
);
const showFreeUpgradeBanner = computed(
() =>
isFreePlan() && !isBlocked() && !hideFreeUpgradeBanner.value && !showBlackFridayBanner.value
);
const hideBlackFridayBanner = useSessionStorage(
'hideBlackFridayBanner-' + getCurrentOrganizationId(),
false
);
const showBlackFridayBanner = computed(() => {
if (hideBlackFridayBanner.value) {
return false;
}
const today = new Date();
const blackFriday = new Date(2024, 10, 30);
return today < blackFriday;
});
</script> </script>
<template> <template>
<div
v-if="showBlackFridayBanner"
class="bg-tertiary text-xs lg:text-sm pb-1 pt-2 border-b border-border-secondary">
<MainContainer class="flex items-center justify-between">
<div class="flex items-center space-x-1.5">
<svg class="w-4 mr-1" viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg">
<path
fill="#FF37AD"
d="M22.498 68.97a11.845 11.845 0 1 0 0-23.687c-6.471.098-11.666 5.372-11.666 11.844s5.195 11.746 11.666 11.844m181.393-10.04a11.845 11.845 0 1 0-.003-23.688c-6.471.098-11.665 5.373-11.665 11.845c.001 6.472 5.197 11.745 11.668 11.842" />
<path
fill="#FCC954"
d="M213.503 211.097a11.845 11.845 0 1 0-.003-23.687c-6.471.098-11.665 5.373-11.664 11.845s5.196 11.745 11.667 11.842M70.872 23.689a11.845 11.845 0 1 0 0-23.688C64.4.1 59.206 5.373 59.206 11.845S64.4 23.591 70.872 23.689" />
<path
fill="#2890E9"
d="M140.945 105.94a9.25 9.25 0 0 1-8.974-11.484c.37-1.482.672-2.97.899-4.455a25.4 25.4 0 0 1-8.732 1.904c-5.379.205-10.195-.702-14.3-2.69a22.23 22.23 0 0 1-9.614-8.877c-4.415-7.652-4.034-17.718.964-25.645c4.765-7.568 12.836-11.664 21.586-10.995c6.74.527 12.647 3.051 17.378 7.382q1.293-3.647 2.473-7.803c4.833-17.058 6.429-34.187 6.442-34.36a9.24 9.24 0 0 1 10.041-8.37a9.25 9.25 0 0 1 8.37 10.044c-.067.767-1.768 19.03-7.068 37.735c-2.676 9.445-5.838 17.426-9.42 23.798q.396 2.13.631 4.372c.746 7.211.152 14.974-1.714 22.445a9.256 9.256 0 0 1-8.962 6.998m-20.123-43.827c-.956 0-2.64.28-3.996 2.43c-1.298 2.06-1.552 4.873-.588 6.544c1.282 2.223 5.054 2.417 7.19 2.336c2.424-.092 4.908-1.612 7.338-4.382a16 16 0 0 0-1.43-2.422c-2.007-2.787-4.547-4.212-7.998-4.482c-.13-.008-.305-.024-.516-.024" />
<path
fill="#F0A420"
d="M114.361 131.268c-38.343-30.224-78.42-43.319-89.514-29.246a12.8 12.8 0 0 0-2.257 4.509a4 4 0 0 0-.156.61v.024q-.223.947-.333 1.917L.393 236.18c-3.477 20.412 16.73 36.755 35.967 29.093l117.721-46.908c2.076-.826 7.185-3.982 8.583-5.724q.556-.544 1.037-1.153c11.092-14.075-11-49.988-49.34-80.223z" />
<path
fill="#FCC954"
d="M163.688 211.494c11.1-14.08-10.984-50-49.327-80.226c-38.343-30.227-78.425-43.316-89.524-29.236s10.983 50 49.326 80.226c38.343 30.227 78.425 43.316 89.525 29.236" />
<path
fill="#F0A420"
d="M156.994 203.294c9.108-11.556-10.956-42.563-44.817-69.256c-33.861-26.695-68.697-38.966-77.804-27.413c-9.11 11.556 10.954 42.563 44.815 69.256c33.86 26.695 68.697 38.969 77.806 27.413" />
<path
fill="#2E6AC9"
d="M76.059 249.456c-14.327.07-26.004-7.101-40.158-18.257C19.431 218.21 8.493 202.665 7.63 193.81l-4.668 27.327c2.16 7.798 9.523 17.683 20.202 26.101c8.883 7.004 17.844 11.813 27.135 12.48l25.76-10.266zm-14.332-49.6c-27.443-21.637-45.271-46.467-44.77-60.669l-4.549 26.63c.351 12.685 15.175 33.184 36.262 49.808c18.894 14.896 38.583 25.38 53.66 23.363l25.593-10.2c-20.62 1.425-42.376-10.147-66.196-28.931" />
<path
fill="#2890E9"
d="M118.535 145.052a11.845 11.845 0 1 0 0-23.688c-6.471.098-11.666 5.372-11.666 11.844s5.195 11.746 11.666 11.844" />
<path
fill="#FF37AD"
d="m182.412 122.007l.087-.097c.108-.116.308-.33.596-.621a45 45 0 0 1 2.8-2.56c3.56-2.98 7.45-5.54 11.594-7.63c10.128-5.125 25.208-9.307 44.985-4.747c5.943 1.37 11.87-2.336 13.241-8.278c1.37-5.942-2.336-11.87-8.278-13.24c-25.602-5.903-45.957-.506-59.922 6.566a82.5 82.5 0 0 0-15.857 10.449a66 66 0 0 0-4.215 3.866a45 45 0 0 0-1.53 1.615l-.12.135l-.042.048l-.02.022l-.007.008c-.003.005-.009.01 8.361 7.21l-8.37-7.2c-3.877 4.622-3.328 11.5 1.233 15.448s11.446 3.506 15.464-.994M73.03 43.248a11.75 11.75 0 0 0-16.23-3.664a11.76 11.76 0 0 0-3.665 16.227c.427.683 9.178 14.86 10.976 34.276c1.83 19.727-3.966 37.86-17.253 54.12c4.474 5.686 9.858 11.596 16.008 17.507c8.51-9.834 14.913-20.402 19.12-31.583c5.175-13.756 7.006-28.342 5.445-43.348c-2.487-23.874-12.874-41.11-14.402-43.535" />
<path
fill="#2890E9"
d="M220.242 156.578c6.002 1.553 10.244 3.246 12.077 4.034a11.86 11.86 0 0 0 13.94-1.12a11.87 11.87 0 0 0 4.107-8.765a11.85 11.85 0 0 0-8.06-11.426c-5.618-2.495-26.905-10.92-55.044-9.423c-18.941 1.007-37.155 6.253-54.133 15.608c-16.076 8.86-31.004 21.412-44.556 37.425a199 199 0 0 0 20.17 12.607c22.882-26.08 49.283-40.217 78.7-42.085a105.9 105.9 0 0 1 32.8 3.145" />
</svg>
<div class="flex-1 space-x-1">
<span class="font-medium">
<strong>BLACK FRIDAY SALE!</strong> Use the code
<strong>BLACKFRIDAY</strong> at checkout and get
<strong>30% off</strong> the solidtime yearly plan.
</span>
</div>
</div>
<div class="flex items-center space-x-2">
<Link v-if="canManageBilling()" href="/billing">
<div
class="text-text-primary font-semibold uppercase text-xs flex space-x-1 items-center hover:bg-white/10 rounded-lg px-2 py-1.5">
<span>Upgrade now</span>
</div>
</Link>
<button class="p-1" @click="hideBlackFridayBanner = true">
<XMarkIcon class="w-4 opacity-50 hover:opacity-100"></XMarkIcon>
</button>
</div>
</MainContainer>
</div>
<div
v-if="showTrialBanner"
class="bg-accent-600/50 text-xs lg:text-sm py-0.5 border-b border-border-secondary">
<MainContainer class="flex items-center justify-between">
<div class="flex items-center space-x-1.5">
<CheckBadgeIcon class="w-4 text-text-primary/50"></CheckBadgeIcon>
<div class="flex-1 space-x-1">
<span class="font-medium">
Your trial expires in {{ daysLeftInTrial() }} days.
</span>
<span class="hidden md:inline">
To continue using all features & support the development of solidtime,
please upgrade your plan.
</span>
</div>
</div>
<div class="flex items-center space-x-2">
<Link v-if="canManageBilling()" href="/billing">
<div
class="text-text-primary font-semibold uppercase text-xs flex space-x-1 items-center hover:bg-white/10 rounded-lg px-2 py-1.5">
<span>Upgrade now</span>
</div>
</Link>
<button class="p-1" @click="hideTrialBanner = true">
<XMarkIcon class="w-4 opacity-50 hover:opacity-100"></XMarkIcon>
</button>
</div>
</MainContainer>
</div>
<div <div
v-if="showBlockedBanner" v-if="showBlockedBanner"
class="bg-red-600/50 text-xs lg:text-sm py-0.5 border-b border-border-secondary"> class="bg-red-600/50 text-xs lg:text-sm py-0.5 border-b border-border-secondary">
@@ -159,33 +43,6 @@ const showBlackFridayBanner = computed(() => {
</div> </div>
</MainContainer> </MainContainer>
</div> </div>
<div
v-if="showFreeUpgradeBanner"
class="bg-tertiary text-xs lg:text-sm py-0.5 border-b border-border-secondary">
<MainContainer class="flex items-center justify-between">
<div class="flex items-center space-x-1.5">
<XCircleIcon class="w-4 text-text-primary/50"></XCircleIcon>
<div class="flex-1 space-x-1">
<span class="font-medium"> You are currently using the Free Plan. </span>
<span class="hidden md:inline">
To unlock all premium features & support the development of solidtime,
please upgrade your plan.</span
>
</div>
</div>
<div class="flex items-center space-x-2">
<Link v-if="isBillingActivated() && canManageBilling()" href="/billing">
<div
class="text-text-primary font-semibold uppercase text-xs flex space-x-1 items-center hover:bg-white/10 rounded-lg px-2 py-1.5">
<span>Upgrade now</span>
</div>
</Link>
<button class="p-1" @click="hideFreeUpgradeBanner = true">
<XMarkIcon class="w-4 opacity-50 hover:opacity-100"></XMarkIcon>
</button>
</div>
</MainContainer>
</div>
</template> </template>
<style scoped></style> <style scoped></style>

View File

@@ -0,0 +1,86 @@
<script setup lang="ts">
import { ref } from 'vue';
import { router } from '@inertiajs/vue3';
import DialogModal from '@/packages/ui/src/DialogModal.vue';
import DangerButton from '@/packages/ui/src/Buttons/DangerButton.vue';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import { XMarkIcon } from '@heroicons/vue/16/solid';
import { useNotificationsStore } from '@/utils/notification';
import { getCurrentOrganizationId } from '@/utils/useUser';
import { api } from '@/packages/api/src';
const show = defineModel<boolean>('show', { default: false });
const processing = ref(false);
const { handleApiRequestNotifications } = useNotificationsStore();
const lostFeatures = [
'Billable hours & rates',
'Invoicing',
'Project & task estimates',
'Rounding of time entries',
'PDF reports & shareable reports',
'Team support & access management',
];
async function cancelTrial() {
// Note: The organization of this page, the current organization on the server can differ if the user switched organizations in another tab
const organizationId = getCurrentOrganizationId();
if (processing.value || organizationId === null) {
return;
}
processing.value = true;
try {
await handleApiRequestNotifications(
() => api.cancelTrial(undefined, { params: { organization: organizationId } }),
'Your trial has been canceled',
'Your trial could not be canceled, please try again',
() => {
show.value = false;
// Reloads the shared billing props of the page, they still contain the trial otherwise
router.reload();
}
);
} finally {
processing.value = false;
}
}
</script>
<template>
<DialogModal :show="show" closeable @close="show = false">
<template #title>Cancel trial</template>
<template #content>
<p class="text-sm text-text-secondary">
Your organization switches to the Free plan right away. You will lose access to
these Professional features:
</p>
<ul class="mt-4 grid gap-2 sm:grid-cols-2">
<li
v-for="feature in lostFeatures"
:key="feature"
class="flex items-center gap-2 text-sm text-text-primary">
<XMarkIcon class="h-4 w-4 shrink-0 text-red-500/80" />
{{ feature }}
</li>
</ul>
<p class="mt-5 text-sm text-text-secondary">
Organizations with more than one member are blocked until you upgrade or remove
everyone except the owner.
</p>
</template>
<template #footer>
<SecondaryButton @click="show = false">Keep trial</SecondaryButton>
<DangerButton
class="ms-3"
data-testid="confirm_cancel_trial"
:class="{ 'opacity-25': processing }"
:disabled="processing"
@click="cancelTrial">
Cancel trial
</DangerButton>
</template>
</DialogModal>
</template>

View File

@@ -0,0 +1,56 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { router } from '@inertiajs/vue3';
import { SparklesIcon } from '@heroicons/vue/16/solid';
import { Button } from '@/packages/ui/src/Buttons';
import TrialUpgradeModal from '@/Components/Billing/TrialUpgradeModal.vue';
import CancelTrialModal from '@/Components/Billing/CancelTrialModal.vue';
import { daysLeftInTrial, isBlocked, isFreePlan, isInTrial } from '@/utils/billing';
const showTrialUpgradeModal = ref(false);
const showCancelTrialModal = ref(false);
const daysLeftLabel = computed(() => {
const days = daysLeftInTrial();
return days === 1 ? '1 day left' : `${days} days left`;
});
function openBilling() {
router.visit('/billing');
}
</script>
<template>
<div v-if="isInTrial() || (isFreePlan() && !isBlocked())" class="pb-3">
<Button
v-if="isInTrial()"
variant="outline"
size="sm"
data-testid="sidebar_trial_days_left"
class="w-full justify-between rounded-lg px-2.5 text-xs font-normal [&_svg]:size-3.5"
@click="showTrialUpgradeModal = true">
<span class="flex items-center gap-1.5 font-medium text-text-primary">
<SparklesIcon class="h-3.5 w-3.5 text-accent-500 dark:text-accent-300" />
Professional trial
</span>
<span class="text-text-secondary">{{ daysLeftLabel }}</span>
</Button>
<div
v-else-if="isFreePlan() && !isBlocked()"
class="flex items-center justify-between gap-2 rounded-lg border border-border-secondary bg-card-background py-1.5 pl-2.5 pr-1.5 text-xs">
<span class="font-medium text-text-primary">Free plan</span>
<Button
size="xs"
data-testid="sidebar_upgrade_button"
class="h-6 bg-accent-500 px-2.5 font-semibold text-white hover:bg-accent-400"
@click="openBilling">
Upgrade
</Button>
</div>
<TrialUpgradeModal
v-model:show="showTrialUpgradeModal"
@cancel-trial="showCancelTrialModal = true" />
<CancelTrialModal v-model:show="showCancelTrialModal" />
</div>
</template>

View File

@@ -0,0 +1,79 @@
<script setup lang="ts">
import { computed } from 'vue';
import { Dialog, DialogContent, DialogTitle, DialogDescription } from '@/packages/ui/src/dialog';
import { Button } from '@/packages/ui/src/Buttons';
import { SparklesIcon } from '@heroicons/vue/16/solid';
import { featureIcons } from '@/packages/ui/src/UpgradeModal/featureIcons';
import { professionalFeatures, useUpgradeCta } from '@/packages/ui/src/UpgradeModal/useUpgradeCta';
import { daysLeftInTrial } from '@/utils/billing';
const show = defineModel<boolean>('show', { default: false });
const emit = defineEmits<{ cancelTrial: [] }>();
const { goToBilling } = useUpgradeCta();
const daysLeftLabel = computed(() => {
const days = daysLeftInTrial();
return days === 1 ? '1 day' : `${days} days`;
});
function cancelTrial() {
show.value = false;
emit('cancelTrial');
}
</script>
<template>
<Dialog v-model:open="show">
<DialogContent class="sm:max-w-xl p-0 overflow-hidden">
<div class="px-8 pt-8 text-center">
<div
class="mx-auto inline-flex items-center gap-1.5 rounded-full border border-border-secondary bg-card-background px-3 py-1 text-xs font-medium text-text-primary">
<SparklesIcon class="h-3.5 w-3.5 text-accent-500 dark:text-accent-300" />
{{ daysLeftLabel }} left in your trial
</div>
<DialogTitle class="mt-4 text-xl font-semibold tracking-tight text-text-primary">
Keep solidtime Professional
</DialogTitle>
<DialogDescription class="mt-1.5 text-sm text-text-secondary">
Upgrade now to keep these features after your trial ends.
</DialogDescription>
</div>
<div class="grid grid-cols-2 gap-x-6 gap-y-4 px-8 pt-8">
<div v-for="item in professionalFeatures" :key="item.key" class="flex gap-3">
<div
class="flex h-7 w-7 shrink-0 items-center justify-center rounded-md border border-border-tertiary bg-card-background">
<component
:is="featureIcons[item.key]"
class="h-3.5 w-3.5 text-text-secondary" />
</div>
<div class="min-w-0">
<p class="text-sm font-medium text-text-primary">{{ item.title }}</p>
<p class="mt-0.5 text-xs leading-relaxed text-text-tertiary">
{{ item.description }}
</p>
</div>
</div>
</div>
<div class="mx-auto flex max-w-xs flex-col items-center gap-3 px-8 pt-8 pb-6">
<Button
data-testid="trial_upgrade_modal_cta"
class="w-full bg-accent-500 px-4 font-semibold text-white shadow-[0_0_24px_-6px] shadow-accent-500/60 hover:bg-accent-400"
@click="goToBilling">
Upgrade to Professional
</Button>
<Button
variant="ghost"
size="xs"
data-testid="trial_upgrade_modal_cancel_trial"
class="font-normal text-text-tertiary hover:text-text-primary"
@click="cancelTrial">
Cancel trial
</Button>
</div>
</DialogContent>
</Dialog>
</template>

View File

@@ -21,6 +21,7 @@ import { getOrganizationCurrencyString } from '@/utils/money';
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue'; import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
import { useOrganizationQuery } from '@/utils/useOrganizationQuery'; import { useOrganizationQuery } from '@/utils/useOrganizationQuery';
import { getCurrentOrganizationId } from '@/utils/useUser'; import { getCurrentOrganizationId } from '@/utils/useUser';
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
const { updateMember } = useMembersStore(); const { updateMember } = useMembersStore();
const { organization } = useOrganizationQuery(getCurrentOrganizationId()!); const { organization } = useOrganizationQuery(getCurrentOrganizationId()!);
@@ -71,6 +72,8 @@ function saveWithChecks() {
} }
} }
const { locked: billableRatesLocked } = useBillableRatesLock();
const billableRateSelect = ref<MemberBillableKey>('default-rate'); const billableRateSelect = ref<MemberBillableKey>('default-rate');
onMounted(() => { onMounted(() => {
@@ -159,7 +162,12 @@ const roleDescription = computed(() => {
<FieldLabel :icon="BillableIcon" for="billableRateType" <FieldLabel :icon="BillableIcon" for="billableRateType"
>Billable Rate</FieldLabel >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"> <Select v-model="billableRateSelect">
<SelectTrigger id="billableRateType"> <SelectTrigger id="billableRateType">
<SelectValue /> <SelectValue />

View File

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

View File

@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
import ProjectMoreOptionsDropdown from '@/Components/Common/Project/ProjectMoreOptionsDropdown.vue'; import ProjectMoreOptionsDropdown from '@/Components/Common/Project/ProjectMoreOptionsDropdown.vue';
import type { Project } from '@/packages/api/src'; import type { Project } from '@/packages/api/src';
import { computed, ref, inject, type ComputedRef } from 'vue'; import { computed, ref, inject, type ComputedRef } from 'vue';
@@ -15,10 +16,11 @@ import { useTasksQuery } from '@/utils/useTasksQuery';
import { useProjectsStore } from '@/utils/useProjects'; import { useProjectsStore } from '@/utils/useProjects';
import TableRow from '@/Components/TableRow.vue'; import TableRow from '@/Components/TableRow.vue';
import ProjectEditModal from '@/Components/Common/Project/ProjectEditModal.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 { formatCents } from '@/packages/ui/src/utils/money';
import { getOrganizationCurrencyString } from '@/utils/money'; import { getOrganizationCurrencyString } from '@/utils/money';
import EstimatedTimeProgress from '@/packages/ui/src/EstimatedTimeProgress.vue'; import EstimatedTimeProgress from '@/packages/ui/src/EstimatedTimeProgress.vue';
import UpgradeBadge from '@/Components/Common/UpgradeBadge.vue'; import UpgradeBadge from '@/packages/ui/src/UpgradeBadge.vue';
import { formatHumanReadableDuration } from '../../../packages/ui/src/utils/time'; import { formatHumanReadableDuration } from '../../../packages/ui/src/utils/time';
import { isAllowedToPerformPremiumAction } from '@/utils/billing'; import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import { canUpdateProjects, canDeleteProjects } from '@/utils/permissions'; import { canUpdateProjects, canDeleteProjects } from '@/utils/permissions';
@@ -78,6 +80,8 @@ const billableRateInfo = computed(() => {
}); });
const showEditProjectModal = ref(false); const showEditProjectModal = ref(false);
const { locked: billableRatesLocked } = useBillableRatesLock();
</script> </script>
<template> <template>
@@ -100,13 +104,15 @@ const showEditProjectModal = ref(false);
</span> </span>
<span class="text-text-secondary"> {{ projectTasksCount }} Tasks </span> <span class="text-text-secondary"> {{ projectTasksCount }} Tasks </span>
</div> </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"> <div v-if="project.client_id" class="overflow-ellipsis overflow-hidden">
{{ client?.name }} {{ client?.name }}
</div> </div>
<div v-else class="text-text-tertiary">No client</div> <div v-else class="text-text-tertiary">No client</div>
</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"> <div v-if="project.spent_time">
{{ {{
formatHumanReadableDuration( formatHumanReadableDuration(
@@ -119,7 +125,9 @@ const showEditProjectModal = ref(false);
<div v-else class="text-text-tertiary">--</div> <div v-else class="text-text-tertiary">--</div>
</div> </div>
<div class="whitespace-nowrap px-3 flex items-center text-sm text-text-primary"> <div class="whitespace-nowrap px-3 flex items-center text-sm text-text-primary">
<UpgradeBadge v-if="!isAllowedToPerformPremiumAction()"></UpgradeBadge> <UpgradeBadge
v-if="!isAllowedToPerformPremiumAction()"
feature="estimates"></UpgradeBadge>
<EstimatedTimeProgress <EstimatedTimeProgress
v-else-if="project.estimated_time" v-else-if="project.estimated_time"
:estimated="project.estimated_time" :estimated="project.estimated_time"
@@ -128,8 +136,9 @@ const showEditProjectModal = ref(false);
</div> </div>
<div <div
v-if="showBillableRate" v-if="showBillableRate"
class="whitespace-nowrap px-3 py-4 text-sm text-text-primary"> class="whitespace-nowrap px-3 flex items-center 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> <span v-else class="text-text-tertiary">--</span>
</div> </div>
<div <div

View File

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

View File

@@ -4,7 +4,7 @@ import ReportCreateModal from '@/Components/Common/Report/ReportCreateModal.vue'
import { h, ref } from 'vue'; import { h, ref } from 'vue';
import type { CreateReportBodyProperties } from '@/packages/api/src'; import type { CreateReportBodyProperties } from '@/packages/api/src';
import { isAllowedToPerformPremiumAction } from '@/utils/billing'; import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import UpgradeModal from '@/Components/Common/UpgradeModal.vue'; import UpgradeModal from '@/packages/ui/src/UpgradeModal.vue';
import { canCreateReports } from '@/utils/permissions'; import { canCreateReports } from '@/utils/permissions';
defineProps<{ defineProps<{
reportProperties: CreateReportBodyProperties; reportProperties: CreateReportBodyProperties;
@@ -30,9 +30,7 @@ function onSaveReportClick() {
<ReportCreateModal <ReportCreateModal
v-model:show="showCreateReportModal" v-model:show="showCreateReportModal"
:properties="reportProperties"></ReportCreateModal> :properties="reportProperties"></ReportCreateModal>
<UpgradeModal v-model:show="showPremiumModal"> <UpgradeModal v-model:show="showPremiumModal" feature="shareable-reports" />
<strong>Sharable Reports</strong> is only available in solidtime Professional.
</UpgradeModal>
<SecondaryButton v-if="canCreateReports()" :icon="SaveIcon" @click="onSaveReportClick" <SecondaryButton v-if="canCreateReports()" :icon="SaveIcon" @click="onSaveReportClick"
>Save Report</SecondaryButton >Save Report</SecondaryButton
> >

View File

@@ -10,7 +10,7 @@ import {
import type { ExportFormat } from '@/types/reporting'; import type { ExportFormat } from '@/types/reporting';
import { ref } from 'vue'; import { ref } from 'vue';
import { isAllowedToPerformPremiumAction } from '@/utils/billing'; import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import UpgradeModal from '@/Components/Common/UpgradeModal.vue'; import UpgradeModal from '@/packages/ui/src/UpgradeModal.vue';
const props = defineProps<{ const props = defineProps<{
download: (format: ExportFormat) => Promise<void>; download: (format: ExportFormat) => Promise<void>;
@@ -48,9 +48,7 @@ function triggerDownload(format: ExportFormat) {
<DropdownMenuItem @click="triggerDownload('ods')"> Export as ODS </DropdownMenuItem> <DropdownMenuItem @click="triggerDownload('ods')"> Export as ODS </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
<UpgradeModal v-model:show="showPremiumModal"> <UpgradeModal v-model:show="showPremiumModal" feature="pdf-reports" />
<strong>PDF Reports</strong> are only available in solidtime Professional.
</UpgradeModal>
</template> </template>
<style scoped></style> <style scoped></style>

View File

@@ -1,10 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { useBreaksEnabled } from '@/packages/ui/src/utils/useBreaksEnabled'; 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 { FolderIcon } from '@heroicons/vue/16/solid';
import { Check, Coffee } from '@lucide/vue'; import { Check, Coffee } from '@lucide/vue';
import { RadioGroupIndicator, RadioGroupItem, RadioGroupRoot, type AcceptableValue } from 'reka-ui'; import { RadioGroupIndicator, RadioGroupItem, RadioGroupRoot, type AcceptableValue } from 'reka-ui';
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue'; 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 ReportingRoundingControls from '@/Components/Common/Reporting/ReportingRoundingControls.vue';
import TaskMultiselectDropdown from '@/Components/Common/Task/TaskMultiselectDropdown.vue'; import TaskMultiselectDropdown from '@/Components/Common/Task/TaskMultiselectDropdown.vue';
import ClientMultiselectDropdown from '@/Components/Common/Client/ClientMultiselectDropdown.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 ReportingFilterBadge from '@/Components/Common/Reporting/ReportingFilterBadge.vue';
import ProjectMultiselectDropdown from '@/Components/Common/Project/ProjectMultiselectDropdown.vue'; import ProjectMultiselectDropdown from '@/Components/Common/Project/ProjectMultiselectDropdown.vue';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/packages/ui/src'; 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 MainContainer from '@/packages/ui/src/MainContainer.vue';
import DateRangePicker from '@/packages/ui/src/Input/DateRangePicker.vue'; import DateRangePicker from '@/packages/ui/src/Input/DateRangePicker.vue';
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.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 startDate = defineModel<string>('startDate', { required: true });
const endDate = defineModel<string>('endDate', { required: true }); const endDate = defineModel<string>('endDate', { required: true });
const { locked: billableRatesLocked, requestUpgrade } = useBillableRatesLock();
const emit = defineEmits<{ const emit = defineEmits<{
submit: []; submit: [];
}>(); }>();
@@ -141,7 +145,17 @@ async function createTag(name: string) {
</template> </template>
</TagDropdown> </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 <SelectTrigger
size="sm" size="sm"
variant="outline" variant="outline"

View File

@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import UpgradeLockedValue from '@/packages/ui/src/UpgradeLockedValue.vue';
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
import { import {
ChartBarIcon, ChartBarIcon,
ArrowDownTrayIcon, ArrowDownTrayIcon,
@@ -30,7 +32,8 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/packages/ui/src'; } from '@/packages/ui/src';
import ReportCreateModal from '@/Components/Common/Report/ReportCreateModal.vue'; import ReportCreateModal from '@/Components/Common/Report/ReportCreateModal.vue';
import UpgradeModal from '@/Components/Common/UpgradeModal.vue'; import UpgradeModal from '@/packages/ui/src/UpgradeModal.vue';
import type { UpgradeFeature } from '@/packages/ui/src/UpgradeModal/useUpgradeCta';
import { canCreateReports } from '@/utils/permissions'; import { canCreateReports } from '@/utils/permissions';
import { isAllowedToPerformPremiumAction } from '@/utils/billing'; import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import { computed, type ComputedRef, inject, ref, watch } from 'vue'; import { computed, type ComputedRef, inject, ref, watch } from 'vue';
@@ -214,10 +217,13 @@ const showExportModal = ref(false);
const exportUrl = ref<string | null>(null); const exportUrl = ref<string | null>(null);
const showCreateReportModal = ref(false); const showCreateReportModal = ref(false);
const showPremiumModal = ref(false); const showPremiumModal = ref(false);
// Note: The premium modal is shared by the PDF export and saving a report
const premiumFeature = ref<UpgradeFeature>('pdf-reports');
const exportLoading = ref(false); const exportLoading = ref(false);
function triggerExport(format: ExportFormat) { function triggerExport(format: ExportFormat) {
if (format === 'pdf' && !isAllowedToPerformPremiumAction()) { if (format === 'pdf' && !isAllowedToPerformPremiumAction()) {
premiumFeature.value = 'pdf-reports';
showPremiumModal.value = true; showPremiumModal.value = true;
return; return;
} }
@@ -231,6 +237,7 @@ function onSaveReportClick() {
if (isAllowedToPerformPremiumAction()) { if (isAllowedToPerformPremiumAction()) {
showCreateReportModal.value = true; showCreateReportModal.value = true;
} else { } else {
premiumFeature.value = 'shareable-reports';
showPremiumModal.value = true; showPremiumModal.value = true;
} }
} }
@@ -290,6 +297,8 @@ const tableData = computed(() => {
}; };
}); });
}); });
const { locked: billableRatesLocked } = useBillableRatesLock();
</script> </script>
<template> <template>
@@ -299,9 +308,7 @@ const tableData = computed(() => {
<ReportCreateModal <ReportCreateModal
v-model:show="showCreateReportModal" v-model:show="showCreateReportModal"
:properties="reportProperties"></ReportCreateModal> :properties="reportProperties"></ReportCreateModal>
<UpgradeModal v-model:show="showPremiumModal"> <UpgradeModal v-model:show="showPremiumModal" :feature="premiumFeature" />
This feature is only available in solidtime Professional.
</UpgradeModal>
<MainContainer <MainContainer
class="h-14 sm:h-16 border-b border-default-background-separator flex flex-wrap gap-y-3 justify-between items-center"> class="h-14 sm:h-16 border-b border-default-background-separator flex flex-wrap gap-y-3 justify-between items-center">
<div class="flex items-center space-x-3 sm:space-x-6"> <div class="flex items-center space-x-3 sm:space-x-6">
@@ -447,17 +454,20 @@ const tableData = computed(() => {
<div <div
v-if="showBillableRate" v-if="showBillableRate"
class="justify-end pr-6 flex items-center font-medium"> class="justify-end pr-6 flex items-center font-medium">
{{ <UpgradeLockedValue v-if="billableRatesLocked" />
aggregatedTableTimeEntries.cost <template v-else>
? formatCents( {{
aggregatedTableTimeEntries.cost, aggregatedTableTimeEntries.cost
getOrganizationCurrencyString(), ? formatCents(
organization?.currency_format, aggregatedTableTimeEntries.cost,
organization?.currency_symbol, getOrganizationCurrencyString(),
organization?.number_format organization?.currency_format,
) organization?.currency_symbol,
: '--' organization?.number_format
}} )
: '--'
}}
</template>
</div> </div>
</div> </div>
</template> </template>

View File

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

View File

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

View File

@@ -9,7 +9,7 @@ import TaskEditModal from '@/Components/Common/Task/TaskEditModal.vue';
import { ref, inject, type ComputedRef } from 'vue'; import { ref, inject, type ComputedRef } from 'vue';
import { isAllowedToPerformPremiumAction } from '@/utils/billing'; import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import EstimatedTimeProgress from '@/packages/ui/src/EstimatedTimeProgress.vue'; import EstimatedTimeProgress from '@/packages/ui/src/EstimatedTimeProgress.vue';
import UpgradeBadge from '@/Components/Common/UpgradeBadge.vue'; import UpgradeBadge from '@/packages/ui/src/UpgradeBadge.vue';
import { formatHumanReadableDuration } from '../../../packages/ui/src/utils/time'; import { formatHumanReadableDuration } from '../../../packages/ui/src/utils/time';
import type { Organization } from '@/packages/api/src'; import type { Organization } from '@/packages/api/src';
@@ -55,7 +55,9 @@ const showTaskEditModal = ref(false);
<span v-else> -- </span> <span v-else> -- </span>
</div> </div>
<div class="whitespace-nowrap px-3 flex items-center text-sm text-text-secondary"> <div class="whitespace-nowrap px-3 flex items-center text-sm text-text-secondary">
<UpgradeBadge v-if="!isAllowedToPerformPremiumAction()"></UpgradeBadge> <UpgradeBadge
v-if="!isAllowedToPerformPremiumAction()"
feature="estimates"></UpgradeBadge>
<EstimatedTimeProgress <EstimatedTimeProgress
v-else-if="task.estimated_time" v-else-if="task.estimated_time"
:estimated="task.estimated_time" :estimated="task.estimated_time"

View File

@@ -1,67 +0,0 @@
<script setup lang="ts">
import DialogModal from '@/packages/ui/src/DialogModal.vue';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import { Link } from '@inertiajs/vue3';
import { isBillingActivated } from '@/utils/billing';
import { CreditCardIcon, UserGroupIcon } from '@heroicons/vue/20/solid';
import { canManageBilling, canUpdateOrganization } from '@/utils/permissions';
import { SecondaryButton } from '@/packages/ui/src';
const show = defineModel('show', { default: false });
</script>
<template>
<DialogModal closeable :show="show" @close="show = false">
<template #title>
<div class="flex space-x-2">
<span> Upgrade Plan </span>
</div>
</template>
<template #content>
<div>
<div
class="rounded-full flex items-center justify-center w-20 h-20 mx-auto border border-border-tertiary bg-secondary">
<UserGroupIcon class="w-12"></UserGroupIcon>
</div>
<div class="max-w-sm text-center mx-auto py-4 text-base">
<p class="py-1">
<slot></slot>
</p>
<p class="py-1 text-sm">
If you want to use this feature,
<strong class="font-semibold text-text-primary"
>please upgrade to a paid plan</strong
>
or
<strong class="font-semibold text-text-primary"
>request a free trial</strong
>
via
<a
class="text-accent-200/80 transition text-accent-300"
href="mailto:hello@solidtime.io"
>hello@solidtime.io</a
>
to try out this feature.
</p>
<Link v-if="isBillingActivated() && canManageBilling()" href="/billing">
<PrimaryButton
v-if="isBillingActivated() && canUpdateOrganization()"
type="button"
class="mt-6"
:icon="CreditCardIcon">
Go to Billing
</PrimaryButton>
</Link>
</div>
</div>
</template>
<template #footer>
<SecondaryButton @click="show = false">Close</SecondaryButton>
</template>
</DialogModal>
</template>
<style scoped></style>

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head, usePage } from '@inertiajs/vue3'; import { Head, router, usePage } from '@inertiajs/vue3';
import Banner from '@/Components/Banner.vue'; import Banner from '@/Components/Banner.vue';
import OrganizationSwitcher from '@/Components/OrganizationSwitcher.vue'; import OrganizationSwitcher from '@/Components/OrganizationSwitcher.vue';
import CurrentSidebarTimer from '@/Components/CurrentSidebarTimer.vue'; import CurrentSidebarTimer from '@/Components/CurrentSidebarTimer.vue';
@@ -23,7 +23,7 @@ import { PanelLeft, Target } from '@lucide/vue';
import NavigationSidebarItem from '@/Components/NavigationSidebarItem.vue'; import NavigationSidebarItem from '@/Components/NavigationSidebarItem.vue';
import UserSettingsIcon from '@/Components/UserSettingsIcon.vue'; import UserSettingsIcon from '@/Components/UserSettingsIcon.vue';
import MainContainer from '@/packages/ui/src/MainContainer.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 NotificationContainer from '@/Components/NotificationContainer.vue';
import { initializeStores } from '@/utils/init'; import { initializeStores } from '@/utils/init';
import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry'; import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
@@ -38,12 +38,16 @@ import {
canViewReport, canViewReport,
canViewTags, canViewTags,
} from '@/utils/permissions'; } 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 type { User } from '@/types/models';
import { ArrowsRightLeftIcon } from '@heroicons/vue/16/solid'; import { ArrowsRightLeftIcon } from '@heroicons/vue/16/solid';
import { fetchToken, isTokenValid } from '@/utils/session'; import { fetchToken, isTokenValid } from '@/utils/session';
import UpdateSidebarNotification from '@/Components/UpdateSidebarNotification.vue'; import UpdateSidebarNotification from '@/Components/UpdateSidebarNotification.vue';
import BillingBanner from '@/Components/Billing/BillingBanner.vue'; import BillingBanner from '@/Components/Billing/BillingBanner.vue';
import SidebarPlanStatus from '@/Components/Billing/SidebarPlanStatus.vue';
import UserTimezoneMismatchModal from '@/Components/Common/User/UserTimezoneMismatchModal.vue'; import UserTimezoneMismatchModal from '@/Components/Common/User/UserTimezoneMismatchModal.vue';
import { useTheme } from '@/utils/theme'; import { useTheme } from '@/utils/theme';
import { useOrganizationQuery } from '@/utils/useOrganizationQuery'; import { useOrganizationQuery } from '@/utils/useOrganizationQuery';
@@ -89,6 +93,19 @@ const { organization, isLoading: isOrganizationLoading } = useOrganizationQuery(
provide('organization', organization); 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'),
});
onMounted(async () => { onMounted(async () => {
useTheme(); useTheme();
// make sure that the initial requests are only loaded once, this can be removed once we move away from inertia // make sure that the initial requests are only loaded once, this can be removed once we move away from inertia
@@ -306,6 +323,7 @@ const page = usePage<{
</div> </div>
<div class="justify-self-end"> <div class="justify-self-end">
<UpdateSidebarNotification></UpdateSidebarNotification> <UpdateSidebarNotification></UpdateSidebarNotification>
<SidebarPlanStatus v-if="isBillingActivated() && canManageBilling()" />
<ul <ul
class="border-t border-default-background-separator pt-3 gap-1 flex justify-between items-center"> class="border-t border-default-background-separator pt-3 gap-1 flex justify-between items-center">
<UserSettingsIcon></UserSettingsIcon> <UserSettingsIcon></UserSettingsIcon>
@@ -384,4 +402,5 @@ const page = usePage<{
<NotificationContainer></NotificationContainer> <NotificationContainer></NotificationContainer>
<UserTimezoneMismatchModal></UserTimezoneMismatchModal> <UserTimezoneMismatchModal></UserTimezoneMismatchModal>
<CommandPaletteProvider></CommandPaletteProvider> <CommandPaletteProvider></CommandPaletteProvider>
<UpgradeModal v-model:show="showBillableRatesUpgradeModal" feature="billable-rates" />
</template> </template>

View File

@@ -8,9 +8,9 @@ import { computed, ref } from 'vue';
import GoalTable from '@/Components/Common/Goal/GoalTable.vue'; import GoalTable from '@/Components/Common/Goal/GoalTable.vue';
import GoalCreateModal from '@/Components/Common/Goal/GoalCreateModal.vue'; import GoalCreateModal from '@/Components/Common/Goal/GoalCreateModal.vue';
import PageTitle from '@/Components/Common/PageTitle.vue'; import PageTitle from '@/Components/Common/PageTitle.vue';
import UpgradeBadge from '@/Components/Common/UpgradeBadge.vue'; import UpgradeBadge from '@/packages/ui/src/UpgradeBadge.vue';
import { canCreateGoals } from '@/utils/permissions'; import { canCreateGoals } from '@/utils/permissions';
import { canCreateMoreGoals, goalLimit } from '@/utils/billing'; import { canCreateMoreGoals } from '@/utils/billing';
import { useGoalsQuery } from '@/utils/useGoalsQuery'; import { useGoalsQuery } from '@/utils/useGoalsQuery';
import { TabBar, TabBarItem } from '@/packages/ui/src'; import { TabBar, TabBarItem } from '@/packages/ui/src';
@@ -25,7 +25,6 @@ const visibleGoals = computed(() =>
); );
const archivedCount = computed(() => goals.value.filter((goal) => goal.is_archived).length); const archivedCount = computed(() => goals.value.filter((goal) => goal.is_archived).length);
const canCreateMore = computed(() => canCreateMoreGoals(goals.value.length)); const canCreateMore = computed(() => canCreateMoreGoals(goals.value.length));
const limit = goalLimit();
</script> </script>
<template> <template>
@@ -43,10 +42,7 @@ const limit = goalLimit();
</TabBar> </TabBar>
</div> </div>
<div class="flex items-center space-x-3"> <div class="flex items-center space-x-3">
<UpgradeBadge v-if="canCreateGoals() && !canCreateMore"> <UpgradeBadge v-if="canCreateGoals() && !canCreateMore" feature="goals" />
<strong>More than {{ limit }} goal{{ limit === 1 ? '' : 's' }}</strong> is only
available in solidtime Professional.
</UpgradeBadge>
<SecondaryButton <SecondaryButton
v-if="canCreateGoals() && canCreateMore" v-if="canCreateGoals() && canCreateMore"
:icon="PlusIcon" :icon="PlusIcon"

View File

@@ -1,4 +1,6 @@
<script setup lang="ts"> <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 MainContainer from '@/packages/ui/src/MainContainer.vue';
import AppLayout from '@/Layouts/AppLayout.vue'; import AppLayout from '@/Layouts/AppLayout.vue';
import { FolderIcon, PlusIcon } from '@heroicons/vue/20/solid'; import { FolderIcon, PlusIcon } from '@heroicons/vue/20/solid';
@@ -80,6 +82,9 @@ const shownTasks = computed(() => {
return task.project_id === projectId && task.is_done; 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> </script>
<template> <template>
@@ -117,11 +122,17 @@ const shownTasks = computed(() => {
</li> </li>
</ol> </ol>
<div class="px-4 space-x-1"> <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 }} {{ billableRateFormatted }}
/ h / h
</Badge> </Badge>
<Badge v-if="project?.is_billable && !project?.billable_rate"> <Badge
v-if="
project?.is_billable && !project?.billable_rate && !billableRatesLocked
">
Default Rate Default Rate
</Badge> </Badge>
<Badge v-if="!project?.is_billable"> Non-Billable </Badge> <Badge v-if="!project?.is_billable"> Non-Billable </Badge>

View File

@@ -43,7 +43,7 @@ import { useMembersQuery } from '@/utils/useMembersQuery';
import { useQueryClient } from '@tanstack/vue-query'; import { useQueryClient } from '@tanstack/vue-query';
import { getCurrentOrganizationId, getCurrentMembershipId } from '@/utils/useUser'; import { getCurrentOrganizationId, getCurrentMembershipId } from '@/utils/useUser';
import ReportingTabNavbar from '@/Components/Common/Reporting/ReportingTabNavbar.vue'; import ReportingTabNavbar from '@/Components/Common/Reporting/ReportingTabNavbar.vue';
import UpgradeModal from '@/Components/Common/UpgradeModal.vue'; import UpgradeModal from '@/packages/ui/src/UpgradeModal.vue';
import type { ExportFormat } from '@/types/reporting'; import type { ExportFormat } from '@/types/reporting';
import { useNotificationsStore } from '@/utils/notification'; import { useNotificationsStore } from '@/utils/notification';
import TimeEntryMassActionRow from '@/packages/ui/src/TimeEntry/TimeEntryMassActionRow.vue'; import TimeEntryMassActionRow from '@/packages/ui/src/TimeEntry/TimeEntryMassActionRow.vue';
@@ -253,9 +253,7 @@ async function downloadExport(format: ExportFormat) {
<ReportingExportModal <ReportingExportModal
v-model:show="showExportModal" v-model:show="showExportModal"
:export-url="exportUrl"></ReportingExportModal> :export-url="exportUrl"></ReportingExportModal>
<UpgradeModal v-model:show="showPremiumModal"> <UpgradeModal v-model:show="showPremiumModal" feature="pdf-reports" />
<strong>PDF Reports</strong> are only available in solidtime Professional.
</UpgradeModal>
<MainContainer <MainContainer
class="h-14 sm:h-16 border-b border-default-background-separator flex flex-wrap gap-y-3 justify-between items-center"> class="h-14 sm:h-16 border-b border-default-background-separator flex flex-wrap gap-y-3 justify-between items-center">
<div class="flex items-center space-x-3 sm:space-x-6"> <div class="flex items-center space-x-3 sm:space-x-6">

View File

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

View File

@@ -0,0 +1,51 @@
<script setup lang="ts">
import { computed, inject, ref, type ComputedRef } from 'vue';
import { usePage } from '@inertiajs/vue3';
import ActionSection from '@/Components/ActionSection.vue';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import CancelTrialModal from '@/Components/Billing/CancelTrialModal.vue';
import { daysLeftInTrial } from '@/utils/billing';
import { formatDateLocalized } from '@/packages/ui/src/utils/time';
import type { Organization } from '@/packages/api/src';
const showCancelTrialModal = ref(false);
const organization = inject<ComputedRef<Organization | undefined>>('organization');
const page = usePage<{ billing: { trial_until: string | null } | null }>();
const trialUntil = computed(() =>
page.props.billing?.trial_until
? formatDateLocalized(page.props.billing.trial_until, organization?.value?.date_format)
: null
);
const daysLeftLabel = computed(() => {
const days = daysLeftInTrial();
return days === 1 ? '1 day' : `${days} days`;
});
</script>
<template>
<ActionSection>
<template #title>Trial</template>
<template #description>Your organization is trying out solidtime Professional.</template>
<template #content>
<div class="max-w-xl text-sm text-text-secondary">
Your trial ends in
<span class="font-medium text-text-primary">{{ daysLeftLabel }}</span>
<template v-if="trialUntil"> ({{ trialUntil }})</template>. If you don't need the
Professional features, you can switch to the Free plan right away.
</div>
<div class="mt-5">
<SecondaryButton
data-testid="cancel_trial_button"
@click="showCancelTrialModal = true">
Cancel trial
</SecondaryButton>
</div>
<CancelTrialModal v-model:show="showCancelTrialModal" />
</template>
</ActionSection>
</template>

View File

@@ -8,6 +8,9 @@ import type { Permissions } from '@/types/jetstream';
import OrganizationBillableRate from '@/Pages/Teams/Partials/OrganizationBillableRate.vue'; import OrganizationBillableRate from '@/Pages/Teams/Partials/OrganizationBillableRate.vue';
import OrganizationFormatSettings from '@/Pages/Teams/Partials/OrganizationFormatSettings.vue'; import OrganizationFormatSettings from '@/Pages/Teams/Partials/OrganizationFormatSettings.vue';
import OrganizationTimeEntrySettings from '@/Pages/Teams/Partials/OrganizationTimeEntrySettings.vue'; import OrganizationTimeEntrySettings from '@/Pages/Teams/Partials/OrganizationTimeEntrySettings.vue';
import OrganizationTrial from '@/Pages/Teams/Partials/OrganizationTrial.vue';
import { isBillingActivated, isInTrial } from '@/utils/billing';
import { canManageBilling } from '@/utils/permissions';
import { onMounted, ref } from 'vue'; import { onMounted, ref } from 'vue';
import { useOrganizationStore } from '@/utils/useOrganization'; import { useOrganizationStore } from '@/utils/useOrganization';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
@@ -52,6 +55,11 @@ onMounted(async () => {
<SectionBorder v-if="permissions.canUpdateTeam" /> <SectionBorder v-if="permissions.canUpdateTeam" />
<OrganizationTimeEntrySettings v-if="permissions.canUpdateTeam" /> <OrganizationTimeEntrySettings v-if="permissions.canUpdateTeam" />
<template v-if="isBillingActivated() && isInTrial() && canManageBilling()">
<SectionBorder />
<OrganizationTrial />
</template>
<template v-if="permissions.canDeleteTeam"> <template v-if="permissions.canDeleteTeam">
<SectionBorder /> <SectionBorder />
<DeleteTeamForm class="mt-10 sm:mt-0" :team="team" /> <DeleteTeamForm class="mt-10 sm:mt-0" :team="team" />

View File

@@ -326,6 +326,7 @@ const IntervalFormat = z.enum([
const TimeFormat = z.enum(['12-hours', '24-hours']); const TimeFormat = z.enum(['12-hours', '24-hours']);
const OrganizationResource = z const OrganizationResource = z
.object({ .object({
can_use_billable_rates: z.boolean().optional(),
id: z.string(), id: z.string(),
name: z.string(), name: z.string(),
is_personal: z.boolean(), is_personal: z.boolean(),
@@ -5069,6 +5070,45 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
}, },
], ],
}, },
{
method: 'post',
path: '/v1/organizations/:organization/trial/cancel',
alias: 'cancelTrial',
description: `The organization switches to the free plan right away.`,
requestFormat: 'json',
parameters: [
{
name: 'organization',
type: 'Path',
schema: z.string(),
},
],
response: z.void(),
errors: [
{
status: 400,
description: `API exception`,
schema: z
.object({ error: z.boolean(), key: z.string(), message: z.string() })
.passthrough(),
},
{
status: 401,
description: `Unauthenticated`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 403,
description: `Authorization error`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 404,
description: `Not found`,
schema: z.object({ message: z.string() }).passthrough(),
},
],
},
{ {
method: 'get', method: 'get',
path: '/v1/public/reports', path: '/v1/public/reports',

View File

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

View File

@@ -2,6 +2,7 @@
import { computed } from 'vue'; import { computed } from 'vue';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue'; import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
@@ -10,7 +11,13 @@ import {
} from '@/packages/ui/src/tooltip'; } from '@/packages/ui/src/tooltip';
const active = defineModel<boolean>({ default: false }); const active = defineModel<boolean>({ default: false });
const emit = defineEmits(['changed']); const emit = defineEmits(['changed']);
const { locked: billableRatesLocked, requestUpgrade } = useBillableRatesLock();
function toggleBillable() { 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; active.value = !active.value;
emit('changed', 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(() => { const iconColorClasses = computed(() => {
if (active.value) { if (active.value) {

View File

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

View File

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

View File

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

View File

@@ -17,6 +17,7 @@ import type {
} from '@/packages/api/src'; } from '@/packages/api/src';
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue'; import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '..';
import { Button } from '@/packages/ui/src/Buttons'; import { Button } from '@/packages/ui/src/Buttons';
import DatePicker from '@/packages/ui/src/Input/DatePicker.vue'; import DatePicker from '@/packages/ui/src/Input/DatePicker.vue';
@@ -64,6 +65,7 @@ watch(show, (value) => {
}); });
const editableTimeEntry = ref<TimeEntry | null>(null); const editableTimeEntry = ref<TimeEntry | null>(null);
const { locked: billableRatesLocked } = useBillableRatesLock();
watch( watch(
() => props.timeEntry, () => 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({ const billableProxy = computed({
get: () => get: () =>
editableTimeEntry.value ? (editableTimeEntry.value.billable ? 'true' : 'false') : 'false', editableTimeEntry.value ? (editableTimeEntry.value.billable ? 'true' : 'false') : 'false',
@@ -245,7 +252,9 @@ const typeProxy = computed({
</SelectValue> </SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="true">Billable</SelectItem> <SelectItem value="true" :disabled="billableOptionLocked"
>Billable</SelectItem
>
<SelectItem value="false">Non Billable</SelectItem> <SelectItem value="false">Non Billable</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>

View File

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

View File

@@ -1,17 +1,21 @@
<script setup lang="ts"> <script setup lang="ts">
import { LockClosedIcon } from '@heroicons/vue/20/solid'; import { LockClosedIcon } from '@heroicons/vue/20/solid';
import UpgradeModal from '@/Components/Common/UpgradeModal.vue'; import UpgradeModal from './UpgradeModal.vue';
import type { UpgradeFeature } from './UpgradeModal/useUpgradeCta';
import { ref } from 'vue'; import { ref } from 'vue';
defineProps<{
// The Professional feature behind the badge, picks the variant of the upgrade modal
feature: UpgradeFeature;
}>();
const showUpgradeModal = ref(false); const showUpgradeModal = ref(false);
</script> </script>
<template> <template>
<UpgradeModal v-model:show="showUpgradeModal"> <UpgradeModal v-model:show="showUpgradeModal" :feature="feature" />
<slot>
<strong>Project and Task Estimates</strong> is only available in solidtime Professional.
</slot>
</UpgradeModal>
<button <button
data-testid="upgrade_badge"
class="inline-flex bg-secondary hover:bg-tertiary px-2 py-1 rounded border border-border-secondary hover:border-border-tertiary items-center space-x-1" class="inline-flex bg-secondary hover:bg-tertiary px-2 py-1 rounded border border-border-secondary hover:border-border-tertiary items-center space-x-1"
@click.prevent="showUpgradeModal = true"> @click.prevent="showUpgradeModal = true">
<LockClosedIcon class="w-3 text-text-tertiary"></LockClosedIcon> <LockClosedIcon class="w-3 text-text-tertiary"></LockClosedIcon>

View File

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

View File

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

View File

@@ -0,0 +1,139 @@
<script setup lang="ts">
import { Dialog, DialogContent, DialogTitle, DialogDescription } from './dialog';
import { LockClosedIcon } from '@heroicons/vue/16/solid';
import { computed, type Component } from 'vue';
import MockupBillableRates from './UpgradeModal/MockupBillableRates.vue';
import MockupEstimates from './UpgradeModal/MockupEstimates.vue';
import MockupGoals from './UpgradeModal/MockupGoals.vue';
import MockupInvoicing from './UpgradeModal/MockupInvoicing.vue';
import MockupPdfReports from './UpgradeModal/MockupPdfReports.vue';
import MockupShareableReports from './UpgradeModal/MockupShareableReports.vue';
import UpgradeCtaFooter from './UpgradeModal/UpgradeCtaFooter.vue';
import { featureIcons } from './UpgradeModal/featureIcons';
import {
professionalFeatures,
type ProfessionalFeatureKey,
type UpgradeFeature,
} from './UpgradeModal/useUpgradeCta';
const show = defineModel<boolean>('show', { default: false });
const props = defineProps<{
// The Professional feature the user tried to use, picks the mock-up and the copy
feature: UpgradeFeature;
}>();
const contents: Record<
UpgradeFeature,
{
mockup: Component;
pill: string;
headline: string;
body: string;
// Feature of the pricing page that is listed first, if the feature is listed there
featureKey?: ProfessionalFeatureKey;
}
> = {
'billable-rates': {
mockup: MockupBillableRates,
pill: 'Unlock billable rates',
headline: 'Turn tracked hours into revenue',
body: 'Upgrade to solidtime Professional to track billable hours',
featureKey: 'rates',
},
estimates: {
mockup: MockupEstimates,
pill: 'Unlock time estimates',
headline: 'Keep every project on budget',
body: 'Upgrade to solidtime Professional to set time estimates',
featureKey: 'estimates',
},
goals: {
mockup: MockupGoals,
pill: 'Unlock unlimited goals',
headline: 'Set a goal for everything that matters',
body: 'Upgrade to solidtime Professional to track unlimited goals',
},
invoicing: {
mockup: MockupInvoicing,
pill: 'Unlock invoicing',
headline: 'Get paid for every tracked hour',
body: 'Upgrade to solidtime Professional to create invoices',
featureKey: 'invoicing',
},
'pdf-reports': {
mockup: MockupPdfReports,
pill: 'Unlock PDF reports',
headline: 'Send reports your clients will read',
body: 'Upgrade to solidtime Professional to export PDF reports',
featureKey: 'pdf',
},
'shareable-reports': {
mockup: MockupShareableReports,
pill: 'Unlock shareable reports',
headline: 'Share live reports with one link',
body: 'Upgrade to solidtime Professional to share reports',
featureKey: 'shared',
},
};
const content = computed(() => contents[props.feature]);
// The feature the user tried to use comes first
const orderedFeatures = computed(() => [
...professionalFeatures.filter((feature) => feature.key === content.value.featureKey),
...professionalFeatures.filter((feature) => feature.key !== content.value.featureKey),
]);
</script>
<template>
<Dialog v-model:open="show">
<DialogContent class="sm:max-w-xl p-0 overflow-hidden">
<div class="relative">
<component :is="content.mockup" />
<div
class="absolute bottom-0 left-1/2 z-10 -translate-x-1/2 translate-y-1/2 whitespace-nowrap">
<div
class="flex items-center gap-1.5 rounded-full border border-gray-200 bg-white px-3 py-1 dark:border-border-secondary dark:bg-default-background text-xs font-medium text-text-primary shadow-sm">
<LockClosedIcon class="h-3.5 w-3.5 text-accent-500 dark:text-accent-300" />
{{ content.pill }}
</div>
</div>
</div>
<div class="px-8 pt-9 text-center">
<DialogTitle class="text-xl font-semibold tracking-tight text-text-primary">
{{ content.headline }}
</DialogTitle>
<DialogDescription class="mt-1.5 text-sm text-text-secondary">
{{ content.body }}
</DialogDescription>
</div>
<div class="grid grid-cols-2 gap-x-6 gap-y-4 px-8 pt-9">
<div v-for="item in orderedFeatures" :key="item.key" class="flex gap-3">
<div
class="flex h-7 w-7 shrink-0 items-center justify-center rounded-md border border-border-tertiary bg-card-background">
<component
:is="featureIcons[item.key]"
class="h-3.5 w-3.5 text-text-secondary" />
</div>
<div class="min-w-0">
<p class="text-sm font-medium text-text-primary">{{ item.title }}</p>
<p class="mt-0.5 text-xs leading-relaxed text-text-tertiary">
{{ item.description }}
</p>
</div>
</div>
</div>
<div class="px-8 pt-9 pb-6">
<UpgradeCtaFooter
align="stack"
label="Upgrade to Professional"
class="mx-auto max-w-xs"
@close="show = false" />
</div>
</DialogContent>
</Dialog>
</template>

View File

@@ -0,0 +1,39 @@
<script setup lang="ts">
import { LockClosedIcon } from '@heroicons/vue/16/solid';
import MockupFrame from './MockupFrame.vue';
const previewRows = [
{ name: 'Acme Corp', duration: '42h 30min', cost: '€ 5,100.00' },
{ name: 'Globex', duration: '27h 15min', cost: '€ 3,270.00' },
{ name: 'Initech', duration: '12h 45min', cost: '€ 1,530.00' },
];
</script>
<template>
<MockupFrame :glow-in-light-mode="false">
<div class="px-6 pt-4">
<div
class="grid grid-cols-[1fr_100px_100px] border-b border-gray-300/70 pb-1.5 text-[11px] text-gray-400 dark:border-white/10 dark:text-white/40">
<span>Client</span>
<span class="text-right">Duration</span>
<span class="flex items-center justify-end gap-1">
<LockClosedIcon class="h-3 w-3" />
Cost
</span>
</div>
<div
v-for="row in previewRows"
:key="row.name"
class="grid grid-cols-[1fr_100px_100px] border-b border-gray-300/50 py-1.5 text-sm last:border-b-0 dark:border-white/5">
<span class="text-gray-800 dark:text-white/90">{{ row.name }}</span>
<span class="text-right tabular-nums text-gray-500 dark:text-white/50">{{
row.duration
}}</span>
<span
class="select-none text-right font-medium text-accent-600 blur-[5px] dark:text-accent-300"
>{{ row.cost }}</span
>
</div>
</div>
</MockupFrame>
</template>

View File

@@ -0,0 +1,48 @@
<script setup lang="ts">
import { LockClosedIcon } from '@heroicons/vue/16/solid';
import MockupFrame from './MockupFrame.vue';
const previewRows = [
{ name: 'Website Redesign', spent: '32h', estimate: '40h', progress: 80, muted: false },
{ name: 'Mobile App', spent: '11h', estimate: '20h', progress: 55, muted: true },
{ name: 'Support Retainer', spent: '6h', estimate: '20h', progress: 30, muted: false },
];
</script>
<template>
<MockupFrame :glow-in-light-mode="false">
<div class="px-6 pt-4">
<div
class="grid grid-cols-[1fr_170px] border-b border-gray-300/70 pb-1.5 text-[11px] text-gray-400 dark:border-white/10 dark:text-white/40">
<span>Project</span>
<span class="flex items-center gap-1">
<LockClosedIcon class="h-3 w-3" />
Progress
</span>
</div>
<div
v-for="row in previewRows"
:key="row.name"
class="grid grid-cols-[1fr_170px] items-center border-b border-gray-300/50 py-1.5 text-sm last:border-b-0 dark:border-white/5">
<span class="text-gray-800 dark:text-white/90">{{ row.name }}</span>
<div class="flex select-none items-center gap-2.5 blur-[3px]">
<div
class="h-1.5 flex-1 overflow-hidden rounded-full bg-gray-300/60 dark:bg-white/10">
<div
class="h-full rounded-full"
:class="
row.muted
? 'bg-gray-400 dark:bg-white/40'
: 'bg-accent-500 dark:bg-accent-300'
"
:style="{ width: row.progress + '%' }" />
</div>
<span
class="w-16 text-right text-xs tabular-nums text-gray-500 dark:text-white/60"
>{{ row.spent }} / {{ row.estimate }}</span
>
</div>
</div>
</div>
</MockupFrame>
</template>

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
withDefaults(
defineProps<{
// Show the blue glow in light mode too, dark mode always has it
glowInLightMode?: boolean;
}>(),
{ glowInLightMode: true }
);
</script>
<template>
<!-- Note: Shared header of the upgrade modal, all mock-ups have the same height -->
<div
class="relative h-[156px] overflow-hidden border-b border-border-tertiary bg-background dark:border-white/10 dark:bg-[#0b1220]">
<!-- Note: Glow rises from behind the divider, around the pill -->
<div
:class="glowInLightMode ? '' : 'hidden dark:block'"
class="pointer-events-none absolute -bottom-32 left-1/2 h-56 w-[30rem] -translate-x-1/2 rounded-full bg-accent-400/25 blur-3xl dark:bg-accent-500/[0.18]" />
<div class="relative h-full" aria-hidden="true">
<slot />
</div>
</div>
</template>

View File

@@ -0,0 +1,54 @@
<script setup lang="ts">
import { LockClosedIcon } from '@heroicons/vue/16/solid';
import MockupFrame from './MockupFrame.vue';
// Note: The first goal is the one of the free plan, the others are locked
const previewGoals = [
{
name: 'Billable hours per month',
tracked: '62h',
target: '80h',
progress: 78,
locked: false,
},
{ name: 'Deep work per week', tracked: '14h', target: '20h', progress: 70, locked: true },
{ name: 'Support per week', tracked: '3h', target: '8h', progress: 40, locked: true },
];
</script>
<template>
<MockupFrame :glow-in-light-mode="false">
<div class="px-6 pt-4">
<div
class="grid grid-cols-[1fr_170px] border-b border-gray-300/70 pb-1.5 text-[11px] text-gray-400 dark:border-white/10 dark:text-white/40">
<span>Goal</span>
<span>Progress</span>
</div>
<div
v-for="goal in previewGoals"
:key="goal.name"
class="grid grid-cols-[1fr_170px] items-center border-b border-gray-300/50 py-1.5 text-sm last:border-b-0 dark:border-white/5">
<span class="flex items-center gap-1.5 text-gray-800 dark:text-white/90">
<LockClosedIcon
v-if="goal.locked"
class="h-3 w-3 text-gray-400 dark:text-white/40" />
{{ goal.name }}
</span>
<div
class="flex select-none items-center gap-2.5"
:class="goal.locked ? 'blur-[3px]' : ''">
<div
class="h-1.5 flex-1 overflow-hidden rounded-full bg-gray-300/60 dark:bg-white/10">
<div
class="h-full rounded-full bg-accent-500 dark:bg-accent-300"
:style="{ width: goal.progress + '%' }" />
</div>
<span
class="w-16 text-right text-xs tabular-nums text-gray-500 dark:text-white/60"
>{{ goal.tracked }} / {{ goal.target }}</span
>
</div>
</div>
</div>
</MockupFrame>
</template>

View File

@@ -0,0 +1,38 @@
<script setup lang="ts">
import MockupFrame from './MockupFrame.vue';
const lineItems = [
{ name: 'Website Redesign', hours: '32h', amount: '€ 3,840.00' },
{ name: 'Mobile App', hours: '18h', amount: '€ 2,160.00' },
];
</script>
<template>
<MockupFrame>
<!-- Note: An invoice created from tracked time, it runs off the bottom edge of the header -->
<div
class="absolute left-1/2 top-5 w-60 -translate-x-1/2 rounded-t-md border border-b-0 border-gray-200 bg-white px-5 pt-4 pb-10 shadow-xl shadow-gray-400/25 dark:border-transparent dark:shadow-2xl dark:shadow-black/50">
<div class="flex items-start justify-between">
<div>
<p class="text-[11px] font-semibold text-gray-900">Invoice 2026-014</p>
<p class="text-[9px] text-gray-500">Acme Corp · Due November 14</p>
</div>
<span
class="rounded bg-accent-500 px-1.5 py-0.5 text-[8px] font-bold tracking-wide text-white"
>SENT</span
>
</div>
<div class="mt-3 space-y-1 text-[9px] text-gray-600">
<div v-for="item in lineItems" :key="item.name" class="flex justify-between">
<span>{{ item.name }} · {{ item.hours }}</span>
<span class="tabular-nums">{{ item.amount }}</span>
</div>
</div>
<div
class="mt-2 flex justify-between border-t border-gray-200 pt-1.5 text-[10px] font-semibold text-gray-900">
<span>Total</span>
<span class="tabular-nums">€ 6,000.00</span>
</div>
</div>
</MockupFrame>
</template>

View File

@@ -0,0 +1,35 @@
<script setup lang="ts">
const bars = [45, 70, 55, 85, 65, 35, 50];
</script>
<template>
<!-- Note: A PDF page that runs off the bottom edge of the header -->
<div
class="rounded-t-md border border-b-0 border-gray-200 bg-white px-5 pt-4 pb-10 shadow-xl shadow-gray-400/25 dark:border-transparent dark:shadow-2xl dark:shadow-black/50">
<div class="flex items-start justify-between">
<div>
<p class="text-[11px] font-semibold text-gray-900">Time report</p>
<p class="text-[9px] text-gray-500">Acme Corp · October 2026</p>
</div>
<span
class="rounded bg-red-500 px-1.5 py-0.5 text-[8px] font-bold tracking-wide text-white"
>PDF</span
>
</div>
<div class="mt-2.5 flex h-7 items-end gap-1">
<div
v-for="(height, index) in bars"
:key="index"
class="flex-1 rounded-sm bg-accent-500/70"
:style="{ height: height + '%' }" />
</div>
<div class="mt-2 space-y-1 text-[9px] text-gray-600">
<div class="flex justify-between">
<span>Website Redesign</span><span class="tabular-nums">32h 15min</span>
</div>
<div class="flex justify-between">
<span>Mobile App</span><span class="tabular-nums">18h 40min</span>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
import MockupFrame from './MockupFrame.vue';
import MockupPdfPage from './MockupPdfPage.vue';
</script>
<template>
<MockupFrame>
<!-- Note: The PDF report with two more pages fanned out behind it -->
<div class="absolute left-1/2 top-5 h-full w-56 -translate-x-1/2">
<div
class="absolute inset-x-0 top-3 h-48 origin-bottom -translate-x-10 -rotate-[8deg] rounded-md border border-gray-200 bg-white/80 shadow-lg shadow-gray-400/20 dark:border-transparent dark:bg-white/70 dark:shadow-xl dark:shadow-black/40" />
<div
class="absolute inset-x-0 top-3 h-48 origin-bottom translate-x-10 rotate-[8deg] rounded-md border border-gray-200 bg-white/80 shadow-lg shadow-gray-400/20 dark:border-transparent dark:bg-white/70 dark:shadow-xl dark:shadow-black/40" />
<MockupPdfPage class="absolute inset-x-0 top-0" />
</div>
</MockupFrame>
</template>

View File

@@ -0,0 +1,50 @@
<script setup lang="ts">
import { LinkIcon, LockClosedIcon } from '@heroicons/vue/16/solid';
import MockupFrame from './MockupFrame.vue';
const previewRows = [
{ name: 'Website Redesign', duration: '32h 15min' },
{ name: 'Mobile App', duration: '18h 40min' },
];
</script>
<template>
<MockupFrame>
<!-- Note: A browser window showing the shared report, it runs into the bottom edge -->
<div
class="absolute inset-x-10 top-4 -bottom-px rounded-t-lg border border-b-0 border-gray-300/80 bg-white shadow-xl shadow-gray-400/20 dark:border-white/10 dark:bg-white/[0.04] dark:shadow-2xl dark:shadow-black/40">
<div
class="flex items-center gap-2 border-b border-gray-200 px-3 py-2 dark:border-white/10">
<div class="flex gap-1">
<span class="h-2 w-2 rounded-full bg-gray-300 dark:bg-white/15" />
<span class="h-2 w-2 rounded-full bg-gray-300 dark:bg-white/15" />
<span class="h-2 w-2 rounded-full bg-gray-300 dark:bg-white/15" />
</div>
<div
class="flex min-w-0 flex-1 items-center gap-1.5 rounded-md bg-gray-100 px-2 py-1 text-[11px] text-gray-500 dark:bg-black/30 dark:text-white/50">
<LockClosedIcon class="h-3 w-3 shrink-0 text-gray-400 dark:text-white/30" />
<span class="truncate">app.solidtime.io/shared-report</span>
</div>
<span
class="flex shrink-0 items-center gap-1 rounded-md border border-gray-200 bg-gray-50 px-2 py-1 text-[11px] font-medium text-gray-700 dark:border-transparent dark:bg-white/15 dark:text-white/90">
<LinkIcon class="h-3 w-3" />
Copy link
</span>
</div>
<div class="px-4 pt-2 pb-1.5">
<p class="py-1.5 text-xs font-medium text-gray-900 dark:text-white/90">
Acme Corp · October
</p>
<div
v-for="row in previewRows"
:key="row.name"
class="flex justify-between border-b border-gray-100 py-1 text-xs last:border-b-0 dark:border-white/5">
<span class="text-gray-600 dark:text-white/70">{{ row.name }}</span>
<span class="tabular-nums text-gray-400 dark:text-white/50">{{
row.duration
}}</span>
</div>
</div>
</div>
</MockupFrame>
</template>

View File

@@ -0,0 +1,78 @@
<script setup lang="ts">
import { ArrowTopRightOnSquareIcon } from '@heroicons/vue/16/solid';
import { pricingUrl, useUpgradeCta } from './useUpgradeCta';
const props = withDefaults(
defineProps<{
label?: string;
align?: 'between' | 'end' | 'stack';
showCompare?: boolean;
}>(),
{ label: 'Upgrade to Professional', align: 'between', showCompare: true }
);
const emit = defineEmits<{ close: [] }>();
const { canUpgrade, goToBilling } = useUpgradeCta();
</script>
<template>
<div
:class="
props.align === 'stack'
? 'flex flex-col items-center gap-3'
: 'flex flex-wrap items-center gap-2'
">
<a
v-if="props.align !== 'stack' && props.showCompare"
:href="pricingUrl"
target="_blank"
rel="noopener"
class="mr-auto inline-flex items-center gap-1 text-xs text-text-tertiary transition-colors hover:text-text-primary">
Compare plans
<ArrowTopRightOnSquareIcon class="h-3 w-3" />
</a>
<template v-if="canUpgrade">
<button
v-if="props.align !== 'stack'"
type="button"
class="h-9 whitespace-nowrap rounded-md px-3 text-sm font-medium text-text-secondary transition-colors hover:bg-white/5 hover:text-text-primary"
@click="emit('close')">
Not now
</button>
<button
type="button"
data-testid="upgrade_modal_cta"
class="h-9 whitespace-nowrap rounded-md bg-accent-500 px-4 text-sm font-semibold text-white shadow-[0_0_24px_-6px] shadow-accent-500/60 transition-colors hover:bg-accent-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
:class="props.align === 'stack' ? 'w-full' : ''"
@click="goToBilling">
{{ props.label }}
</button>
</template>
<template v-else>
<span class="text-xs text-text-tertiary">Only the organization owner can upgrade.</span>
<button
type="button"
class="h-9 rounded-md border border-border-secondary px-3 text-sm font-medium text-text-primary transition-colors hover:bg-white/5"
@click="emit('close')">
Got it
</button>
</template>
<div v-if="props.align === 'stack'" class="flex items-center gap-4 text-xs">
<button
v-if="canUpgrade"
type="button"
class="text-text-tertiary transition-colors hover:text-text-primary"
@click="emit('close')">
Not now
</button>
<a
:href="pricingUrl"
target="_blank"
rel="noopener"
class="inline-flex items-center gap-1 text-text-tertiary transition-colors hover:text-text-primary">
Compare plans
<ArrowTopRightOnSquareIcon class="h-3 w-3" />
</a>
</div>
</div>
</template>

View File

@@ -0,0 +1,19 @@
import type { Component } from 'vue';
import {
AdjustmentsHorizontalIcon,
ClockIcon,
CurrencyEuroIcon,
DocumentArrowDownIcon,
DocumentTextIcon,
LinkIcon,
} from '@heroicons/vue/16/solid';
import type { ProfessionalFeatureKey } from './useUpgradeCta';
export const featureIcons: Record<ProfessionalFeatureKey, Component> = {
rates: CurrencyEuroIcon,
invoicing: DocumentTextIcon,
estimates: ClockIcon,
rounding: AdjustmentsHorizontalIcon,
pdf: DocumentArrowDownIcon,
shared: LinkIcon,
};

View File

@@ -0,0 +1,72 @@
import { computed, inject, type ComputedRef, type InjectionKey } from 'vue';
export interface UpgradeCta {
canUpgrade: ComputedRef<boolean>;
goToBilling: () => void;
}
export const upgradeCtaKey: InjectionKey<UpgradeCta> = Symbol('upgradeCta');
/**
* Shared call to action for the upgrade modal variations: members who can manage billing are
* sent to the billing page, everyone else is asked to contact the organization owner.
*
* The app layout provides who can upgrade and how to get to the billing page. Without a
* provider (e.g. other consumers of this package) nobody can upgrade from the modal.
*/
export function useUpgradeCta(): UpgradeCta {
return inject(upgradeCtaKey, {
canUpgrade: computed(() => false),
goToBilling: () => {},
});
}
// Note: Copy mirrors https://www.solidtime.io/pricing so the gate and the pricing page match.
export const pricingUrl = 'https://www.solidtime.io/pricing';
// Professional features the upgrade modal has a variant for
export type UpgradeFeature =
'billable-rates' | 'estimates' | 'goals' | 'invoicing' | 'pdf-reports' | 'shareable-reports';
export type ProfessionalFeatureKey =
'rates' | 'invoicing' | 'estimates' | 'rounding' | 'pdf' | 'shared';
export type ProfessionalFeature = {
key: ProfessionalFeatureKey;
title: string;
description: string;
};
// Note: Billing and reporting features of the Professional plan on https://www.solidtime.io/pricing
export const professionalFeatures: ProfessionalFeature[] = [
{
key: 'rates',
title: 'Billable rates',
description: 'Set rates per organization, project or member.',
},
{
key: 'invoicing',
title: 'Invoicing',
description: 'Create invoices straight from your tracked time.',
},
{
key: 'estimates',
title: 'Time estimates',
description: 'Budget hours per project and task and see what is left.',
},
{
key: 'rounding',
title: 'Rounding',
description: 'Round time in reports, e.g. to 15 minutes.',
},
{
key: 'pdf',
title: 'PDF reports',
description: 'Export clean reports to send to clients.',
},
{
key: 'shared',
title: 'Shareable reports',
description: 'Share a live report link, no login needed.',
},
];

View File

@@ -21,6 +21,10 @@ export type { CalendarSettings } from './FullCalendar/calendarSettings';
export type { ActivityPeriod } from './FullCalendar/activityTypes'; export type { ActivityPeriod } from './FullCalendar/activityTypes';
export { cn } from './utils/cn'; export { cn } from './utils/cn';
export { useCssVariable } from './utils/useCssVariable'; 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';
import Badge from './Badge.vue'; import Badge from './Badge.vue';
import Button from './Buttons/Button.vue'; import Button from './Buttons/Button.vue';
@@ -162,6 +166,10 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from './tabs/index';
import TabBar from './TabBar/TabBar.vue'; import TabBar from './TabBar/TabBar.vue';
import TabBarItem from './TabBar/TabBarItem.vue'; import TabBarItem from './TabBar/TabBarItem.vue';
import TimezoneMismatchModal from './TimezoneMismatchModal.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'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './tooltip/index';
export { export {
@@ -305,4 +313,8 @@ export {
TooltipContent, TooltipContent,
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
UpgradeBadge,
UpgradeLockedBadge,
UpgradeLockedValue,
UpgradeModal,
}; };

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,511 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Endpoint\Api\V1;
use App\Enums\Role;
use App\Http\Controllers\Api\V1\Controller;
use App\Models\Member;
use App\Models\Project;
use App\Models\ProjectMember;
use App\Models\TimeEntry;
use App\Models\User;
use Illuminate\Support\Carbon;
use Laravel\Passport\Passport;
use PHPUnit\Framework\Attributes\UsesClass;
/**
* Note: The test data is created before the organization loses access to billable rates,
* otherwise the billable flags and rates of the test data would already be reverted on creation.
*/
#[UsesClass(Controller::class)]
class BillableRateRestrictionEndpointTest extends ApiEndpointTestAbstract
{
public function test_project_store_ignores_billable_flag_and_rate_if_organization_can_not_use_billable_rates(): void
{
// Arrange
$data = $this->createUserWithPermission([
'projects:create',
]);
$this->actAsOrganizationWithoutBillableRates();
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.projects.store', [$data->organization->getKey()]), [
'name' => 'Project',
'color' => '#ef5350',
'client_id' => null,
'is_billable' => true,
'billable_rate' => 10000,
]);
// Assert
$response->assertCreated();
$response->assertJsonPath('data.is_billable', false);
$response->assertJsonPath('data.billable_rate', null);
$project = Project::query()->findOrFail($response->json('data.id'));
$this->assertFalse($project->is_billable);
$this->assertNull($project->billable_rate);
}
public function test_project_store_sets_billable_flag_and_rate_if_organization_can_use_billable_rates(): void
{
// Arrange
$data = $this->createUserWithPermission([
'projects:create',
]);
$this->actAsOrganizationWithSubscription();
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.projects.store', [$data->organization->getKey()]), [
'name' => 'Project',
'color' => '#ef5350',
'client_id' => null,
'is_billable' => true,
'billable_rate' => 10000,
]);
// Assert
$response->assertCreated();
$response->assertJsonPath('data.is_billable', true);
$response->assertJsonPath('data.billable_rate', 10000);
}
public function test_project_update_keeps_or_removes_existing_billable_flag_but_does_not_set_new_one_if_organization_can_not_use_billable_rates(): void
{
// Arrange
$data = $this->createUserWithPermission([
'projects:update',
]);
$billableProject = Project::factory()->forOrganization($data->organization)->create(['is_billable' => true]);
$removedBillableProject = Project::factory()->forOrganization($data->organization)->create(['is_billable' => true]);
$nonBillableProject = Project::factory()->forOrganization($data->organization)->create(['is_billable' => false]);
$this->actAsOrganizationWithoutBillableRates();
Passport::actingAs($data->user);
// Act
$responseKeep = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $billableProject->getKey()]), [
'name' => 'Still billable',
'color' => $billableProject->color,
'client_id' => null,
'is_billable' => true,
]);
$responseRemove = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $removedBillableProject->getKey()]), [
'name' => 'No longer billable',
'color' => $removedBillableProject->color,
'client_id' => null,
'is_billable' => false,
]);
$responseSet = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $nonBillableProject->getKey()]), [
'name' => 'Not billable',
'color' => $nonBillableProject->color,
'client_id' => null,
'is_billable' => true,
]);
// Assert
$responseKeep->assertOk();
$responseKeep->assertJsonPath('data.is_billable', true);
$responseRemove->assertOk();
$responseRemove->assertJsonPath('data.is_billable', false);
$responseSet->assertOk();
$responseSet->assertJsonPath('data.is_billable', false);
$this->assertTrue($billableProject->refresh()->is_billable);
$this->assertFalse($removedBillableProject->refresh()->is_billable);
$nonBillableProject->refresh();
$this->assertFalse($nonBillableProject->is_billable);
$this->assertSame('Not billable', $nonBillableProject->name);
}
public function test_project_update_can_enable_billable_flag_if_organization_can_use_billable_rates(): void
{
// Arrange
$data = $this->createUserWithPermission([
'projects:update',
]);
$project = Project::factory()->forOrganization($data->organization)->create(['is_billable' => false]);
$this->actAsOrganizationWithSubscription();
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
'name' => $project->name,
'color' => $project->color,
'client_id' => null,
'is_billable' => true,
]);
// Assert
$response->assertOk();
$this->assertTrue($project->refresh()->is_billable);
}
public function test_project_update_keeps_existing_billable_rate_and_time_entry_rates_if_organization_can_not_use_billable_rates(): void
{
// Arrange
$data = $this->createUserWithPermission([
'projects:update',
]);
$project = Project::factory()->forOrganization($data->organization)->create([
'billable_rate' => 10000,
'is_billable' => true,
]);
$timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->forProject($project)->create([
'billable' => true,
'billable_rate' => 10000,
]);
$this->actAsOrganizationWithoutBillableRates();
Passport::actingAs($data->user);
// Act
$responseChange = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
'name' => 'Changed rate',
'color' => $project->color,
'client_id' => null,
'is_billable' => true,
'billable_rate' => 20000,
]);
// Note: Organizations that can not use billable rates do not receive the rate of the project, so clients send `null` back
$responseNull = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
'name' => 'Hidden rate',
'color' => $project->color,
'client_id' => null,
'is_billable' => true,
'billable_rate' => null,
]);
$responseOmitted = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
'name' => 'Omitted rate',
'color' => $project->color,
'client_id' => null,
'is_billable' => true,
]);
// Assert
$responseChange->assertOk();
$responseNull->assertOk();
$responseOmitted->assertOk();
$project->refresh();
$this->assertSame('Omitted rate', $project->name);
$this->assertSame(10000, $project->billable_rate);
$this->assertSame(10000, $timeEntry->refresh()->billable_rate);
}
public function test_organization_update_ignores_billable_rate_and_employee_visibility_if_organization_can_not_use_billable_rates(): void
{
// Arrange
$data = $this->createUserWithPermission([
'organizations:update',
]);
$data->organization->billable_rate = 10000;
$data->organization->employees_can_see_billable_rates = false;
$data->organization->save();
$timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->create([
'billable' => true,
'billable_rate' => 10000,
]);
$this->actAsOrganizationWithoutBillableRates();
Passport::actingAs($data->user);
// Act
$responseRate = $this->putJson(route('api.v1.organizations.update', [$data->organization->getKey()]), [
'name' => 'Organization',
'billable_rate' => 20000,
]);
$responseRemoveRate = $this->putJson(route('api.v1.organizations.update', [$data->organization->getKey()]), [
'name' => 'Organization',
'billable_rate' => null,
]);
$responseVisibility = $this->putJson(route('api.v1.organizations.update', [$data->organization->getKey()]), [
'name' => 'Renamed organization',
'employees_can_see_billable_rates' => true,
]);
// Assert
$responseRate->assertOk();
$responseRemoveRate->assertOk();
$responseVisibility->assertOk();
$responseVisibility->assertJsonPath('data.employees_can_see_billable_rates', false);
$data->organization->refresh();
$this->assertSame('Renamed organization', $data->organization->name);
$this->assertSame(10000, $data->organization->billable_rate);
$this->assertFalse($data->organization->employees_can_see_billable_rates);
$this->assertSame(10000, $timeEntry->refresh()->billable_rate);
}
public function test_organization_update_can_remove_employee_visibility_if_organization_can_not_use_billable_rates(): void
{
// Arrange
$data = $this->createUserWithPermission([
'organizations:update',
]);
$data->organization->employees_can_see_billable_rates = true;
$data->organization->save();
$this->actAsOrganizationWithoutBillableRates();
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.organizations.update', [$data->organization->getKey()]), [
'name' => 'Organization',
'employees_can_see_billable_rates' => false,
]);
// Assert
$response->assertOk();
$this->assertFalse($data->organization->refresh()->employees_can_see_billable_rates);
}
public function test_member_update_ignores_billable_rate_but_updates_role_if_organization_can_not_use_billable_rates(): void
{
// Arrange
$data = $this->createUserWithPermission([
'members:update',
]);
$member = Member::factory()->forOrganization($data->organization)->forUser(User::factory()->create())->role(Role::Employee)->create([
'billable_rate' => 10000,
]);
$timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($member)->create([
'billable' => true,
'billable_rate' => 10000,
]);
$this->actAsOrganizationWithoutBillableRates();
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.members.update', [$data->organization->getKey(), $member->getKey()]), [
'billable_rate' => 20000,
'role' => Role::Manager->value,
]);
// Assert
$response->assertOk();
$member->refresh();
$this->assertSame(10000, $member->billable_rate);
$this->assertSame(Role::Manager->value, $member->role);
$this->assertSame(10000, $timeEntry->refresh()->billable_rate);
}
public function test_project_member_store_and_update_ignore_billable_rate_if_organization_can_not_use_billable_rates(): void
{
// Arrange
$data = $this->createUserWithPermission([
'project-members:create',
'project-members:update',
]);
$project = Project::factory()->forOrganization($data->organization)->create();
$otherMember = Member::factory()->forOrganization($data->organization)->forUser(User::factory()->create())->create();
$projectMember = ProjectMember::factory()->forProject($project)->forMember($data->member)->create([
'billable_rate' => 10000,
]);
$this->actAsOrganizationWithoutBillableRates();
Passport::actingAs($data->user);
// Act
$responseStore = $this->postJson(route('api.v1.project-members.store', [$data->organization->getKey(), $project->getKey()]), [
'member_id' => $otherMember->getKey(),
'billable_rate' => 10000,
]);
$responseUpdate = $this->putJson(route('api.v1.project-members.update', [$data->organization->getKey(), $projectMember->getKey()]), [
'billable_rate' => 20000,
]);
// Assert
$responseStore->assertCreated();
$responseStore->assertJsonPath('data.billable_rate', null);
$responseUpdate->assertOk();
$responseUpdate->assertJsonPath('data.billable_rate', 10000);
$this->assertSame(10000, $projectMember->refresh()->billable_rate);
$this->assertDatabaseHas(ProjectMember::class, [
'member_id' => $otherMember->getKey(),
'billable_rate' => null,
]);
}
public function test_organization_exposes_billable_rate_eligibility_for_api_clients(): void
{
// Arrange
$data = $this->createUserWithPermission([
'organizations:view',
]);
$this->actAsOrganizationWithoutBillableRates();
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.organizations.show', [$data->organization->getKey()]));
// Assert
$response->assertOk();
$response->assertJsonPath('data.can_use_billable_rates', false);
}
public function test_aggregate_endpoint_hides_cost_if_organization_can_not_use_billable_rates(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:view:all',
]);
$start = Carbon::now()->timezone($data->user->timezone)->subDays(2);
TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration($start, 3600)->create([
'billable' => true,
'billable_rate' => 10000,
]);
$this->actAsOrganizationWithoutBillableRates();
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.time-entries.aggregate', [
$data->organization->getKey(),
]));
// Assert
$response->assertSuccessful();
$response->assertJsonPath('data.seconds', 3600);
$response->assertJsonPath('data.cost', null);
}
public function test_total_weekly_billable_amount_is_forbidden_if_organization_can_not_use_billable_rates(): void
{
// Arrange
$data = $this->createUserWithPermission([
'charts:view:own',
]);
$this->actAsOrganizationWithoutBillableRates();
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.charts.total-weekly-billable-amount', [$data->organization->getKey()]));
// Assert
$response->assertForbidden();
}
public function test_time_entry_store_creates_non_billable_time_entry_if_organization_can_not_use_billable_rates(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:create:own',
]);
$data->organization->billable_rate = 10000;
$data->organization->save();
$this->actAsOrganizationWithoutBillableRates();
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [
'billable' => true,
'start' => Carbon::now()->subHour()->toIso8601ZuluString(),
'end' => Carbon::now()->toIso8601ZuluString(),
'member_id' => $data->member->getKey(),
]);
// Assert
$response->assertCreated();
$response->assertJsonPath('data.billable', false);
$timeEntry = TimeEntry::query()->findOrFail($response->json('data.id'));
$this->assertFalse($timeEntry->billable);
$this->assertNull($timeEntry->billable_rate);
}
public function test_time_entry_store_creates_billable_time_entry_if_organization_can_use_billable_rates(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:create:own',
]);
$this->actAsOrganizationWithSubscription();
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [
'billable' => true,
'start' => Carbon::now()->subHour()->toIso8601ZuluString(),
'end' => Carbon::now()->toIso8601ZuluString(),
'member_id' => $data->member->getKey(),
]);
// Assert
$response->assertCreated();
$response->assertJsonPath('data.billable', true);
}
public function test_time_entry_update_keeps_or_removes_existing_billable_flag_but_does_not_set_new_one_if_organization_can_not_use_billable_rates(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:update:own',
]);
$billableTimeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->billable()
->startWithDuration(Carbon::now()->subHours(7), 3600)->create();
$removedBillableTimeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->billable()
->startWithDuration(Carbon::now()->subHours(5), 3600)->create();
$nonBillableTimeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->notBillable()
->startWithDuration(Carbon::now()->subHours(3), 3600)->create();
$this->actAsOrganizationWithoutBillableRates();
Passport::actingAs($data->user);
// Act
$responseKeep = $this->putJson(route('api.v1.time-entries.update', [$data->organization->getKey(), $billableTimeEntry->getKey()]), [
'billable' => true,
'description' => 'Still billable',
]);
$responseRemove = $this->putJson(route('api.v1.time-entries.update', [$data->organization->getKey(), $removedBillableTimeEntry->getKey()]), [
'billable' => false,
]);
$responseSet = $this->putJson(route('api.v1.time-entries.update', [$data->organization->getKey(), $nonBillableTimeEntry->getKey()]), [
'billable' => true,
'description' => 'Not billable',
]);
// Assert
$responseKeep->assertOk();
$responseKeep->assertJsonPath('data.billable', true);
$responseRemove->assertOk();
$responseRemove->assertJsonPath('data.billable', false);
$responseSet->assertOk();
$responseSet->assertJsonPath('data.billable', false);
$this->assertTrue($billableTimeEntry->refresh()->billable);
$this->assertSame('Still billable', $billableTimeEntry->description);
$this->assertFalse($removedBillableTimeEntry->refresh()->billable);
$nonBillableTimeEntry->refresh();
$this->assertFalse($nonBillableTimeEntry->billable);
$this->assertSame('Not billable', $nonBillableTimeEntry->description);
}
public function test_time_entry_update_multiple_keeps_existing_billable_flags_but_does_not_set_new_ones_if_organization_can_not_use_billable_rates(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:update:own',
]);
$data->organization->billable_rate = 10000;
$data->organization->save();
$billableTimeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->billable()->create();
$nonBillableTimeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->notBillable()->create();
$this->actAsOrganizationWithoutBillableRates();
Passport::actingAs($data->user);
// Act
$response = $this->patchJson(route('api.v1.time-entries.update-multiple', [$data->organization->getKey()]), [
'ids' => [$billableTimeEntry->getKey(), $nonBillableTimeEntry->getKey()],
'changes' => [
'billable' => true,
'description' => 'Mass update',
],
]);
// Assert
$response->assertOk();
$response->assertExactJson([
'success' => [$billableTimeEntry->getKey(), $nonBillableTimeEntry->getKey()],
'error' => [],
]);
$billableTimeEntry->refresh();
$this->assertTrue($billableTimeEntry->billable);
$this->assertSame('Mass update', $billableTimeEntry->description);
$nonBillableTimeEntry->refresh();
$this->assertFalse($nonBillableTimeEntry->billable);
$this->assertNull($nonBillableTimeEntry->billable_rate);
$this->assertSame('Mass update', $nonBillableTimeEntry->description);
}
}

View File

@@ -1426,6 +1426,33 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
]); ]);
} }
public function test_update_endpoint_keeps_billable_rate_if_it_is_not_part_of_the_request(): void
{
// Arrange
$data = $this->createUserWithPermission([
'projects:update',
]);
$project = Project::factory()->forOrganization($data->organization)->create([
'billable_rate' => 10000,
]);
$this->assertBillableRateServiceIsUnused();
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
'name' => 'Renamed',
'color' => $project->color,
'client_id' => null,
'is_billable' => $project->is_billable,
]);
// Assert
$response->assertStatus(200);
$project->refresh();
$this->assertSame('Renamed', $project->name);
$this->assertSame(10000, $project->billable_rate);
}
public function test_update_endpoint_can_archive_a_project(): void public function test_update_endpoint_can_archive_a_project(): void
{ {
// Arrange // Arrange

View File

@@ -36,6 +36,7 @@ class HandleInertiaRequestsMiddlewareTest extends MiddlewareTestAbstract
$mock->shouldReceive('getTrialUntil')->andReturn(null); $mock->shouldReceive('getTrialUntil')->andReturn(null);
$mock->shouldReceive('isBlocked')->andReturn(false); $mock->shouldReceive('isBlocked')->andReturn(false);
$mock->shouldReceive('getGoalLimit')->andReturn(1); $mock->shouldReceive('getGoalLimit')->andReturn(1);
$mock->shouldReceive('canUseBillableRates')->andReturn(true);
}); });
Passport::actingAs($user->user); Passport::actingAs($user->user);
@@ -49,6 +50,7 @@ class HandleInertiaRequestsMiddlewareTest extends MiddlewareTestAbstract
->where('billing.trial_until', null) ->where('billing.trial_until', null)
->where('billing.is_blocked', false) ->where('billing.is_blocked', false)
->where('billing.goal_limit', 1) ->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('getTrialUntil')->andReturn($trialUntil);
$mock->shouldReceive('isBlocked')->andReturn(false); $mock->shouldReceive('isBlocked')->andReturn(false);
$mock->shouldReceive('getGoalLimit')->andReturn(null); $mock->shouldReceive('getGoalLimit')->andReturn(null);
$mock->shouldReceive('canUseBillableRates')->andReturn(true);
}); });
Passport::actingAs($user->user); Passport::actingAs($user->user);
@@ -77,6 +80,7 @@ class HandleInertiaRequestsMiddlewareTest extends MiddlewareTestAbstract
->where('billing.trial_until', $trialUntil->toIso8601ZuluString()) ->where('billing.trial_until', $trialUntil->toIso8601ZuluString())
->where('billing.is_blocked', false) ->where('billing.is_blocked', false)
->where('billing.goal_limit', null) ->where('billing.goal_limit', null)
->where('billing.can_use_billable_rates', true)
); );
} }
} }

View File

@@ -0,0 +1,161 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Model;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Project;
use App\Models\ProjectMember;
use App\Models\TimeEntry;
use App\Models\User;
use App\Service\BillingContract;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Mockery\MockInterface;
use PHPUnit\Framework\Attributes\CoversClass;
use Tests\TestCaseWithDatabase;
#[CoversClass(Organization::class)]
#[CoversClass(Project::class)]
#[CoversClass(ProjectMember::class)]
#[CoversClass(Member::class)]
#[CoversClass(TimeEntry::class)]
class BillableRateRestrictionTest extends TestCaseWithDatabase
{
use RefreshDatabase;
public function test_new_time_entry_is_not_billable_if_organization_can_not_use_billable_rates(): void
{
// Arrange
$organization = Organization::factory()->create();
$member = Member::factory()->forOrganization($organization)->forUser(User::factory()->create())->create();
$this->actAsOrganizationWithoutBillableRates();
// Act
$timeEntry = TimeEntry::factory()->forOrganization($organization)->forMember($member)->billable()->create();
// Assert
$this->assertFalse($timeEntry->refresh()->billable);
}
public function test_existing_time_entry_can_keep_and_remove_billable_flag_but_not_get_it_if_organization_can_not_use_billable_rates(): void
{
// Arrange
$organization = Organization::factory()->create();
$member = Member::factory()->forOrganization($organization)->forUser(User::factory()->create())->create();
$billableTimeEntry = TimeEntry::factory()->forOrganization($organization)->forMember($member)->billable()->create();
$removedBillableTimeEntry = TimeEntry::factory()->forOrganization($organization)->forMember($member)->billable()->create();
$nonBillableTimeEntry = TimeEntry::factory()->forOrganization($organization)->forMember($member)->notBillable()->create();
$this->actAsOrganizationWithoutBillableRates();
// Act
$billableTimeEntry->description = 'Changed';
$billableTimeEntry->save();
$removedBillableTimeEntry->billable = false;
$removedBillableTimeEntry->save();
$nonBillableTimeEntry->billable = true;
$nonBillableTimeEntry->save();
// Assert
$this->assertTrue($billableTimeEntry->refresh()->billable);
$this->assertFalse($removedBillableTimeEntry->refresh()->billable);
$this->assertFalse($nonBillableTimeEntry->refresh()->billable);
}
public function test_time_entry_can_get_billable_flag_if_organization_can_use_billable_rates(): void
{
// Arrange
$organization = Organization::factory()->create();
$member = Member::factory()->forOrganization($organization)->forUser(User::factory()->create())->create();
$timeEntry = TimeEntry::factory()->forOrganization($organization)->forMember($member)->notBillable()->create();
$this->actAsOrganizationWithSubscription();
// Act
$timeEntry->billable = true;
$timeEntry->save();
// Assert
$this->assertTrue($timeEntry->refresh()->billable);
}
public function test_billing_is_only_asked_if_a_restricted_change_is_saved(): void
{
// Arrange
$organization = Organization::factory()->billableRate(10000)->create();
$member = Member::factory()->forOrganization($organization)->forUser(User::factory()->create())->billableRate(10000)->create();
$project = Project::factory()->forOrganization($organization)->billable(10000)->create();
$projectMember = ProjectMember::factory()->forProject($project)->forMember($member)->create(['billable_rate' => 10000]);
$timeEntry = TimeEntry::factory()->forOrganization($organization)->forMember($member)->billable()->create();
$this->mock(BillingContract::class, function (MockInterface $mock): void {
$mock->shouldNotReceive('canUseBillableRates');
});
// Act
$organization->name = 'Changed';
$organization->save();
$member->billable_rate = 10000;
$member->save();
$project->name = 'Changed';
$project->is_billable = true;
$project->save();
$projectMember->billable_rate = 10000;
$projectMember->save();
$timeEntry->description = 'Changed';
$timeEntry->billable = true;
$timeEntry->save();
$timeEntry->billable = false;
$timeEntry->save();
TimeEntry::factory()->forOrganization($organization)->forMember($member)->notBillable()->create();
// Assert
$this->assertFalse($timeEntry->refresh()->billable);
}
public function test_billable_rates_can_not_be_set_or_changed_if_organization_can_not_use_billable_rates(): void
{
// Arrange
$organization = Organization::factory()->billableRate(10000)->create();
$member = Member::factory()->forOrganization($organization)->forUser(User::factory()->create())->billableRate(10000)->create();
$project = Project::factory()->forOrganization($organization)->billable(10000)->create();
$projectMember = ProjectMember::factory()->forProject($project)->forMember($member)->create(['billable_rate' => 10000]);
$this->actAsOrganizationWithoutBillableRates();
// Act
$organization->billable_rate = 20000;
$organization->save();
$member->billable_rate = null;
$member->save();
$project->billable_rate = 20000;
$project->save();
$projectMember->billable_rate = null;
$projectMember->save();
$newProject = Project::factory()->forOrganization($organization)->billable(10000)->create();
$newProjectMember = ProjectMember::factory()->forProject($newProject)->forMember($member)->create(['billable_rate' => 10000]);
// Assert
$this->assertSame(10000, $organization->refresh()->billable_rate);
$this->assertSame(10000, $member->refresh()->billable_rate);
$this->assertSame(10000, $project->refresh()->billable_rate);
$this->assertTrue($project->is_billable);
$this->assertSame(10000, $projectMember->refresh()->billable_rate);
$newProject->refresh();
$this->assertNull($newProject->billable_rate);
$this->assertFalse($newProject->is_billable);
$this->assertNull($newProjectMember->refresh()->billable_rate);
}
public function test_employees_can_not_be_allowed_to_see_billable_rates_if_organization_can_not_use_billable_rates(): void
{
// Arrange
$organization = Organization::factory()->create(['employees_can_see_billable_rates' => false]);
$this->actAsOrganizationWithoutBillableRates();
// Act
$organization->employees_can_see_billable_rates = true;
$organization->save();
// Assert
$this->assertFalse($organization->refresh()->employees_can_see_billable_rates);
}
}

View File

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