Update unit tests for ActiveSubscription

GitOrigin-RevId: 181f5a097fff2fa31ed11d39b76f40c9a4b4ca31
This commit is contained in:
Simon Gardner
2025-09-09 10:35:59 +01:00
committed by Copybot
parent 75030aa410
commit d4fe9cf34b
11 changed files with 930 additions and 699 deletions

View File

@@ -1,7 +1,7 @@
import { Trans, useTranslation } from 'react-i18next'
import { PaidSubscription } from '../../../../../../types/subscription/dashboard/subscription'
import { PausedSubscription } from './states/active/paused'
import { ActiveSubscriptionNew } from '@/features/subscription/components/dashboard/states/active/active-new'
import { ActiveSubscription } from '@/features/subscription/components/dashboard/states/active/active'
import { CanceledSubscription } from './states/canceled'
import { ExpiredSubscription } from './states/expired'
import { useSubscriptionDashboardContext } from '../../context/subscription-dashboard-context'
@@ -43,7 +43,7 @@ function PersonalSubscriptionStates({
if (state === 'active') {
// This version handles subscriptions with and without addons
return <ActiveSubscriptionNew subscription={subscription} />
return <ActiveSubscription subscription={subscription} />
} else if (state === 'canceled') {
return <CanceledSubscription subscription={subscription} />
} else if (state === 'expired') {

View File

@@ -1,391 +0,0 @@
import { useTranslation, Trans } from 'react-i18next'
import { PriceExceptions } from '../../../shared/price-exceptions'
import { useSubscriptionDashboardContext } from '../../../../context/subscription-dashboard-context'
import { PaidSubscription } from '../../../../../../../../types/subscription/dashboard/subscription'
import { CancelSubscriptionButton } from './cancel-subscription-button'
import { CancelSubscription } from './cancel-plan/cancel-subscription'
import { TrialEnding } from './trial-ending'
import { ChangePlanModal } from './change-plan/modals/change-plan-modal'
import { ConfirmChangePlanModal } from './change-plan/modals/confirm-change-plan-modal'
import { KeepCurrentPlanModal } from './change-plan/modals/keep-current-plan-modal'
import { ChangeToGroupModal } from './change-plan/modals/change-to-group-modal'
import { CancelAiAddOnModal } from '@/features/subscription/components/dashboard/states/active/change-plan/modals/cancel-ai-add-on-modal'
import OLButton from '@/shared/components/ol/ol-button'
import isInFreeTrial from '../../../../util/is-in-free-trial'
import AddOns from '@/features/subscription/components/dashboard/states/active/add-ons'
import {
AI_ADD_ON_CODE,
AI_ASSIST_STANDALONE_MONTHLY_PLAN_CODE,
isStandaloneAiPlanCode,
} from '@/features/subscription/data/add-on-codes'
import getMeta from '@/utils/meta'
import SubscriptionRemainder from '@/features/subscription/components/dashboard/states/active/subscription-remainder'
import { sendMB } from '../../../../../../infrastructure/event-tracking'
import PauseSubscriptionModal from '@/features/subscription/components/dashboard/pause-modal'
import LoadingSpinner from '@/shared/components/loading-spinner'
import { postJSON } from '@/infrastructure/fetch-json'
import { debugConsole } from '@/utils/debugging'
import useAsync from '@/shared/hooks/use-async'
import { useLocation } from '@/shared/hooks/use-location'
import { FlashMessage } from '@/features/subscription/components/dashboard/states/active/flash-message'
import Notification from '@/shared/components/notification'
export function ActiveSubscriptionNew({
subscription,
}: {
subscription: PaidSubscription
}) {
const { t } = useTranslation()
const {
recurlyLoadError,
setModalIdShown,
showCancellation,
institutionMemberships,
memberGroupSubscriptions,
getFormattedRenewalDate,
} = useSubscriptionDashboardContext()
const cancelPauseReq = useAsync()
const { isError: isErrorPause } = cancelPauseReq
if (showCancellation) return <CancelSubscription />
const onStandalonePlan = isStandaloneAiPlanCode(subscription.planCode)
let planName
if (onStandalonePlan) {
planName = 'Overleaf Free'
if (institutionMemberships && institutionMemberships.length > 0) {
planName = 'Overleaf Professional'
}
if (memberGroupSubscriptions.length > 0) {
if (
memberGroupSubscriptions.some(s => s.planLevelName === 'Professional')
) {
planName = 'Overleaf Professional'
} else {
planName = 'Overleaf Standard'
}
}
} else {
planName = subscription.plan.name
}
const handlePlanChange = () => setModalIdShown('change-plan')
const handleCancelClick = (addOnCode: string) => {
if (
[AI_ASSIST_STANDALONE_MONTHLY_PLAN_CODE, AI_ADD_ON_CODE].includes(
addOnCode
)
) {
setModalIdShown('cancel-ai-add-on')
}
}
const hasPendingPause = Boolean(
subscription.payment.state === 'active' &&
subscription.payment.remainingPauseCycles &&
subscription.payment.remainingPauseCycles > 0
)
const isLegacyPlan =
subscription.payment.totalLicenses !==
subscription.payment.additionalLicenses
return (
<>
<div className="notification-list">
<FlashMessage />
{isErrorPause && (
<Notification
type="error"
content={t('generic_something_went_wrong')}
/>
)}
</div>
<h2 className="h3 fw-bold">{t('billing')}</h2>
<p className="mb-1">
{subscription.plan.annual ? (
<Trans
i18nKey="billed_annually_at"
values={{ price: subscription.payment.displayPrice }}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
components={[
// eslint-disable-next-line react/jsx-key
<strong />,
// eslint-disable-next-line react/jsx-key
<i />,
]}
/>
) : (
<Trans
i18nKey="billed_monthly_at"
values={{ price: subscription.payment.displayPrice }}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
components={[
// eslint-disable-next-line react/jsx-key
<strong />,
// eslint-disable-next-line react/jsx-key
<i />,
]}
/>
)}
</p>
<p className="mb-1">
<Trans
i18nKey="renews_on"
values={{ date: subscription.payment.nextPaymentDueDate }}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
components={[<strong />]} // eslint-disable-line react/jsx-key
/>
</p>
<div>
{subscription.payment.billingDetailsLink ? (
<>
<a
href={subscription.payment.accountManagementLink}
target="_blank"
rel="noreferrer noopener"
className="me-2"
>
{t('view_invoices')}
</a>
<a
href={subscription.payment.billingDetailsLink}
target="_blank"
rel="noreferrer noopener"
>
{t('view_billing_details')}
</a>
</>
) : (
<a
href={subscription.payment.accountManagementLink}
rel="noreferrer noopener"
className="me-2"
>
{t('view_payment_portal')}
</a>
)}
</div>
<div className="mt-3">
<PriceExceptions subscription={subscription} />
{!recurlyLoadError && (
<p>
<i>
<SubscriptionRemainder subscription={subscription} hideTime />
</i>
</p>
)}
</div>
<hr />
<h2 className="h3 fw-bold">{t('plan')}</h2>
<h3 className="h5 mt-0 mb-1 fw-bold">{planName}</h3>
{subscription.pendingPlan &&
subscription.pendingPlan.name !== subscription.plan.name && (
<p className="mb-1">{t('want_change_to_apply_before_plan_end')}</p>
)}
{isInFreeTrial(subscription.payment.trialEndsAt) &&
subscription.payment.trialEndsAtFormatted && (
<TrialEnding
trialEndsAtFormatted={subscription.payment.trialEndsAtFormatted}
className="mb-1"
/>
)}
{subscription.payment.totalLicenses > 0 && (
<p className="mb-1">
{isLegacyPlan && subscription.payment.additionalLicenses > 0 ? (
<Trans
i18nKey="plus_x_additional_licenses_for_a_total_of_y_licenses"
values={{
count: subscription.payment.totalLicenses,
additionalLicenses: subscription.payment.additionalLicenses,
}}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
components={[<strong />, <strong />]} // eslint-disable-line react/jsx-key
/>
) : (
<Trans
i18nKey="supports_up_to_x_licenses"
values={{ count: subscription.payment.totalLicenses }}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
components={[<strong />]} // eslint-disable-line react/jsx-key
/>
)}
</p>
)}
{hasPendingPause && (
<>
<p>
<Trans
i18nKey="your_subscription_will_pause_on"
values={{
planName: subscription.plan.name,
pauseDate: subscription.payment.nextPaymentDueAt,
reactivationDate: getFormattedRenewalDate(),
}}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
components={[
// eslint-disable-next-line react/jsx-key
<strong />,
]}
/>
</p>
<p>{t('you_can_still_use_your_premium_features')}</p>
</>
)}
{!onStandalonePlan && (
<p className="mb-1">
{subscription.plan.annual
? t('x_price_per_year', {
price: subscription.payment.planOnlyDisplayPrice,
})
: t('x_price_per_month', {
price: subscription.payment.planOnlyDisplayPrice,
})}
</p>
)}
{!recurlyLoadError && (
<PlanActions
subscription={subscription}
onStandalonePlan={onStandalonePlan}
handlePlanChange={handlePlanChange}
hasPendingPause={hasPendingPause}
cancelPauseReq={cancelPauseReq}
/>
)}
<hr />
<AddOns
subscription={subscription}
onStandalonePlan={onStandalonePlan}
handleCancelClick={handleCancelClick}
/>
<ChangePlanModal />
<ConfirmChangePlanModal />
<KeepCurrentPlanModal />
<ChangeToGroupModal />
<CancelAiAddOnModal />
<PauseSubscriptionModal />
</>
)
}
type PlanActionsProps = {
subscription: PaidSubscription
onStandalonePlan: boolean
handlePlanChange: () => void
hasPendingPause: boolean
cancelPauseReq: ReturnType<typeof useAsync>
}
function PlanActions({
subscription,
onStandalonePlan,
handlePlanChange,
hasPendingPause,
cancelPauseReq,
}: PlanActionsProps) {
const { t } = useTranslation()
const isSubscriptionEligibleForFlexibleGroupLicensing = getMeta(
'ol-canUseFlexibleLicensing'
)
const location = useLocation()
const { runAsync: runAsyncCancelPause, isLoading: isLoadingCancelPause } =
cancelPauseReq
const handleCancelPendingPauseClick = async () => {
try {
await runAsyncCancelPause(postJSON('/user/subscription/pause/0'))
const newUrl = new URL(location.toString())
newUrl.searchParams.set('flash', 'unpaused')
window.history.replaceState(null, '', newUrl)
location.reload()
} catch (e) {
debugConsole.error(e)
}
}
return (
<div className="mt-3">
{isSubscriptionEligibleForFlexibleGroupLicensing ? (
<FlexibleGroupLicensingActions subscription={subscription} />
) : (
<>
{!hasPendingPause && !subscription.payment.hasPastDueInvoice && (
<OLButton variant="secondary" onClick={handlePlanChange}>
{t('change_plan')}
</OLButton>
)}
</>
)}
{hasPendingPause && (
<OLButton
variant="primary"
onClick={handleCancelPendingPauseClick}
disabled={isLoadingCancelPause}
>
{isLoadingCancelPause ? (
<LoadingSpinner />
) : (
t('unpause_subscription')
)}
</OLButton>
)}
{!onStandalonePlan && (
<>
{' '}
<CancelSubscriptionButton />
</>
)}
</div>
)
}
function FlexibleGroupLicensingActions({
subscription,
}: {
subscription: PaidSubscription
}) {
const { t } = useTranslation()
if (subscription.pendingPlan || subscription.payment.hasPastDueInvoice) {
return null
}
const isProfessionalPlan = subscription.planCode
.toLowerCase()
.includes('professional')
return (
<>
{!isProfessionalPlan && (
<>
<OLButton
variant="secondary"
href="/user/subscription/group/upgrade-subscription"
onClick={() =>
sendMB('flex-upgrade', { location: 'upgrade-plan-button' })
}
>
{t('upgrade_plan')}
</OLButton>{' '}
</>
)}
{subscription.plan.membersLimitAddOn === 'additional-license' && (
<OLButton
variant="secondary"
href="/user/subscription/group/add-users"
onClick={() => sendMB('flex-add-users')}
>
{t('buy_more_licenses')}
</OLButton>
)}
</>
)
}

View File

@@ -4,25 +4,32 @@ import { useSubscriptionDashboardContext } from '../../../../context/subscriptio
import { PaidSubscription } from '../../../../../../../../types/subscription/dashboard/subscription'
import { CancelSubscriptionButton } from './cancel-subscription-button'
import { CancelSubscription } from './cancel-plan/cancel-subscription'
import { PendingPlanChange } from './pending-plan-change'
import { TrialEnding } from './trial-ending'
import { PendingAdditionalLicenses } from './pending-additional-licenses'
import { ContactSupportToChangeGroupPlan } from './contact-support-to-change-group-plan'
import SubscriptionRemainder from './subscription-remainder'
import isInFreeTrial from '../../../../util/is-in-free-trial'
import { ChangePlanModal } from './change-plan/modals/change-plan-modal'
import { ConfirmChangePlanModal } from './change-plan/modals/confirm-change-plan-modal'
import { KeepCurrentPlanModal } from './change-plan/modals/keep-current-plan-modal'
import { ChangeToGroupModal } from './change-plan/modals/change-to-group-modal'
import { CancelAiAddOnModal } from '@/features/subscription/components/dashboard/states/active/change-plan/modals/cancel-ai-add-on-modal'
import OLButton from '@/shared/components/ol/ol-button'
import useAsync from '@/shared/hooks/use-async'
import { postJSON } from '@/infrastructure/fetch-json'
import PauseSubscriptionModal from '../../pause-modal'
import Notification from '@/shared/components/notification'
import { debugConsole } from '@/utils/debugging'
import { FlashMessage } from './flash-message'
import { useLocation } from '@/shared/hooks/use-location'
import isInFreeTrial from '../../../../util/is-in-free-trial'
import AddOns from '@/features/subscription/components/dashboard/states/active/add-ons'
import {
AI_ADD_ON_CODE,
AI_ASSIST_STANDALONE_MONTHLY_PLAN_CODE,
isStandaloneAiPlanCode,
} from '@/features/subscription/data/add-on-codes'
import getMeta from '@/utils/meta'
import SubscriptionRemainder from '@/features/subscription/components/dashboard/states/active/subscription-remainder'
import { sendMB } from '../../../../../../infrastructure/event-tracking'
import PauseSubscriptionModal from '@/features/subscription/components/dashboard/pause-modal'
import LoadingSpinner from '@/shared/components/loading-spinner'
import { postJSON } from '@/infrastructure/fetch-json'
import { debugConsole } from '@/utils/debugging'
import useAsync from '@/shared/hooks/use-async'
import { useLocation } from '@/shared/hooks/use-location'
import { FlashMessage } from '@/features/subscription/components/dashboard/states/active/flash-message'
import Notification from '@/shared/components/notification'
import { PendingPlanChange } from './pending-plan-change'
export function ActiveSubscription({
subscription,
@@ -34,33 +41,55 @@ export function ActiveSubscription({
recurlyLoadError,
setModalIdShown,
showCancellation,
institutionMemberships,
memberGroupSubscriptions,
getFormattedRenewalDate,
} = useSubscriptionDashboardContext()
const {
isError: isErrorPause,
runAsync: runAsyncCancelPause,
isLoading: isLoadingCancelPause,
} = useAsync()
const location = useLocation()
const cancelPauseReq = useAsync()
const { isError: isErrorPause } = cancelPauseReq
if (showCancellation) return <CancelSubscription />
const hasPendingPause =
subscription.payment.state === 'active' &&
subscription.payment.remainingPauseCycles &&
subscription.payment.remainingPauseCycles > 0
const onStandalonePlan = isStandaloneAiPlanCode(subscription.planCode)
const handleCancelPendingPauseClick = async () => {
try {
await runAsyncCancelPause(postJSON('/user/subscription/pause/0'))
const newUrl = new URL(location.toString())
newUrl.searchParams.set('flash', 'unpaused')
window.history.replaceState(null, '', newUrl)
location.reload()
} catch (e) {
debugConsole.error(e)
let planName
if (onStandalonePlan) {
planName = 'Overleaf Free'
if (institutionMemberships && institutionMemberships.length > 0) {
planName = 'Overleaf Professional'
}
if (memberGroupSubscriptions.length > 0) {
if (
memberGroupSubscriptions.some(s => s.planLevelName === 'Professional')
) {
planName = 'Overleaf Professional'
} else {
planName = 'Overleaf Standard'
}
}
} else {
planName = subscription.plan.name
}
const handlePlanChange = () => setModalIdShown('change-plan')
const handleCancelClick = (addOnCode: string) => {
if (
[AI_ASSIST_STANDALONE_MONTHLY_PLAN_CODE, AI_ADD_ON_CODE].includes(
addOnCode
)
) {
setModalIdShown('cancel-ai-add-on')
}
}
const hasPendingPause = Boolean(
subscription.payment.state === 'active' &&
subscription.payment.remainingPauseCycles &&
subscription.payment.remainingPauseCycles > 0
)
const isLegacyPlan =
subscription.payment.totalLicenses !==
subscription.payment.additionalLicenses
return (
<>
@@ -74,66 +103,130 @@ export function ActiveSubscription({
/>
)}
</div>
<p>
{!hasPendingPause && (
<h2 className="h3 fw-bold">{t('billing')}</h2>
<p className="mb-1" data-testid="billing-period">
{subscription.plan.annual ? (
<Trans
i18nKey="currently_subscribed_to_plan"
values={{
planName: subscription.plan.name,
}}
i18nKey="billed_annually_at"
values={{ price: subscription.payment.displayPrice }}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
components={[
// eslint-disable-next-line react/jsx-key
<strong />,
// eslint-disable-next-line react/jsx-key
<i />,
]}
/>
) : (
<Trans
i18nKey="billed_monthly_at"
values={{ price: subscription.payment.displayPrice }}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
components={[
// eslint-disable-next-line react/jsx-key
<strong />,
// eslint-disable-next-line react/jsx-key
<i />,
]}
/>
)}
{subscription.pendingPlan && (
<>
{' '}
<PendingPlanChange subscription={subscription} />
</>
)}
{!subscription.pendingPlan &&
subscription.payment.additionalLicenses > 0 && (
<>
{' '}
<PendingAdditionalLicenses
additionalLicenses={subscription.payment.additionalLicenses}
totalLicenses={subscription.payment.totalLicenses}
/>
</>
)}
{!recurlyLoadError &&
!subscription.groupPlan &&
!hasPendingPause &&
!subscription.payment.hasPastDueInvoice && (
<>
{' '}
<OLButton
variant="link"
className="btn-inline-link"
onClick={() => setModalIdShown('change-plan')}
>
{t('change_plan')}
</OLButton>
</>
)}
</p>
<p className="mb-1" data-testid="renews-on">
<Trans
i18nKey="renews_on"
values={{ date: subscription.payment.nextPaymentDueDate }}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
components={[<strong />]} // eslint-disable-line react/jsx-key
/>
</p>
<div>
{subscription.payment.billingDetailsLink ? (
<>
<a
href={subscription.payment.accountManagementLink}
target="_blank"
rel="noreferrer noopener"
className="me-2"
>
{t('view_invoices')}
</a>
<a
href={subscription.payment.billingDetailsLink}
target="_blank"
rel="noreferrer noopener"
>
{t('view_billing_details')}
</a>
</>
) : (
<a
href={subscription.payment.accountManagementLink}
rel="noreferrer noopener"
className="me-2"
>
{t('view_payment_portal')}
</a>
)}
</div>
<div className="mt-3">
<PriceExceptions subscription={subscription} />
{!recurlyLoadError && (
<p>
<i>
<SubscriptionRemainder subscription={subscription} hideTime />
</i>
</p>
)}
</div>
<hr />
<h2 className="h3 fw-bold">{t('plan')}</h2>
<h3 className="h5 mt-0 mb-1 fw-bold">{planName}</h3>
{subscription.pendingPlan &&
subscription.pendingPlan.name !== subscription.plan.name && (
<p>{t('want_change_to_apply_before_plan_end')}</p>
<p className="mb-1">{t('want_change_to_apply_before_plan_end')}</p>
)}
{(!subscription.pendingPlan ||
subscription.pendingPlan.name === subscription.plan.name) &&
subscription.plan.groupPlan && <ContactSupportToChangeGroupPlan />}
{isInFreeTrial(subscription.payment.trialEndsAt) &&
subscription.payment.trialEndsAtFormatted && (
<TrialEnding
trialEndsAtFormatted={subscription.payment.trialEndsAtFormatted}
className="mb-1"
/>
)}
{subscription.payment.totalLicenses > 0 && (
<p className="mb-1" data-testid="plan-licenses">
{isLegacyPlan &&
subscription.payment.additionalLicenses > 0 &&
!subscription.payment.pendingAdditionalLicenses ? (
<Trans
i18nKey="plus_x_additional_licenses_for_a_total_of_y_licenses"
values={{
count: subscription.payment.totalLicenses,
additionalLicenses: subscription.payment.additionalLicenses,
}}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
components={[<strong />, <strong />]} // eslint-disable-line react/jsx-key
/>
) : (
<Trans
i18nKey="supports_up_to_x_licenses"
values={{ count: subscription.payment.totalLicenses }}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
components={[<strong />]} // eslint-disable-line react/jsx-key
/>
)}
</p>
)}
{subscription.pendingPlan && (
<p className="mb-1" data-testid="pending-plan-change">
{' '}
<PendingPlanChange subscription={subscription} />
</p>
)}
{hasPendingPause && (
<>
@@ -154,83 +247,155 @@ export function ActiveSubscription({
/>
</p>
<p>{t('you_can_still_use_your_premium_features')}</p>
<p>
<OLButton
variant="primary"
onClick={handleCancelPendingPauseClick}
disabled={isLoadingCancelPause}
>
{isLoadingCancelPause ? (
<LoadingSpinner />
) : (
t('unpause_subscription')
)}
</OLButton>
</p>
</>
)}
<p>
<Trans
i18nKey="next_payment_of_x_collectected_on_y"
values={{
paymentAmmount: subscription.payment.displayPrice,
collectionDate: getFormattedRenewalDate(),
}}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
components={[
// eslint-disable-next-line react/jsx-key
<strong />,
// eslint-disable-next-line react/jsx-key
<strong />,
]}
/>
</p>
<hr />
<PriceExceptions subscription={subscription} />
<p className="d-inline-flex flex-wrap gap-1">
<a
href={subscription.payment.billingDetailsLink}
target="_blank"
rel="noreferrer noopener"
className="btn btn-secondary-info btn-secondary"
>
{t('update_your_billing_details')}
</a>{' '}
<a
href={subscription.payment.accountManagementLink}
target="_blank"
rel="noreferrer noopener"
className="btn btn-secondary-info btn-secondary"
>
{t('view_your_invoices')}
</a>
{!recurlyLoadError && (
<>
{' '}
<CancelSubscriptionButton />
</>
)}
</p>
{!onStandalonePlan && (
<p className="mb-1" data-testid="plan-only-price">
{subscription.plan.annual
? t('x_price_per_year', {
price: subscription.payment.planOnlyDisplayPrice,
})
: t('x_price_per_month', {
price: subscription.payment.planOnlyDisplayPrice,
})}
</p>
)}
{!recurlyLoadError && (
<>
<br />
<p>
<i>
<SubscriptionRemainder subscription={subscription} />
</i>
</p>
</>
<PlanActions
subscription={subscription}
onStandalonePlan={onStandalonePlan}
handlePlanChange={handlePlanChange}
hasPendingPause={hasPendingPause}
cancelPauseReq={cancelPauseReq}
/>
)}
<hr />
<AddOns
subscription={subscription}
onStandalonePlan={onStandalonePlan}
handleCancelClick={handleCancelClick}
/>
<ChangePlanModal />
<ConfirmChangePlanModal />
<KeepCurrentPlanModal />
<ChangeToGroupModal />
<CancelAiAddOnModal />
<PauseSubscriptionModal />
</>
)
}
type PlanActionsProps = {
subscription: PaidSubscription
onStandalonePlan: boolean
handlePlanChange: () => void
hasPendingPause: boolean
cancelPauseReq: ReturnType<typeof useAsync>
}
function PlanActions({
subscription,
onStandalonePlan,
handlePlanChange,
hasPendingPause,
cancelPauseReq,
}: PlanActionsProps) {
const { t } = useTranslation()
const isSubscriptionEligibleForFlexibleGroupLicensing = getMeta(
'ol-canUseFlexibleLicensing'
)
const location = useLocation()
const { runAsync: runAsyncCancelPause, isLoading: isLoadingCancelPause } =
cancelPauseReq
const handleCancelPendingPauseClick = async () => {
try {
await runAsyncCancelPause(postJSON('/user/subscription/pause/0'))
const newUrl = new URL(location.toString())
newUrl.searchParams.set('flash', 'unpaused')
window.history.replaceState(null, '', newUrl)
location.reload()
} catch (e) {
debugConsole.error(e)
}
}
return (
<div className="mt-3">
{isSubscriptionEligibleForFlexibleGroupLicensing ? (
<FlexibleGroupLicensingActions subscription={subscription} />
) : (
<>
{!hasPendingPause && !subscription.payment.hasPastDueInvoice && (
<OLButton variant="secondary" onClick={handlePlanChange}>
{t('change_plan')}
</OLButton>
)}
</>
)}
{hasPendingPause && (
<OLButton
variant="primary"
onClick={handleCancelPendingPauseClick}
disabled={isLoadingCancelPause}
>
{isLoadingCancelPause ? (
<LoadingSpinner />
) : (
t('unpause_subscription')
)}
</OLButton>
)}
{!onStandalonePlan && (
<>
{' '}
<CancelSubscriptionButton />
</>
)}
</div>
)
}
function FlexibleGroupLicensingActions({
subscription,
}: {
subscription: PaidSubscription
}) {
const { t } = useTranslation()
if (subscription.pendingPlan || subscription.payment.hasPastDueInvoice) {
return null
}
const isProfessionalPlan = subscription.planCode
.toLowerCase()
.includes('professional')
return (
<>
{!isProfessionalPlan && (
<>
<OLButton
variant="secondary"
href="/user/subscription/group/upgrade-subscription"
onClick={() =>
sendMB('flex-upgrade', { location: 'upgrade-plan-button' })
}
>
{t('upgrade_plan')}
</OLButton>{' '}
</>
)}
{subscription.plan.membersLimitAddOn === 'additional-license' && (
<OLButton
variant="secondary"
href="/user/subscription/group/add-users"
onClick={() => sendMB('flex-add-users')}
>
{t('buy_more_licenses')}
</OLButton>
)}
</>
)
}

View File

@@ -10,7 +10,7 @@ export function TrialEnding({
className,
}: TrialEndingProps) {
return (
<p className={className}>
<p className={className} data-testid="trial-ending">
<Trans
i18nKey="youre_on_free_trial_which_ends_on"
values={{ date: trialEndsAtFormatted }}

View File

@@ -17,7 +17,7 @@ export function PriceExceptions({ subscription }: PriceExceptionsProps) {
{activeCoupons.length > 0 && (
<>
<i>* {t('coupons_not_included')}:</i>
<ul>
<ul data-testid="active-coupons">
{activeCoupons.map(coupon => (
<li key={coupon.code}>
<i>{coupon.description || coupon.name}</i>

View File

@@ -0,0 +1,150 @@
import React from 'react'
import { ActiveSubscription } from '../../js/features/subscription/components/dashboard/states/active/active'
import {
annualActiveSubscription,
groupActiveSubscription,
groupActiveSubscriptionWithPendingLicenseChange,
monthlyActiveCollaborator,
pendingSubscriptionChange,
trialCollaboratorSubscription,
trialSubscription,
pastDueExpiredSubscription,
annualActiveSubscriptionEuro,
annualActiveSubscriptionWithAddons,
annualActiveSubscriptionWithCoupons,
pendingPausedSubscription,
groupProfessionalActiveSubscription,
} from '../../../test/frontend/features/subscription/fixtures/subscriptions'
import { SubscriptionDashboardProvider } from '../../js/features/subscription/context/subscription-dashboard-context'
import { SplitTestProvider } from '@/shared/context/split-test-context'
import { PaidSubscription } from '@ol-types/subscription/dashboard/subscription'
import type { StoryFn } from '@storybook/react'
import { setupSubscriptionDashContext } from '../../../test/frontend/features/subscription/helpers/setup-subscription-dash-context'
export default {
title: 'Subscription/ActiveSubscription',
component: ActiveSubscription,
argTypes: {
subscription: { control: 'object' },
canUseFlexibleLicensing: { control: 'boolean' },
},
}
const Template: StoryFn<{
subscription: PaidSubscription
canUseFlexibleLicensing: boolean
}> = args => {
window.metaAttributesCache = window.metaAttributesCache || new Map()
// @ts-ignore
delete global.recurly
setupSubscriptionDashContext({
metaTags: [
{ name: 'ol-subscription', value: args.subscription },
{
name: 'ol-canUseFlexibleLicensing',
value: args.canUseFlexibleLicensing,
},
],
currencyCode: args.subscription.payment.currency,
recurlyNotLoaded: false,
queryingRecurly: false,
})
return (
<SplitTestProvider>
<SubscriptionDashboardProvider>
<ActiveSubscription {...args} />
</SubscriptionDashboardProvider>
</SplitTestProvider>
)
}
export const CollaboratorAnnual = Template.bind({})
CollaboratorAnnual.args = {
subscription: annualActiveSubscription,
canUseFlexibleLicensing:
annualActiveSubscription.plan?.canUseFlexibleLicensing,
}
export const CollaboratorMonthly = Template.bind({})
CollaboratorMonthly.args = {
subscription: monthlyActiveCollaborator,
canUseFlexibleLicensing:
monthlyActiveCollaborator.plan?.canUseFlexibleLicensing,
}
export const CollaboratorAnnualEuro = Template.bind({})
CollaboratorAnnualEuro.args = {
subscription: annualActiveSubscriptionEuro,
canUseFlexibleLicensing:
annualActiveSubscriptionEuro.plan?.canUseFlexibleLicensing,
}
export const CollaboratorTrial = Template.bind({})
CollaboratorTrial.args = {
subscription: trialCollaboratorSubscription,
canUseFlexibleLicensing:
trialCollaboratorSubscription.plan?.canUseFlexibleLicensing,
}
export const PersonalTrial = Template.bind({})
PersonalTrial.args = {
subscription: trialSubscription,
canUseFlexibleLicensing: trialSubscription.plan?.canUseFlexibleLicensing,
}
export const GroupStandard = Template.bind({})
GroupStandard.args = {
subscription: groupActiveSubscription,
canUseFlexibleLicensing:
groupActiveSubscription.plan?.canUseFlexibleLicensing,
}
export const GroupProfessional = Template.bind({})
GroupProfessional.args = {
subscription: groupProfessionalActiveSubscription,
canUseFlexibleLicensing:
groupProfessionalActiveSubscription.plan?.canUseFlexibleLicensing,
}
export const GroupPendingLicenseChange = Template.bind({})
GroupPendingLicenseChange.args = {
subscription: groupActiveSubscriptionWithPendingLicenseChange,
canUseFlexibleLicensing:
groupActiveSubscriptionWithPendingLicenseChange.plan
?.canUseFlexibleLicensing,
}
export const PastDueExpired = Template.bind({})
PastDueExpired.args = {
subscription: pastDueExpiredSubscription,
canUseFlexibleLicensing:
pastDueExpiredSubscription.plan?.canUseFlexibleLicensing,
}
export const Addons = Template.bind({})
Addons.args = {
subscription: annualActiveSubscriptionWithAddons,
canUseFlexibleLicensing:
annualActiveSubscriptionWithAddons.plan?.canUseFlexibleLicensing,
}
export const PendingPaused = Template.bind({})
PendingPaused.args = {
subscription: pendingPausedSubscription,
canUseFlexibleLicensing:
pendingPausedSubscription.plan?.canUseFlexibleLicensing,
}
export const PendingPlanChange = Template.bind({})
PendingPlanChange.args = {
subscription: pendingSubscriptionChange,
canUseFlexibleLicensing:
pendingSubscriptionChange.plan?.canUseFlexibleLicensing,
}
export const Coupons = Template.bind({})
Coupons.args = {
subscription: annualActiveSubscriptionWithCoupons,
canUseFlexibleLicensing:
annualActiveSubscriptionWithCoupons.plan?.canUseFlexibleLicensing,
}

View File

@@ -1,11 +1,15 @@
import { expect } from 'chai'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { fireEvent, screen, waitFor, within } from '@testing-library/react'
import * as eventTracking from '@/infrastructure/event-tracking'
import { PaidSubscription } from '../../../../../../../../types/subscription/dashboard/subscription'
import {
annualActiveSubscription,
annualActiveSubscriptionEuro,
annualActiveSubscriptionWithAddons,
annualActiveSubscriptionWithCoupons,
groupActiveSubscription,
groupActiveSubscriptionWithPendingLicenseChange,
groupProfessionalActiveSubscription,
monthlyActiveCollaborator,
pendingSubscriptionChange,
trialCollaboratorSubscription,
@@ -37,45 +41,78 @@ describe('<ActiveSubscription />', function () {
})
function expectedInActiveSubscription(subscription: PaidSubscription) {
// sentence broken up by bolding
screen.getByText('You are currently subscribed to the', { exact: false })
screen.getByText(subscription.plan.name, { exact: false })
if (subscription.plan.annual) {
within(screen.getByTestId('billing-period')).getByText((_, el) =>
Boolean(
el?.textContent?.includes(
`Billed annually at ${subscription.payment.displayPrice}`
)
)
)
within(screen.getByTestId('plan-only-price')).getByText((_, el) =>
Boolean(
el?.textContent?.includes(
`${subscription.payment.planOnlyDisplayPrice} per year`
)
)
)
} else {
within(screen.getByTestId('billing-period')).getByText((_, el) =>
Boolean(
el?.textContent?.includes(
`Billed monthly at ${subscription.payment.displayPrice}`
)
)
)
within(screen.getByTestId('plan-only-price')).getByText((_, el) =>
Boolean(
el?.textContent?.includes(
`${subscription.payment.planOnlyDisplayPrice} per month`
)
)
)
}
within(screen.getByTestId('renews-on')).getByText((_, el) =>
Boolean(
el?.textContent?.includes(
`Renews on ${subscription.payment.nextPaymentDueDate}`
)
)
)
screen.getByRole('button', { name: 'Change plan' })
// sentence broken up by bolding
screen.getByText('The next payment of', { exact: false })
screen.getByText(subscription.payment.displayPrice, {
exact: false,
})
screen.getByText('will be collected on', { exact: false })
const dates = screen.getAllByText(subscription.payment.nextPaymentDueAt, {
exact: false,
})
expect(dates.length).to.equal(2)
screen.getByRole('heading', { name: subscription.plan.name, level: 3 })
screen.getByText(
'* Prices may be subject to additional VAT, depending on your country.'
)
screen.getByRole('link', { name: 'Update your billing details' })
screen.getByRole('link', { name: 'View your invoices' })
screen.getByRole('link', { name: 'View invoices' })
if (subscription.payment.billingDetailsLink) {
screen.getByRole('link', { name: 'View billing details' })
}
}
it('renders the dash annual active subscription', function () {
renderActiveSubscription(annualActiveSubscription)
expectedInActiveSubscription(annualActiveSubscription)
const button = screen.getByRole('button', { name: 'Change plan' })
expect(button).to.exist
})
it('renders the dash annual active subscription in EUR', function () {
renderActiveSubscription(annualActiveSubscriptionEuro)
expectedInActiveSubscription(annualActiveSubscriptionEuro)
})
it('shows change plan UI when button clicked', async function () {
renderActiveSubscription(annualActiveSubscription)
expectedInActiveSubscription(annualActiveSubscription)
const button = screen.getByRole('button', { name: 'Change plan' })
fireEvent.click(button)
// confirm main dash UI still shown
screen.getByText('You are currently subscribed to the', { exact: false })
await screen.findByRole('heading', { name: 'Change plan' })
await waitFor(
() =>
@@ -86,34 +123,9 @@ describe('<ActiveSubscription />', function () {
)
})
it('notes when user is changing plan at end of current plan term', function () {
renderActiveSubscription(pendingSubscriptionChange)
expectedInActiveSubscription(pendingSubscriptionChange)
screen.getByText('Your plan is changing to', { exact: false })
screen.getByText(pendingSubscriptionChange.pendingPlan!.name)
screen.getByText(' at the end of the current billing period', {
exact: false,
})
screen.getByText(
'If you wish this change to apply before the end of your current billing period, please contact us.'
)
expect(screen.queryByRole('link', { name: 'contact Support' })).to.be.null
expect(screen.queryByText('if you wish to change your group subscription.'))
.to.be.null
})
it('does not show "Change plan" option when past due', function () {
// account is likely in expired state, but be sure to not show option if state is still active
const activePastDueSubscription = Object.assign(
{},
JSON.parse(JSON.stringify(annualActiveSubscription))
)
const activePastDueSubscription = cloneDeep(annualActiveSubscription)
activePastDueSubscription.payment.hasPastDueInvoice = true
renderActiveSubscription(activePastDueSubscription)
@@ -122,23 +134,41 @@ describe('<ActiveSubscription />', function () {
expect(changePlan).to.be.null
})
it('notes when user is changing plan at end of current plan term', function () {
renderActiveSubscription(pendingSubscriptionChange)
expectedInActiveSubscription(pendingSubscriptionChange)
within(screen.getByTestId('pending-plan-change')).getByText((_, el) =>
Boolean(
el?.textContent?.includes(
`Your plan is changing to ${pendingSubscriptionChange.pendingPlan!.name} at the end of the current billing period`
)
)
)
screen.getByText(
'If you wish this change to apply before the end of your current billing period, please contact us.'
)
})
it('shows the pending license change message when plan change is pending', function () {
renderActiveSubscription(groupActiveSubscriptionWithPendingLicenseChange)
screen.getByText('Your subscription is changing to include', {
exact: false,
})
screen.getByText(
groupActiveSubscriptionWithPendingLicenseChange.payment
.pendingAdditionalLicenses!
within(screen.getByTestId('pending-plan-change')).getByText((_, el) =>
Boolean(
el?.textContent?.includes(
`Your subscription is changing to include ${groupActiveSubscriptionWithPendingLicenseChange.payment.pendingAdditionalLicenses} additional license(s) for a total of ${groupActiveSubscriptionWithPendingLicenseChange.payment.pendingTotalLicenses}`
)
)
)
screen.getByText('additional license(s) for a total of', { exact: false })
screen.getByText(
groupActiveSubscriptionWithPendingLicenseChange.payment
.pendingTotalLicenses!
within(screen.getByTestId('plan-licenses')).getByText((_, el) =>
Boolean(
el?.textContent?.includes(
`Supports up to ${groupActiveSubscriptionWithPendingLicenseChange.payment.totalLicenses}`
)
)
)
expect(
@@ -148,8 +178,8 @@ describe('<ActiveSubscription />', function () {
).to.be.null
})
it('shows the pending license change message when plan change is not pending', function () {
const subscription = Object.assign({}, groupActiveSubscription)
it('for legacy plans shows the pending license change message when plan change is not pending', function () {
const subscription = cloneDeep(groupActiveSubscription)
subscription.payment.additionalLicenses = 4
subscription.payment.totalLicenses =
subscription.payment.totalLicenses +
@@ -157,42 +187,71 @@ describe('<ActiveSubscription />', function () {
renderActiveSubscription(subscription)
screen.getByText('Your subscription includes', {
exact: false,
})
screen.getByText(subscription.payment.additionalLicenses)
screen.getByText('additional license(s) for a total of', { exact: false })
screen.getByText(subscription.payment.totalLicenses)
within(screen.getByTestId('plan-licenses')).getByText((_, el) =>
Boolean(
el?.textContent?.includes(
`Plus ${subscription.payment.additionalLicenses} additional license(s) for a total of ${subscription.payment.totalLicenses}`
)
)
)
})
it('shows when trial ends and first payment collected and when subscription would become inactive if cancelled', function () {
renderActiveSubscription(trialSubscription)
screen.getByText('Youre on a free trial which ends on', { exact: false })
const endDate = screen.getAllByText(
trialSubscription.payment.trialEndsAtFormatted!
within(screen.getByTestId('trial-ending')).getByText((_, el) =>
Boolean(
el?.textContent?.includes(
`Youre on a free trial which ends on ${trialSubscription.payment.trialEndsAtFormatted}`
)
)
)
expect(endDate.length).to.equal(3)
})
it('shows current discounts', function () {
const subscriptionWithActiveCoupons = cloneDeep(annualActiveSubscription)
subscriptionWithActiveCoupons.payment.activeCoupons = [
{
name: 'fake coupon name',
code: 'fake-coupon',
description: '',
},
]
renderActiveSubscription(subscriptionWithActiveCoupons)
screen.getByText(
/this does not include your current discounts, which will be applied automatically before your next payment/i
it('shows correct actions for group plan: professional ', function () {
renderActiveSubscription(groupProfessionalActiveSubscription)
screen.getByRole('link', { name: /buy more licenses/i })
})
it('shows correct actions for group plan: standard (collaborator)', function () {
renderActiveSubscription(groupActiveSubscription)
screen.getByRole('link', { name: /upgrade plan/i })
screen.getByRole('link', { name: /buy more licenses/i })
})
it('shows add-ons if present', function () {
renderActiveSubscription(annualActiveSubscriptionWithAddons)
screen.getByText('AI Assist')
})
it('shows empty add-ons message if none present', function () {
renderActiveSubscription(annualActiveSubscription)
screen.getByText(/You dont have any add-ons on your account/i)
})
it('shows multiple active coupons', function () {
renderActiveSubscription(annualActiveSubscriptionWithCoupons)
within(screen.getByTestId('active-coupons')).getByText(
'Coupon1 for 10% off',
{ exact: false }
)
screen.getByText(
subscriptionWithActiveCoupons.payment.activeCoupons[0].name
within(screen.getByTestId('active-coupons')).getByText(
'Coupon2 for 15% off',
{ exact: false }
)
})
it('renders correct hrefs for invoice and billing details links', function () {
renderActiveSubscription(annualActiveSubscription)
const invoiceLink = screen.getByRole('link', { name: 'View invoices' })
expect(invoiceLink.getAttribute('href')).to.equal(
annualActiveSubscription.payment.accountManagementLink
)
const billingLink = screen.getByRole('link', {
name: 'View billing details',
})
expect(billingLink.getAttribute('href')).to.equal(
annualActiveSubscription.payment.billingDetailsLink
)
})
@@ -523,13 +582,5 @@ describe('<ActiveSubscription />', function () {
const changePlan = screen.queryByRole('button', { name: 'Change plan' })
expect(changePlan).to.be.null
})
it('shows contact Support message for group plan change requests', function () {
renderActiveSubscription(groupActiveSubscription)
screen.getByRole('link', { name: 'contact Support' })
screen.getByText('if you wish to change your group subscription.', {
exact: false,
})
})
})
})

View File

@@ -31,6 +31,7 @@ export const annualActiveSubscription: PaidSubscription = {
price_in_cents: 21900,
annual: true,
featureDescription: [],
canUseFlexibleLicensing: false,
},
payment: {
taxRate: 0,
@@ -48,7 +49,7 @@ export const annualActiveSubscription: PaidSubscription = {
accountEmail: 'fake@example.com',
hasPastDueInvoice: false,
displayPrice: '$199.00',
planOnlyDisplayPrice: '',
planOnlyDisplayPrice: '$199.00',
addOns: [],
addOnDisplayPricesWithoutAdditionalLicense: {},
isEligibleForGroupPlan: true,
@@ -57,6 +58,186 @@ export const annualActiveSubscription: PaidSubscription = {
},
}
export const annualActiveSubscriptionWithCoupons: PaidSubscription = {
manager_ids: ['abc123'],
member_ids: [],
invited_emails: [],
groupPlan: false,
membersLimit: 0,
_id: 'def456',
admin_id: 'abc123',
teamInvites: [],
planCode: 'collaborator-annual',
plan: {
planCode: 'collaborator-annual',
name: 'Standard (Collaborator) Annual',
price_in_cents: 21900,
annual: true,
featureDescription: [],
canUseFlexibleLicensing: false,
},
payment: {
taxRate: 0,
billingDetailsLink: '/user/subscription/payment/billing-details',
accountManagementLink: '/user/subscription/payment/account-management',
additionalLicenses: 0,
totalLicenses: 0,
nextPaymentDueAt,
nextPaymentDueDate,
currency: 'USD',
state: 'active',
trialEndsAtFormatted: null,
trialEndsAt: null,
activeCoupons: [
{ name: 'Coupon1', code: 'c1', description: 'Coupon1 for 10% off' },
{ name: 'Coupon2', code: 'c2', description: 'Coupon2 for 15% off' },
],
accountEmail: 'fake@example.com',
hasPastDueInvoice: false,
displayPrice: '$199.00',
planOnlyDisplayPrice: '$199.00',
addOns: [],
addOnDisplayPricesWithoutAdditionalLicense: {},
isEligibleForGroupPlan: true,
isEligibleForPause: false,
isEligibleForDowngradeUpsell: false,
},
}
export const pendingPausedSubscription: PaidSubscription = {
manager_ids: ['abc123'],
member_ids: [],
invited_emails: [],
groupPlan: false,
membersLimit: 0,
_id: 'def456',
admin_id: 'abc123',
teamInvites: [],
planCode: 'collaborator-annual',
plan: {
planCode: 'collaborator-annual',
name: 'Standard (Collaborator) Annual',
price_in_cents: 21900,
annual: true,
featureDescription: [],
canUseFlexibleLicensing: false,
},
payment: {
taxRate: 0,
billingDetailsLink: '/user/subscription/payment/billing-details',
accountManagementLink: '/user/subscription/payment/account-management',
additionalLicenses: 0,
totalLicenses: 0,
nextPaymentDueAt,
nextPaymentDueDate,
currency: 'USD',
state: 'active',
remainingPauseCycles: 1,
trialEndsAtFormatted: null,
trialEndsAt: null,
activeCoupons: [],
accountEmail: 'fake@example.com',
hasPastDueInvoice: false,
displayPrice: '$199.00',
planOnlyDisplayPrice: '$199.00',
addOns: [],
addOnDisplayPricesWithoutAdditionalLicense: {},
isEligibleForGroupPlan: true,
isEligibleForPause: false,
isEligibleForDowngradeUpsell: false,
},
}
export const pausedSubscription: PaidSubscription = {
manager_ids: ['abc123'],
member_ids: [],
invited_emails: [],
groupPlan: false,
membersLimit: 0,
_id: 'def456',
admin_id: 'abc123',
teamInvites: [],
planCode: 'collaborator-annual',
plan: {
planCode: 'collaborator-annual',
name: 'Standard (Collaborator) Annual',
price_in_cents: 21900,
annual: true,
featureDescription: [],
canUseFlexibleLicensing: false,
},
payment: {
taxRate: 0,
billingDetailsLink: '/user/subscription/payment/billing-details',
accountManagementLink: '/user/subscription/payment/account-management',
additionalLicenses: 0,
totalLicenses: 0,
nextPaymentDueAt,
nextPaymentDueDate,
currency: 'USD',
state: 'paused',
remainingPauseCycles: 1,
trialEndsAtFormatted: null,
trialEndsAt: null,
activeCoupons: [],
accountEmail: 'fake@example.com',
hasPastDueInvoice: false,
displayPrice: '$199.00',
planOnlyDisplayPrice: '$199.00',
addOns: [],
addOnDisplayPricesWithoutAdditionalLicense: {},
isEligibleForGroupPlan: true,
isEligibleForPause: false,
isEligibleForDowngradeUpsell: false,
},
}
export const annualActiveSubscriptionWithAddons: PaidSubscription = {
manager_ids: ['abc123'],
member_ids: [],
invited_emails: [],
groupPlan: false,
membersLimit: 0,
_id: 'def456',
admin_id: 'abc123',
teamInvites: [],
planCode: 'collaborator-annual',
plan: {
planCode: 'collaborator-annual',
name: 'Standard (Collaborator) Annual',
price_in_cents: 21900,
annual: true,
featureDescription: [],
canUseFlexibleLicensing: false,
},
payment: {
taxRate: 0,
billingDetailsLink: '/user/subscription/payment/billing-details',
accountManagementLink: '/user/subscription/payment/account-management',
additionalLicenses: 0,
totalLicenses: 0,
nextPaymentDueAt,
nextPaymentDueDate,
currency: 'USD',
state: 'active',
trialEndsAtFormatted: null,
trialEndsAt: null,
activeCoupons: [],
accountEmail: 'fake@example.com',
hasPastDueInvoice: false,
displayPrice: '$199.00',
planOnlyDisplayPrice: '$199.00',
addOns: [],
addOnDisplayPricesWithoutAdditionalLicense: {
assistant: '$100.00',
},
isEligibleForGroupPlan: true,
isEligibleForPause: false,
isEligibleForDowngradeUpsell: false,
},
addOns: [{ addOnCode: 'assistant', quantity: 1, unitAmountInCents: 10000 }],
}
export const annualActiveSubscriptionEuro: PaidSubscription = {
manager_ids: ['abc123'],
member_ids: [],
@@ -73,6 +254,7 @@ export const annualActiveSubscriptionEuro: PaidSubscription = {
price_in_cents: 21900,
annual: true,
featureDescription: [],
canUseFlexibleLicensing: false,
},
payment: {
taxRate: 0.24,
@@ -90,7 +272,7 @@ export const annualActiveSubscriptionEuro: PaidSubscription = {
accountEmail: 'fake@example.com',
hasPastDueInvoice: false,
displayPrice: '€221.96',
planOnlyDisplayPrice: '',
planOnlyDisplayPrice: '€221.96',
addOns: [],
addOnDisplayPricesWithoutAdditionalLicense: {},
isEligibleForGroupPlan: true,
@@ -114,6 +296,7 @@ export const annualActiveSubscriptionPro: PaidSubscription = {
name: 'Professional',
price_in_cents: 4500,
featureDescription: [],
canUseFlexibleLicensing: false,
},
payment: {
taxRate: 0,
@@ -131,7 +314,7 @@ export const annualActiveSubscriptionPro: PaidSubscription = {
accountEmail: 'fake@example.com',
hasPastDueInvoice: false,
displayPrice: '$42.00',
planOnlyDisplayPrice: '',
planOnlyDisplayPrice: '$42.00',
addOns: [],
addOnDisplayPricesWithoutAdditionalLicense: {},
isEligibleForGroupPlan: true,
@@ -156,6 +339,7 @@ export const pastDueExpiredSubscription: PaidSubscription = {
price_in_cents: 21900,
annual: true,
featureDescription: [],
canUseFlexibleLicensing: false,
},
payment: {
taxRate: 0,
@@ -173,7 +357,7 @@ export const pastDueExpiredSubscription: PaidSubscription = {
accountEmail: 'fake@example.com',
hasPastDueInvoice: true,
displayPrice: '$199.00',
planOnlyDisplayPrice: '',
planOnlyDisplayPrice: '$199.00',
addOns: [],
addOnDisplayPricesWithoutAdditionalLicense: {},
isEligibleForGroupPlan: true,
@@ -198,6 +382,7 @@ export const canceledSubscription: PaidSubscription = {
price_in_cents: 21900,
annual: true,
featureDescription: [],
canUseFlexibleLicensing: false,
},
payment: {
taxRate: 0,
@@ -215,7 +400,7 @@ export const canceledSubscription: PaidSubscription = {
accountEmail: 'fake@example.com',
hasPastDueInvoice: false,
displayPrice: '$199.00',
planOnlyDisplayPrice: '',
planOnlyDisplayPrice: '$199.00',
addOns: [],
addOnDisplayPricesWithoutAdditionalLicense: {},
isEligibleForGroupPlan: true,
@@ -240,6 +425,7 @@ export const pendingSubscriptionChange: PaidSubscription = {
price_in_cents: 21900,
annual: true,
featureDescription: [],
canUseFlexibleLicensing: false,
},
payment: {
taxRate: 0,
@@ -257,7 +443,7 @@ export const pendingSubscriptionChange: PaidSubscription = {
accountEmail: 'fake@example.com',
hasPastDueInvoice: false,
displayPrice: '$199.00',
planOnlyDisplayPrice: '',
planOnlyDisplayPrice: '$199.00',
addOns: [],
addOnDisplayPricesWithoutAdditionalLicense: {},
isEligibleForGroupPlan: true,
@@ -293,6 +479,7 @@ export const groupActiveSubscription: GroupSubscription = {
groupPlan: true,
membersLimit: 10,
membersLimitAddOn: 'additional-license',
canUseFlexibleLicensing: true,
},
payment: {
taxRate: 0,
@@ -310,7 +497,54 @@ export const groupActiveSubscription: GroupSubscription = {
accountEmail: 'fake@example.com',
hasPastDueInvoice: false,
displayPrice: '$1290.00',
planOnlyDisplayPrice: '',
planOnlyDisplayPrice: '$1290.00',
addOns: [],
addOnDisplayPricesWithoutAdditionalLicense: {},
isEligibleForGroupPlan: true,
isEligibleForPause: false,
isEligibleForDowngradeUpsell: false,
},
}
export const groupProfessionalActiveSubscription: GroupSubscription = {
manager_ids: ['abc123'],
member_ids: ['abc123'],
invited_emails: [],
groupPlan: true,
teamName: 'GAS',
membersLimit: 2,
_id: 'bcd567',
admin_id: 'abc123',
teamInvites: [],
planCode: 'group_professional_2_enterprise',
plan: {
planCode: 'group_professional_2_enterprise',
name: 'Group Professional Plan (2 licenses)',
hideFromUsers: true,
price_in_cents: 129000,
annual: true,
groupPlan: true,
membersLimit: 2,
membersLimitAddOn: 'additional-license',
canUseFlexibleLicensing: true,
},
payment: {
taxRate: 0,
billingDetailsLink: '/user/subscription/payment/billing-details',
accountManagementLink: '/user/subscription/payment/account-management',
additionalLicenses: 0,
totalLicenses: 10,
nextPaymentDueAt,
nextPaymentDueDate,
currency: 'USD',
state: 'active',
trialEndsAtFormatted: null,
trialEndsAt: null,
activeCoupons: [],
accountEmail: 'fake@example.com',
hasPastDueInvoice: false,
displayPrice: '$1290.00',
planOnlyDisplayPrice: '$1290.00',
addOns: [],
addOnDisplayPricesWithoutAdditionalLicense: {},
isEligibleForGroupPlan: true,
@@ -340,6 +574,7 @@ export const groupActiveSubscriptionWithPendingLicenseChange: GroupSubscription
groupPlan: true,
membersLimit: 10,
membersLimitAddOn: 'additional-license',
canUseFlexibleLicensing: true,
},
payment: {
taxRate: 0,
@@ -359,7 +594,7 @@ export const groupActiveSubscriptionWithPendingLicenseChange: GroupSubscription
displayPrice: '$2967.00',
pendingAdditionalLicenses: 13,
pendingTotalLicenses: 23,
planOnlyDisplayPrice: '',
planOnlyDisplayPrice: '$2967.00',
addOns: [],
addOnDisplayPricesWithoutAdditionalLicense: {},
isEligibleForGroupPlan: true,
@@ -411,7 +646,7 @@ export const trialSubscription: PaidSubscription = {
accountEmail: 'fake@example.com',
hasPastDueInvoice: false,
displayPrice: '$14.00',
planOnlyDisplayPrice: '',
planOnlyDisplayPrice: '$14.00',
addOns: [],
addOnDisplayPricesWithoutAdditionalLicense: {},
isEligibleForGroupPlan: true,
@@ -456,6 +691,7 @@ export const trialCollaboratorSubscription: PaidSubscription = {
price_in_cents: 2300,
featureDescription: [],
hideFromUsers: true,
canUseFlexibleLicensing: false,
},
payment: {
taxRate: 0,
@@ -473,7 +709,7 @@ export const trialCollaboratorSubscription: PaidSubscription = {
accountEmail: 'foo@example.com',
hasPastDueInvoice: false,
displayPrice: '$21.00',
planOnlyDisplayPrice: '',
planOnlyDisplayPrice: '$21.00',
addOns: [],
addOnDisplayPricesWithoutAdditionalLicense: {},
isEligibleForGroupPlan: true,
@@ -497,6 +733,7 @@ export const monthlyActiveCollaborator: PaidSubscription = {
name: 'Standard (Collaborator)',
price_in_cents: 212300900,
featureDescription: [],
canUseFlexibleLicensing: false,
},
payment: {
taxRate: 0,
@@ -514,7 +751,7 @@ export const monthlyActiveCollaborator: PaidSubscription = {
accountEmail: 'foo@example.com',
hasPastDueInvoice: false,
displayPrice: '$21.00',
planOnlyDisplayPrice: '',
planOnlyDisplayPrice: '$21.00',
addOns: [],
addOnDisplayPricesWithoutAdditionalLicense: {},
isEligibleForGroupPlan: true,

View File

@@ -8,7 +8,8 @@ import { CurrencyCode } from '../../../../../types/subscription/currency'
export function renderActiveSubscription(
subscription: PaidSubscription,
tags: MetaTag[] = [],
currencyCode?: CurrencyCode
currencyCode?: CurrencyCode,
canUseFlexibleLicensing?: boolean
) {
renderWithSubscriptionDashContext(
<ActiveSubscription subscription={subscription} />,
@@ -26,6 +27,13 @@ export function renderActiveSubscription(
name: 'ol-recommendedCurrency',
value: currencyCode || 'USD',
},
{
name: 'ol-canUseFlexibleLicensing',
value:
canUseFlexibleLicensing ||
subscription.plan?.canUseFlexibleLicensing ||
false,
},
],
}
)

View File

@@ -1,10 +1,10 @@
import { render } from '@testing-library/react'
import _ from 'lodash'
import { SubscriptionDashboardProvider } from '../../../../../frontend/js/features/subscription/context/subscription-dashboard-context'
import { groupPriceByUsageTypeAndSize, plans } from '../fixtures/plans'
import fetchMock from 'fetch-mock'
import { SplitTestProvider } from '@/shared/context/split-test-context'
import { MetaTag } from '@/utils/meta'
import { setupSubscriptionDashContext } from './setup-subscription-dash-context'
export function renderWithSubscriptionDashContext(
component: React.ReactElement,
@@ -25,62 +25,7 @@ export function renderWithSubscriptionDashContext(
</SplitTestProvider>
)
options?.metaTags?.forEach(tag =>
window.metaAttributesCache.set(tag!.name, tag!.value)
)
window.metaAttributesCache.set('ol-user', {})
if (!options?.recurlyNotLoaded) {
// @ts-ignore
global.recurly = {
configure: () => {},
Pricing: {
Subscription: () => {
return {
plan: (planCode: string) => {
let plan
const isGroupPlan = planCode.includes('group')
if (isGroupPlan) {
const [, planType, size, usage] = planCode.split('_')
const currencyCode = options?.currencyCode || 'USD'
plan = _.get(groupPriceByUsageTypeAndSize, [
usage,
planType,
currencyCode,
size,
])
} else {
plan = plans.find(p => p.planCode === planCode)
}
const response = {
next: {
total: plan?.price_in_cents
? plan.price_in_cents / 100
: undefined,
},
}
return {
currency: () => {
return {
catch: () => {
return {
done: (callback: (response: object) => void) => {
if (!options?.queryingRecurly) {
return callback(response)
}
},
}
},
}
},
}
},
}
},
},
}
}
setupSubscriptionDashContext(options)
return render(component, {
wrapper: SubscriptionDashboardProviderWrapper,

View File

@@ -0,0 +1,66 @@
import _ from 'lodash'
import { groupPriceByUsageTypeAndSize, plans } from '../fixtures/plans'
import { MetaTag } from '@/utils/meta'
export function setupSubscriptionDashContext(options?: {
metaTags?: MetaTag[]
recurlyNotLoaded?: boolean
queryingRecurly?: boolean
currencyCode?: string
}) {
options?.metaTags?.forEach(tag =>
window.metaAttributesCache.set(tag!.name, tag!.value)
)
window.metaAttributesCache.set('ol-user', {})
if (!options?.recurlyNotLoaded) {
// @ts-ignore
global.recurly = {
configure: () => {},
Pricing: {
Subscription: () => {
return {
plan: (planCode: string) => {
let plan
const isGroupPlan = planCode.includes('group')
if (isGroupPlan) {
const [, planType, size, usage] = planCode.split('_')
const currencyCode = options?.currencyCode || 'USD'
plan = _.get(groupPriceByUsageTypeAndSize, [
usage,
planType,
currencyCode,
size,
])
} else {
plan = plans.find(p => p.planCode === planCode)
}
const response = {
next: {
total: plan?.price_in_cents
? plan.price_in_cents / 100
: undefined,
},
}
return {
currency: () => {
return {
catch: () => {
return {
done: (callback: (response: object) => void) => {
if (!options?.queryingRecurly) {
return callback(response)
}
},
}
},
}
},
}
},
}
},
},
}
}
}