From f46047f10a7b18a6deb497af6f157bcdc7f9636b Mon Sep 17 00:00:00 2001 From: olav Date: Tue, 14 Jun 2022 11:51:11 +0200 Subject: [PATCH] refactor: fix trial expiration calculations (#1090) * refactor: fix trial expiration calculations * refactor: count full trial days for warning banner * refactor: fix flaky test --- .../BillingPlan/BillingPlan.tsx | 13 ++-- .../common/InstanceStatus/InstanceStatus.tsx | 18 ++--- .../InstanceStatus/InstanceStatusBar.tsx | 25 +++---- .../InstanceStatusBar.test.tsx.snap | 3 +- frontend/src/utils/billing.ts | 10 --- frontend/src/utils/instanceTrial.test.ts | 66 +++++++++++++++++++ frontend/src/utils/instanceTrial.ts | 48 ++++++++++++++ 7 files changed, 135 insertions(+), 48 deletions(-) delete mode 100644 frontend/src/utils/billing.ts create mode 100644 frontend/src/utils/instanceTrial.test.ts create mode 100644 frontend/src/utils/instanceTrial.ts diff --git a/frontend/src/component/admin/billing/BillingDashboard/BillingPlan/BillingPlan.tsx b/frontend/src/component/admin/billing/BillingDashboard/BillingPlan/BillingPlan.tsx index 537b56b807..569b37a4b7 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 { calculateTrialDaysRemaining } from 'utils/billing'; +import { hasTrialExpired } 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 trialDaysRemaining = calculateTrialDaysRemaining(instanceStatus); + const trialHasExpired = hasTrialExpired(instanceStatus); const price = { [InstancePlan.PRO]: 80, @@ -91,11 +91,6 @@ export const BillingPlan: FC = ({ instanceStatus }) => { user: 15, }; - const statusExpired = - instanceStatus.state === InstanceState.TRIAL && - typeof trialDaysRemaining === 'number' && - trialDaysRemaining <= 0; - const planPrice = price[instanceStatus.plan]; const seats = instanceStatus.seats ?? 5; const freeAssigned = Math.min(users.length, seats); @@ -135,12 +130,12 @@ export const BillingPlan: FC = ({ instanceStatus }) => { show={ ({ - color: statusExpired + color: trialHasExpired ? theme.palette.error.dark : theme.palette.warning.dark, })} > - {statusExpired + {trialHasExpired ? '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 d9cee6065d..70e63bea00 100644 --- a/frontend/src/component/common/InstanceStatus/InstanceStatus.tsx +++ b/frontend/src/component/common/InstanceStatus/InstanceStatus.tsx @@ -9,7 +9,7 @@ import { IInstanceStatus, InstanceState } from 'interfaces/instance'; import { ADMIN } from 'component/providers/AccessProvider/permissions'; import AccessContext from 'contexts/AccessContext'; import useInstanceStatusApi from 'hooks/api/actions/useInstanceStatusApi/useInstanceStatusApi'; -import { calculateTrialDaysRemaining } from 'utils/billing'; +import { hasTrialExpired } from 'utils/instanceTrial'; import useToast from 'hooks/useToast'; import { formatUnknownError } from 'utils/formatUnknownError'; @@ -24,22 +24,16 @@ const TrialDialog: VFC = ({ }) => { const { hasAccess } = useContext(AccessContext); const navigate = useNavigate(); - const trialDaysRemaining = calculateTrialDaysRemaining(instanceStatus); - - const statusExpired = - instanceStatus.state === InstanceState.TRIAL && - typeof trialDaysRemaining === 'number' && - trialDaysRemaining <= 0; - - const [dialogOpen, setDialogOpen] = useState(statusExpired); + const trialHasExpired = hasTrialExpired(instanceStatus); + const [dialogOpen, setDialogOpen] = useState(trialHasExpired); useEffect(() => { - setDialogOpen(statusExpired); + setDialogOpen(trialHasExpired); const interval = setInterval(() => { - setDialogOpen(statusExpired); + setDialogOpen(trialHasExpired); }, 60000); return () => clearInterval(interval); - }, [statusExpired]); + }, [trialHasExpired]); if (hasAccess(ADMIN)) { return ( diff --git a/frontend/src/component/common/InstanceStatus/InstanceStatusBar.tsx b/frontend/src/component/common/InstanceStatus/InstanceStatusBar.tsx index 7737234236..6135691270 100644 --- a/frontend/src/component/common/InstanceStatus/InstanceStatusBar.tsx +++ b/frontend/src/component/common/InstanceStatus/InstanceStatusBar.tsx @@ -1,5 +1,5 @@ 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 { InfoOutlined, WarningAmber } from '@mui/icons-material'; import { useNavigate } from 'react-router-dom'; @@ -7,7 +7,10 @@ import { useContext } from 'react'; import AccessContext from 'contexts/AccessContext'; import { ADMIN } from 'component/providers/AccessProvider/permissions'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; -import { calculateTrialDaysRemaining } from 'utils/billing'; +import { + hasTrialExpired, + formatTrialExpirationWarning, +} from 'utils/instanceTrial'; const StyledWarningBar = styled('aside')(({ theme }) => ({ position: 'relative', @@ -59,14 +62,10 @@ export const InstanceStatusBar = ({ instanceStatus, }: IInstanceStatusBarProps) => { const { hasAccess } = useContext(AccessContext); + const trialHasExpired = hasTrialExpired(instanceStatus); + const trialExpirationWarning = formatTrialExpirationWarning(instanceStatus); - const trialDaysRemaining = calculateTrialDaysRemaining(instanceStatus); - - if ( - instanceStatus.state === InstanceState.TRIAL && - typeof trialDaysRemaining === 'number' && - trialDaysRemaining <= 0 - ) { + if (trialHasExpired) { return ( @@ -87,11 +86,7 @@ export const InstanceStatusBar = ({ ); } - if ( - instanceStatus.state === InstanceState.TRIAL && - typeof trialDaysRemaining === 'number' && - trialDaysRemaining <= 10 - ) { + if (trialExpirationWarning) { return ( @@ -101,7 +96,7 @@ export const InstanceStatusBar = ({ })} > Heads up! You have{' '} - {trialDaysRemaining} days left of your free{' '} + {trialExpirationWarning} left of your free{' '} {instanceStatus.plan} trial. - 4 - days + 4 days left of your free diff --git a/frontend/src/utils/billing.ts b/frontend/src/utils/billing.ts deleted file mode 100644 index ec354604bd..0000000000 --- a/frontend/src/utils/billing.ts +++ /dev/null @@ -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; -}; diff --git a/frontend/src/utils/instanceTrial.test.ts b/frontend/src/utils/instanceTrial.test.ts new file mode 100644 index 0000000000..b354e4a698 --- /dev/null +++ b/frontend/src/utils/instanceTrial.test.ts @@ -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'); +}); diff --git a/frontend/src/utils/instanceTrial.ts b/frontend/src/utils/instanceTrial.ts new file mode 100644 index 0000000000..93bdba6aae --- /dev/null +++ b/frontend/src/utils/instanceTrial.ts @@ -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); + } +};