[CLD-6537] Cloud Annual Renewals - Banners Implementation (#25267)

* Initial implementation of Cloud Annual Renewal flow - system banners for admins and end users

* Actually add the file

* Add AlertBanner to system console billing_subscriptions page

* Fixes for non-admin banner and pipeline

* Updates to coincide with the CWS side of thigns

* Add a feature flag for the 60 day experience to ensure banners don't show accidentally

* Fix tests

---------

Co-authored-by: Mattermost Build <build@mattermost.com>
This commit is contained in:
Nick Misasi 2023-11-29 09:10:17 -05:00 committed by GitHub
parent 95670abcea
commit 8b07655a65
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 788 additions and 105 deletions

View File

@ -96,20 +96,26 @@ func getSubscription(c *Context, w http.ResponseWriter, r *http.Request) {
ProductID: subscription.ProductID,
IsFreeTrial: subscription.IsFreeTrial,
TrialEndAt: subscription.TrialEndAt,
EndAt: subscription.EndAt,
CancelAt: subscription.CancelAt,
DelinquentSince: subscription.DelinquentSince,
CustomerID: "",
AddOns: []string{},
StartAt: 0,
EndAt: 0,
CreateAt: 0,
Seats: 0,
Status: "",
DNS: "",
LastInvoice: &model.Invoice{},
DelinquentSince: subscription.DelinquentSince,
BillingType: "",
}
}
if !c.App.Config().FeatureFlags.CloudAnnualRenewals {
subscription.WillRenew = ""
subscription.CancelAt = nil
}
json, err := json.Marshal(subscription)
if err != nil {
c.Err = model.NewAppError("Api4.getSubscription", "api.cloud.request_error", nil, "", http.StatusInternalServerError).Wrap(err)

View File

@ -154,7 +154,7 @@ func Test_GetSubscription(t *testing.T) {
ProductID: "SomeProductId",
AddOns: []string{},
StartAt: 0,
EndAt: 0,
EndAt: 2000000000,
CreateAt: 0,
Seats: 0,
IsFreeTrial: "true",

View File

@ -180,6 +180,8 @@ type Subscription struct {
OriginallyLicensedSeats int `json:"originally_licensed_seats"`
ComplianceBlocked string `json:"compliance_blocked"`
BillingType string `json:"billing_type"`
CancelAt *int64 `json:"cancel_at"`
WillRenew string `json:"will_renew"`
}
// Subscription History model represents true up event in a yearly subscription

View File

@ -46,6 +46,8 @@ type FeatureFlags struct {
CloudIPFiltering bool
ConsumePostHook bool
CloudAnnualRenewals bool
}
func (f *FeatureFlags) SetDefaults() {
@ -63,6 +65,7 @@ func (f *FeatureFlags) SetDefaults() {
f.StreamlinedMarketplace = true
f.CloudIPFiltering = false
f.ConsumePostHook = false
f.CloudAnnualRenewals = false
}
// ToMap returns the feature flags as a map[string]string

View File

@ -0,0 +1,108 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {unixTimestampFromNow} from 'tests/helpers/date';
import {renderWithContext} from 'tests/react_testing_utils';
import {CloudProducts} from 'utils/constants';
import {CloudAnnualRenewalBanner} from './billing_subscriptions';
describe('CloudAnnualRenewalBanner', () => {
const initialState = {
entities: {
general: {
license: {
IsLicensed: 'true',
Cloud: 'true',
},
},
users: {
currentUserId: 'current_user_id',
profiles: {
current_user_id: {roles: 'system_admin'},
},
},
cloud: {
subscription: {
product_id: 'test_prod_1',
trial_end_at: 1652807380,
is_free_trial: 'false',
cancel_at: 1652807380,
},
products: {
test_prod_1: {
id: 'test_prod_1',
sku: CloudProducts.STARTER,
price_per_seat: 0,
},
test_prod_2: {
id: 'test_prod_2',
sku: CloudProducts.ENTERPRISE,
price_per_seat: 0,
},
test_prod_3: {
id: 'test_prod_3',
sku: CloudProducts.PROFESSIONAL,
price_per_seat: 0,
},
},
},
},
};
it('should not render if subscription is not available', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.cloud.subscription = null;
const {queryByText} = renderWithContext(<CloudAnnualRenewalBanner/>, state);
expect(queryByText(/Your annual subscription expires in/)).not.toBeInTheDocument();
});
it('should render with correct title and buttons', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.cloud.subscription = {
...state.entities.cloud.subscription,
end_at: unixTimestampFromNow(30),
};
const {getByText} = renderWithContext(<CloudAnnualRenewalBanner/>, state);
expect(getByText(/Your annual subscription expires in 31 days. Please renew now to avoid any disruption/)).toBeInTheDocument();
expect(getByText(/Renew/)).toBeInTheDocument();
expect(getByText(/Contact Sales/)).toBeInTheDocument();
const renewButton = getByText(/Renew/);
renewButton.click();
});
it('should render with danger mode if expiration is within 7 days', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.cloud.subscription = {
...state.entities.cloud.subscription,
end_at: unixTimestampFromNow(4),
};
const {getByText, getByTestId} = renderWithContext(<CloudAnnualRenewalBanner/>, state);
expect(getByText(/Your annual subscription expires in 5 days. Please renew now to avoid any disruption/)).toBeInTheDocument();
expect(getByText(/Renew/)).toBeInTheDocument();
expect(getByText(/Contact Sales/)).toBeInTheDocument();
expect(getByTestId('cloud_annual_renewal_alert_banner_danger')).toBeInTheDocument();
});
it('should render with with different title when end_at time has passed', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.cloud.subscription = {
...state.entities.cloud.subscription,
end_at: unixTimestampFromNow(-5),
cancel_at: unixTimestampFromNow(5),
};
const {getByText, getByTestId} = renderWithContext(<CloudAnnualRenewalBanner/>, state);
expect(getByText(/Your subscription has expired. Your workspace will be deleted in 6 days. Please renew now to avoid any disruption/)).toBeInTheDocument();
expect(getByText(/Renew/)).toBeInTheDocument();
expect(getByText(/Contact Sales/)).toBeInTheDocument();
expect(getByTestId('cloud_annual_renewal_alert_banner_danger')).toBeInTheDocument();
});
});

