add billable rate restriction for free organizations

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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