mirror of
https://github.com/Unleash/unleash.git
synced 2025-01-25 00:07:47 +01:00
refactor: fix trial expiration calculations (#1090)
* refactor: fix trial expiration calculations * refactor: count full trial days for warning banner * refactor: fix flaky test
This commit is contained in:
parent
67a4f2e67f
commit
f46047f10a
@ -9,7 +9,7 @@ import {
|
|||||||
InstanceState,
|
InstanceState,
|
||||||
InstancePlan,
|
InstancePlan,
|
||||||
} from 'interfaces/instance';
|
} from 'interfaces/instance';
|
||||||
import { calculateTrialDaysRemaining } from 'utils/billing';
|
import { hasTrialExpired } from 'utils/instanceTrial';
|
||||||
import { GridRow } from 'component/common/GridRow/GridRow';
|
import { GridRow } from 'component/common/GridRow/GridRow';
|
||||||
import { GridCol } from 'component/common/GridCol/GridCol';
|
import { GridCol } from 'component/common/GridCol/GridCol';
|
||||||
import { GridColLink } from './GridColLink/GridColLink';
|
import { GridColLink } from './GridColLink/GridColLink';
|
||||||
@ -81,7 +81,7 @@ interface IBillingPlanProps {
|
|||||||
|
|
||||||
export const BillingPlan: FC<IBillingPlanProps> = ({ instanceStatus }) => {
|
export const BillingPlan: FC<IBillingPlanProps> = ({ instanceStatus }) => {
|
||||||
const { users } = useUsers();
|
const { users } = useUsers();
|
||||||
const trialDaysRemaining = calculateTrialDaysRemaining(instanceStatus);
|
const trialHasExpired = hasTrialExpired(instanceStatus);
|
||||||
|
|
||||||
const price = {
|
const price = {
|
||||||
[InstancePlan.PRO]: 80,
|
[InstancePlan.PRO]: 80,
|
||||||
@ -91,11 +91,6 @@ export const BillingPlan: FC<IBillingPlanProps> = ({ instanceStatus }) => {
|
|||||||
user: 15,
|
user: 15,
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusExpired =
|
|
||||||
instanceStatus.state === InstanceState.TRIAL &&
|
|
||||||
typeof trialDaysRemaining === 'number' &&
|
|
||||||
trialDaysRemaining <= 0;
|
|
||||||
|
|
||||||
const planPrice = price[instanceStatus.plan];
|
const planPrice = price[instanceStatus.plan];
|
||||||
const seats = instanceStatus.seats ?? 5;
|
const seats = instanceStatus.seats ?? 5;
|
||||||
const freeAssigned = Math.min(users.length, seats);
|
const freeAssigned = Math.min(users.length, seats);
|
||||||
@ -135,12 +130,12 @@ export const BillingPlan: FC<IBillingPlanProps> = ({ instanceStatus }) => {
|
|||||||
show={
|
show={
|
||||||
<StyledTrialSpan
|
<StyledTrialSpan
|
||||||
sx={theme => ({
|
sx={theme => ({
|
||||||
color: statusExpired
|
color: trialHasExpired
|
||||||
? theme.palette.error.dark
|
? theme.palette.error.dark
|
||||||
: theme.palette.warning.dark,
|
: theme.palette.warning.dark,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{statusExpired
|
{trialHasExpired
|
||||||
? 'Trial expired'
|
? 'Trial expired'
|
||||||
: instanceStatus.trialExtended
|
: instanceStatus.trialExtended
|
||||||
? 'Extended Trial'
|
? 'Extended Trial'
|
||||||
|
@ -9,7 +9,7 @@ import { IInstanceStatus, InstanceState } from 'interfaces/instance';
|
|||||||
import { ADMIN } from 'component/providers/AccessProvider/permissions';
|
import { ADMIN } from 'component/providers/AccessProvider/permissions';
|
||||||
import AccessContext from 'contexts/AccessContext';
|
import AccessContext from 'contexts/AccessContext';
|
||||||
import useInstanceStatusApi from 'hooks/api/actions/useInstanceStatusApi/useInstanceStatusApi';
|
import useInstanceStatusApi from 'hooks/api/actions/useInstanceStatusApi/useInstanceStatusApi';
|
||||||
import { calculateTrialDaysRemaining } from 'utils/billing';
|
import { hasTrialExpired } from 'utils/instanceTrial';
|
||||||
import useToast from 'hooks/useToast';
|
import useToast from 'hooks/useToast';
|
||||||
import { formatUnknownError } from 'utils/formatUnknownError';
|
import { formatUnknownError } from 'utils/formatUnknownError';
|
||||||
|
|
||||||
@ -24,22 +24,16 @@ const TrialDialog: VFC<ITrialDialogProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const { hasAccess } = useContext(AccessContext);
|
const { hasAccess } = useContext(AccessContext);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const trialDaysRemaining = calculateTrialDaysRemaining(instanceStatus);
|
const trialHasExpired = hasTrialExpired(instanceStatus);
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(trialHasExpired);
|
||||||
const statusExpired =
|
|
||||||
instanceStatus.state === InstanceState.TRIAL &&
|
|
||||||
typeof trialDaysRemaining === 'number' &&
|
|
||||||
trialDaysRemaining <= 0;
|
|
||||||
|
|
||||||
const [dialogOpen, setDialogOpen] = useState(statusExpired);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setDialogOpen(statusExpired);
|
setDialogOpen(trialHasExpired);
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
setDialogOpen(statusExpired);
|
setDialogOpen(trialHasExpired);
|
||||||
}, 60000);
|
}, 60000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [statusExpired]);
|
}, [trialHasExpired]);
|
||||||
|
|
||||||
if (hasAccess(ADMIN)) {
|
if (hasAccess(ADMIN)) {
|
||||||
return (
|
return (
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { styled, Button, Typography } from '@mui/material';
|
import { styled, Button, Typography } from '@mui/material';
|
||||||
import { IInstanceStatus, InstanceState } from 'interfaces/instance';
|
import { IInstanceStatus } from 'interfaces/instance';
|
||||||
import { INSTANCE_STATUS_BAR_ID } from 'utils/testIds';
|
import { INSTANCE_STATUS_BAR_ID } from 'utils/testIds';
|
||||||
import { InfoOutlined, WarningAmber } from '@mui/icons-material';
|
import { InfoOutlined, WarningAmber } from '@mui/icons-material';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
@ -7,7 +7,10 @@ import { useContext } from 'react';
|
|||||||
import AccessContext from 'contexts/AccessContext';
|
import AccessContext from 'contexts/AccessContext';
|
||||||
import { ADMIN } from 'component/providers/AccessProvider/permissions';
|
import { ADMIN } from 'component/providers/AccessProvider/permissions';
|
||||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||||
import { calculateTrialDaysRemaining } from 'utils/billing';
|
import {
|
||||||
|
hasTrialExpired,
|
||||||
|
formatTrialExpirationWarning,
|
||||||
|
} from 'utils/instanceTrial';
|
||||||
|
|
||||||
const StyledWarningBar = styled('aside')(({ theme }) => ({
|
const StyledWarningBar = styled('aside')(({ theme }) => ({
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
@ -59,14 +62,10 @@ export const InstanceStatusBar = ({
|
|||||||
instanceStatus,
|
instanceStatus,
|
||||||
}: IInstanceStatusBarProps) => {
|
}: IInstanceStatusBarProps) => {
|
||||||
const { hasAccess } = useContext(AccessContext);
|
const { hasAccess } = useContext(AccessContext);
|
||||||
|
const trialHasExpired = hasTrialExpired(instanceStatus);
|
||||||
|
const trialExpirationWarning = formatTrialExpirationWarning(instanceStatus);
|
||||||
|
|
||||||
const trialDaysRemaining = calculateTrialDaysRemaining(instanceStatus);
|
if (trialHasExpired) {
|
||||||
|
|
||||||
if (
|
|
||||||
instanceStatus.state === InstanceState.TRIAL &&
|
|
||||||
typeof trialDaysRemaining === 'number' &&
|
|
||||||
trialDaysRemaining <= 0
|
|
||||||
) {
|
|
||||||
return (
|
return (
|
||||||
<StyledWarningBar data-testid={INSTANCE_STATUS_BAR_ID}>
|
<StyledWarningBar data-testid={INSTANCE_STATUS_BAR_ID}>
|
||||||
<StyledWarningIcon />
|
<StyledWarningIcon />
|
||||||
@ -87,11 +86,7 @@ export const InstanceStatusBar = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (trialExpirationWarning) {
|
||||||
instanceStatus.state === InstanceState.TRIAL &&
|
|
||||||
typeof trialDaysRemaining === 'number' &&
|
|
||||||
trialDaysRemaining <= 10
|
|
||||||
) {
|
|
||||||
return (
|
return (
|
||||||
<StyledInfoBar data-testid={INSTANCE_STATUS_BAR_ID}>
|
<StyledInfoBar data-testid={INSTANCE_STATUS_BAR_ID}>
|
||||||
<StyledInfoIcon />
|
<StyledInfoIcon />
|
||||||
@ -101,7 +96,7 @@ export const InstanceStatusBar = ({
|
|||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<strong>Heads up!</strong> You have{' '}
|
<strong>Heads up!</strong> You have{' '}
|
||||||
<strong>{trialDaysRemaining} days</strong> left of your free{' '}
|
<strong>{trialExpirationWarning}</strong> left of your free{' '}
|
||||||
{instanceStatus.plan} trial.
|
{instanceStatus.plan} trial.
|
||||||
</Typography>
|
</Typography>
|
||||||
<ConditionallyRender
|
<ConditionallyRender
|
||||||
|
@ -65,8 +65,7 @@ exports[`InstanceStatusBar should warn when the trial is about to expire 1`] = `
|
|||||||
You have
|
You have
|
||||||
|
|
||||||
<strong>
|
<strong>
|
||||||
4
|
4 days
|
||||||
days
|
|
||||||
</strong>
|
</strong>
|
||||||
left of your free
|
left of your free
|
||||||
|
|
||||||
|
@ -1,10 +0,0 @@
|
|||||||
import { differenceInDays, parseISO } from 'date-fns';
|
|
||||||
import { IInstanceStatus } from 'interfaces/instance';
|
|
||||||
|
|
||||||
export const calculateTrialDaysRemaining = (
|
|
||||||
instanceStatus?: IInstanceStatus
|
|
||||||
): number | undefined => {
|
|
||||||
return instanceStatus?.trialExpiry
|
|
||||||
? differenceInDays(parseISO(instanceStatus.trialExpiry), new Date())
|
|
||||||
: undefined;
|
|
||||||
};
|
|
66
frontend/src/utils/instanceTrial.test.ts
Normal file
66
frontend/src/utils/instanceTrial.test.ts
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import {
|
||||||
|
hasTrialExpired,
|
||||||
|
formatTrialExpirationWarning,
|
||||||
|
} from 'utils/instanceTrial';
|
||||||
|
import { InstancePlan, InstanceState } from 'interfaces/instance';
|
||||||
|
import { subHours, addHours, addMinutes, subMinutes } from 'date-fns';
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
undefined,
|
||||||
|
{ plan: InstancePlan.UNKNOWN },
|
||||||
|
{ plan: InstancePlan.UNKNOWN, state: InstanceState.ACTIVE },
|
||||||
|
{ plan: InstancePlan.UNKNOWN, state: InstanceState.TRIAL },
|
||||||
|
{ plan: InstancePlan.COMPANY, state: InstanceState.TRIAL },
|
||||||
|
{ plan: InstancePlan.PRO, state: InstanceState.TRIAL },
|
||||||
|
])('unknown trial states should not count as expired', input => {
|
||||||
|
expect(hasTrialExpired(input)).toEqual(false);
|
||||||
|
expect(formatTrialExpirationWarning(input)).toEqual(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hasTrialExpired', () => {
|
||||||
|
expect(
|
||||||
|
hasTrialExpired({
|
||||||
|
plan: InstancePlan.UNKNOWN,
|
||||||
|
state: InstanceState.TRIAL,
|
||||||
|
trialExpiry: subHours(new Date(), 2).toISOString(),
|
||||||
|
})
|
||||||
|
).toEqual(true);
|
||||||
|
expect(
|
||||||
|
hasTrialExpired({
|
||||||
|
plan: InstancePlan.UNKNOWN,
|
||||||
|
state: InstanceState.TRIAL,
|
||||||
|
trialExpiry: addHours(new Date(), 2).toISOString(),
|
||||||
|
})
|
||||||
|
).toEqual(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formatTrialExpirationWarning', () => {
|
||||||
|
expect(
|
||||||
|
formatTrialExpirationWarning({
|
||||||
|
plan: InstancePlan.UNKNOWN,
|
||||||
|
state: InstanceState.TRIAL,
|
||||||
|
trialExpiry: subMinutes(new Date(), 1).toISOString(),
|
||||||
|
})
|
||||||
|
).toEqual(undefined);
|
||||||
|
expect(
|
||||||
|
formatTrialExpirationWarning({
|
||||||
|
plan: InstancePlan.UNKNOWN,
|
||||||
|
state: InstanceState.TRIAL,
|
||||||
|
trialExpiry: addMinutes(new Date(), 23 * 60 + 1).toISOString(),
|
||||||
|
})
|
||||||
|
).toEqual('23 hours');
|
||||||
|
expect(
|
||||||
|
formatTrialExpirationWarning({
|
||||||
|
plan: InstancePlan.UNKNOWN,
|
||||||
|
state: InstanceState.TRIAL,
|
||||||
|
trialExpiry: addHours(new Date(), 25).toISOString(),
|
||||||
|
})
|
||||||
|
).toEqual('1 day');
|
||||||
|
expect(
|
||||||
|
formatTrialExpirationWarning({
|
||||||
|
plan: InstancePlan.UNKNOWN,
|
||||||
|
state: InstanceState.TRIAL,
|
||||||
|
trialExpiry: addHours(new Date(), 24 * 11 - 1).toISOString(),
|
||||||
|
})
|
||||||
|
).toEqual('10 days');
|
||||||
|
});
|
48
frontend/src/utils/instanceTrial.ts
Normal file
48
frontend/src/utils/instanceTrial.ts
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import { parseISO, formatDistanceToNowStrict, isPast } from 'date-fns';
|
||||||
|
import { IInstanceStatus, InstanceState } from 'interfaces/instance';
|
||||||
|
import differenceInDays from 'date-fns/differenceInDays';
|
||||||
|
|
||||||
|
const TRIAL_EXPIRATION_WARNING_DAYS_THRESHOLD = 10;
|
||||||
|
|
||||||
|
export const hasTrialExpired = (
|
||||||
|
instanceStatus: IInstanceStatus | undefined
|
||||||
|
): boolean => {
|
||||||
|
const trialExpiry = parseTrialExpiryDate(instanceStatus);
|
||||||
|
|
||||||
|
if (!trialExpiry) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isPast(trialExpiry);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatTrialExpirationWarning = (
|
||||||
|
instanceStatus: IInstanceStatus | undefined
|
||||||
|
): string | undefined => {
|
||||||
|
const trialExpiry = parseTrialExpiryDate(instanceStatus);
|
||||||
|
|
||||||
|
if (!trialExpiry || isPast(trialExpiry)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
differenceInDays(trialExpiry, new Date()) <=
|
||||||
|
TRIAL_EXPIRATION_WARNING_DAYS_THRESHOLD
|
||||||
|
) {
|
||||||
|
return formatDistanceToNowStrict(trialExpiry, {
|
||||||
|
roundingMethod: 'floor',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseTrialExpiryDate = (
|
||||||
|
instanceStatus: IInstanceStatus | undefined
|
||||||
|
): Date | undefined => {
|
||||||
|
if (
|
||||||
|
instanceStatus &&
|
||||||
|
instanceStatus.state === InstanceState.TRIAL &&
|
||||||
|
instanceStatus.trialExpiry
|
||||||
|
) {
|
||||||
|
return parseISO(instanceStatus.trialExpiry);
|
||||||
|
}
|
||||||
|
};
|
Loading…
Reference in New Issue
Block a user