1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-05-26 01:17:00 +02:00

refactor: fix handling of expired/churned trial states (#1107)

This commit is contained in:
olav 2022-06-21 11:22:27 +02:00 committed by GitHub
parent 9522c59674
commit 583d636144
7 changed files with 358 additions and 166 deletions

View File

@ -9,7 +9,7 @@ import {
InstanceState,
InstancePlan,
} from 'interfaces/instance';
import { hasTrialExpired } from 'utils/instanceTrial';
import { trialHasExpired, isTrialInstance } from 'utils/instanceTrial';
import { GridRow } from 'component/common/GridRow/GridRow';
import { GridCol } from 'component/common/GridCol/GridCol';
import { GridColLink } from './GridColLink/GridColLink';
@ -81,7 +81,7 @@ interface IBillingPlanProps {
export const BillingPlan: FC<IBillingPlanProps> = ({ instanceStatus }) => {
const { users } = useUsers();
const trialHasExpired = hasTrialExpired(instanceStatus);
const expired = trialHasExpired(instanceStatus);
const price = {
[InstancePlan.PRO]: 80,
@ -124,18 +124,16 @@ export const BillingPlan: FC<IBillingPlanProps> = ({ instanceStatus }) => {
{instanceStatus.plan}
</StyledPlanSpan>
<ConditionallyRender
condition={
instanceStatus.state === InstanceState.TRIAL
}
condition={isTrialInstance(instanceStatus)}
show={
<StyledTrialSpan
sx={theme => ({
color: trialHasExpired
color: expired
? theme.palette.error.dark
: theme.palette.warning.dark,
})}
>
{trialHasExpired
{expired
? 'Trial expired'
: instanceStatus.trialExtended
? 'Extended Trial'

View File

@ -5,11 +5,11 @@ import { ConditionallyRender } from 'component/common/ConditionallyRender/Condit
import { Dialogue } from 'component/common/Dialogue/Dialogue';
import { Typography } from '@mui/material';
import { useNavigate } from 'react-router-dom';
import { IInstanceStatus, InstanceState } from 'interfaces/instance';
import { IInstanceStatus } from 'interfaces/instance';
import { ADMIN } from 'component/providers/AccessProvider/permissions';
import AccessContext from 'contexts/AccessContext';
import useInstanceStatusApi from 'hooks/api/actions/useInstanceStatusApi/useInstanceStatusApi';
import { hasTrialExpired } from 'utils/instanceTrial';
import { trialHasExpired, canExtendTrial } from 'utils/instanceTrial';
import useToast from 'hooks/useToast';
import { formatUnknownError } from 'utils/formatUnknownError';
@ -24,16 +24,16 @@ const TrialDialog: VFC<ITrialDialogProps> = ({
}) => {
const { hasAccess } = useContext(AccessContext);
const navigate = useNavigate();
const trialHasExpired = hasTrialExpired(instanceStatus);
const [dialogOpen, setDialogOpen] = useState(trialHasExpired);
const expired = trialHasExpired(instanceStatus);
const [dialogOpen, setDialogOpen] = useState(expired);
useEffect(() => {
setDialogOpen(trialHasExpired);
setDialogOpen(expired);
const interval = setInterval(() => {
setDialogOpen(trialHasExpired);
setDialogOpen(expired);
}, 60000);
return () => clearInterval(interval);
}, [trialHasExpired]);
}, [expired]);
if (hasAccess(ADMIN)) {
return (
@ -41,9 +41,9 @@ const TrialDialog: VFC<ITrialDialogProps> = ({
open={dialogOpen}
primaryButtonText="Upgrade trial"
secondaryButtonText={
instanceStatus?.trialExtended
? 'Remind me later'
: 'Extend trial (5 days)'
canExtendTrial(instanceStatus)
? 'Extend trial (5 days)'
: 'Remind me later'
}
onClick={() => {
navigate('/admin/billing');
@ -92,16 +92,11 @@ export const InstanceStatus: FC = ({ children }) => {
const { setToastApiError } = useToast();
const onExtendTrial = async () => {
if (
instanceStatus?.state === InstanceState.TRIAL &&
!instanceStatus?.trialExtended
) {
try {
await extendTrial();
await refetchInstanceStatus();
} catch (error: unknown) {
setToastApiError(formatUnknownError(error));
}
try {
await extendTrial();
await refetchInstanceStatus();
} catch (error: unknown) {
setToastApiError(formatUnknownError(error));
}
};

View File

@ -2,7 +2,7 @@ import { InstanceStatusBar } from 'component/common/InstanceStatus/InstanceStatu
import { InstancePlan, InstanceState } from 'interfaces/instance';
import { render } from 'utils/testRenderer';
import { screen } from '@testing-library/react';
import { addDays } from 'date-fns';
import { addDays, subDays } from 'date-fns';
import { INSTANCE_STATUS_BAR_ID } from 'utils/testIds';
import { UNKNOWN_INSTANCE_STATUS } from 'hooks/api/getters/useInstanceStatus/useInstanceStatus';
@ -14,7 +14,22 @@ test('InstanceStatusBar should be hidden by default', async () => {
).not.toBeInTheDocument();
});
test('InstanceStatusBar should be hidden when the trial is far from expired', async () => {
test('InstanceStatusBar should be hidden when state is active', async () => {
render(
<InstanceStatusBar
instanceStatus={{
plan: InstancePlan.PRO,
state: InstanceState.ACTIVE,
}}
/>
);
expect(
screen.queryByTestId(INSTANCE_STATUS_BAR_ID)
).not.toBeInTheDocument();
});
test('InstanceStatusBar should warn when the trial is far from expired', async () => {
render(
<InstanceStatusBar
instanceStatus={{
@ -25,9 +40,8 @@ test('InstanceStatusBar should be hidden when the trial is far from expired', as
/>
);
expect(
screen.queryByTestId(INSTANCE_STATUS_BAR_ID)
).not.toBeInTheDocument();
expect(screen.getByTestId(INSTANCE_STATUS_BAR_ID)).toBeInTheDocument();
expect(await screen.findByTestId(INSTANCE_STATUS_BAR_ID)).toMatchSnapshot();
});
test('InstanceStatusBar should warn when the trial is about to expire', async () => {
@ -45,13 +59,41 @@ test('InstanceStatusBar should warn when the trial is about to expire', async ()
expect(await screen.findByTestId(INSTANCE_STATUS_BAR_ID)).toMatchSnapshot();
});
test('InstanceStatusBar should warn when the trial has expired', async () => {
test('InstanceStatusBar should warn when trialExpiry has passed', async () => {
render(
<InstanceStatusBar
instanceStatus={{
plan: InstancePlan.PRO,
state: InstanceState.TRIAL,
trialExpiry: new Date().toISOString(),
trialExpiry: subDays(new Date(), 1).toISOString(),
}}
/>
);
expect(screen.getByTestId(INSTANCE_STATUS_BAR_ID)).toBeInTheDocument();
expect(await screen.findByTestId(INSTANCE_STATUS_BAR_ID)).toMatchSnapshot();
});
test('InstanceStatusBar should warn when the trial has expired', async () => {
render(
<InstanceStatusBar
instanceStatus={{
plan: InstancePlan.PRO,
state: InstanceState.EXPIRED,
}}
/>
);
expect(screen.getByTestId(INSTANCE_STATUS_BAR_ID)).toBeInTheDocument();
expect(await screen.findByTestId(INSTANCE_STATUS_BAR_ID)).toMatchSnapshot();
});
test('InstanceStatusBar should warn when the trial has churned', async () => {
render(
<InstanceStatusBar
instanceStatus={{
plan: InstancePlan.PRO,
state: InstanceState.CHURNED,
}}
/>
);

View File

@ -6,11 +6,12 @@ import { useNavigate } from 'react-router-dom';
import { useContext } from 'react';
import AccessContext from 'contexts/AccessContext';
import { ADMIN } from 'component/providers/AccessProvider/permissions';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import {
hasTrialExpired,
formatTrialExpirationWarning,
trialHasExpired,
trialExpiresSoon,
isTrialInstance,
} from 'utils/instanceTrial';
import { formatDistanceToNowStrict, parseISO } from 'date-fns';
const StyledWarningBar = styled('aside')(({ theme }) => ({
position: 'relative',
@ -61,58 +62,75 @@ interface IInstanceStatusBarProps {
export const InstanceStatusBar = ({
instanceStatus,
}: IInstanceStatusBarProps) => {
const { hasAccess } = useContext(AccessContext);
const trialHasExpired = hasTrialExpired(instanceStatus);
const trialExpirationWarning = formatTrialExpirationWarning(instanceStatus);
if (trialHasExpired) {
return (
<StyledWarningBar data-testid={INSTANCE_STATUS_BAR_ID}>
<StyledWarningIcon />
<Typography
sx={theme => ({
fontSize: theme.fontSizes.smallBody,
})}
>
<strong>Warning!</strong> Your free {instanceStatus.plan}{' '}
trial has expired. <strong>Upgrade trial</strong> otherwise
your <strong>account will be deleted.</strong>
</Typography>
<ConditionallyRender
condition={hasAccess(ADMIN)}
show={<UpgradeButton />}
/>
</StyledWarningBar>
);
if (trialHasExpired(instanceStatus)) {
return <StatusBarExpired instanceStatus={instanceStatus} />;
}
if (trialExpirationWarning) {
return (
<StyledInfoBar data-testid={INSTANCE_STATUS_BAR_ID}>
<StyledInfoIcon />
<Typography
sx={theme => ({
fontSize: theme.fontSizes.smallBody,
})}
>
<strong>Heads up!</strong> You have{' '}
<strong>{trialExpirationWarning}</strong> left of your free{' '}
{instanceStatus.plan} trial.
</Typography>
<ConditionallyRender
condition={hasAccess(ADMIN)}
show={<UpgradeButton />}
/>
</StyledInfoBar>
);
if (trialExpiresSoon(instanceStatus)) {
return <StatusBarExpiresSoon instanceStatus={instanceStatus} />;
}
if (isTrialInstance(instanceStatus)) {
return <StatusBarExpiresLater instanceStatus={instanceStatus} />;
}
return null;
};
const UpgradeButton = () => {
const StatusBarExpired = ({ instanceStatus }: IInstanceStatusBarProps) => {
return (
<StyledWarningBar data-testid={INSTANCE_STATUS_BAR_ID}>
<StyledWarningIcon />
<Typography sx={theme => ({ fontSize: theme.fontSizes.smallBody })}>
<strong>Warning!</strong> Your free {instanceStatus.plan} trial
has expired. <strong>Upgrade trial</strong> otherwise your{' '}
<strong>account will be deleted.</strong>
</Typography>
<BillingLink />
</StyledWarningBar>
);
};
const StatusBarExpiresSoon = ({ instanceStatus }: IInstanceStatusBarProps) => {
const timeRemaining = formatDistanceToNowStrict(
parseISO(instanceStatus.trialExpiry!),
{ roundingMethod: 'floor' }
);
return (
<StyledInfoBar data-testid={INSTANCE_STATUS_BAR_ID}>
<StyledInfoIcon />
<Typography sx={theme => ({ fontSize: theme.fontSizes.smallBody })}>
<strong>Heads up!</strong> You have{' '}
<strong>{timeRemaining}</strong> left of your free{' '}
{instanceStatus.plan} trial.
</Typography>
<BillingLink />
</StyledInfoBar>
);
};
const StatusBarExpiresLater = ({ instanceStatus }: IInstanceStatusBarProps) => {
return (
<StyledInfoBar data-testid={INSTANCE_STATUS_BAR_ID}>
<StyledInfoIcon />
<Typography sx={theme => ({ fontSize: theme.fontSizes.smallBody })}>
<strong>Heads up!</strong> You're currently on a free{' '}
{instanceStatus.plan} trial account.
</Typography>
<BillingLink />
</StyledInfoBar>
);
};
const BillingLink = () => {
const { hasAccess } = useContext(AccessContext);
const navigate = useNavigate();
if (!hasAccess(ADMIN)) {
return null;
}
return (
<StyledButton
onClick={() => navigate('/admin/billing')}

View File

@ -1,5 +1,45 @@
// Vitest Snapshot v1
exports[`InstanceStatusBar should warn when the trial has churned 1`] = `
<aside
class="mui-jmsogz"
data-testid="INSTANCE_STATUS_BAR_ID"
>
<svg
aria-hidden="true"
class="MuiSvgIcon-root MuiSvgIcon-fontSizeMedium mui-prk1jy-MuiSvgIcon-root"
data-testid="WarningAmberIcon"
focusable="false"
viewBox="0 0 24 24"
>
<path
d="M12 5.99 19.53 19H4.47L12 5.99M12 2 1 21h22L12 2z"
/>
<path
d="M13 16h-2v2h2zm0-6h-2v5h2z"
/>
</svg>
<p
class="MuiTypography-root MuiTypography-body1 mui-rviqjc-MuiTypography-root"
>
<strong>
Warning!
</strong>
Your free
Pro
trial has expired.
<strong>
Upgrade trial
</strong>
otherwise your
<strong>
account will be deleted.
</strong>
</p>
</aside>
`;
exports[`InstanceStatusBar should warn when the trial has expired 1`] = `
<aside
class="mui-jmsogz"
@ -27,12 +67,12 @@ exports[`InstanceStatusBar should warn when the trial has expired 1`] = `
</strong>
Your free
Pro
trial has expired.
trial has expired.
<strong>
Upgrade trial
</strong>
otherwise your
otherwise your
<strong>
account will be deleted.
</strong>
@ -74,3 +114,73 @@ exports[`InstanceStatusBar should warn when the trial is about to expire 1`] = `
</p>
</aside>
`;
exports[`InstanceStatusBar should warn when the trial is far from expired 1`] = `
<aside
class="mui-yx2rkt"
data-testid="INSTANCE_STATUS_BAR_ID"
>
<svg
aria-hidden="true"
class="MuiSvgIcon-root MuiSvgIcon-fontSizeMedium mui-cle2im-MuiSvgIcon-root"
data-testid="InfoOutlinedIcon"
focusable="false"
viewBox="0 0 24 24"
>
<path
d="M11 7h2v2h-2zm0 4h2v6h-2zm1-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z"
/>
</svg>
<p
class="MuiTypography-root MuiTypography-body1 mui-rviqjc-MuiTypography-root"
>
<strong>
Heads up!
</strong>
You're currently on a free
Pro
trial account.
</p>
</aside>
`;
exports[`InstanceStatusBar should warn when trialExpiry has passed 1`] = `
<aside
class="mui-jmsogz"
data-testid="INSTANCE_STATUS_BAR_ID"
>
<svg
aria-hidden="true"
class="MuiSvgIcon-root MuiSvgIcon-fontSizeMedium mui-prk1jy-MuiSvgIcon-root"
data-testid="WarningAmberIcon"
focusable="false"
viewBox="0 0 24 24"
>
<path
d="M12 5.99 19.53 19H4.47L12 5.99M12 2 1 21h22L12 2z"
/>
<path
d="M13 16h-2v2h2zm0-6h-2v5h2z"
/>
</svg>
<p
class="MuiTypography-root MuiTypography-body1 mui-rviqjc-MuiTypography-root"
>
<strong>
Warning!
</strong>
Your free
Pro
trial has expired.
<strong>
Upgrade trial
</strong>
otherwise your
<strong>
account will be deleted.
</strong>
</p>
</aside>
`;

View File

@ -1,66 +1,81 @@
import {
hasTrialExpired,
formatTrialExpirationWarning,
trialHasExpired,
canExtendTrial,
trialExpiresSoon,
} from 'utils/instanceTrial';
import { InstancePlan, InstanceState } from 'interfaces/instance';
import { subHours, addHours, addMinutes, subMinutes } from 'date-fns';
import { subHours, addHours, addDays } 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', () => {
test('trialHasExpired', () => {
expect(
hasTrialExpired({
trialHasExpired({
plan: InstancePlan.UNKNOWN,
state: InstanceState.UNASSIGNED,
})
).toEqual(false);
expect(
trialHasExpired({
plan: InstancePlan.UNKNOWN,
state: InstanceState.ACTIVE,
})
).toEqual(false);
expect(
trialHasExpired({
plan: InstancePlan.UNKNOWN,
state: InstanceState.TRIAL,
trialExpiry: addHours(new Date(), 2).toISOString(),
})
).toEqual(false);
expect(
trialHasExpired({
plan: InstancePlan.UNKNOWN,
state: InstanceState.TRIAL,
trialExpiry: subHours(new Date(), 2).toISOString(),
})
).toEqual(true);
expect(
hasTrialExpired({
trialHasExpired({
plan: InstancePlan.UNKNOWN,
state: InstanceState.EXPIRED,
})
).toEqual(true);
expect(
trialHasExpired({
plan: InstancePlan.UNKNOWN,
state: InstanceState.CHURNED,
})
).toEqual(true);
});
test('trialExpiresSoon', () => {
expect(
trialExpiresSoon({
plan: InstancePlan.UNKNOWN,
state: InstanceState.TRIAL,
trialExpiry: addHours(new Date(), 2).toISOString(),
trialExpiry: addDays(new Date(), 12).toISOString(),
})
).toEqual(false);
expect(
trialExpiresSoon({
plan: InstancePlan.UNKNOWN,
state: InstanceState.TRIAL,
trialExpiry: addDays(new Date(), 8).toISOString(),
})
).toEqual(true);
});
test('canExtendTrial', () => {
expect(
canExtendTrial({
plan: InstancePlan.UNKNOWN,
state: InstanceState.EXPIRED,
})
).toEqual(true);
expect(
canExtendTrial({
plan: InstancePlan.UNKNOWN,
state: InstanceState.EXPIRED,
trialExtended: 1,
})
).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');
});

View File

@ -1,48 +1,62 @@
import { parseISO, formatDistanceToNowStrict, isPast } from 'date-fns';
import { parseISO, isPast } from 'date-fns';
import { IInstanceStatus, InstanceState } from 'interfaces/instance';
import differenceInDays from 'date-fns/differenceInDays';
const TRIAL_EXPIRATION_WARNING_DAYS_THRESHOLD = 10;
const TRIAL_EXPIRES_SOON_DAYS_THRESHOLD = 10;
export const hasTrialExpired = (
export const isTrialInstance = (
instanceStatus: IInstanceStatus | undefined
): boolean => {
const trialExpiry = parseTrialExpiryDate(instanceStatus);
return (
instanceStatus?.state === InstanceState.TRIAL ||
instanceStatus?.state === InstanceState.EXPIRED ||
instanceStatus?.state === InstanceState.CHURNED
);
};
if (!trialExpiry) {
export const trialHasExpired = (
instanceStatus: IInstanceStatus | undefined
): boolean => {
if (
instanceStatus?.state === InstanceState.EXPIRED ||
instanceStatus?.state === InstanceState.CHURNED
) {
return true;
}
if (
instanceStatus?.state === InstanceState.TRIAL &&
instanceStatus?.trialExpiry
) {
return isPast(parseISO(instanceStatus.trialExpiry));
}
return false;
};
export const trialExpiresSoon = (
instanceStatus: IInstanceStatus | undefined
) => {
if (
!instanceStatus ||
instanceStatus.state !== InstanceState.TRIAL ||
!instanceStatus.trialExpiry
) {
return false;
}
return isPast(trialExpiry);
return (
differenceInDays(parseISO(instanceStatus.trialExpiry), new Date()) <=
TRIAL_EXPIRES_SOON_DAYS_THRESHOLD
);
};
export const formatTrialExpirationWarning = (
export const canExtendTrial = (
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 (
): boolean => {
return Boolean(
instanceStatus &&
instanceStatus.state === InstanceState.TRIAL &&
instanceStatus.trialExpiry
) {
return parseISO(instanceStatus.trialExpiry);
}
instanceStatus.state === InstanceState.EXPIRED &&
!instanceStatus.trialExtended
);
};