View File

@ -2,10 +2,16 @@
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage} from 'react-intl';
import {FormattedMessage, useIntl} from 'react-intl';
import BlockableLink from 'components/admin_console/blockable_link';
import type {ModeType} from 'components/alert_banner';
import AlertBanner from 'components/alert_banner';
import useGetSubscription from 'components/common/hooks/useGetSubscription';
import useOpenCloudPurchaseModal from 'components/common/hooks/useOpenCloudPurchaseModal';
import useOpenSalesLink from 'components/common/hooks/useOpenSalesLink';
import {daysToExpiration} from 'utils/cloud_utils';
export const creditCardExpiredBanner = (setShowCreditCardBanner: (value: boolean) => void) => {
return (
@ -53,3 +59,57 @@ export const paymentFailedBanner = () => {
/>
);
};
export const CloudAnnualRenewalBanner = () => {
// TODO: Update with renewal modal
const openPurchaseModal = useOpenCloudPurchaseModal({});
const subscription = useGetSubscription();
const {formatMessage} = useIntl();
const [openSalesLink] = useOpenSalesLink();
if (!subscription || !subscription.cancel_at) {
return null;
}
const daysUntilExpiration = daysToExpiration(subscription?.end_at * 1000);
const daysUntilCancelation = daysToExpiration(subscription?.cancel_at * 1000);
const renewButton = (
<button
className='btn btn-primary'
onClick={() => openPurchaseModal({})}
>
{formatMessage({id: 'cloud_annual_renewal.banner.buttonText.renew', defaultMessage: 'Renew'})}
</button>
);
const contactSalesButton = (
<button
className='btn btn-tertiary'
onClick={openSalesLink}
>
{formatMessage({id: 'cloud_annual_renewal.banner.buttonText.contactSales', defaultMessage: 'Contact Sales'})}
</button>
);
const alertBannerProps = {
mode: 'info' as ModeType,
title: (<>{formatMessage({id: 'billing_subscriptions.cloud_annual_renewal_alert_banner_title', defaultMessage: 'Your annual subscription expires in {days} days. Please renew now to avoid any disruption'}, {days: daysUntilExpiration})}</>),
actionButtonLeft: renewButton,
actionButtonRight: contactSalesButton,
message: <></>,
};
if (daysUntilExpiration <= 7) {
alertBannerProps.mode = 'danger';
}
if (daysUntilExpiration <= 0) {
alertBannerProps.title = <>{formatMessage({id: 'billing_subscriptions.cloud_annual_renewal_alert_banner_title_expired', defaultMessage: 'Your subscription has expired. Your workspace will be deleted in {days} days. Please renew now to avoid any disruption'}, {days: daysUntilCancelation})}</>;
}
return (
<AlertBanner
id={'cloud_annual_renewal_alert_banner_' + alertBannerProps.mode}
{...alertBannerProps}
/>
);
};

View File

@ -37,6 +37,7 @@ import {hasSomeLimits} from 'utils/limits';
import {getRemainingDaysFromFutureTimestamp} from 'utils/utils';
import {
CloudAnnualRenewalBanner,
creditCardExpiredBanner,
paymentFailedBanner,
} from './billing_subscriptions';
@ -138,6 +139,7 @@ const BillingSubscriptions = () => {
product={product}
/>
{shouldShowPaymentFailedBanner() && paymentFailedBanner()}
{<CloudAnnualRenewalBanner/>}
{<ToYearlyNudgeBanner/>}
{<ToPaidNudgeBanner/>}
{showCreditCardBanner &&

View File

@ -9,6 +9,7 @@ import {ToPaidPlanBannerDismissable} from 'components/admin_console/billing/bill
import {ToYearlyNudgeBannerDismissable} from 'components/admin_console/billing/billing_subscriptions/to_yearly_nudge_banner';
import withGetCloudSubscription from 'components/common/hocs/cloud/with_get_cloud_subscription';
import CloudAnnualRenewalAnnouncementBar from './cloud_annual_renewal';
import CloudDelinquencyAnnouncementBar from './cloud_delinquency';
import CloudTrialAnnouncementBar from './cloud_trial_announcement_bar';
import CloudTrialEndAnnouncementBar from './cloud_trial_ended_announcement_bar';
@ -70,6 +71,7 @@ class AnnouncementBarController extends React.PureComponent<Props> {
let cloudTrialAnnouncementBar = null;
let cloudTrialEndAnnouncementBar = null;
let cloudDelinquencyAnnouncementBar = null;
let cloudRenewalAnnouncementBar = null;
let notifyAdminDowngradeDelinquencyBar = null;
let toYearlyNudgeBannerDismissable = null;
let toPaidPlanNudgeBannerDismissable = null;
@ -86,6 +88,9 @@ class AnnouncementBarController extends React.PureComponent<Props> {
cloudDelinquencyAnnouncementBar = (
<CloudDelinquencyAnnouncementBar/>
);
cloudRenewalAnnouncementBar = (
<CloudAnnualRenewalAnnouncementBar/>
);
notifyAdminDowngradeDelinquencyBar = (
<NotifyAdminDowngradeDelinquencyBar/>
);
@ -108,6 +113,7 @@ class AnnouncementBarController extends React.PureComponent<Props> {
{cloudTrialAnnouncementBar}
{cloudTrialEndAnnouncementBar}
{cloudDelinquencyAnnouncementBar}
{cloudRenewalAnnouncementBar}
{notifyAdminDowngradeDelinquencyBar}
{toYearlyNudgeBannerDismissable}
{toPaidPlanNudgeBannerDismissable}

View File

@ -0,0 +1,374 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {unixTimestampFromNow} from 'tests/helpers/date';
import {renderWithContext} from 'tests/react_testing_utils';
import {CloudBanners, CloudProducts, Preferences} from 'utils/constants';
import CloudAnnualRenewalAnnouncementBar, {getCurrentYearAsString} from './index';
describe('components/announcement_bar/cloud_delinquency', () => {
const initialState = {
views: {
announcementBar: {
announcementBarState: {
announcementBarCount: 1,
},
},
},
entities: {
admin: {
config: {
FeatureFlags: {
CloudAnnualRenewals: true,
},
},
},
general: {
license: {
IsLicensed: 'true',
Cloud: 'true',
},
},
users: {
currentUserId: 'current_user_id',
profiles: {
current_user_id: {roles: 'system_admin'},
},
},
cloud: {
subscription: {
product_id: 'test_prod_1',
trial_end_at: 1652807380,
is_free_trial: 'false',
cancel_at: null,
},
products: {
test_prod_1: {
id: 'test_prod_1',
sku: CloudProducts.STARTER,
price_per_seat: 0,
},
test_prod_2: {
id: 'test_prod_2',
sku: CloudProducts.ENTERPRISE,
price_per_seat: 0,
},
test_prod_3: {
id: 'test_prod_3',
sku: CloudProducts.PROFESSIONAL,
price_per_seat: 0,
},
},
},
},
};
it('Should not show banner when feature flag is disabled time set', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.general.config = {
...state.entities.admin.config,
CloudAnnualRenewals: false,
};
const {queryByText} = renderWithContext(
<CloudAnnualRenewalAnnouncementBar/>,
state,
);
expect(queryByText('Your annual subscription expires in')).not.toBeInTheDocument();
});
it('Should not show banner when no cancel_at time set', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.cloud.subscription = {
...state.entities.cloud.subscription,
cancel_at: null,
};
const {queryByText} = renderWithContext(
<CloudAnnualRenewalAnnouncementBar/>,
state,
);
expect(queryByText('Your annual subscription expires in')).not.toBeInTheDocument();
});
it('Should show 60 day banner to admin when cancel_at time is set accordingly', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.cloud.subscription = {
...state.entities.cloud.subscription,
cancel_at: unixTimestampFromNow(69),
end_at: unixTimestampFromNow(55),
};
const {getByText} = renderWithContext(
<CloudAnnualRenewalAnnouncementBar/>,
state,
);
expect(getByText('Your annual subscription expires in 56 days. Please renew to avoid any disruption.')).toBeInTheDocument();
});
it('Should NOT show 60 day banner to non-admin when cancel_at time is set accordingly', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.cloud.subscription = {
...state.entities.cloud.subscription,
cancel_at: unixTimestampFromNow(69),
end_at: unixTimestampFromNow(55),
};
state.entities.users = {
currentUserId: 'current_user_id',
profiles: {
current_user_id: {roles: 'user'},
},
};
const {queryByText} = renderWithContext(
<CloudAnnualRenewalAnnouncementBar/>,
state,
);
expect(queryByText('Your annual subscription expires in')).not.toBeInTheDocument();
});
it('Should show 30 day banner to admin when cancel_at time is set accordingly', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.cloud.subscription = {
...state.entities.cloud.subscription,
cancel_at: unixTimestampFromNow(69),
end_at: unixTimestampFromNow(25),
};
const {getByText} = renderWithContext(
<CloudAnnualRenewalAnnouncementBar/>,
state,
);
expect(getByText('Your annual subscription expires in 26 days. Please renew to avoid any disruption.')).toBeInTheDocument();
});
it('Should NOT show 30 day banner to non-admin when cancel_at time is set accordingly', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.cloud.subscription = {
...state.entities.cloud.subscription,
cancel_at: unixTimestampFromNow(69),
end_at: unixTimestampFromNow(25),
};
state.entities.users = {
currentUserId: 'current_user_id',
profiles: {
current_user_id: {roles: 'user'},
},
};
const {queryByText} = renderWithContext(
<CloudAnnualRenewalAnnouncementBar/>,
state,
);
expect(queryByText('Your annual subscription expires in')).not.toBeInTheDocument();
});
it('Should show 7 day banner to admin when cancel_at time is set accordingly', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.cloud.subscription = {
...state.entities.cloud.subscription,
cancel_at: unixTimestampFromNow(69),
end_at: unixTimestampFromNow(5),
};
const {getByText} = renderWithContext(
<CloudAnnualRenewalAnnouncementBar/>,
state,
);
expect(getByText('Your annual subscription expires in 6 days. Failure to renew will result in your workspace being deleted.')).toBeInTheDocument();
});
it('Should NOT show 7 day banner to non admin when cancel_at time is set accordingly', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.cloud.subscription = {
...state.entities.cloud.subscription,
cancel_at: unixTimestampFromNow(69),
end_at: unixTimestampFromNow(5),
};
state.entities.users = {
currentUserId: 'current_user_id',
profiles: {
current_user_id: {roles: 'user'},
},
};
const {queryByText} = renderWithContext(
<CloudAnnualRenewalAnnouncementBar/>,
state,
);
expect(queryByText('Your annual subscription expires in')).not.toBeInTheDocument();
});
it('Should NOT show 7 day banner to admin when delinquent_since is set', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.cloud.subscription = {
...state.entities.cloud.subscription,
cancel_at: unixTimestampFromNow(69),
end_at: unixTimestampFromNow(5),
delinquent_since: unixTimestampFromNow(5),
};
const {queryByText} = renderWithContext(
<CloudAnnualRenewalAnnouncementBar/>,
state,
);
expect(queryByText('Your annual subscription expires in 6 days. Failure to renew will result in your workspace being deleted.')).not.toBeInTheDocument();
});
it('Should NOT show 60 day banner to admin when they dismissed the banner', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.cloud.subscription = {
...state.entities.cloud.subscription,
cancel_at: unixTimestampFromNow(69),
end_at: unixTimestampFromNow(55),
};
state.entities.preferences = {
myPreferences: {
[`${Preferences.CLOUD_ANNUAL_RENEWAL_BANNER}--${CloudBanners.ANNUAL_RENEWAL_60_DAY}_${getCurrentYearAsString()}`]: {
user_id: 'rq7fq4hfjp8ifywsfwk114545a',
category: 'cloud_annual_renewal_banner',
name: 'annual_renewal_60_day_2023',
value: 'true',
},
},
};
const {queryByText} = renderWithContext(
<CloudAnnualRenewalAnnouncementBar/>,
state,
);
expect(queryByText('Your annual subscription expires in')).not.toBeInTheDocument();
});
it('Should NOT show 30 day banner to admin when they dismissed the banner', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.cloud.subscription = {
...state.entities.cloud.subscription,
cancel_at: unixTimestampFromNow(69),
end_at: unixTimestampFromNow(25),
};
state.entities.preferences = {
myPreferences: {
[`${Preferences.CLOUD_ANNUAL_RENEWAL_BANNER}--${CloudBanners.ANNUAL_RENEWAL_30_DAY}_${getCurrentYearAsString()}`]: {
user_id: 'rq7fq4hfjp8ifywsfwk114545a',
category: 'cloud_annual_renewal_banner',
name: 'annual_renewal_30_day_2023',
value: 'true',
},
},
};
const {queryByText} = renderWithContext(
<CloudAnnualRenewalAnnouncementBar/>,
state,
);
expect(queryByText('Your annual subscription expires in')).not.toBeInTheDocument();
});
it('Should show 60 day banner to admin in 2023 when they dismissed the banner in 2022', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.cloud.subscription = {
...state.entities.cloud.subscription,
cancel_at: unixTimestampFromNow(69),
end_at: unixTimestampFromNow(55),
};
state.entities.preferences = {
myPreferences: {
[`${Preferences.CLOUD_ANNUAL_RENEWAL_BANNER}--${CloudBanners.ANNUAL_RENEWAL_60_DAY}_2022`]: {
user_id: 'rq7fq4hfjp8ifywsfwk114545a',
category: 'cloud_annual_renewal_banner',
name: 'annual_renewal_60_day_2022',
value: 'true',
},
},
};
const {queryByText} = renderWithContext(
<CloudAnnualRenewalAnnouncementBar/>,
state,
);
expect(queryByText('Your annual subscription expires in 56 days. Please renew to avoid any disruption.')).toBeInTheDocument();
});
it('Should show 30 day banner to admin in 2023 when they dismissed the banner in 2022', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.cloud.subscription = {
...state.entities.cloud.subscription,
cancel_at: unixTimestampFromNow(69),
end_at: unixTimestampFromNow(25),
};
state.entities.preferences = {
myPreferences: {
[`${Preferences.CLOUD_ANNUAL_RENEWAL_BANNER}--${CloudBanners.ANNUAL_RENEWAL_30_DAY}_2022`]: {
user_id: 'rq7fq4hfjp8ifywsfwk114545a',
category: 'cloud_annual_renewal_banner',
name: 'annual_renewal_30_day_2022',
value: 'true',
},
},
};
const {queryByText} = renderWithContext(
<CloudAnnualRenewalAnnouncementBar/>,
state,
);
expect(queryByText('Your annual subscription expires in 26 days. Please renew to avoid any disruption.')).toBeInTheDocument();
});
it('Should NOT show any banner if renewal date is more than 60 days away"', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.cloud.subscription = {
...state.entities.cloud.subscription,
cancel_at: unixTimestampFromNow(69),
end_at: unixTimestampFromNow(75),
};
const {queryByText} = renderWithContext(
<CloudAnnualRenewalAnnouncementBar/>,
state,
);
expect(queryByText('Your annual subscription expires in')).not.toBeInTheDocument();
});
it('Should NOT show any banner if within renewal period but will_renew is set to "true"', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.cloud.subscription = {
...state.entities.cloud.subscription,
cancel_at: unixTimestampFromNow(69),
end_at: unixTimestampFromNow(25),
will_renew: 'true',
};
const {queryByText} = renderWithContext(
<CloudAnnualRenewalAnnouncementBar/>,
state,
);
expect(queryByText('Your annual subscription expires in')).not.toBeInTheDocument();
});
});

