From 8b07655a6576dbeae837aa2d3482116f6c817b5a Mon Sep 17 00:00:00 2001 From: Nick Misasi Date: Wed, 29 Nov 2023 09:10:17 -0500 Subject: [PATCH] [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 --- server/channels/api4/cloud.go | 10 +- server/channels/api4/cloud_test.go | 2 +- server/public/model/cloud.go | 2 + server/public/model/feature_flags.go | 3 + .../billing_subscriptions.test.tsx | 108 +++++ .../billing_subscriptions.tsx | 62 ++- .../billing/billing_subscriptions/index.tsx | 2 + .../announcement_bar_controller.tsx | 6 + ...d_annual_renewal_announcement_bar.test.tsx | 374 ++++++++++++++++++ .../cloud_annual_renewal/index.tsx | 132 +++++++ ...loud_delinquency_announcement_bar.test.tsx | 82 ++-- .../cloud_delinquency/index.tsx | 82 ++-- webapp/channels/src/i18n/en.json | 11 +- webapp/channels/src/tests/helpers/date.ts | 5 + webapp/channels/src/utils/cloud_utils.ts | 7 + webapp/channels/src/utils/constants.tsx | 3 + webapp/platform/types/src/cloud.ts | 2 + 17 files changed, 788 insertions(+), 105 deletions(-) create mode 100644 webapp/channels/src/components/admin_console/billing/billing_subscriptions/billing_subscriptions.test.tsx create mode 100644 webapp/channels/src/components/announcement_bar/cloud_annual_renewal/cloud_annual_renewal_announcement_bar.test.tsx create mode 100644 webapp/channels/src/components/announcement_bar/cloud_annual_renewal/index.tsx diff --git a/server/channels/api4/cloud.go b/server/channels/api4/cloud.go index f0590494d6..58b06dc9c2 100644 --- a/server/channels/api4/cloud.go +++ b/server/channels/api4/cloud.go @@ -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) diff --git a/server/channels/api4/cloud_test.go b/server/channels/api4/cloud_test.go index 90aabc3a38..376f209267 100644 --- a/server/channels/api4/cloud_test.go +++ b/server/channels/api4/cloud_test.go @@ -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", diff --git a/server/public/model/cloud.go b/server/public/model/cloud.go index 737b26baf5..1d9d169171 100644 --- a/server/public/model/cloud.go +++ b/server/public/model/cloud.go @@ -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 diff --git a/server/public/model/feature_flags.go b/server/public/model/feature_flags.go index a34f07d851..e09e0a425c 100644 --- a/server/public/model/feature_flags.go +++ b/server/public/model/feature_flags.go @@ -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 diff --git a/webapp/channels/src/components/admin_console/billing/billing_subscriptions/billing_subscriptions.test.tsx b/webapp/channels/src/components/admin_console/billing/billing_subscriptions/billing_subscriptions.test.tsx new file mode 100644 index 0000000000..f7f3995a20 --- /dev/null +++ b/webapp/channels/src/components/admin_console/billing/billing_subscriptions/billing_subscriptions.test.tsx @@ -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(, 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(, 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(, 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(, 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(); + }); +}); diff --git a/webapp/channels/src/components/admin_console/billing/billing_subscriptions/billing_subscriptions.tsx b/webapp/channels/src/components/admin_console/billing/billing_subscriptions/billing_subscriptions.tsx index add38033e2..0d5b3efcd1 100644 --- a/webapp/channels/src/components/admin_console/billing/billing_subscriptions/billing_subscriptions.tsx +++ b/webapp/channels/src/components/admin_console/billing/billing_subscriptions/billing_subscriptions.tsx @@ -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 = ( + + ); + + const contactSalesButton = ( + + ); + + 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 ( + + + ); +}; diff --git a/webapp/channels/src/components/admin_console/billing/billing_subscriptions/index.tsx b/webapp/channels/src/components/admin_console/billing/billing_subscriptions/index.tsx index 0b3cd9079d..8cc9382d14 100644 --- a/webapp/channels/src/components/admin_console/billing/billing_subscriptions/index.tsx +++ b/webapp/channels/src/components/admin_console/billing/billing_subscriptions/index.tsx @@ -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()} + {} {} {} {showCreditCardBanner && diff --git a/webapp/channels/src/components/announcement_bar/announcement_bar_controller.tsx b/webapp/channels/src/components/announcement_bar/announcement_bar_controller.tsx index 9eacc76f99..a16726cd3d 100644 --- a/webapp/channels/src/components/announcement_bar/announcement_bar_controller.tsx +++ b/webapp/channels/src/components/announcement_bar/announcement_bar_controller.tsx @@ -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 { 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 { cloudDelinquencyAnnouncementBar = ( ); + cloudRenewalAnnouncementBar = ( + + ); notifyAdminDowngradeDelinquencyBar = ( ); @@ -108,6 +113,7 @@ class AnnouncementBarController extends React.PureComponent { {cloudTrialAnnouncementBar} {cloudTrialEndAnnouncementBar} {cloudDelinquencyAnnouncementBar} + {cloudRenewalAnnouncementBar} {notifyAdminDowngradeDelinquencyBar} {toYearlyNudgeBannerDismissable} {toPaidPlanNudgeBannerDismissable} diff --git a/webapp/channels/src/components/announcement_bar/cloud_annual_renewal/cloud_annual_renewal_announcement_bar.test.tsx b/webapp/channels/src/components/announcement_bar/cloud_annual_renewal/cloud_annual_renewal_announcement_bar.test.tsx new file mode 100644 index 0000000000..032c63d7ff --- /dev/null +++ b/webapp/channels/src/components/announcement_bar/cloud_annual_renewal/cloud_annual_renewal_announcement_bar.test.tsx @@ -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( + , + 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( + , + 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( + , + 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( + , + 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( + , + 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( + , + 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( + , + 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( + , + 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( + , + 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( + , + 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( + , + 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( + , + 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( + , + 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( + , + 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( + , + state, + ); + + expect(queryByText('Your annual subscription expires in')).not.toBeInTheDocument(); + }); +}); diff --git a/webapp/channels/src/components/announcement_bar/cloud_annual_renewal/index.tsx b/webapp/channels/src/components/announcement_bar/cloud_annual_renewal/index.tsx new file mode 100644 index 0000000000..198660a80c --- /dev/null +++ b/webapp/channels/src/components/announcement_bar/cloud_annual_renewal/index.tsx @@ -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: , + 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: (), + 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: (), + 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: (), + type: AnnouncementBarTypes.CRITICAL, + showCloseButton: false, + }; + } + + return ; + }, [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; diff --git a/webapp/channels/src/components/announcement_bar/cloud_delinquency/cloud_delinquency_announcement_bar.test.tsx b/webapp/channels/src/components/announcement_bar/cloud_delinquency/cloud_delinquency_announcement_bar.test.tsx index 834ec5c661..565aaddb1a 100644 --- a/webapp/channels/src/components/announcement_bar/cloud_delinquency/cloud_delinquency_announcement_bar.test.tsx +++ b/webapp/channels/src/components/announcement_bar/cloud_delinquency/cloud_delinquency_announcement_bar.test.tsx @@ -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( - - - , + const {queryByText} = renderWithContext( + , + 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( + , + 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( - - - , + const {queryByText, getByText} = renderWithContext( + , + 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( - - - , + const {getByText} = renderWithContext( + , + 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( - - - , - ); - - 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(); }); }); diff --git a/webapp/channels/src/components/announcement_bar/cloud_delinquency/index.tsx b/webapp/channels/src/components/announcement_bar/cloud_delinquency/index.tsx index 7208449524..ef55415ca9 100644 --- a/webapp/channels/src/components/announcement_bar/cloud_delinquency/index.tsx +++ b/webapp/channels/src/components/announcement_bar/cloud_delinquency/index.tsx @@ -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: , + 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 ( { - 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={} - showLinkAsButton={true} - isTallBanner={true} - icon={} + {...props} /> ); }; diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index 9afc9f3e6a..c024da1d4c 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -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 update your payment information 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. See how billing works.", "cloud_subscribe.contact_support": "Compare plans", "cloud_upgrade.error_min_seats": "Minimum of 10 seats required", diff --git a/webapp/channels/src/tests/helpers/date.ts b/webapp/channels/src/tests/helpers/date.ts index 4615095ec7..a9e1ed73b5 100644 --- a/webapp/channels/src/tests/helpers/date.ts +++ b/webapp/channels/src/tests/helpers/date.ts @@ -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); +}; diff --git a/webapp/channels/src/utils/cloud_utils.ts b/webapp/channels/src/utils/cloud_utils.ts index 9422f45ea3..7f8384f68d 100644 --- a/webapp/channels/src/utils/cloud_utils.ts +++ b/webapp/channels/src/utils/cloud_utils.ts @@ -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)); +} diff --git a/webapp/channels/src/utils/constants.tsx b/webapp/channels/src/utils/constants.tsx index 6c70a57cb7..67dacd40d8 100644 --- a/webapp/channels/src/utils/constants.tsx +++ b/webapp/channels/src/utils/constants.tsx @@ -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 = { diff --git a/webapp/platform/types/src/cloud.ts b/webapp/platform/types/src/cloud.ts index 79811a5525..5e78ac4716 100644 --- a/webapp/platform/types/src/cloud.ts +++ b/webapp/platform/types/src/cloud.ts @@ -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 = {