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 = {