View File

@ -0,0 +1,132 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useMemo} from 'react';
import {useIntl} from 'react-intl';
import {useDispatch, useSelector} from 'react-redux';
import {InformationOutlineIcon} from '@mattermost/compass-icons/components';
import {savePreferences} from 'mattermost-redux/actions/preferences';
import {getConfig} from 'mattermost-redux/selectors/entities/admin';
import {get} from 'mattermost-redux/selectors/entities/preferences';
import {getCurrentUserId, isCurrentUserSystemAdmin} from 'mattermost-redux/selectors/entities/users';
import {useDelinquencySubscription} from 'components/common/hooks/useDelinquencySubscription';
import useGetSubscription from 'components/common/hooks/useGetSubscription';
import useOpenCloudPurchaseModal from 'components/common/hooks/useOpenCloudPurchaseModal';
import {daysToExpiration} from 'utils/cloud_utils';
import {Preferences, AnnouncementBarTypes, CloudBanners} from 'utils/constants';
import type {GlobalState} from 'types/store';
import AnnouncementBar from '../default_announcement_bar';
const between = (x: number, min: number, max: number) => {
return x >= min && x <= max;
};
export const getCurrentYearAsString = () => {
const now = new Date();
const year = now.getFullYear();
return year.toString();
};
const CloudAnnualRenewalAnnouncementBar = () => {
const subscription = useGetSubscription();
// TODO: Update with renewal modal
const openPurchaseModal = useOpenCloudPurchaseModal({});
const {formatMessage} = useIntl();
const {isDelinquencySubscription} = useDelinquencySubscription();
const isAdmin = useSelector(isCurrentUserSystemAdmin);
const dispatch = useDispatch();
const currentUserId = useSelector(getCurrentUserId);
const hasDismissed60DayBanner = useSelector((state: GlobalState) => get(state, Preferences.CLOUD_ANNUAL_RENEWAL_BANNER, `${CloudBanners.ANNUAL_RENEWAL_60_DAY}_${getCurrentYearAsString()}`)) === 'true';
const hasDismissed30DayBanner = useSelector((state: GlobalState) => get(state, Preferences.CLOUD_ANNUAL_RENEWAL_BANNER, `${CloudBanners.ANNUAL_RENEWAL_30_DAY}_${getCurrentYearAsString()}`)) === 'true';
const cloudAnnualRenewalsEnabled = useSelector(getConfig).FeatureFlags?.CloudAnnualRenewals;
const daysUntilExpiration = useMemo(() => {
if (!subscription || !subscription.end_at || !subscription.cancel_at) {
return 0;
}
return daysToExpiration(subscription.end_at * 1000);
}, [subscription]);
const handleDismiss = (banner: string) => {
// We store the preference name with the current year as a string appended to the end,
// so that next renewal period we can show the banner again despite the user having dismissed it in the previous year
dispatch(savePreferences(currentUserId, [{
category: Preferences.CLOUD_ANNUAL_RENEWAL_BANNER,
name: `${banner}_${getCurrentYearAsString()}`,
user_id: currentUserId,
value: 'true',
}]));
};
const getBanner = useMemo(() => {
const defaultProps = {
showLinkAsButton: true,
isTallBanner: true,
icon: <i className='icon icon-alert-outline'/>,
modalButtonText: formatMessage({id: 'cloud_annual_renewal.banner.buttonText.renew', defaultMessage: 'Renew'}),
modalButtonDefaultText: 'Renew',
message: <></>,
onButtonClick: openPurchaseModal,
handleClose: () => { },
showCloseButton: true,
};
let bannerProps = {
...defaultProps,
type: '',
};
if (between(daysUntilExpiration, 31, 60) && !hasDismissed60DayBanner) {
if (hasDismissed60DayBanner) {
return null;
}
bannerProps = {
...defaultProps,
message: (<>{formatMessage({id: 'cloud_annual_renewal.banner.message.60', defaultMessage: 'Your annual subscription expires in {days} days. Please renew to avoid any disruption.'}, {days: daysUntilExpiration})}</>),
icon: (<InformationOutlineIcon size={18}/>),
type: AnnouncementBarTypes.ANNOUNCEMENT,
handleClose: () => handleDismiss(CloudBanners.ANNUAL_RENEWAL_60_DAY),
};
} else if (between(daysUntilExpiration, 8, 30)) {
if (hasDismissed30DayBanner) {
return null;
}
bannerProps = {
...defaultProps,
message: (<>{formatMessage({id: 'cloud_annual_renewal.banner.message.30', defaultMessage: 'Your annual subscription expires in {days} days. Please renew to avoid any disruption.'}, {days: daysUntilExpiration})}</>),
icon: (<InformationOutlineIcon size={18}/>),
type: AnnouncementBarTypes.ADVISOR,
handleClose: () => handleDismiss(CloudBanners.ANNUAL_RENEWAL_30_DAY),
};
} else if (between(daysUntilExpiration, 0, 7) && !isDelinquencySubscription()) {
bannerProps = {
...defaultProps,
message: (<>{formatMessage({id: 'cloud_annual_renewal.banner.message.7', defaultMessage: 'Your annual subscription expires in {days} days. Failure to renew will result in your workspace being deleted.'}, {days: daysUntilExpiration})}</>),
icon: (<i className='icon icon-alert-outline'/>),
type: AnnouncementBarTypes.CRITICAL,
showCloseButton: false,
};
}
return <AnnouncementBar {...bannerProps}/>;
}, [daysUntilExpiration, hasDismissed60DayBanner, hasDismissed30DayBanner]);
// Delinquent subscriptions will have a cancel_at time, but the banner is handled separately
if (!cloudAnnualRenewalsEnabled || !subscription || !subscription.cancel_at || subscription.will_renew === 'true' || isDelinquencySubscription() || !isAdmin || daysUntilExpiration > 60) {
return null;
}
return (
<>
{getBanner}
</>
);
};
export default CloudAnnualRenewalAnnouncementBar;

