From 583d6361448f8caa78308e5e3ef37d87037d9de1 Mon Sep 17 00:00:00 2001
From: olav
Date: Tue, 21 Jun 2022 11:22:27 +0200
Subject: [PATCH] refactor: fix handling of expired/churned trial states
(#1107)
---
.../BillingPlan/BillingPlan.tsx | 12 +-
.../common/InstanceStatus/InstanceStatus.tsx | 35 +++---
.../InstanceStatus/InstanceStatusBar.test.tsx | 56 +++++++--
.../InstanceStatus/InstanceStatusBar.tsx | 110 ++++++++++-------
.../InstanceStatusBar.test.tsx.snap | 116 +++++++++++++++++-
frontend/src/utils/instanceTrial.test.ts | 115 +++++++++--------
frontend/src/utils/instanceTrial.ts | 80 +++++++-----
7 files changed, 358 insertions(+), 166 deletions(-)
diff --git a/frontend/src/component/admin/billing/BillingDashboard/BillingPlan/BillingPlan.tsx b/frontend/src/component/admin/billing/BillingDashboard/BillingPlan/BillingPlan.tsx
index 569b37a4b7..8cd01433ff 100644
--- a/frontend/src/component/admin/billing/BillingDashboard/BillingPlan/BillingPlan.tsx
+++ b/frontend/src/component/admin/billing/BillingDashboard/BillingPlan/BillingPlan.tsx
@@ -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 = ({ 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 = ({ instanceStatus }) => {
{instanceStatus.plan}
({
- color: trialHasExpired
+ color: expired
? theme.palette.error.dark
: theme.palette.warning.dark,
})}
>
- {trialHasExpired
+ {expired
? 'Trial expired'
: instanceStatus.trialExtended
? 'Extended Trial'
diff --git a/frontend/src/component/common/InstanceStatus/InstanceStatus.tsx b/frontend/src/component/common/InstanceStatus/InstanceStatus.tsx
index 70e63bea00..242d5473f1 100644
--- a/frontend/src/component/common/InstanceStatus/InstanceStatus.tsx
+++ b/frontend/src/component/common/InstanceStatus/InstanceStatus.tsx
@@ -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 = ({
}) => {
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 = ({
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));
}
};
diff --git a/frontend/src/component/common/InstanceStatus/InstanceStatusBar.test.tsx b/frontend/src/component/common/InstanceStatus/InstanceStatusBar.test.tsx
index 726e788b85..8791740e68 100644
--- a/frontend/src/component/common/InstanceStatus/InstanceStatusBar.test.tsx
+++ b/frontend/src/component/common/InstanceStatus/InstanceStatusBar.test.tsx
@@ -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(
+
+ );
+
+ expect(
+ screen.queryByTestId(INSTANCE_STATUS_BAR_ID)
+ ).not.toBeInTheDocument();
+});
+
+test('InstanceStatusBar should warn when the trial is far from expired', async () => {
render(
);
- 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(
+ );
+
+ 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(
+
+ );
+
+ 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(
+
);
diff --git a/frontend/src/component/common/InstanceStatus/InstanceStatusBar.tsx b/frontend/src/component/common/InstanceStatus/InstanceStatusBar.tsx
index 6135691270..089c225800 100644
--- a/frontend/src/component/common/InstanceStatus/InstanceStatusBar.tsx
+++ b/frontend/src/component/common/InstanceStatus/InstanceStatusBar.tsx
@@ -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 (
-
-
- ({
- fontSize: theme.fontSizes.smallBody,
- })}
- >
- Warning! Your free {instanceStatus.plan}{' '}
- trial has expired. Upgrade trial otherwise
- your account will be deleted.
-
- }
- />
-
- );
+ if (trialHasExpired(instanceStatus)) {
+ return ;
}
- if (trialExpirationWarning) {
- return (
-
-
- ({
- fontSize: theme.fontSizes.smallBody,
- })}
- >
- Heads up! You have{' '}
- {trialExpirationWarning} left of your free{' '}
- {instanceStatus.plan} trial.
-
- }
- />
-
- );
+ if (trialExpiresSoon(instanceStatus)) {
+ return ;
+ }
+
+ if (isTrialInstance(instanceStatus)) {
+ return ;
}
return null;
};
-const UpgradeButton = () => {
+const StatusBarExpired = ({ instanceStatus }: IInstanceStatusBarProps) => {
+ return (
+
+
+ ({ fontSize: theme.fontSizes.smallBody })}>
+ Warning! Your free {instanceStatus.plan} trial
+ has expired. Upgrade trial otherwise your{' '}
+ account will be deleted.
+
+
+
+ );
+};
+
+const StatusBarExpiresSoon = ({ instanceStatus }: IInstanceStatusBarProps) => {
+ const timeRemaining = formatDistanceToNowStrict(
+ parseISO(instanceStatus.trialExpiry!),
+ { roundingMethod: 'floor' }
+ );
+
+ return (
+
+
+ ({ fontSize: theme.fontSizes.smallBody })}>
+ Heads up! You have{' '}
+ {timeRemaining} left of your free{' '}
+ {instanceStatus.plan} trial.
+
+
+
+ );
+};
+
+const StatusBarExpiresLater = ({ instanceStatus }: IInstanceStatusBarProps) => {
+ return (
+
+
+ ({ fontSize: theme.fontSizes.smallBody })}>
+ Heads up! You're currently on a free{' '}
+ {instanceStatus.plan} trial account.
+
+
+
+ );
+};
+
+const BillingLink = () => {
+ const { hasAccess } = useContext(AccessContext);
const navigate = useNavigate();
+ if (!hasAccess(ADMIN)) {
+ return null;
+ }
+
return (
navigate('/admin/billing')}
diff --git a/frontend/src/component/common/InstanceStatus/__snapshots__/InstanceStatusBar.test.tsx.snap b/frontend/src/component/common/InstanceStatus/__snapshots__/InstanceStatusBar.test.tsx.snap
index 73af0196ca..24c0071941 100644
--- a/frontend/src/component/common/InstanceStatus/__snapshots__/InstanceStatusBar.test.tsx.snap
+++ b/frontend/src/component/common/InstanceStatus/__snapshots__/InstanceStatusBar.test.tsx.snap
@@ -1,5 +1,45 @@
// Vitest Snapshot v1
+exports[`InstanceStatusBar should warn when the trial has churned 1`] = `
+
+`;
+
exports[`InstanceStatusBar should warn when the trial has expired 1`] = `
`;
+
+exports[`InstanceStatusBar should warn when the trial is far from expired 1`] = `
+
+`;
+
+exports[`InstanceStatusBar should warn when trialExpiry has passed 1`] = `
+
+`;
diff --git a/frontend/src/utils/instanceTrial.test.ts b/frontend/src/utils/instanceTrial.test.ts
index b354e4a698..ff89517add 100644
--- a/frontend/src/utils/instanceTrial.test.ts
+++ b/frontend/src/utils/instanceTrial.test.ts
@@ -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');
-});
diff --git a/frontend/src/utils/instanceTrial.ts b/frontend/src/utils/instanceTrial.ts
index 93bdba6aae..6814992e37 100644
--- a/frontend/src/utils/instanceTrial.ts
+++ b/frontend/src/utils/instanceTrial.ts
@@ -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
+ );
};