View File

@ -2,15 +2,16 @@
// See LICENSE.txt for license information.
import React from 'react';
import * as reactRedux from 'react-redux';
import {mountWithIntl} from 'tests/helpers/intl-test-helper';
import mockStore from 'tests/test_store';
import {renderWithContext} from 'tests/react_testing_utils';
import {CloudProducts} from 'utils/constants';
import CloudDelinquencyAnnouncementBar from './index';
describe('components/announcement_bar/cloud_delinquency', () => {
const now = new Date();
const fiveDaysAgo = new Date(now.getTime() - (5 * 24 * 60 * 60 * 1000)).getTime() / 1000;
const fiveDaysFromNow = new Date(now.getTime() + (5 * 24 * 60 * 60 * 1000)).getTime() / 1000;
const initialState = {
views: {
announcementBar: {
@ -37,7 +38,8 @@ describe('components/announcement_bar/cloud_delinquency', () => {
product_id: 'test_prod_1',
trial_end_at: 1652807380,
is_free_trial: 'false',
delinquent_since: 1652807380, // may 17 2022
delinquent_since: fiveDaysAgo, // may 17 2022
cancel_at: fiveDaysFromNow, // may 17 2022
},
products: {
test_prod_1: {
@ -67,20 +69,30 @@ describe('components/announcement_bar/cloud_delinquency', () => {
delinquent_since: null,
};
jest.useFakeTimers().setSystemTime(new Date('2022-06-20'));
const store = mockStore(state);
const wrapper = mountWithIntl(
<reactRedux.Provider store={store}>
<CloudDelinquencyAnnouncementBar/>
</reactRedux.Provider>,
const {queryByText} = renderWithContext(
<CloudDelinquencyAnnouncementBar/>,
state,
);
expect(wrapper.find('AnnouncementBar').exists()).toEqual(false);
expect(queryByText('Your annual subscription has expired')).not.toBeInTheDocument();
});
it('Should not show banner when user is not admin', () => {
it('Should not show banner when no cancel_at time is set', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.cloud.subscription = {
...state.entities.cloud.subscription,
cancel_at: null,
};
const {queryByText} = renderWithContext(
<CloudDelinquencyAnnouncementBar/>,
state,
);
expect(queryByText('Your annual subscription has expired')).not.toBeInTheDocument();
});
it('Should show banner when user is not admin, but should not show CTA', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.users = {
currentUserId: 'current_user_id',
@ -89,44 +101,24 @@ describe('components/announcement_bar/cloud_delinquency', () => {
},
};
jest.useFakeTimers().setSystemTime(new Date('2022-06-20'));
const store = mockStore(state);
const wrapper = mountWithIntl(
<reactRedux.Provider store={store}>
<CloudDelinquencyAnnouncementBar/>
</reactRedux.Provider>,
const {queryByText, getByText} = renderWithContext(
<CloudDelinquencyAnnouncementBar/>,
state,
);
expect(wrapper.find('AnnouncementBar').exists()).toEqual(false);
expect(getByText('Your annual subscription has expired. Please contact your System Admin to keep this workspace')).toBeInTheDocument();
expect(queryByText('Update billing now')).not.toBeInTheDocument();
});
it('Should match snapshot when delinquent < 90 days', () => {
it('Should show banner and CTA when user is admin', () => {
const state = JSON.parse(JSON.stringify(initialState));
const store = mockStore(state);
jest.useFakeTimers().setSystemTime(new Date('2022-06-20'));
const wrapper = mountWithIntl(
<reactRedux.Provider store={store}>
<CloudDelinquencyAnnouncementBar/>
</reactRedux.Provider>,
const {getByText} = renderWithContext(
<CloudDelinquencyAnnouncementBar/>,
state,
);
expect(wrapper.find('.announcement-bar-advisor').exists()).toEqual(true);
});
it('Should match snapshot when delinquent > 90 days', () => {
const state = JSON.parse(JSON.stringify(initialState));
const store = mockStore(state);
jest.useFakeTimers().setSystemTime(new Date('2022-12-20'));
const wrapper = mountWithIntl(
<reactRedux.Provider store={store}>
<CloudDelinquencyAnnouncementBar/>
</reactRedux.Provider>,
);
expect(wrapper.find('.announcement-bar-critical').exists()).toEqual(true);
expect(getByText('Your annual subscription has expired. Please renew now to keep this workspace')).toBeInTheDocument();
expect(getByText('Update billing now')).toBeInTheDocument();
});
});

View File

@ -2,11 +2,10 @@
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage} from 'react-intl';
import {useIntl} from 'react-intl';
import {useSelector} from 'react-redux';
import {getCurrentUser} from 'mattermost-redux/selectors/entities/users';
import {isSystemAdmin} from 'mattermost-redux/utils/user_utils';
import {isCurrentUserSystemAdmin} from 'mattermost-redux/selectors/entities/users';
import {trackEvent} from 'actions/telemetry_actions';
@ -17,9 +16,6 @@ import useOpenCloudPurchaseModal from 'components/common/hooks/useOpenCloudPurch
import {
AnnouncementBarTypes, TELEMETRY_CATEGORIES,
} from 'utils/constants';
import {t} from 'utils/i18n';
import type {GlobalState} from 'types/store';
import AnnouncementBar from '../default_announcement_bar';
@ -27,65 +23,43 @@ const CloudDelinquencyAnnouncementBar = () => {
const subscription = useGetSubscription();
const openPurchaseModal = useOpenCloudPurchaseModal({});
const {isDelinquencySubscription} = useDelinquencySubscription();
const currentUser = useSelector((state: GlobalState) =>
getCurrentUser(state),
);
const {formatMessage} = useIntl();
const isAdmin = useSelector(isCurrentUserSystemAdmin);
const getBannerType = () => {
const delinquencyDate = new Date(
(subscription?.delinquent_since || 0) * 1000,
);
const oneDay = 24 * 60 * 60 * 1000; // hours*minutes*seconds*milliseconds
const today = new Date();
const diffDays = Math.round(
Math.abs((today.valueOf() - delinquencyDate.valueOf()) / oneDay),
);
if (diffDays > 90) {
return AnnouncementBarTypes.CRITICAL;
}
return AnnouncementBarTypes.ADVISOR;
};
if (!isDelinquencySubscription() || !isSystemAdmin(currentUser.roles)) {
if (!isDelinquencySubscription() || !subscription?.cancel_at) {
return null;
}
const bannerType = getBannerType();
let message = {
id: t('cloud_delinquency.banner.title'),
defaultMessage:
'Update your billing information now to keep paid features.',
let props = {
message: (<>{formatMessage({id: 'cloud_annual_renewal_delinquency.banner.message', defaultMessage: 'Your annual subscription has expired. Please renew now to keep this workspace'})}</>),
modalButtonText: formatMessage({id: 'cloud_delinquency.banner.buttonText', defaultMessage: 'Update billing now'}),
modalButtonDefaultText: 'Update billing now',
showLinkAsButton: true,
isTallBanner: true,
icon: <i className='icon icon-alert-outline'/>,
showCTA: true,
onButtonClick: () => {
trackEvent(TELEMETRY_CATEGORIES.CLOUD_DELINQUENCY, 'click_update_billing');
openPurchaseModal({
trackingLocation:
'cloud_delinquency_announcement_bar',
});
},
type: AnnouncementBarTypes.CRITICAL,
showCloseButton: false,
};
// If critical banner, wording is different
if (bannerType === AnnouncementBarTypes.CRITICAL) {
message = {
id: t('cloud_delinquency.post_downgrade_banner.title'),
defaultMessage:
'Update your billing information now to re-activate paid features.',
if (!isAdmin) {
props = {
...props,
message: (<>{formatMessage({id: 'cloud_annual_renewal_delinquency.banner.end_user.message', defaultMessage: 'Your annual subscription has expired. Please contact your System Admin to keep this workspace'})}</>),
showCTA: false,
};
}
return (
<AnnouncementBar
type={bannerType}
showCloseButton={false}
onButtonClick={() => {
trackEvent(TELEMETRY_CATEGORIES.CLOUD_DELINQUENCY, 'click_update_billing');
openPurchaseModal({
trackingLocation:
'cloud_delinquency_announcement_bar',
});
}
}
modalButtonText={t('cloud_delinquency.banner.buttonText')}
modalButtonDefaultText={'Update billing now'}
message={<FormattedMessage {...message}/>}
showLinkAsButton={true}
isTallBanner={true}
icon={<i className='icon icon-alert-outline'/>}
{...props}
/>
);
};

View File

@ -2863,6 +2863,8 @@
"backstage_sidebar.integrations.incoming_webhooks": "Incoming Webhooks",
"backstage_sidebar.integrations.oauthApps": "OAuth 2.0 Applications",
"backstage_sidebar.integrations.outgoing_webhooks": "Outgoing Webhooks",
"billing_subscriptions.cloud_annual_renewal_alert_banner_title": "Your annual subscription expires in {days} days. Please renew now to avoid any disruption",
"billing_subscriptions.cloud_annual_renewal_alert_banner_title_expired": "Your subscription has expired. Your workspace will be deleted in {days} days. Please renew now to avoid any disruption",
"billing.subscription.info.mostRecentPaymentFailed": "Your most recent payment failed",
"billing.subscription.info.mostRecentPaymentFailed.description.mostRecentPaymentFailed": "It looks your most recent payment failed because the credit card on your account has expired. Please <link>update your payment information</link> to avoid any disruption.",
"bot.add.description": "Description",
@ -3125,6 +3127,13 @@
"claim.oauth_to_email.pwdNotMatch": "Passwords do not match.",
"claim.oauth_to_email.switchTo": "Switch {type} to Email and Password",
"claim.oauth_to_email.title": "Switch {type} Account to Email",
"cloud_annual_renewal_delinquency.banner.end_user.message": "Your annual subscription has expired. Please contact your System Admin to keep this workspace",
"cloud_annual_renewal_delinquency.banner.message": "Your annual subscription has expired. Please renew now to keep this workspace",
"cloud_annual_renewal.banner.buttonText.contactSales": "Contact Sales",
"cloud_annual_renewal.banner.buttonText.renew": "Renew",
"cloud_annual_renewal.banner.message.30": "Your annual subscription expires in {days} days. Please renew to avoid any disruption.",
"cloud_annual_renewal.banner.message.60": "Your annual subscription expires in {days} days. Please renew to avoid any disruption.",
"cloud_annual_renewal.banner.message.7": "Your annual subscription expires in {days} days. Failure to renew will result in your workspace being deleted.",
"cloud_archived.error.access": "Permalink belongs to a message that has been archived because of {planName} limits. Upgrade to access message again.",
"cloud_archived.error.title": "Message Archived",
"cloud_billing_history_modal.title": "Invoice(s)",
@ -3142,7 +3151,6 @@
"cloud_delinquency.banner.buttonText": "Update billing now",
"cloud_delinquency.banner.end_user_notify_admin_button": "Notify admin",
"cloud_delinquency.banner.end_user_notify_admin_title": "Your workspace has been downgraded. Notify your admin to fix billing issues",
"cloud_delinquency.banner.title": "Update your billing information now to keep paid features.",
"cloud_delinquency.cc_modal.card.reactivate": "Reactivate",
"cloud_delinquency.cc_modal.card.totalOwed": "Total owed",
"cloud_delinquency.cc_modal.card.viewBreakdown": "View breakdown",
@ -3160,7 +3168,6 @@
"cloud_delinquency.modal.workspace_downgraded_messages_surpassed": "Some of your workspace's message history are no longer accessible. Upgrade to a paid plan and get unlimited access to your message history.",
"cloud_delinquency.modal.workspace_downgraded_multiples_limits_surpassed": "Your workspace has reached free plan limits. Upgrade to a paid plan.",
"cloud_delinquency.modal.workspace_downgraded_storage_surpassed": "Some of your workspace's files are no longer accessible. Upgrade to a paid plan and get unlimited access to your files.",
"cloud_delinquency.post_downgrade_banner.title": "Update your billing information now to re-activate paid features.",
"cloud_signup.signup_consequences": "Your credit card will be charged today. <a>See how billing works.</a>",
"cloud_subscribe.contact_support": "Compare plans",
"cloud_upgrade.error_min_seats": "Minimum of 10 seats required",

View File

@ -27,3 +27,8 @@ export const fakeDate = (expected: Date): () => void => {
global.Date = OGDate;
};
};
export const unixTimestampFromNow = (daysFromNow: number) => {
const now = new Date();
return Math.ceil(new Date(now.getTime() + (daysFromNow * 24 * 60 * 60 * 1000)).getTime() / 1000);
};

View File

@ -36,3 +36,10 @@ export function isCloudFreePlan(product: Product | undefined, limits: Limits): b
}
export const FREEMIUM_TO_ENTERPRISE_TRIAL_LENGTH_DAYS = 30;
export function daysToExpiration(expirationDate: number): number {
const now = new Date();
const expiration = new Date(expirationDate);
const diff = expiration.getTime() - now.getTime();
return Math.ceil(diff / (1000 * 3600 * 24));
}

View File

@ -148,6 +148,7 @@ export const Preferences = {
OVERAGE_USERS_BANNER: 'overage_users_banner',
TO_CLOUD_YEARLY_PLAN_NUDGE: 'to_cloud_yearly_plan_nudge',
TO_PAID_PLAN_NUDGE: 'to_paid_plan_nudge',
CLOUD_ANNUAL_RENEWAL_BANNER: 'cloud_annual_renewal_banner',
};
// For one off things that have a special, attention-grabbing UI until you interact with them
@ -719,6 +720,8 @@ export const CloudBanners = {
THREE_DAYS_LEFT_TRIAL_MODAL_DISMISSED: 'dismiss_3_days_left_trial_modal',
NUDGE_TO_CLOUD_YEARLY_PLAN_SNOOZED: 'nudge_to_cloud_yearly_plan_snoozed',
NUDGE_TO_PAID_PLAN_SNOOZED: 'nudge_to_paid_plan_snoozed',
ANNUAL_RENEWAL_60_DAY: 'annual_renewal_60_day',
ANNUAL_RENEWAL_30_DAY: 'annual_renewal_30_day',
};
export const ConfigurationBanners = {

View File

@ -49,6 +49,8 @@ export type Subscription = {
delinquent_since?: number;
compliance_blocked?: string;
billing_type?: string;
cancel_at?: number;
will_renew?: string;
}
export type Product = {