[CLD-6538] Renewal Card for Cloud Purchase Modal, remove unnecessary components (#25606)

* Add renewal card component to purchase modal, remove some unneeded components

* Add back commented code

* Fixes for pipelines

* Delinquency modal skips invoice summary table if there is only one invoice

* Updates

* Fixes from ms precision changes

* Fixes for tests after ms precision changes

* A couple more occurrences

* Removal of a bunch of code that's no longer necessary

* Fix linter

* Fix i18n

* Fix

* More fixing

* Updates

* Updates based on PR feedback

---------

Co-authored-by: Mattermost Build <build@mattermost.com>
This commit is contained in:
Nick Misasi
2024-01-10 14:19:29 -05:00
committed by GitHub
parent 978f335925
commit 1d108f0d9f
42 changed files with 945 additions and 2552 deletions

View File

@@ -228,6 +228,8 @@ type InvoiceLineItem struct {
Description string `json:"description"`
Type string `json:"type"`
Metadata map[string]any `json:"metadata"`
PeriodStart int64 `json:"period_start"`
PeriodEnd int64 `json:"period_end"`
}
type DelinquencyEmailTrigger struct {

View File

@@ -15,7 +15,6 @@ import type {ActionResult} from 'mattermost-redux/types/actions';
import SchemaAdminSettings from 'components/admin_console/schema_admin_settings';
import AnnouncementBarController from 'components/announcement_bar';
import BackstageNavbar from 'components/backstage/components/backstage_navbar';
import DelinquencyModal from 'components/delinquency_modal';
import DiscardChangesModal from 'components/discard_changes_modal';
import ModalController from 'components/modal_controller';
import SystemNotice from 'components/system_notice';
@@ -229,7 +228,6 @@ export default class AdminConsole extends React.PureComponent<Props, State> {
</Highlight>
</div>
{discardChangesModal}
<DelinquencyModal/>
<ModalController/>
</>
);

View File

@@ -7,7 +7,7 @@ import React from 'react';
import {FormattedMessage} from 'react-intl';
import {AccountMultipleOutlineIcon, ChartBarIcon, CogOutlineIcon, CreditCardOutlineIcon, FlaskOutlineIcon, FormatListBulletedIcon, InformationOutlineIcon, PowerPlugOutlineIcon, ServerVariantIcon, ShieldOutlineIcon, SitemapIcon} from '@mattermost/compass-icons/components';
import type {CloudState, Product, Limits} from '@mattermost/types/cloud';
import type {CloudState, Product} from '@mattermost/types/cloud';
import type {AdminConfig, ClientLicense} from '@mattermost/types/config';
import type {Job} from '@mattermost/types/jobs';
import type {DeepPartial} from '@mattermost/types/utilities';
@@ -32,7 +32,6 @@ import TeamAnalytics from 'components/analytics/team_analytics';
import ExternalLink from 'components/external_link';
import RestrictedIndicator from 'components/widgets/menu/menu_items/restricted_indicator';
import {isCloudFreePlan} from 'utils/cloud_utils';
import {Constants, CloudProducts, LicenseSkus, AboutLinks, DocLinks, DeveloperLinks} from 'utils/constants';
import {t} from 'utils/i18n';
import {isCloudLicense} from 'utils/license_utils';
@@ -222,10 +221,7 @@ export const it = {
if (!productId) {
return false;
}
const limits = cloud.limits || {};
const subscriptionProduct = cloud.products?.[productId];
const isCloudFreeProduct = isCloudFreePlan(subscriptionProduct, limits as Limits);
return cloud?.subscription?.is_free_trial === 'true' || isCloudFreeProduct;
return cloud?.subscription?.is_free_trial === 'true';
},
userHasReadPermissionOnResource: (key: string) => (config: DeepPartial<AdminConfig>, state: any, license?: ClientLicense, enterpriseReady?: boolean, consoleAccess?: ConsoleAccess) => (consoleAccess?.read as any)?.[key],
userHasReadPermissionOnSomeResources: (key: string | {[key: string]: string}) => Object.values(key).some((resource) => it.userHasReadPermissionOnResource(resource)),

View File

@@ -32,6 +32,8 @@ const invoiceA = TestHelper.getInvoiceMock({
'1 × Cloud Professional (at $10.00 / month)',
type: 'onpremise',
metadata: {},
period_end: 1642330466000,
period_start: 1643540066000,
},
],
});
@@ -56,6 +58,8 @@ const invoiceB = TestHelper.getInvoiceMock({
'Trial period for Cloud Professional',
type: 'onpremise',
metadata: {},
period_end: 1642330466000,
period_start: 1643540066000,
},
],
});

View File

@@ -69,7 +69,7 @@ describe('CloudAnnualRenewalBanner', () => {
};
const {getByText} = renderWithContext(<CloudAnnualRenewalBanner/>, state);
expect(getByText(/Your annual subscription expires in 31 days. Please renew now to avoid any disruption/)).toBeInTheDocument();
expect(getByText(/Your annual subscription expires in 30 days. Please renew now to avoid any disruption/)).toBeInTheDocument();
expect(getByText(/Renew/)).toBeInTheDocument();
expect(getByText(/Contact Sales/)).toBeInTheDocument();
@@ -85,7 +85,7 @@ describe('CloudAnnualRenewalBanner', () => {
};
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(/Your annual subscription expires in 4 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();
@@ -100,7 +100,7 @@ describe('CloudAnnualRenewalBanner', () => {
};
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(/Your subscription has expired. Your workspace will be deleted 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();

View File

@@ -61,16 +61,15 @@ 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) {
if (!subscription || !subscription.cancel_at || (subscription.will_renew === 'true' && !subscription.delinquent_since)) {
return null;
}
const daysUntilExpiration = daysToExpiration(subscription?.end_at * 1000);
const daysUntilCancelation = daysToExpiration(subscription?.cancel_at * 1000);
const daysUntilExpiration = daysToExpiration(subscription?.end_at);
const daysUntilCancelation = daysToExpiration(subscription?.cancel_at);
const renewButton = (
<button
className='btn btn-primary'

View File

@@ -18,7 +18,6 @@ import type {DispatchFunc} from 'mattermost-redux/types/actions';
import {pageVisited} from 'actions/telemetry_actions';
import DeleteWorkspaceCTA from 'components/admin_console/billing//delete_workspace/delete_workspace_cta';
import CloudTrialBanner from 'components/admin_console/billing/billing_subscriptions/cloud_trial_banner';
import CloudFetchError from 'components/cloud_fetch_error';
import useGetLimits from 'components/common/hooks/useGetLimits';
@@ -28,8 +27,6 @@ import AdminHeader from 'components/widgets/admin_console/admin_header';
import {isCustomerCardExpired} from 'utils/cloud_utils';
import {
CloudProducts,
RecurringIntervals,
TrialPeriodDays,
} from 'utils/constants';
import {useQuery} from 'utils/http_utils';
@@ -46,7 +43,6 @@ import ContactSalesCard from './contact_sales_card';
import LimitReachedBanner from './limit_reached_banner';
import Limits from './limits';
import {ToPaidNudgeBanner} from './to_paid_plan_nudge_banner';
import {ToYearlyNudgeBanner} from './to_yearly_nudge_banner';
import BillingSummary from '../billing_summary';
import PlanDetails from '../plan_details';
@@ -72,7 +68,6 @@ const BillingSubscriptions = () => {
const actionQueryParam = query.get('action');
const product = useSelector(getSubscriptionProduct);
const isAnnualProfessionalOrEnterprise = product?.sku === CloudProducts.ENTERPRISE || (product?.sku === CloudProducts.PROFESSIONAL && product?.recurring_interval === RecurringIntervals.YEAR);
const openPricingModal = useOpenPricingModal();
@@ -140,7 +135,6 @@ const BillingSubscriptions = () => {
/>
{shouldShowPaymentFailedBanner() && paymentFailedBanner()}
{<CloudAnnualRenewalBanner/>}
{<ToYearlyNudgeBanner/>}
{<ToPaidNudgeBanner/>}
{showCreditCardBanner &&
isCardExpired &&
@@ -166,7 +160,7 @@ const BillingSubscriptions = () => {
onUpgradeMattermostCloud={openPricingModal}
/>
)}
{isAnnualProfessionalOrEnterprise && !isFreeTrial ? <CancelSubscription/> : <DeleteWorkspaceCTA/>}
<CancelSubscription/>
</>}
</div>
</div>

View File

@@ -1,28 +0,0 @@
@import 'utils/mixins';
.ToYearlyNudgeBanner {
&__actions {
padding-top: 12px;
}
&__primary {
font-size: 12px;
@include primary-button;
&:hover {
color: var(--button-color);
}
}
&__secondary {
margin-left: 4px;
font-size: 12px;
@include tertiary-button;
&:hover {
color: var(--button-bg);
}
}
}

View File

@@ -1,345 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {renderWithContext, screen, waitFor} from 'tests/react_testing_utils';
import {CloudProducts, RecurringIntervals} from 'utils/constants';
import {ToYearlyNudgeBanner, ToYearlyNudgeBannerDismissable} from './to_yearly_nudge_banner';
const initialState = {
views: {
announcementBar: {
announcementBarState: {
announcementBarCount: 1,
},
},
},
entities: {
general: {
config: {
CWSURL: '',
},
license: {
IsLicensed: 'true',
Cloud: 'true',
},
},
users: {
currentUserId: 'current_user_id',
profiles: {
current_user_id: {roles: 'system_user'},
},
},
preferences: {
myPreferences: {},
},
cloud: {},
},
};
describe('ToYearlyNudgeBannerDismissable', () => {
test('should show for admins cloud professional monthly', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.users.profiles = {
current_user_id: {roles: 'system_admin'},
};
state.entities.cloud = {
subscription: {
product_id: 'prod_professional',
is_free_trial: 'false',
trial_end_at: 1,
},
products: {
prod_professional: {
id: 'prod_professional',
sku: CloudProducts.PROFESSIONAL,
recurring_interval: RecurringIntervals.MONTH,
},
},
};
renderWithContext(<ToYearlyNudgeBannerDismissable/>, state, {useMockedStore: true});
screen.getByTestId('cloud-pro-monthly-deprecation-announcement-bar');
});
test('should NOT show for NON admins', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.users.profiles = {
current_user_id: {roles: 'system_user'},
};
state.entities.cloud = {
subscription: {
product_id: 'prod_professional',
is_free_trial: 'false',
trial_end_at: 1,
},
products: {
prod_professional: {
id: 'prod_professional',
sku: CloudProducts.PROFESSIONAL,
recurring_interval: RecurringIntervals.MONTH,
},
},
};
renderWithContext(<ToYearlyNudgeBannerDismissable/>, state, {useMockedStore: true});
expect(() => screen.getByTestId('cloud-pro-monthly-deprecation-announcement-bar')).toThrow();
});
test('should NOT show for admins on cloud free', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.users.profiles = {
current_user_id: {roles: 'system_admin'},
};
state.entities.cloud = {
subscription: {
product_id: 'prod_starter',
is_free_trial: 'false',
trial_end_at: 1,
},
products: {
prod_starter: {
id: 'prod_starter',
sku: CloudProducts.STARTER,
recurring_interval: RecurringIntervals.MONTH,
},
},
};
renderWithContext(<ToYearlyNudgeBannerDismissable/>, state, {useMockedStore: true});
expect(() => screen.getByTestId('cloud-pro-monthly-deprecation-announcement-bar')).toThrow();
});
test('should NOT show for admins on cloud enterprise', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.users.profiles = {
current_user_id: {roles: 'system_admin'},
};
state.entities.cloud = {
subscription: {
product_id: 'prod_enterprise',
is_free_trial: 'false',
trial_end_at: 1,
},
products: {
prod_enterprise: {
id: 'prod_enterprise',
sku: CloudProducts.ENTERPRISE,
recurring_interval: RecurringIntervals.MONTH,
},
},
};
renderWithContext(<ToYearlyNudgeBannerDismissable/>, state, {useMockedStore: true});
expect(() => screen.getByTestId('cloud-pro-monthly-deprecation-announcement-bar')).toThrow();
});
test('should NOT show for admins on cloud pro annual', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.users.profiles = {
current_user_id: {roles: 'system_admin'},
};
state.entities.cloud = {
subscription: {
product_id: 'prod_pro',
is_free_trial: 'false',
trial_end_at: 1,
},
products: {
prod_pro: {
id: 'prod_pro',
sku: CloudProducts.PROFESSIONAL,
recurring_interval: RecurringIntervals.YEAR,
},
},
};
renderWithContext(<ToYearlyNudgeBannerDismissable/>, state, {useMockedStore: true});
expect(() => screen.getByTestId('cloud-pro-monthly-deprecation-announcement-bar')).toThrow();
});
test('should NOT show for admins when banner was dismissed in preferences', async () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.users.profiles = {
current_user_id: {roles: 'system_admin'},
};
state.entities.preferences = {
myPreferences: {
'to_cloud_yearly_plan_nudge--nudge_to_cloud_yearly_plan_snoozed': {
category: 'to_cloud_yearly_plan_nudge',
name: 'nudge_to_cloud_yearly_plan_snoozed',
value: '{"range": 0, "show": false}',
},
},
};
state.entities.cloud = {
subscription: {
product_id: 'prod_professional',
is_free_trial: 'false',
trial_end_at: 1,
},
products: {
prod_professional: {
id: 'prod_professional',
sku: CloudProducts.PROFESSIONAL,
recurring_interval: RecurringIntervals.MONTH,
},
},
};
await waitFor(() => {
renderWithContext(<ToYearlyNudgeBannerDismissable/>, state, {useMockedStore: true});
});
expect(() => screen.getByTestId('cloud-pro-monthly-deprecation-announcement-bar')).toThrow();
});
test('should NOT show when subscription has billing type of internal', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.users.profiles = {
current_user_id: {roles: 'system_admin'},
};
state.entities.cloud = {
subscription: {
product_id: 'prod_professional',
is_free_trial: 'false',
trial_end_at: 1,
billing_type: 'internal',
},
products: {
prod_professional: {
id: 'prod_professional',
sku: CloudProducts.PROFESSIONAL,
recurring_interval: RecurringIntervals.MONTH,
},
},
};
renderWithContext(<ToYearlyNudgeBannerDismissable/>, state, {useMockedStore: true});
expect(() => screen.getByTestId('cloud-pro-monthly-deprecation-announcement-bar')).toThrow();
});
test('should NOT show when subscription has billing type of licensed', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.users.profiles = {
current_user_id: {roles: 'system_admin'},
};
state.entities.cloud = {
subscription: {
product_id: 'prod_professional',
is_free_trial: 'false',
trial_end_at: 1,
billing_type: 'licensed',
},
products: {
prod_professional: {
id: 'prod_professional',
sku: CloudProducts.PROFESSIONAL,
recurring_interval: RecurringIntervals.MONTH,
},
},
};
renderWithContext(<ToYearlyNudgeBannerDismissable/>, state, {useMockedStore: true});
expect(() => screen.getByTestId('cloud-pro-monthly-deprecation-announcement-bar')).toThrow();
});
});
describe('ToYearlyNudgeBanner', () => {
test('should show for cloud professional monthly', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.cloud = {
subscription: {
product_id: 'prod_professional',
is_free_trial: 'false',
trial_end_at: 1,
},
products: {
prod_professional: {
id: 'prod_professional',
sku: CloudProducts.PROFESSIONAL,
recurring_interval: RecurringIntervals.MONTH,
},
},
};
renderWithContext(<ToYearlyNudgeBanner/>, state, {useMockedStore: true});
screen.getByTestId('cloud-pro-monthly-deprecation-alert-banner');
});
test('should NOT show for non cloud professional monthly', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.cloud = {
subscription: {
product_id: 'prod_starter',
is_free_trial: 'false',
trial_end_at: 1,
},
products: {
prod_starter: {
id: 'prod_starter',
sku: CloudProducts.STARTER,
recurring_interval: RecurringIntervals.MONTH,
},
},
};
renderWithContext(<ToYearlyNudgeBanner/>, state, {useMockedStore: true});
expect(() => screen.getByTestId('cloud-pro-monthly-deprecation-alert-banner')).toThrow();
});
test('should NOT show when subscription has billing type of internal', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.cloud = {
subscription: {
product_id: 'prod_professional',
is_free_trial: 'false',
trial_end_at: 1,
billing_type: 'internal',
},
products: {
prod_professional: {
id: 'prod_professional',
sku: CloudProducts.PROFESSIONAL,
recurring_interval: RecurringIntervals.MONTH,
},
},
};
renderWithContext(<ToYearlyNudgeBanner/>, state, {useMockedStore: true});
expect(() => screen.getByTestId('cloud-pro-monthly-deprecation-alert-banner')).toThrow();
});
test('should NOT show when subscription has billing type of licensed', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.cloud = {
subscription: {
product_id: 'prod_professional',
is_free_trial: 'false',
trial_end_at: 1,
billing_type: 'licensed',
},
products: {
prod_professional: {
id: 'prod_professional',
sku: CloudProducts.PROFESSIONAL,
recurring_interval: RecurringIntervals.MONTH,
},
},
};
renderWithContext(<ToYearlyNudgeBanner/>, state, {useMockedStore: true});
expect(() => screen.getByTestId('cloud-pro-monthly-deprecation-alert-banner')).toThrow();
});
});

View File

@@ -1,250 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import moment from 'moment';
import React, {useEffect} from 'react';
import {useIntl, FormattedMessage} from 'react-intl';
import {useDispatch, useSelector} from 'react-redux';
import type {GlobalState} from '@mattermost/types/store';
import {savePreferences} from 'mattermost-redux/actions/preferences';
import {getSubscriptionProduct as selectSubscriptionProduct, getCloudSubscription as selectCloudSubscription} from 'mattermost-redux/selectors/entities/cloud';
import {get as getPreference} from 'mattermost-redux/selectors/entities/preferences';
import {getCurrentUser, isCurrentUserSystemAdmin} from 'mattermost-redux/selectors/entities/users';
import AlertBanner from 'components/alert_banner';
import AnnouncementBar from 'components/announcement_bar/default_announcement_bar';
import useOpenCloudPurchaseModal from 'components/common/hooks/useOpenCloudPurchaseModal';
import useOpenSalesLink from 'components/common/hooks/useOpenSalesLink';
import {AnnouncementBarTypes, CloudBanners, CloudProducts, Preferences, RecurringIntervals, CloudBillingTypes} from 'utils/constants';
import {t} from 'utils/i18n';
import './to_yearly_nudge_banner.scss';
enum DismissShowRange {
GreaterThanEqual90 = '>=90',
BetweenNinetyAnd60 = '89-61',
SixtyTo31 = '60-31',
ThirtyTo11 = '30-11',
TenTo1 = '10-1',
Zero = '0'
}
const cloudProMonthlyCloseMoment = '20230727';
interface ToYearlyPlanDismissPreference {
// range represents the range for the days to the deprecation of cloud free e.g. in 30 to 10 days to deprecate cloud free
// Incase of dismissing the banner, range represents the time (days) period when this banner was dismissed.
// This is important because in case the banner was dismissed for a certain period, it helps us know that we should not show it again for that period.
range: DismissShowRange;
show: boolean;
}
const ToYearlyNudgeBannerDismissable = () => {
const dispatch = useDispatch();
const openPurchaseModal = useOpenCloudPurchaseModal({});
const snoozePreferenceVal = useSelector((state: GlobalState) => getPreference(state, Preferences.TO_CLOUD_YEARLY_PLAN_NUDGE, CloudBanners.NUDGE_TO_CLOUD_YEARLY_PLAN_SNOOZED, '{"range": 0, "show": true}'));
const snoozeInfo = JSON.parse(snoozePreferenceVal) as ToYearlyPlanDismissPreference;
const show = snoozeInfo.show;
const currentUser = useSelector(getCurrentUser);
const subscription = useSelector(selectCloudSubscription);
const isAdmin = useSelector(isCurrentUserSystemAdmin);
const product = useSelector(selectSubscriptionProduct);
const currentProductProfessional = product?.sku === CloudProducts.PROFESSIONAL;
const currentProductIsMonthly = product?.recurring_interval === RecurringIntervals.MONTH;
const currentProductProMonthly = currentProductProfessional && currentProductIsMonthly;
const now = moment(Date.now());
const proMonthlyEndDate = moment(cloudProMonthlyCloseMoment, 'YYYYMMDD');
const daysToProMonthlyEnd = proMonthlyEndDate.diff(now, 'days');
const snoozedForRange = (range: DismissShowRange) => {
return snoozeInfo.range === range;
};
useEffect(() => {
if (!snoozeInfo.show) {
if (daysToProMonthlyEnd >= 90 && !snoozedForRange(DismissShowRange.GreaterThanEqual90)) {
showBanner(true);
}
if (daysToProMonthlyEnd < 90 && daysToProMonthlyEnd > 60 && !snoozedForRange(DismissShowRange.BetweenNinetyAnd60)) {
showBanner(true);
}
if (daysToProMonthlyEnd <= 60 && daysToProMonthlyEnd > 30 && !snoozedForRange(DismissShowRange.SixtyTo31)) {
showBanner(true);
}
if (daysToProMonthlyEnd <= 30 && daysToProMonthlyEnd > 10 && !snoozedForRange(DismissShowRange.ThirtyTo11)) {
showBanner(true);
}
if (daysToProMonthlyEnd <= 10) {
showBanner(true);
}
}
}, []);
const showBanner = (show = false) => {
let dRange = DismissShowRange.Zero;
if (daysToProMonthlyEnd >= 90) {
dRange = DismissShowRange.GreaterThanEqual90;
}
if (daysToProMonthlyEnd < 90 && daysToProMonthlyEnd > 60) {
dRange = DismissShowRange.BetweenNinetyAnd60;
}
if (daysToProMonthlyEnd <= 60 && daysToProMonthlyEnd > 30) {
dRange = DismissShowRange.SixtyTo31;
}
if (daysToProMonthlyEnd <= 30 && daysToProMonthlyEnd > 10) {
dRange = DismissShowRange.ThirtyTo11;
}
// ideally this case should not happen because snooze button is not shown when TenTo1 days are remaining
if (daysToProMonthlyEnd <= 10 && daysToProMonthlyEnd > 0) {
dRange = DismissShowRange.TenTo1;
}
const snoozeInfo: ToYearlyPlanDismissPreference = {
range: dRange,
show,
};
dispatch(savePreferences(currentUser.id, [{
category: Preferences.TO_CLOUD_YEARLY_PLAN_NUDGE,
name: CloudBanners.NUDGE_TO_CLOUD_YEARLY_PLAN_SNOOZED,
user_id: currentUser.id,
value: JSON.stringify(snoozeInfo),
}]));
};
if (!show) {
return null;
}
if (!isAdmin) {
return null;
}
if (!currentProductProMonthly) {
return null;
}
if (subscription?.billing_type === CloudBillingTypes.INTERNAL || subscription?.billing_type === CloudBillingTypes.LICENSED) {
return null;
}
const message = (
<FormattedMessage
id='cloud_billing.nudge_to_yearly.announcement_bar'
defaultMessage='Monthly billing will be discontinued in {days} days . Switch to annual billing'
values={{
days: daysToProMonthlyEnd,
}}
/>
);
const announcementType = (daysToProMonthlyEnd <= 10) ? AnnouncementBarTypes.CRITICAL : AnnouncementBarTypes.ANNOUNCEMENT;
return (
<AnnouncementBar
id='cloud-pro-monthly-deprecation-announcement-bar'
type={announcementType}
showCloseButton={daysToProMonthlyEnd > 10}
onButtonClick={() => openPurchaseModal({trackingLocation: 'to_yearly_nudge_annoucement_bar'})}
modalButtonText={t('cloud_billing.nudge_to_yearly.update_billing')}
modalButtonDefaultText='Update billing'
message={message}
showLinkAsButton={true}
handleClose={showBanner}
/>
);
};
const ToYearlyNudgeBanner = () => {
const {formatMessage} = useIntl();
const [openSalesLink] = useOpenSalesLink();
const openPurchaseModal = useOpenCloudPurchaseModal({});
const subscription = useSelector(selectCloudSubscription);
const product = useSelector(selectSubscriptionProduct);
const currentProductProfessional = product?.sku === CloudProducts.PROFESSIONAL;
const currentProductIsMonthly = product?.recurring_interval === RecurringIntervals.MONTH;
const currentProductProMonthly = currentProductProfessional && currentProductIsMonthly;
if (!currentProductProMonthly) {
return null;
}
if (subscription?.billing_type === CloudBillingTypes.INTERNAL || subscription?.billing_type === CloudBillingTypes.LICENSED) {
return null;
}
const now = moment(Date.now());
const proMonthlyEndDate = moment(cloudProMonthlyCloseMoment, 'YYYYMMDD');
const daysToProMonthlyEnd = proMonthlyEndDate.diff(now, 'days');
const title = (
<FormattedMessage
id='cloud_billing.nudge_to_yearly.title'
defaultMessage='Action required: Switch to annual billing to keep your workspace.'
/>
);
const description = (
<FormattedMessage
id='cloud_billing.nudge_to_yearly.description'
defaultMessage='Monthly billing will be discontinued on {date}. To keep your workspace, switch to annual billing.'
values={{date: moment(cloudProMonthlyCloseMoment, 'YYYYMMDD').format('MMMM DD, YYYY')}}
/>
);
const viewPlansAction = (
<button
onClick={() => openPurchaseModal({trackingLocation: 'to_yearly_nudge_banner'})}
className='btn ToYearlyNudgeBanner__primary'
>
{formatMessage({id: 'cloud_billing.nudge_to_yearly.learn_more', defaultMessage: 'Learn more'})}
</button>
);
const contactSalesAction = (
<button
onClick={openSalesLink}
className='btn ToYearlyNudgeBanner__secondary'
>
{formatMessage({id: 'cloud_billing.nudge_to_yearly.contact_sales', defaultMessage: 'Contact sales'})}
</button>
);
const bannerMode = (daysToProMonthlyEnd <= 10) ? 'danger' : 'info';
return (
<AlertBanner
id='cloud-pro-monthly-deprecation-alert-banner'
mode={bannerMode}
title={title}
message={description}
className='ToYearlyNudgeBanner'
actionButtonLeft={viewPlansAction}
actionButtonRight={contactSalesAction}
/>
);
};
export {
ToYearlyNudgeBanner,
ToYearlyNudgeBannerDismissable,
};

View File

@@ -87,6 +87,10 @@
margin: 0 auto;
}
}
span {
margin-left: 4px;
}
}
.BillingSummary__lastInvoice-date {

View File

@@ -5,7 +5,7 @@ import React from 'react';
import {FormattedDate, FormattedMessage, FormattedNumber} from 'react-intl';
import {useDispatch} from 'react-redux';
import {CheckCircleOutlineIcon} from '@mattermost/compass-icons/components';
import {CheckCircleOutlineIcon, CheckIcon, ClockOutlineIcon} from '@mattermost/compass-icons/components';
import type {Invoice, InvoiceLineItem, Product} from '@mattermost/types/cloud';
import {Client4} from 'mattermost-redux/client';
@@ -123,36 +123,47 @@ export const freeTrial = (onUpgradeMattermostCloud: (callerInfo: string) => void
</div>
);
export const getPaymentStatus = (status: string) => {
export const getPaymentStatus = (status: string, willRenew?: boolean) => {
if (willRenew) {
return (
<div className='BillingSummary__lastInvoice-headerStatus paid'>
<CheckIcon/> {' '}
<FormattedMessage
id='admin.billing.subscriptions.billing_summary.lastInvoice.approved'
defaultMessage='Approved'
/>
</div>
);
}
switch (status.toLowerCase()) {
case 'failed':
return (
<div className='BillingSummary__lastInvoice-headerStatus failed'>
<i className='icon icon-alert-outline'/> {' '}
<FormattedMessage
id='admin.billing.subscriptions.billing_summary.lastInvoice.failed'
defaultMessage='Failed'
/>
<i className='icon icon-alert-outline'/>
</div>
);
case 'paid':
return (
<div className='BillingSummary__lastInvoice-headerStatus paid'>
<CheckCircleOutlineIcon/> {' '}
<FormattedMessage
id='admin.billing.subscriptions.billing_summary.lastInvoice.paid'
defaultMessage='Paid'
/>
<CheckCircleOutlineIcon/>
</div>
);
default:
return (
<div className='BillingSummary__lastInvoice-headerStatus pending'>
<ClockOutlineIcon/> {' '}
<FormattedMessage
id='admin.billing.subscriptions.billing_summary.lastInvoice.pending'
defaultMessage='Pending'
/>
<CheckCircleOutlineIcon/>
</div>
);
}
@@ -164,9 +175,10 @@ type InvoiceInfoProps = {
fullCharges: InvoiceLineItem[];
partialCharges: InvoiceLineItem[];
hasMore?: number;
willRenew?: boolean;
}
export const InvoiceInfo = ({invoice, product, fullCharges, partialCharges, hasMore}: InvoiceInfoProps) => {
export const InvoiceInfo = ({invoice, product, fullCharges, partialCharges, hasMore, willRenew}: InvoiceInfoProps) => {
const dispatch = useDispatch();
const isUpcomingInvoice = invoice?.status.toLowerCase() === 'upcoming';
const openInvoicePreview = () => {
@@ -202,7 +214,7 @@ export const InvoiceInfo = ({invoice, product, fullCharges, partialCharges, hasM
<div className='BillingSummary__lastInvoice-headerTitle'>
{title()}
</div>
{getPaymentStatus(invoice.status)}
{getPaymentStatus(invoice.status, willRenew)}
</div>
<div className='BillingSummary__lastInvoice-date'>
<FormattedDate

View File

@@ -7,6 +7,7 @@ import {useSelector} from 'react-redux';
import {getSubscriptionProduct, checkHadPriorTrial, getCloudSubscription} from 'mattermost-redux/selectors/entities/cloud';
import {cloudReverseTrial} from 'mattermost-redux/selectors/entities/preferences';
import {buildInvoiceSummaryPropsFromLineItems} from 'utils/cloud_utils';
import {CloudProducts} from 'utils/constants';
import {
@@ -59,16 +60,7 @@ const BillingSummary = ({isFreeTrial, daysLeftOnTrial, onUpgradeMattermostCloud}
);
} else if (subscription?.upcoming_invoice) {
const invoice = subscription.upcoming_invoice;
let fullCharges = invoice.line_items.filter((item) => item.type === 'full');
const partialCharges = invoice.line_items.filter((item) => item.type === 'partial');
if (!partialCharges.length && !fullCharges.length) {
fullCharges = invoice.line_items;
}
let hasMoreLineItems = 0;
if (fullCharges.length > 5) {
hasMoreLineItems = fullCharges.length - 5;
fullCharges = fullCharges.slice(0, 5);
}
const {fullCharges, partialCharges, hasMore} = buildInvoiceSummaryPropsFromLineItems(invoice.line_items);
body = (
<InvoiceInfo
@@ -76,7 +68,8 @@ const BillingSummary = ({isFreeTrial, daysLeftOnTrial, onUpgradeMattermostCloud}
product={product}
fullCharges={fullCharges}
partialCharges={partialCharges}
hasMore={hasMoreLineItems}
hasMore={hasMore}
willRenew={subscription?.will_renew === 'true'}
/>
);
}

View File

@@ -32,6 +32,8 @@ function makeInvoice(...lines: Array<[number, typeof InvoiceLineItemType[keyof t
description: '',
type,
metadata: {} as Record<string, string>,
period_end: 1642330466000,
period_start: 1643540066000,
};
if (type === InvoiceLineItemType.Full || type === InvoiceLineItemType.Partial) {
lineItem.metadata.type = type;

View File

@@ -6,7 +6,6 @@ import React from 'react';
import type {ClientLicense, ClientConfig, WarnMetricStatus} from '@mattermost/types/config';
import {ToPaidPlanBannerDismissable} from 'components/admin_console/billing/billing_subscriptions/to_paid_plan_nudge_banner';
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';
@@ -15,7 +14,6 @@ import CloudTrialAnnouncementBar from './cloud_trial_announcement_bar';
import CloudTrialEndAnnouncementBar from './cloud_trial_ended_announcement_bar';
import ConfigurationAnnouncementBar from './configuration_bar';
import AnnouncementBar from './default_announcement_bar';
import NotifyAdminDowngradeDelinquencyBar from './notify_admin_downgrade_delinquency_bar';
import OverageUsersBanner from './overage_users_banner';
import PaymentAnnouncementBar from './payment_announcement_bar';
import AutoStartTrialModal from './show_start_trial_modal/show_start_trial_modal';
@@ -72,8 +70,8 @@ class AnnouncementBarController extends React.PureComponent<Props> {
let cloudTrialEndAnnouncementBar = null;
let cloudDelinquencyAnnouncementBar = null;
let cloudRenewalAnnouncementBar = null;
let notifyAdminDowngradeDelinquencyBar = null;
let toYearlyNudgeBannerDismissable = null;
const notifyAdminDowngradeDelinquencyBar = null;
const toYearlyNudgeBannerDismissable = null;
let toPaidPlanNudgeBannerDismissable = null;
if (this.props.license?.Cloud === 'true') {
paymentAnnouncementBar = (
@@ -91,10 +89,6 @@ class AnnouncementBarController extends React.PureComponent<Props> {
cloudRenewalAnnouncementBar = (
<CloudAnnualRenewalAnnouncementBar/>
);
notifyAdminDowngradeDelinquencyBar = (
<NotifyAdminDowngradeDelinquencyBar/>
);
toYearlyNudgeBannerDismissable = (<ToYearlyNudgeBannerDismissable/>);
toPaidPlanNudgeBannerDismissable = (<ToPaidPlanBannerDismissable/>);
}

View File

@@ -9,7 +9,7 @@ import {CloudBanners, CloudProducts, Preferences} from 'utils/constants';
import CloudAnnualRenewalAnnouncementBar, {getCurrentYearAsString} from './index';
describe('components/announcement_bar/cloud_delinquency', () => {
describe('components/announcement_bar/cloud_annual_renewal', () => {
const initialState = {
views: {
announcementBar: {
@@ -110,7 +110,7 @@ describe('components/announcement_bar/cloud_delinquency', () => {
state,
);
expect(getByText('Your annual subscription expires in 56 days. Please renew to avoid any disruption.')).toBeInTheDocument();
expect(getByText('Your annual subscription expires in 55 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', () => {
@@ -149,7 +149,7 @@ describe('components/announcement_bar/cloud_delinquency', () => {
state,
);
expect(getByText('Your annual subscription expires in 26 days. Please renew to avoid any disruption.')).toBeInTheDocument();
expect(getByText('Your annual subscription expires in 25 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', () => {
@@ -188,7 +188,7 @@ describe('components/announcement_bar/cloud_delinquency', () => {
state,
);
expect(getByText('Your annual subscription expires in 6 days. Failure to renew will result in your workspace being deleted.')).toBeInTheDocument();
expect(getByText('Your annual subscription expires in 5 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', () => {
@@ -309,7 +309,7 @@ describe('components/announcement_bar/cloud_delinquency', () => {
state,
);
expect(queryByText('Your annual subscription expires in 56 days. Please renew to avoid any disruption.')).toBeInTheDocument();
expect(queryByText('Your annual subscription expires in 55 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', () => {
@@ -336,7 +336,7 @@ describe('components/announcement_bar/cloud_delinquency', () => {
state,
);
expect(queryByText('Your annual subscription expires in 26 days. Please renew to avoid any disruption.')).toBeInTheDocument();
expect(queryByText('Your annual subscription expires in 25 days. Please renew to avoid any disruption.')).toBeInTheDocument();
});
it('Should NOT show any banner if renewal date is more than 60 days away"', () => {

View File

@@ -1,12 +1,13 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useMemo} from 'react';
import React, {useEffect, useMemo} from 'react';
import {useIntl} from 'react-intl';
import {useDispatch, useSelector} from 'react-redux';
import {InformationOutlineIcon} from '@mattermost/compass-icons/components';
import {getConfig as adminGetConfig} from 'mattermost-redux/actions/admin';
import {savePreferences} from 'mattermost-redux/actions/preferences';
import {getConfig} from 'mattermost-redux/selectors/entities/admin';
import {get} from 'mattermost-redux/selectors/entities/preferences';
@@ -36,7 +37,6 @@ export const getCurrentYearAsString = () => {
const CloudAnnualRenewalAnnouncementBar = () => {
const subscription = useGetSubscription();
// TODO: Update with renewal modal
const openPurchaseModal = useOpenCloudPurchaseModal({});
const {formatMessage} = useIntl();
const {isDelinquencySubscription} = useDelinquencySubscription();
@@ -45,14 +45,21 @@ const CloudAnnualRenewalAnnouncementBar = () => {
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 config = useSelector(getConfig);
const cloudAnnualRenewalsEnabled = config.FeatureFlags?.CloudAnnualRenewals;
useEffect(() => {
if (!config || !config.FeatureFlags) {
dispatch(adminGetConfig());
}
}, []);
const daysUntilExpiration = useMemo(() => {
if (!subscription || !subscription.end_at || !subscription.cancel_at) {
return 0;
}
return daysToExpiration(subscription.end_at * 1000);
return daysToExpiration(subscription.end_at);
}, [subscription]);
const handleDismiss = (banner: string) => {

View File

@@ -1,128 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useEffect} from 'react';
import {FormattedMessage} from 'react-intl';
import {useSelector, useDispatch} from 'react-redux';
import {savePreferences} from 'mattermost-redux/actions/preferences';
import {getSubscriptionProduct} from 'mattermost-redux/selectors/entities/cloud';
import {makeGetCategory} from 'mattermost-redux/selectors/entities/preferences';
import {getCurrentUser} from 'mattermost-redux/selectors/entities/users';
import {isSystemAdmin} from 'mattermost-redux/utils/user_utils';
import {trackEvent} from 'actions/telemetry_actions';
import {useDelinquencySubscription} from 'components/common/hooks/useDelinquencySubscription';
import {NotifyStatus, useGetNotifyAdmin} from 'components/common/hooks/useGetNotifyAdmin';
import {
AnnouncementBarTypes, CloudProducts, CloudProductToSku, MattermostFeatures, Preferences, TELEMETRY_CATEGORIES,
} from 'utils/constants';
import {t} from 'utils/i18n';
import type {GlobalState} from 'types/store';
import AnnouncementBar from '../default_announcement_bar';
export const BannerPreferenceName = 'notify_upgrade_workspace_banner';
const NotifyAdminDowngradeDelinquencyBar = () => {
const dispatch = useDispatch();
const getCategory = makeGetCategory();
const preferences = useSelector((state: GlobalState) =>
getCategory(state, Preferences.NOTIFY_ADMIN_REVOKE_DOWNGRADED_WORKSPACE),
);
const product = useSelector(getSubscriptionProduct);
const currentUser = useSelector((state: GlobalState) =>
getCurrentUser(state),
);
const {isDelinquencySubscriptionHigherThan90Days} = useDelinquencySubscription();
const {notifyAdmin, notifyStatus} = useGetNotifyAdmin({});
useEffect(() => {
if (notifyStatus === NotifyStatus.Success) {
dispatch(savePreferences(currentUser.id, [{
category: Preferences.NOTIFY_ADMIN_REVOKE_DOWNGRADED_WORKSPACE,
name: BannerPreferenceName,
user_id: currentUser.id,
value: 'adminNotified',
}]));
}
}, [currentUser, dispatch, notifyStatus]);
const shouldShowBanner = () => {
if (!isDelinquencySubscriptionHigherThan90Days()) {
return false;
}
if (isSystemAdmin(currentUser.roles)) {
return false;
}
if (!preferences) {
return false;
}
if (preferences.some((pref) => pref.name === BannerPreferenceName)) {
return false;
}
return true;
};
if (!shouldShowBanner() || product == null) {
return null;
}
const notifyAdminRequestData = {
required_feature: MattermostFeatures.UPGRADE_DOWNGRADED_WORKSPACE,
required_plan: CloudProductToSku[product?.sku] || CloudProductToSku[CloudProducts.PROFESSIONAL],
trial_notification: false,
};
const message = (
<FormattedMessage
id={t('cloud_delinquency.banner.end_user_notify_admin_title')}
defaultMessage={'Your workspace has been downgraded. Notify your admin to fix billing issues'}
/>);
const handleClick = () => {
trackEvent(TELEMETRY_CATEGORIES.CLOUD_DELINQUENCY, 'click_notify_admin_upgrade_workspace_banner');
notifyAdmin({
requestData: notifyAdminRequestData,
trackingArgs: {
category: TELEMETRY_CATEGORIES.CLOUD_DELINQUENCY,
event: 'notify_admin_downgrade_delinquency_bar',
},
});
};
const handleClose = () => {
dispatch(savePreferences(currentUser.id, [{
category: Preferences.NOTIFY_ADMIN_REVOKE_DOWNGRADED_WORKSPACE,
name: BannerPreferenceName,
user_id: currentUser.id,
value: 'dismissBanner',
}]));
};
return (
<AnnouncementBar
type={AnnouncementBarTypes.CRITICAL}
showCloseButton={true}
onButtonClick={handleClick}
modalButtonText={t('cloud_delinquency.banner.end_user_notify_admin_button')}
modalButtonDefaultText={'Notify admin'}
message={message}
showLinkAsButton={true}
isTallBanner={true}
icon={<i className='icon icon-alert-outline'/>}
handleClose={handleClose}
/>
);
};
export default NotifyAdminDowngradeDelinquencyBar;

View File

@@ -1,212 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {savePreferences} from 'mattermost-redux/actions/preferences';
import {Client4} from 'mattermost-redux/client';
import {trackEvent} from 'actions/telemetry_actions';
import {
fireEvent,
renderWithContext,
screen,
waitFor,
} from 'tests/react_testing_utils';
import {CloudProducts, Preferences, TELEMETRY_CATEGORIES} from 'utils/constants';
import {TestHelper} from 'utils/test_helper';
import NotifyAdminDowngradeDeliquencyBar, {BannerPreferenceName} from './index';
jest.mock('actions/telemetry_actions', () => ({
trackEvent: jest.fn(),
}));
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
useDispatch: jest.fn().mockReturnValue(() => {}),
}));
jest.mock('mattermost-redux/actions/preferences', () => ({
savePreferences: jest.fn(),
}));
describe('components/announcement_bar/notify_admin_downgrade_delinquency_bar', () => {
const initialState = {
views: {
announcementBar: {
announcementBarState: {
announcementBarCount: 1,
},
},
},
entities: {
general: {
license: {
IsLicensed: 'true',
Cloud: 'true',
},
},
preferences: {
myPreferences: {},
},
users: {
currentUserId: 'current_user_id',
profiles: {
current_user_id: {id: 'id', roles: 'system_user'},
},
},
cloud: {
subscription: {
product_id: 'test_prod_1',
trial_end_at: 1652807380,
is_free_trial: 'false',
delinquent_since: 1652807380, // may 17 2022
},
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,
},
},
},
},
};
beforeEach(() => {
jest.clearAllMocks();
});
it('Should not show banner when there isn\'t delinquency', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.cloud.subscription = {
product_id: 'test_prod_1',
trial_end_at: 1652807380,
is_free_trial: 'false',
};
renderWithContext(<NotifyAdminDowngradeDeliquencyBar/>, state);
expect(screen.queryByText('Your workspace has been downgraded. Notify your admin to fix billing issues')).not.toBeInTheDocument();
});
it('Should not show banner when deliquency is less than 90 days', () => {
jest.useFakeTimers().setSystemTime(new Date('2022-06-20'));
renderWithContext(<NotifyAdminDowngradeDeliquencyBar/>, initialState);
expect(screen.queryByText('Your workspace has been downgraded. Notify your admin to fix billing issues')).not.toBeInTheDocument();
});
it('Should not show banner when the user has notify their admin', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.preferences.myPreferences = TestHelper.getPreferencesMock(
[
{
category: Preferences.NOTIFY_ADMIN_REVOKE_DOWNGRADED_WORKSPACE,
name: BannerPreferenceName,
value: 'adminNotified',
},
],
);
renderWithContext(<NotifyAdminDowngradeDeliquencyBar/>, state);
expect(screen.queryByText('Your workspace has been downgraded. Notify your admin to fix billing issues')).not.toBeInTheDocument();
});
it('Should not show banner when the user closed the banner', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.preferences.myPreferences = TestHelper.getPreferencesMock(
[
{
category: Preferences.NOTIFY_ADMIN_REVOKE_DOWNGRADED_WORKSPACE,
name: BannerPreferenceName,
value: 'dismissBanner',
},
],
);
renderWithContext(<NotifyAdminDowngradeDeliquencyBar/>, state);
expect(screen.queryByText('Your workspace has been downgraded. Notify your admin to fix billing issues')).not.toBeInTheDocument();
});
it('Should not show banner when the user is an admin', () => {
jest.useFakeTimers().setSystemTime(new Date('2022-08-17'));
const state = JSON.parse(JSON.stringify(initialState));
state.entities.users.profiles.current_user_id = {roles: 'system_admin'};
renderWithContext(<NotifyAdminDowngradeDeliquencyBar/>, state);
expect(screen.queryByText('Your workspace has been downgraded. Notify your admin to fix billing issues')).not.toBeInTheDocument();
});
it('Should not save the preferences if the user can\'t notify', () => {
jest.useFakeTimers().setSystemTime(new Date('2022-08-17'));
Client4.notifyAdmin = jest.fn();
renderWithContext(<NotifyAdminDowngradeDeliquencyBar/>, initialState);
expect(savePreferences).not.toBeCalled();
});
it('Should show banner when deliquency is higher than 90 days', () => {
jest.useFakeTimers().setSystemTime(new Date('2022-08-17'));
renderWithContext(<NotifyAdminDowngradeDeliquencyBar/>, initialState);
expect(screen.getByText('Your workspace has been downgraded. Notify your admin to fix billing issues')).toBeInTheDocument();
});
it('Should save the preferences if the user close the banner', async () => {
jest.useFakeTimers().setSystemTime(new Date('2022-08-17'));
renderWithContext(<NotifyAdminDowngradeDeliquencyBar/>, initialState);
fireEvent.click(screen.getByRole('link'));
expect(savePreferences).toBeCalledTimes(1);
expect(savePreferences).toBeCalledWith(initialState.entities.users.profiles.current_user_id.id, [{
category: Preferences.NOTIFY_ADMIN_REVOKE_DOWNGRADED_WORKSPACE,
name: BannerPreferenceName,
user_id: initialState.entities.users.profiles.current_user_id.id,
value: 'dismissBanner',
}]);
});
it('Should save the preferences and track event after notify their admin', async () => {
jest.useFakeTimers().setSystemTime(new Date('2022-08-17'));
Client4.notifyAdmin = jest.fn();
renderWithContext(<NotifyAdminDowngradeDeliquencyBar/>, initialState);
fireEvent.click(screen.getByText('Notify admin'));
await waitFor(() => {
expect(savePreferences).toBeCalledTimes(1);
expect(savePreferences).toBeCalledWith(initialState.entities.users.profiles.current_user_id.id, [{
category: Preferences.NOTIFY_ADMIN_REVOKE_DOWNGRADED_WORKSPACE,
name: BannerPreferenceName,
user_id: initialState.entities.users.profiles.current_user_id.id,
value: 'adminNotified',
}]);
expect(trackEvent).toBeCalledTimes(2);
expect(trackEvent).toHaveBeenNthCalledWith(1, TELEMETRY_CATEGORIES.CLOUD_DELINQUENCY, 'click_notify_admin_upgrade_workspace_banner');
expect(trackEvent).toHaveBeenNthCalledWith(2, TELEMETRY_CATEGORIES.CLOUD_DELINQUENCY, 'notify_admin_downgrade_delinquency_bar', undefined);
});
});
});

View File

@@ -1,120 +0,0 @@
@import 'sass/utils/_mixins';
.DelinquencyModal {
.modal-dialog {
position: absolute;
top: 50%;
left: 50%;
margin: auto;
transform: translate(-50%, -50%) !important;
}
.modal-content {
max-width: 512px;
max-height: 465px;
border-radius: 12px;
}
.modal-header {
border-radius: 12px 12px 0 0;
}
.modal-footer {
padding-top: 24px;
padding-bottom: 24px;
border-radius: 0 0 12px 12px;
}
.modal-header,
.modal-footer {
display: flex;
padding-right: 32px;
padding-left: 32px;
}
.DelinquencyModal__header {
align-items: center;
border: none;
&.modal-header {
background: var(--center-channel-bg);
}
h3 {
margin: 0;
color: rgba(63, 67, 80, 1);
font-size: 22px;
font-weight: bold;
line-height: 28px;
}
.icon-close {
@include button-medium;
padding: 0;
border: none;
margin-left: auto;
background: var(--center-channel-bg);
color: rgba(63, 67, 80, 1);
font-size: 24px;
}
}
.modal-body {
display: block;
overflow: hidden;
max-height: none;
padding: 0 32px;
margin-bottom: 28px;
color: rgba(63, 67, 80, 1);
}
&__body {
text-align: center;
&__freemium {
text-align: left;
}
&__title {
margin-bottom: 8px;
font-size: 22px;
font-weight: bold;
line-height: 28px;
}
&__information {
margin: 0;
font-size: 12px;
}
&__limits-information {
margin: 0;
margin-bottom: 24px;
font-size: 12px;
}
&__subheader {
display: block;
margin-bottom: 16px;
color: rgba(63, 67, 80, 0.56);
font-size: 10px;
font-weight: bold;
text-transform: uppercase;
}
}
&__footer {
&--secondary {
@include tertiary-button;
@include button-large;
}
&--primary {
@include primary-button;
@include button-large;
margin-left: auto;
}
}
}

View File

@@ -1,105 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import type {ComponentProps} from 'react';
import {savePreferences} from 'mattermost-redux/actions/preferences';
import {trackEvent} from 'actions/telemetry_actions';
import {fireEvent, renderWithContext, screen} from 'tests/react_testing_utils';
import {ModalIdentifiers, Preferences, TELEMETRY_CATEGORIES} from 'utils/constants';
import DelinquencyModal from './delinquency_modal';
jest.mock('mattermost-redux/actions/preferences', () => ({
savePreferences: jest.fn(),
}));
jest.mock('actions/telemetry_actions', () => ({
trackEvent: jest.fn(),
}));
jest.mock('actions/views/modals', () => ({
openModal: jest.fn(),
}));
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
useDispatch: jest.fn().mockReturnValue(() => {}),
}));
describe('components/deliquency_modal/deliquency_modal', () => {
const initialState = {
views: {
modals: {
modalState: {
[ModalIdentifiers.DELINQUENCY_MODAL_DOWNGRADE]: {
open: true,
dialogProps: {
planName: 'plan_name',
onExited: () => {},
closeModal: () => {},
isAdminConsole: false,
},
dialogType: React.Fragment as any,
},
},
showLaunchingWorkspace: false,
},
},
entities: {
users: {
currentUserId: 'current_user_id',
profiles: {
current_user_id: {roles: 'system_admin', id: 'test'},
},
},
},
};
const baseProps: ComponentProps<typeof DelinquencyModal> = {
closeModal: jest.fn(),
onExited: jest.fn(),
planName: 'planName',
isAdminConsole: false,
};
it('should save preferences and track stayOnFremium if admin click Stay on Free', () => {
renderWithContext(<DelinquencyModal {...baseProps}/>, initialState);
fireEvent.click(screen.getByText('Stay on Free'));
expect(savePreferences).toBeCalledTimes(1);
expect(savePreferences).toBeCalledWith(initialState.entities.users.profiles.current_user_id.id, [{
category: Preferences.DELINQUENCY_MODAL_CONFIRMED,
name: ModalIdentifiers.DELINQUENCY_MODAL_DOWNGRADE,
user_id: initialState.entities.users.profiles.current_user_id.id,
value: 'stayOnFremium',
}]);
expect(trackEvent).toBeCalledTimes(1);
expect(trackEvent).toBeCalledWith(TELEMETRY_CATEGORIES.CLOUD_DELINQUENCY, 'clicked_stay_on_freemium');
});
it('should save preferences and track update Billing if admin click Update Billing', () => {
renderWithContext(<DelinquencyModal {...baseProps}/>, initialState);
fireEvent.click(screen.getByText('Update Billing'));
expect(savePreferences).toBeCalledTimes(1);
expect(savePreferences).toBeCalledWith(initialState.entities.users.profiles.current_user_id.id, [{
category: Preferences.DELINQUENCY_MODAL_CONFIRMED,
name: ModalIdentifiers.DELINQUENCY_MODAL_DOWNGRADE,
user_id: initialState.entities.users.profiles.current_user_id.id,
value: 'updateBilling',
}]);
expect(trackEvent).toBeCalledTimes(2);
expect(trackEvent).toHaveBeenNthCalledWith(1, TELEMETRY_CATEGORIES.CLOUD_DELINQUENCY, 'clicked_update_billing');
expect(trackEvent).toHaveBeenNthCalledWith(2, TELEMETRY_CATEGORIES.CLOUD_ADMIN, 'click_open_delinquency_modal', {
callerInfo: 'delinquency_modal_downgrade_admin',
});
});
});

View File

@@ -1,159 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {Modal} from 'react-bootstrap';
import {FormattedMessage} from 'react-intl';
import {useDispatch, useSelector} from 'react-redux';
import {savePreferences} from 'mattermost-redux/actions/preferences';
import {getCurrentUser} from 'mattermost-redux/selectors/entities/common';
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
import {trackEvent} from 'actions/telemetry_actions';
import {openModal, closeModal as closeModalAction} from 'actions/views/modals';
import {isModalOpen} from 'selectors/views/modals';
import useOpenCloudPurchaseModal from 'components/common/hooks/useOpenCloudPurchaseModal';
import UpgradeSvg from 'components/common/svg_images_components/upgrade_svg';
import CompassThemeProvider from 'components/compass_theme_provider/compass_theme_provider';
import {ModalIdentifiers, Preferences, TELEMETRY_CATEGORIES} from 'utils/constants';
import type {GlobalState} from 'types/store';
import {FreemiumModal} from './freemium_modal';
import './delinquency_modal.scss';
interface DelinquencyModalProps {
planName: string;
onExited: () => void;
closeModal: () => void;
isAdminConsole?: boolean;
}
const DelinquencyModal = (props: DelinquencyModalProps) => {
const dispatch = useDispatch();
const show = useSelector((state: GlobalState) => isModalOpen(state, ModalIdentifiers.DELINQUENCY_MODAL_DOWNGRADE));
const currentUser = useSelector((state: GlobalState) => getCurrentUser(state));
const {closeModal, onExited, planName, isAdminConsole} = props;
const openPurchaseModal = useOpenCloudPurchaseModal({isDelinquencyModal: true});
const theme = useSelector(getTheme);
const handleShowFremium = () => {
trackEvent(TELEMETRY_CATEGORIES.CLOUD_DELINQUENCY, 'clicked_stay_on_freemium');
closeModal();
dispatch(savePreferences(currentUser.id, [{
category: Preferences.DELINQUENCY_MODAL_CONFIRMED,
name: ModalIdentifiers.DELINQUENCY_MODAL_DOWNGRADE,
user_id: currentUser.id,
value: 'stayOnFremium',
}]));
dispatch(openModal({
dialogType: FreemiumModal,
modalId: ModalIdentifiers.CLOUD_LIMITS_DOWNGRADE,
dialogProps: {
onExited,
onClose: () => dispatch(closeModalAction(ModalIdentifiers.CLOUD_LIMITS_DOWNGRADE)),
isAdminConsole,
planName,
},
}));
};
const handleClose = () => {
closeModal();
onExited();
};
const handleUpdateBilling = () => {
handleClose();
trackEvent(TELEMETRY_CATEGORIES.CLOUD_DELINQUENCY, 'clicked_update_billing');
openPurchaseModal({
trackingLocation: 'delinquency_modal_downgrade_admin',
});
dispatch(savePreferences(currentUser.id, [{
category: Preferences.DELINQUENCY_MODAL_CONFIRMED,
name: ModalIdentifiers.DELINQUENCY_MODAL_DOWNGRADE,
user_id: currentUser.id,
value: 'updateBilling',
}]));
};
const ModalJSX = (
<Modal
className='DelinquencyModal'
dialogClassName='a11y__modal'
show={show}
onHide={handleClose}
onExited={handleClose}
role='dialog'
id='DelinquencyModal'
aria-modal='true'
>
<Modal.Header className='DelinquencyModal__header '>
<button
id='closeIcon'
className='icon icon-close'
aria-label='Close'
title='Close'
onClick={handleClose}
/>
</Modal.Header>
<Modal.Body className='DelinquencyModal__body'>
<UpgradeSvg
width={217}
height={164}
/>
<FormattedMessage
id='cloud_delinquency.modal.workspace_downgraded'
defaultMessage='Your workspace has been downgraded'
>
{(text) => <h3 className='DelinquencyModal__body__title'>{text}</h3>}
</FormattedMessage>
<FormattedMessage
id='cloud_delinquency.modal.workspace_downgraded_description'
defaultMessage='Due to payment issues on your {paidPlan}, your workspace has been downgraded to the free plan. To access {paidPlan} features again, update your billing information now.'
values={{
paidPlan: planName,
}}
>
{(text) => <p className='DelinquencyModal__body__information'>{text}</p>}
</FormattedMessage>
</Modal.Body>
<Modal.Footer className={'DelinquencyModal__footer '}>
<button
className={'DelinquencyModal__footer--secondary'}
id={'stayOnFremium'}
onClick={handleShowFremium}
>
<FormattedMessage
id='cloud_delinquency.modal.stay_on_freemium'
defaultMessage='Stay on Free'
/>
</button>
<button
className={'DelinquencyModal__footer--primary'}
id={'updanteBillingFromDeliquencyModal'}
onClick={handleUpdateBilling}
>
<FormattedMessage
id='cloud_delinquency.modal.update_billing'
defaultMessage='Update Billing'
/>
</button>
</Modal.Footer>
</Modal>);
if (!isAdminConsole) {
return ModalJSX;
}
return (<CompassThemeProvider theme={theme}>
{ModalJSX}
</CompassThemeProvider>);
};
export default DelinquencyModal;

View File

@@ -1,333 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import type {DeepPartial} from '@mattermost/types/utilities';
import * as cloudActions from 'mattermost-redux/actions/cloud';
import * as StorageSelectors from 'selectors/storage';
import ModalController from 'components/modal_controller';
import {renderWithContext, screen} from 'tests/react_testing_utils';
import {CloudProducts, ModalIdentifiers, Preferences} from 'utils/constants';
import {TestHelper} from 'utils/test_helper';
import type {GlobalState} from 'types/store';
import DelinquencyModalController from './index';
jest.mock('selectors/storage');
(StorageSelectors.makeGetItem as jest.Mock).mockReturnValue(() => false);
describe('components/delinquency_modal/delinquency_modal_controller', () => {
const initialState: DeepPartial<GlobalState> = {
views: {
modals: {
modalState: {},
},
},
entities: {
general: {
license: {
IsLicensed: 'true',
Cloud: 'true',
},
},
preferences: {
myPreferences: {},
},
users: {
currentUserId: 'current_user_id',
profiles: {
current_user_id: {roles: 'system_admin'},
},
},
cloud: {
subscription: TestHelper.getSubscriptionMock({
product_id: 'test_prod_1',
trial_end_at: 1652807380,
is_free_trial: 'false',
delinquent_since: 1652807380, // may 17 2022
}),
products: {
test_prod_1: TestHelper.getProductMock({
id: 'test_prod_1',
sku: CloudProducts.STARTER,
price_per_seat: 0,
name: 'testProd1',
}),
test_prod_2: TestHelper.getProductMock({
id: 'test_prod_2',
sku: CloudProducts.ENTERPRISE,
price_per_seat: 0,
name: 'testProd2',
}),
test_prod_3: TestHelper.getProductMock({
id: 'test_prod_3',
sku: CloudProducts.PROFESSIONAL,
price_per_seat: 0,
name: 'testProd3',
}),
},
},
},
};
it('Should show the modal if the admin hasn\'t a preference', () => {
jest.useFakeTimers().setSystemTime(new Date('2022-12-20'));
renderWithContext(
<>
<div id='root-portal'/>
<ModalController/>
<DelinquencyModalController/>
</>,
initialState,
);
expect(screen.queryByText('Your workspace has been downgraded')).toBeInTheDocument();
});
it('Shouldn\'t show the modal if the admin has a preference', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.preferences.myPreferences = TestHelper.getPreferencesMock(
[
{
category: Preferences.DELINQUENCY_MODAL_CONFIRMED,
name: ModalIdentifiers.DELINQUENCY_MODAL_DOWNGRADE,
value: 'updateBilling',
},
],
);
jest.useFakeTimers().setSystemTime(new Date('2022-12-20'));
renderWithContext(
<>
<div id='root-portal'/>
<ModalController/>
<DelinquencyModalController/>
</>,
state,
);
expect(screen.queryByText('Your workspace has been downgraded')).not.toBeInTheDocument();
});
it('Should show the modal if the deliquency_since is equal 90 days', () => {
jest.useFakeTimers().setSystemTime(new Date('2022-08-16'));
renderWithContext(
<>
<div id='root-portal'/>
<ModalController/>
<DelinquencyModalController/>
</>,
initialState,
);
expect(screen.queryByText('Your workspace has been downgraded')).toBeInTheDocument();
});
it('Should show the modal if the deliquency_since is more than 90 days', () => {
jest.useFakeTimers().setSystemTime(new Date('2022-08-17'));
renderWithContext(
<>
<div id='root-portal'/>
<ModalController/>
<DelinquencyModalController/>
</>,
initialState,
);
expect(screen.queryByText('Your workspace has been downgraded')).toBeInTheDocument();
});
it('Shouldn\'t show the modal if the deliqeuncy_since is less than 90 days', () => {
jest.useFakeTimers().setSystemTime(new Date('2022-08-15'));
renderWithContext(
<>
<div id='root-portal'/>
<ModalController/>
<DelinquencyModalController/>
</>,
initialState,
);
expect(screen.queryByText('Your workspace has been downgraded')).not.toBeInTheDocument();
});
it('Should show the modal if the license is cloud', () => {
jest.useFakeTimers().setSystemTime(new Date('2022-08-17'));
renderWithContext(
<>
<div id='root-portal'/>
<ModalController/>
<DelinquencyModalController/>
</>,
initialState,
);
expect(screen.queryByText('Your workspace has been downgraded')).toBeInTheDocument();
});
it('Shouldn\'t show the modal if the license isn\'t cloud', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.general.license = {
...state.entities.general.license,
Cloud: 'false',
};
jest.useFakeTimers().setSystemTime(new Date('2022-12-20'));
renderWithContext(
<>
<div id='root-portal'/>
<ModalController/>
<DelinquencyModalController/>
</>,
state,
);
expect(screen.queryByText('Your workspace has been downgraded')).not.toBeInTheDocument();
});
it('Shouldn\'t show the modal if the subscription isn\'t in delinquency state', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.cloud.subscription = {
...state.entities.cloud.subscription,
delinquent_since: null,
};
jest.useFakeTimers().setSystemTime(new Date('2022-12-20'));
renderWithContext(
<>
<div id='root-portal'/>
<ModalController/>
<DelinquencyModalController/>
</>,
state,
);
expect(screen.queryByText('Your workspace has been downgraded')).not.toBeInTheDocument();
});
it('Should show the modal if the user is an admin', () => {
jest.useFakeTimers().setSystemTime(new Date('2022-08-17'));
renderWithContext(
<>
<div id='root-portal'/>
<ModalController/>
<DelinquencyModalController/>
</>,
initialState,
);
expect(screen.queryByText('Your workspace has been downgraded')).toBeInTheDocument();
});
it('Shouldn\'t show the modal if the user isn\'t an admin', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.users = {
currentUserId: 'current_user_id',
profiles: {
current_user_id: {roles: 'user'},
},
};
jest.useFakeTimers().setSystemTime(new Date('2022-12-20'));
renderWithContext(
<>
<div id='root-portal'/>
<ModalController/>
<DelinquencyModalController/>
</>,
state,
);
expect(screen.queryByText('Your workspace has been downgraded')).not.toBeInTheDocument();
});
it('Should show the modal if the user just logged in', () => {
jest.useFakeTimers().setSystemTime(new Date('2022-08-17'));
renderWithContext(
<>
<div id='root-portal'/>
<ModalController/>
<DelinquencyModalController/>
</>,
initialState,
);
expect(screen.queryByText('Your workspace has been downgraded')).toBeInTheDocument();
});
it('Shouldn\'t show the modal if we aren\'t log in', () => {
(StorageSelectors.makeGetItem as jest.Mock).mockReturnValue(() => true);
jest.useFakeTimers().setSystemTime(new Date('2022-08-17'));
renderWithContext(
<>
<div id='root-portal'/>
<ModalController/>
<DelinquencyModalController/>
</>,
initialState,
);
expect(screen.queryByText('Your workspace has been downgraded')).not.toBeInTheDocument();
});
it('Should fetch cloud products when on cloud', () => {
jest.useFakeTimers().setSystemTime(new Date('2022-12-20'));
const newState = JSON.parse(JSON.stringify(initialState));
newState.entities.cloud.products = {};
const getCloudProds = jest.spyOn(cloudActions, 'getCloudProducts').mockImplementationOnce(jest.fn().mockReturnValue({type: 'mock_impl'}));
renderWithContext(
<>
<div id='root-portal'/>
<ModalController/>
<DelinquencyModalController/>
</>,
newState,
);
expect(getCloudProds).toHaveBeenCalledTimes(1);
});
it('Should NOT fetch cloud products when NOT on cloud', () => {
jest.useFakeTimers().setSystemTime(new Date('2022-12-20'));
const newState = JSON.parse(JSON.stringify(initialState));
newState.entities.cloud.products = {};
newState.entities.general.license = {
IsLicensed: 'true',
Cloud: 'false',
};
const getCloudProds = jest.spyOn(cloudActions, 'getCloudProducts').mockImplementationOnce(jest.fn().mockReturnValue({type: 'mock_impl'}));
renderWithContext(
<>
<div id='root-portal'/>
<ModalController/>
<DelinquencyModalController/>
</>,
newState,
);
expect(getCloudProds).toHaveBeenCalledTimes(0);
});
});

View File

@@ -1,33 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import type {Subscription} from '@mattermost/types/cloud';
import type {PreferenceType} from '@mattermost/types/preferences';
import withGetCloudSubscription from 'components/common/hocs/cloud/with_get_cloud_subscription';
import type {ModalData} from 'types/actions';
import {useDelinquencyModalController} from './useDelinquencyModalController';
interface DelinquencyModalControllerProps {
userIsAdmin: boolean;
subscription?: Subscription;
isCloud: boolean;
actions: {
getCloudSubscription: () => void;
closeModal: () => void;
openModal: <P>(modalData: ModalData<P>) => void;
};
delinquencyModalPreferencesConfirmed: PreferenceType[];
}
const DelinquencyModalController = (props: DelinquencyModalControllerProps) => {
useDelinquencyModalController(props);
return <></>;
};
export default withGetCloudSubscription(DelinquencyModalController);

View File

@@ -1,129 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import type {ComponentProps} from 'react';
import type {DeepPartial} from '@mattermost/types/utilities';
import {trackEvent} from 'actions/telemetry_actions';
import useGetMultiplesExceededCloudLimit from 'components/common/hooks/useGetMultiplesExceededCloudLimit';
import {fireEvent, renderWithContext, screen} from 'tests/react_testing_utils';
import {ModalIdentifiers, TELEMETRY_CATEGORIES} from 'utils/constants';
import {LimitTypes} from 'utils/limits';
import type {GlobalState} from 'types/store';
import {FreemiumModal} from './freemium_modal';
jest.mock('actions/telemetry_actions', () => ({
trackEvent: jest.fn(),
}));
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
useDispatch: jest.fn().mockReturnValue(() => {}),
}));
jest.mock('components/common/hooks/useGetMultiplesExceededCloudLimit');
describe('components/delinquency_modal/freemium_modal', () => {
const initialState: DeepPartial<GlobalState> = {
views: {
modals: {
modalState: {
[ModalIdentifiers.DELINQUENCY_MODAL_DOWNGRADE]: {
open: true,
dialogProps: {
planName: 'plan_name',
onExited: () => {},
closeModal: () => {},
isAdminConsole: false,
},
dialogType: React.Fragment as any,
},
},
showLaunchingWorkspace: false,
},
},
entities: {
users: {
currentUserId: 'current_user_id',
profiles: {
current_user_id: {roles: 'system_admin', id: 'test'},
},
},
},
};
const planName = 'Testing';
const baseProps: ComponentProps<typeof FreemiumModal> = {
onClose: jest.fn(),
planName,
isAdminConsole: false,
onExited: jest.fn(),
};
it('should track reactivate plan if admin click Re activate plan', () => {
(useGetMultiplesExceededCloudLimit as jest.Mock).mockReturnValue([LimitTypes.fileStorage]);
renderWithContext(
<FreemiumModal {...baseProps}/>,
initialState,
);
fireEvent.click(screen.getByText(`Re-activate ${planName}`));
expect(trackEvent).toBeCalledTimes(2);
expect(trackEvent).toHaveBeenNthCalledWith(1, TELEMETRY_CATEGORIES.CLOUD_DELINQUENCY, 'clicked_re_activate_plan');
expect(trackEvent).toHaveBeenNthCalledWith(2, TELEMETRY_CATEGORIES.CLOUD_ADMIN, 'click_open_delinquency_modal', {
callerInfo: 'delinquency_modal_freemium_admin',
});
});
it('should not show reactivate plan if admin limits isn\'t surpassed', () => {
(useGetMultiplesExceededCloudLimit as jest.Mock).mockReturnValue([]);
renderWithContext(
<FreemiumModal {...baseProps}/>,
initialState,
);
expect(screen.queryByText(`Re-activate ${planName}`)).not.toBeInTheDocument();
expect(trackEvent).toBeCalledTimes(0);
});
it('should display message history text when only message limit is surpassed', () => {
(useGetMultiplesExceededCloudLimit as jest.Mock).mockReturnValue([LimitTypes.messageHistory]);
renderWithContext(
<FreemiumModal {...baseProps}/>,
initialState,
);
expect(screen.queryByText(`Re-activate ${planName}`)).toBeInTheDocument();
expect(screen.getByText('Some of your workspace\'s message history are no longer accessible. Upgrade to a paid plan and get unlimited access to your message history.')).toBeInTheDocument();
});
it('should display storage text when only storage is surpassed', () => {
(useGetMultiplesExceededCloudLimit as jest.Mock).mockReturnValue([LimitTypes.fileStorage]);
renderWithContext(
<FreemiumModal {...baseProps}/>,
initialState,
);
expect(screen.queryByText(`Re-activate ${planName}`)).toBeInTheDocument();
expect(screen.getByText('Some of your workspace\'s files are no longer accessible. Upgrade to a paid plan and get unlimited access to your files.')).toBeInTheDocument();
});
it('should display update to paid plan text when only multiples limits is surpassed', () => {
(useGetMultiplesExceededCloudLimit as jest.Mock).mockReturnValue([LimitTypes.messageHistory, LimitTypes.fileStorage]);
renderWithContext(
<FreemiumModal {...baseProps}/>,
initialState,
);
expect(screen.queryByText(`Re-activate ${planName}`)).toBeInTheDocument();
expect(screen.getByText('Your workspace has reached free plan limits. Upgrade to a paid plan.')).toBeInTheDocument();
});
});

View File

@@ -1,165 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage} from 'react-intl';
import {useSelector} from 'react-redux';
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
import {trackEvent} from 'actions/telemetry_actions';
import CloudUsageModal from 'components/cloud_usage_modal';
import useGetLimits from 'components/common/hooks/useGetLimits';
import useGetMultiplesExceededCloudLimit from 'components/common/hooks/useGetMultiplesExceededCloudLimit';
import useGetUsage from 'components/common/hooks/useGetUsage';
import useOpenCloudPurchaseModal from 'components/common/hooks/useOpenCloudPurchaseModal';
import {TELEMETRY_CATEGORIES} from 'utils/constants';
import {t} from 'utils/i18n';
import type {Message} from 'utils/i18n';
import {LimitTypes} from 'utils/limits';
import './delinquency_modal.scss';
type FreemiumModalProps = {
onClose: () => void;
onExited: () => void;
planName: string;
isAdminConsole?: boolean;
}
type ValueOf<T> = T[keyof T];
type DescriptionStatusKey = ValueOf<typeof LimitTypes> | 'noLimits' | 'multipleLimits';
const DescriptionMessages: Record<DescriptionStatusKey, JSX.Element> = {
noLimits: (
<FormattedMessage
id='cloud_delinquency.modal.workspace_downgraded_freemium'
defaultMessage='Cloud Free is restricted to 10,000 message history and 1GB file storage.'
>
{(text) => <p className='DelinquencyModal__body__limits-information'>{text}</p>}
</FormattedMessage>
),
[LimitTypes.messageHistory]: (
<FormattedMessage
id='cloud_delinquency.modal.workspace_downgraded_messages_surpassed'
defaultMessage={'Some of your workspace\'s message history are no longer accessible. Upgrade to a paid plan and get unlimited access to your message history.'}
>
{(text) => <p className='DelinquencyModal__body__limits-information'>{text}</p>}
</FormattedMessage>
),
[LimitTypes.fileStorage]: (
<FormattedMessage
id='cloud_delinquency.modal.workspace_downgraded_storage_surpassed'
defaultMessage={'Some of your workspace\'s files are no longer accessible. Upgrade to a paid plan and get unlimited access to your files.'}
>
{(text) => <p className='DelinquencyModal__body__limits-information'>{text}</p>}
</FormattedMessage>
),
multipleLimits: (
<FormattedMessage
id='cloud_delinquency.modal.workspace_downgraded_multiples_limits_surpassed'
defaultMessage='Your workspace has reached free plan limits. Upgrade to a paid plan.'
>
{(text) => <p className='DelinquencyModal__body__limits-information'>{text}</p>}
</FormattedMessage>
),
};
const getDescriptionKey = (limits: Array<ValueOf<typeof LimitTypes>>): DescriptionStatusKey => {
if (limits.length > 1) {
return 'multipleLimits';
}
if (limits.length === 1) {
return limits[0];
}
return 'noLimits';
};
export const FreemiumModal = ({onClose, onExited, planName, isAdminConsole}: FreemiumModalProps) => {
const openPurchaseModal = useOpenCloudPurchaseModal({isDelinquencyModal: true});
const [limits] = useGetLimits();
const usage = useGetUsage();
useSelector(getTheme);
const limitsSurpassed = useGetMultiplesExceededCloudLimit(usage, limits);
const handleClose = () => {
onClose();
onExited();
};
const handleReactivate = () => {
handleClose();
trackEvent(TELEMETRY_CATEGORIES.CLOUD_DELINQUENCY, 'clicked_re_activate_plan');
openPurchaseModal({trackingLocation: 'delinquency_modal_freemium_admin'});
};
const title: Message = {
id: t('cloud_delinquency.modal.workspace_downgraded_freemium_title'),
defaultMessage: 'You now have data limits on your plan',
};
const description = (<>
{DescriptionMessages[getDescriptionKey(limitsSurpassed)]}
<FormattedMessage
id='cloud_delinquency.modal.workspace_downgraded_freemium_limits'
defaultMessage='Free plan limits'
>
{(text) => <span className='DelinquencyModal__body__subheader'>{text}</span>}
</FormattedMessage>
</>);
if (limitsSurpassed.length === 0) {
const secondaryAction = {
message: {
id: t('cloud_delinquency.modal.stay_on_freemium_close'),
defaultMessage: 'View plans',
},
onClick: handleClose,
};
return (
<CloudUsageModal
className='DelinquencyModal'
secondaryAction={secondaryAction}
title={title}
description={description}
onClose={handleClose}
/>);
}
const secondaryAction = {
message: {
id: t('cloud_delinquency.modal.stay_on_freemium'),
defaultMessage: 'Stay on Free',
},
onClick: handleClose,
};
const primaryAction = {
message: {
id: t('cloud_delinquency.modal.re_activate_plan'),
defaultMessage: 'Re-activate {planName}',
values: {
planName,
},
},
onClick: handleReactivate,
};
return (
<CloudUsageModal
className='DelinquencyModal'
title={title}
description={description}
primaryAction={primaryAction}
secondaryAction={secondaryAction}
onClose={handleClose}
needsTheme={isAdminConsole}
/>
);
};

View File

@@ -1,50 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import type {Dispatch} from 'redux';
import {getCloudSubscription} from 'mattermost-redux/actions/cloud';
import {getLicense} from 'mattermost-redux/selectors/entities/general';
import {makeGetCategory} from 'mattermost-redux/selectors/entities/preferences';
import {isCurrentUserSystemAdmin} from 'mattermost-redux/selectors/entities/users';
import type {GenericAction} from 'mattermost-redux/types/actions';
import {closeModal, openModal} from 'actions/views/modals';
import {ModalIdentifiers, Preferences} from 'utils/constants';
import type {GlobalState} from 'types/store';
import DeliquencyModalController from './delinquency_modal_controller';
function makeMapStateToProps() {
const getCategory = makeGetCategory();
return function mapStateToProps(state: GlobalState) {
const license = getLicense(state);
const isCloud = license.Cloud === 'true';
const subscription = state.entities.cloud?.subscription;
const userIsAdmin = isCurrentUserSystemAdmin(state);
return {
isCloud,
subscription,
userIsAdmin,
delinquencyModalPreferencesConfirmed: getCategory(state, Preferences.DELINQUENCY_MODAL_CONFIRMED),
};
};
}
function mapDispatchToProps(dispatch: Dispatch<GenericAction>) {
return {
actions: bindActionCreators({
getCloudSubscription,
closeModal: () => closeModal(ModalIdentifiers.DELINQUENCY_MODAL_DOWNGRADE),
openModal,
}, dispatch),
};
}
export default connect(makeMapStateToProps, mapDispatchToProps)(DeliquencyModalController);

View File

@@ -1,111 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useEffect, useState} from 'react';
import {useSelector, useDispatch} from 'react-redux';
import type {Subscription} from '@mattermost/types/cloud';
import type {PreferenceType} from '@mattermost/types/preferences';
import {getCloudProducts} from 'mattermost-redux/actions/cloud';
import {getSubscriptionProduct} from 'mattermost-redux/selectors/entities/cloud';
import {setItem} from 'actions/storage';
import {makeGetItem} from 'selectors/storage';
import {StoragePrefixes, ModalIdentifiers} from 'utils/constants';
import type {ModalData} from 'types/actions';
import DelinquencyModal from './delinquency_modal';
const SESSION_MODAL_ITEM = `${StoragePrefixes.DELINQUENCY}hide_downgrade_modal`;
type UseDelinquencyModalController = {
userIsAdmin: boolean;
subscription?: Subscription;
isCloud: boolean;
actions: {
getCloudSubscription: () => void;
closeModal: () => void;
openModal: <P>(modalData: ModalData<P>) => void;
};
delinquencyModalPreferencesConfirmed: PreferenceType[];
}
export const useDelinquencyModalController = (props: UseDelinquencyModalController) => {
const {isCloud, userIsAdmin, subscription, actions, delinquencyModalPreferencesConfirmed} = props;
const product = useSelector(getSubscriptionProduct);
const sessionModalItem = useSelector(makeGetItem(SESSION_MODAL_ITEM, ''));
const dispatch = useDispatch();
const [showModal, setShowModal] = useState(false);
const {openModal} = actions;
const [requestedProducts, setRequestedProducts] = useState(false);
const handleOnExit = () => {
setShowModal(() => false);
dispatch(setItem(SESSION_MODAL_ITEM, 'true'));
};
useEffect(() => {
if (delinquencyModalPreferencesConfirmed.length === 0 && product === undefined && !requestedProducts && isCloud) {
dispatch(getCloudProducts());
setRequestedProducts(true);
}
}, []);
useEffect(() => {
if (showModal || !isCloud) {
return;
}
if (delinquencyModalPreferencesConfirmed.length > 0) {
return;
}
if (subscription == null) {
return;
}
const isClosed = Boolean(sessionModalItem) === true;
if (isClosed) {
return;
}
if (subscription.delinquent_since == null) {
return;
}
const delinquencyDate = new Date(subscription.delinquent_since * 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;
}
if (!userIsAdmin) {
return;
}
setShowModal(true);
}, [delinquencyModalPreferencesConfirmed.length, isCloud, openModal, showModal, subscription, userIsAdmin]);
useEffect(() => {
if (showModal && product != null) {
openModal({
modalId: ModalIdentifiers.DELINQUENCY_MODAL_DOWNGRADE,
dialogType: DelinquencyModal,
dialogProps: {
closeModal: actions.closeModal,
onExited: handleOnExit,
planName: product.name,
},
});
}
}, [actions.closeModal, openModal, product, showModal]);
};

View File

@@ -0,0 +1,119 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage} from 'react-intl';
import {trackEvent} from 'actions/telemetry_actions';
import {TELEMETRY_CATEGORIES, CloudLinks} from 'utils/constants';
import type {ButtonDetails} from './purchase_modal';
type DelinquencyCardProps = {
topColor: string;
price: string;
buttonDetails: ButtonDetails;
onViewBreakdownClick: () => void;
isCloudDelinquencyGreaterThan90Days: boolean;
users: number;
cost: number;
};
export default function DelinquencyCard(props: DelinquencyCardProps) {
const handleSeeHowBillingWorksClick = (
e: React.MouseEvent<HTMLAnchorElement, MouseEvent>,
) => {
e.preventDefault();
trackEvent(
TELEMETRY_CATEGORIES.CLOUD_ADMIN,
'click_see_how_billing_works',
);
window.open(CloudLinks.DELINQUENCY_DOCS, '_blank');
};
const seeHowBillingWorks = (
<a onClick={handleSeeHowBillingWorksClick}>
<FormattedMessage
defaultMessage={'See how billing works.'}
id={
'admin.billing.subscription.howItWorks'
}
/>
</a>
);
return (
<div className='PlanCard'>
<div
className='top'
style={{backgroundColor: props.topColor}}
/>
<div className='bottom delinquency'>
<div className='delinquency_summary_section'>
<div className={'summary-section'}>
<div className='summary-title'>
<FormattedMessage
id={'cloud_delinquency.cc_modal.card.totalOwed'}
defaultMessage={'Total Owed'}
/>
{':'}
</div>
<div className='summary-total'>{props.price}</div>
<div
onClick={props.onViewBreakdownClick}
className='view-breakdown'
>
<FormattedMessage
defaultMessage={'View Breakdown'}
id={
'cloud_delinquency.cc_modal.card.viewBreakdown'
}
/>
</div>
</div>
</div>
<div>
<button
className={
'plan_action_btn ' + props.buttonDetails.customClass
}
disabled={props.buttonDetails.disabled}
onClick={props.buttonDetails.action}
>
{props.buttonDetails.text}
</button>
</div>
<div className='plan_billing_cycle delinquency'>
{Boolean(!props.isCloudDelinquencyGreaterThan90Days) && (
<FormattedMessage
defaultMessage={
'When you reactivate your subscription, you\'ll be billed the total outstanding amount immediately. Your bill is calculated at the end of the billing cycle based on the number of active users. {seeHowBillingWorks}'
}
id={'cloud_delinquency.cc_modal.disclaimer'}
values={{
seeHowBillingWorks,
}}
/>
)}
{Boolean(props.isCloudDelinquencyGreaterThan90Days) && (
<FormattedMessage
defaultMessage={
'When you reactivate your subscription, you\'ll be billed the total outstanding amount immediately. You\'ll also be billed {cost} immediately for a 1 year subscription based on your current active user count of {users} users. {seeHowBillingWorks}'
}
id={
'cloud_delinquency.cc_modal.disclaimer_with_upgrade_info'
}
values={{
cost: `$${props.cost}`,
users: props.users,
seeHowBillingWorks,
}}
/>
)}
</div>
</div>
</div>
);
}

View File

@@ -8,7 +8,7 @@ import type {Dispatch} from 'redux';
import {getCloudProducts, getCloudSubscription, getInvoices} from 'mattermost-redux/actions/cloud';
import {getClientConfig} from 'mattermost-redux/actions/general';
import {getAdminAnalytics} from 'mattermost-redux/selectors/entities/admin';
import {getAdminAnalytics, getConfig} from 'mattermost-redux/selectors/entities/admin';
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
import {getCurrentTeam} from 'mattermost-redux/selectors/entities/teams';
@@ -21,6 +21,7 @@ import {makeAsyncComponent} from 'components/async_load';
import withGetCloudSubscription from 'components/common/hocs/cloud/with_get_cloud_subscription';
import {getStripePublicKey} from 'components/payment_form/stripe';
import {daysToExpiration} from 'utils/cloud_utils';
import {ModalIdentifiers} from 'utils/constants';
import {getCloudContactSalesLink, getCloudSupportLink} from 'utils/contact_support_sales';
import {findOnlyYearlyProducts} from 'utils/products';
@@ -33,6 +34,7 @@ function mapStateToProps(state: GlobalState) {
const subscription = state.entities.cloud.subscription;
const isDelinquencyModal = Boolean(state.entities.cloud.subscription?.delinquent_since);
const isRenewalModal = daysToExpiration(Number(state.entities.cloud.subscription?.end_at)) <= 60 && !isDelinquencyModal && getConfig(state).FeatureFlags?.CloudAnnualRenewals;
const products = state.entities.cloud!.products;
const yearlyProducts = findOnlyYearlyProducts(products || {});
@@ -61,8 +63,10 @@ function mapStateToProps(state: GlobalState) {
currentTeam: getCurrentTeam(state),
theme: getTheme(state),
isDelinquencyModal,
isRenewalModal,
usersCount: Number(getAdminAnalytics(state)!.TOTAL_USERS) || 1,
stripePublicKey,
subscription,
};
}

View File

@@ -13,10 +13,11 @@ import type {ReactNode} from 'react';
import {FormattedMessage, injectIntl} from 'react-intl';
import type {IntlShape} from 'react-intl';
import type {Address, CloudCustomer, Product, Invoice, Feedback} from '@mattermost/types/cloud';
import type {Address, CloudCustomer, Product, Invoice, Feedback, Subscription, InvoiceLineItem} from '@mattermost/types/cloud';
import {areShippingDetailsValid} from '@mattermost/types/cloud';
import type {Team} from '@mattermost/types/teams';
import {Client4} from 'mattermost-redux/client';
import type {Theme} from 'mattermost-redux/selectors/entities/preferences';
import type {ActionResult} from 'mattermost-redux/types/actions';
@@ -24,12 +25,12 @@ import {trackEvent, pageVisited} from 'actions/telemetry_actions';
import BillingHistoryModal from 'components/admin_console/billing/billing_history_modal';
import PaymentDetails from 'components/admin_console/billing/payment_details';
import CloudInvoicePreview from 'components/cloud_invoice_preview';
import PlanLabel from 'components/common/plan_label';
import ComplianceScreenFailedSvg from 'components/common/svg_images_components/access_denied_happy_svg';
import BackgroundSvg from 'components/common/svg_images_components/background_svg';
import UpgradeSvg from 'components/common/svg_images_components/upgrade_svg';
import ExternalLink from 'components/external_link';
import OverlayTrigger from 'components/overlay_trigger';
import AddressForm from 'components/payment_form/address_form';
import PaymentForm from 'components/payment_form/payment_form';
import {STRIPE_CSS_SRC} from 'components/payment_form/stripe';
@@ -39,12 +40,12 @@ import SeatsCalculator, {errorInvalidNumber} from 'components/seats_calculator';
import type {Seats} from 'components/seats_calculator';
import Consequences from 'components/seats_calculator/consequences';
import SwitchToYearlyPlanConfirmModal from 'components/switch_to_yearly_plan_confirm_modal';
import Tooltip from 'components/tooltip';
import StarMarkSvg from 'components/widgets/icons/star_mark_icon';
import FullScreenModal from 'components/widgets/modals/full_screen_modal';
import 'components/payment_form/payment_form.scss';
import {buildInvoiceSummaryPropsFromLineItems} from 'utils/cloud_utils';
import {
Constants,
TELEMETRY_CATEGORIES,
CloudLinks,
CloudProducts,
@@ -60,13 +61,13 @@ import type {ModalData} from 'types/actions';
import type {BillingDetails} from 'types/cloud/sku';
import {areBillingDetailsValid} from 'types/cloud/sku';
import DelinquencyCard from './delinquency_card';
import IconMessage from './icon_message';
import ProcessPaymentSetup from './process_payment_setup';
import 'components/payment_form/payment_form.scss';
import RenewalCard from './renewal_card';
import {findProductInDictionary, getSelectedProduct} from './utils';
import './purchase.scss';
let stripePromise: Promise<Stripe | null>;
export enum ButtonCustomiserClasses {
@@ -75,23 +76,13 @@ export enum ButtonCustomiserClasses {
special = 'special',
}
type ButtonDetails = {
export type ButtonDetails = {
action: () => void;
text: string;
disabled?: boolean;
customClass?: ButtonCustomiserClasses;
}
type DelinquencyCardProps = {
topColor: string;
price: string;
buttonDetails: ButtonDetails;
onViewBreakdownClick: () => void;
isCloudDelinquencyGreaterThan90Days: boolean;
users: number;
cost: number;
};
type CardProps = {
topColor?: string;
plan: string;
@@ -117,11 +108,13 @@ type Props = {
intl: IntlShape;
theme: Theme;
isDelinquencyModal?: boolean;
isRenewalModal?: boolean;
invoices?: Invoice[];
isCloudDelinquencyGreaterThan90Days: boolean;
usersCount: number;
isComplianceBlocked: boolean;
contactSupportLink: string;
subscription: Subscription | undefined;
// callerCTA is information about the cta that opened this modal. This helps us provide a telemetry path
// showing information about how the modal was opened all the way to more CTAs within the modal itself
@@ -167,55 +160,6 @@ type State = {
isSwitchingToAnnual: boolean;
}
/**
*
* @param products Record<string, Product> | undefined - the list of current cloud products
* @param productId String - a valid product id used to find a particular product in the dictionary
* @param productSku String - the sku value of the product of type either cloud-starter | cloud-professional | cloud-enterprise
* @returns Product
*/
function findProductInDictionary(products: Record<string, Product> | undefined, productId?: string | null, productSku?: string, productRecurringInterval?: string): Product | null {
if (!products) {
return null;
}
const keys = Object.keys(products);
if (!keys.length) {
return null;
}
if (!productId && !productSku) {
return products[keys[0]];
}
let currentProduct = products[keys[0]];
if (keys.length > 1) {
// here find the product by the provided id or name, otherwise return the one with Professional in the name
keys.forEach((key) => {
if (productId && products[key].id === productId) {
currentProduct = products[key];
} else if (productSku && products[key].sku === productSku && products[key].recurring_interval === productRecurringInterval) {
currentProduct = products[key];
}
});
}
return currentProduct;
}
function getSelectedProduct(
products: Record<string, Product> | undefined,
yearlyProducts: Record<string, Product>,
currentProductId?: string | null,
isDelinquencyModal?: boolean,
isCloudDelinquencyGreaterThan90Days?: boolean) {
if (isDelinquencyModal && !isCloudDelinquencyGreaterThan90Days) {
const currentProduct = findProductInDictionary(products, currentProductId, undefined, RecurringIntervals.MONTH);
// if the account hasn't been delinquent for more than 90 days, then we will allow them to settle up and stay on their current product
return currentProduct;
}
// Otherwise, we will default to upgrading them to the yearly professional plan
return findProductInDictionary(yearlyProducts, null, CloudProducts.PROFESSIONAL, RecurringIntervals.YEAR);
}
export function Card(props: CardProps) {
const cardContent = (
<div className='PlanCard'>
@@ -249,111 +193,6 @@ export function Card(props: CardProps) {
);
}
function DelinquencyCard(props: DelinquencyCardProps) {
const seeHowBillingWorks = (
e: React.MouseEvent<HTMLAnchorElement, MouseEvent>,
) => {
e.preventDefault();
trackEvent(
TELEMETRY_CATEGORIES.CLOUD_ADMIN,
'click_see_how_billing_works',
);
window.open(CloudLinks.DELINQUENCY_DOCS, '_blank');
};
return (
<div className='PlanCard'>
<div
className='top'
style={{backgroundColor: props.topColor}}
/>
<div className='bottom delinquency'>
<div className='delinquency_summary_section'>
<div className={'summary-section'}>
<div className='summary-title'>
<FormattedMessage
id={'cloud_delinquency.cc_modal.card.totalOwed'}
defaultMessage={'Total Owed'}
/>
{':'}
</div>
<div className='summary-total'>{props.price}</div>
<div
onClick={props.onViewBreakdownClick}
className='view-breakdown'
>
<FormattedMessage
defaultMessage={'View Breakdown'}
id={
'cloud_delinquency.cc_modal.card.viewBreakdown'
}
/>
</div>
</div>
</div>
<div>
<button
className={
'plan_action_btn ' + props.buttonDetails.customClass
}
disabled={props.buttonDetails.disabled}
onClick={props.buttonDetails.action}
>
{props.buttonDetails.text}
</button>
</div>
<div className='plan_billing_cycle delinquency'>
{Boolean(!props.isCloudDelinquencyGreaterThan90Days) && (
<FormattedMessage
defaultMessage={
'When you reactivate your subscription, you\'ll be billed the total outstanding amount immediately. Your bill is calculated at the end of the billing cycle based on the number of active users. {seeHowBillingWorks}'
}
id={'cloud_delinquency.cc_modal.disclaimer'}
values={{
seeHowBillingWorks: (
<a onClick={seeHowBillingWorks}>
<FormattedMessage
defaultMessage={
'See how billing works.'
}
id={
'admin.billing.subscription.howItWorks'
}
/>
</a>
),
}}
/>
)}
{Boolean(props.isCloudDelinquencyGreaterThan90Days) && (
<FormattedMessage
defaultMessage={
'When you reactivate your subscription, you\'ll be billed the total outstanding amount immediately. You\'ll also be billed {cost} immediately for a 1 year subscription based on your current active user count of {users} users. {seeHowBillingWorks}'
}
id={
'cloud_delinquency.cc_modal.disclaimer_with_upgrade_info'
}
values={{
cost: `$${props.cost}`,
users: props.users,
seeHowBillingWorks: (
<a onClick={seeHowBillingWorks}>
<FormattedMessage
defaultMessage={'See how billing works.'}
id={
'admin.billing.subscription.howItWorks'
}
/>
</a>
),
}}
/>
)}
</div>
</div>
</div>
);
}
class PurchaseModal extends React.PureComponent<Props, State> {
modal = React.createRef();
@@ -375,15 +214,11 @@ class PurchaseModal extends React.PureComponent<Props, State> {
props.productId,
),
selectedProduct: getSelectedProduct(
props.products,
props.yearlyProducts,
props.productId,
props.isDelinquencyModal,
props.isCloudDelinquencyGreaterThan90Days,
props.products!,
),
isUpgradeFromTrial: props.isFreeTrial,
buttonClickedInfo: '',
selectedProductPrice: getSelectedProduct(props.products, props.yearlyProducts, props.productId, props.isDelinquencyModal, props.isCloudDelinquencyGreaterThan90Days)?.price_per_seat.toString() || null,
selectedProductPrice: getSelectedProduct(props.products!)?.price_per_seat.toString() || null,
usersCount: this.props.usersCount,
seats: {
quantity: this.props.usersCount.toString(),
@@ -398,8 +233,8 @@ class PurchaseModal extends React.PureComponent<Props, State> {
await this.props.actions.getCloudProducts();
this.setState({
currentProduct: findProductInDictionary(this.props.products, this.props.productId),
selectedProduct: getSelectedProduct(this.props.products, this.props.yearlyProducts, this.props.productId, this.props.isDelinquencyModal, this.props.isCloudDelinquencyGreaterThan90Days),
selectedProductPrice: getSelectedProduct(this.props.products, this.props.yearlyProducts, this.props.productId, false)?.price_per_seat.toString() ?? null,
selectedProduct: getSelectedProduct(this.props.products!),
selectedProductPrice: getSelectedProduct(this.props.products!)?.price_per_seat.toString() ?? null,
});
}
@@ -565,7 +400,7 @@ class PurchaseModal extends React.PureComponent<Props, State> {
};
paymentFooterText = () => {
const normalPaymentText = (
return (
<div className='plan_payment_commencement'>
<FormattedMessage
defaultMessage={'You\'ll be billed from: {beginDate}'}
@@ -576,61 +411,6 @@ class PurchaseModal extends React.PureComponent<Props, State> {
/>
</div>
);
let payment = normalPaymentText;
if (!this.props.isFreeTrial && this.state.currentProduct?.billing_scheme === BillingSchemes.FLAT_FEE &&
this.state.selectedProduct?.billing_scheme === BillingSchemes.PER_SEAT) {
const announcementTooltip = (
<Tooltip
id='proratedPayment__tooltip'
className='proratedTooltip'
>
<div className='tooltipTitle'>
<FormattedMessage
defaultMessage={'Prorated Payments'}
id={'admin.billing.subscription.proratedPayment.tooltipTitle'}
/>
</div>
<div className='tooltipText'>
<FormattedMessage
defaultMessage={'If you upgrade to {selectedProductName} from {currentProductName} mid-month, you will be charged a prorated amount for both plans.'}
id={'admin.billing.subscription.proratedPayment.tooltipText'}
values={{
beginDate: getNextBillingDate(),
selectedProductName: this.state.selectedProduct?.name,
currentProductName: this.state.currentProduct?.name,
}}
/>
</div>
</Tooltip>
);
const announcementIcon = (
<OverlayTrigger
delayShow={Constants.OVERLAY_TIME_DELAY}
placement='top'
overlay={announcementTooltip}
>
<div className='content__icon'>{'\uF5D6'}</div>
</OverlayTrigger>
);
const prorratedPaymentText = (
<div className='prorrated-payment-text'>
{announcementIcon}
<FormattedMessage
defaultMessage={'Prorated payment begins: {beginDate}. '}
id={'admin.billing.subscription.proratedPaymentBegins'}
values={{
beginDate: getNextBillingDate(),
}}
/>
{this.learnMoreLink()}
</div>
);
payment = prorratedPaymentText;
}
return payment;
};
getPlanNameFromProductName = (productName: string): string => {
@@ -658,24 +438,30 @@ class PurchaseModal extends React.PureComponent<Props, State> {
};
handleViewBreakdownClick = () => {
this.props.actions.openModal({
modalId: ModalIdentifiers.BILLING_HISTORY,
dialogType: BillingHistoryModal,
dialogProps: {
invoices: this.props.invoices,
},
});
// If there is only one invoice, we can skip the summary and go straight to the invoice PDF preview for this singular invoice.
if (this.props.invoices?.length === 1) {
this.props.actions.openModal({
modalId: ModalIdentifiers.CLOUD_INVOICE_PREVIEW,
dialogType: CloudInvoicePreview,
dialogProps: {
url: Client4.getInvoicePdfUrl(this.props.invoices[0].id),
},
});
} else {
this.props.actions.openModal({
modalId: ModalIdentifiers.BILLING_HISTORY,
dialogType: BillingHistoryModal,
dialogProps: {
invoices: this.props.invoices,
},
});
}
};
purchaseScreenCard = () => {
if (this.props.isDelinquencyModal) {
return (
<>
{this.props.isCloudDelinquencyGreaterThan90Days ? null : (
<div className='plan_comparison'>
{this.comparePlan}
</div>
)}
<div className='RHS'>
<DelinquencyCard
topColor='#4A69AC'
price={this.getDelinquencyTotalString()}
@@ -693,6 +479,44 @@ class PurchaseModal extends React.PureComponent<Props, State> {
cost={parseInt(this.state.selectedProductPrice || '', 10) * this.props.usersCount}
users={this.props.usersCount}
/>
</div>
);
}
if (this.props.isRenewalModal) {
if (!this.props.subscription || !this.props.subscription.upcoming_invoice) {
return null;
}
const invoice = this.props.subscription?.upcoming_invoice;
const invoiceSummaryProps = buildInvoiceSummaryPropsFromLineItems(invoice?.line_items || []);
if (this.state.seats.quantity !== this.props.usersCount.toString()) {
// If the user has changed the number of seats, the stripe invoice won't yet reflect that new seat count
// We must look for the invoice item that occurs in the future (ie, the invoice item for the next billing period)
// And adjust the quantity, so the summary equates properly
invoiceSummaryProps.fullCharges = invoiceSummaryProps.fullCharges.map((lineitem: InvoiceLineItem) => {
if (new Date(lineitem.period_start * 1000) > new Date()) {
return {
...lineitem,
quantity: parseInt(this.state.seats.quantity, 10),
total: parseInt(this.state.seats.quantity, 10) * lineitem.price_per_unit,
};
}
return lineitem;
});
}
return (
<>
<RenewalCard
invoice={invoice}
product={this.state.selectedProduct || undefined}
seats={this.state.seats}
existingUsers={this.props.usersCount}
onSeatChange={(seats: Seats) => this.setState({seats})}
buttonDisabled={!this.state.paymentInfoIsValid}
onButtonClick={() => this.handleSubmitClick(this.props.callerCTA + '> purchase_modal > renew_button_click')}
{...invoiceSummaryProps}
/>
</>
);
}
@@ -709,14 +533,10 @@ class PurchaseModal extends React.PureComponent<Props, State> {
const yearlyProductMonthlyPrice = formatNumber(parseInt(this.state.selectedProductPrice || '0', 10) / 12, {maximumFractionDigits: 2});
const currentProductMonthly = this.state.currentProduct?.recurring_interval === RecurringIntervals.MONTH;
const currentProductProfessional = this.state.currentProduct?.sku === CloudProducts.PROFESSIONAL;
const currentProductMonthlyProfessional = currentProductMonthly && currentProductProfessional;
const cardBtnText = currentProductMonthlyProfessional ? formatMessage({id: 'pricing_modal.btn.switch_to_annual', defaultMessage: 'Switch to annual billing'}) : formatMessage({id: 'pricing_modal.btn.upgrade', defaultMessage: 'Upgrade'});
const cardBtnText = formatMessage({id: 'pricing_modal.btn.upgrade', defaultMessage: 'Upgrade'});
return (
<>
<div className='RHS'>
<div
className={showPlanLabel ? 'plan_comparison show_label' : 'plan_comparison'}
>
@@ -739,11 +559,7 @@ class PurchaseModal extends React.PureComponent<Props, State> {
planBriefing={<></>}
buttonDetails={{
action: () => {
if (currentProductMonthlyProfessional) {
this.confirmSwitchToAnnual();
} else {
this.handleSubmitClick(this.props.callerCTA + '> purchase_modal > upgrade_button_click');
}
this.handleSubmitClick(this.props.callerCTA + '> purchase_modal > upgrade_button_click');
},
text: cardBtnText,
customClass:
@@ -788,7 +604,7 @@ class PurchaseModal extends React.PureComponent<Props, State> {
/>
}
/>
</>
</div>
);
};
@@ -912,7 +728,7 @@ class PurchaseModal extends React.PureComponent<Props, State> {
/>
)}
</div>
<div className='RHS'>{this.purchaseScreenCard()}</div>
{this.purchaseScreenCard()}
</div>
);
};

View File

@@ -0,0 +1,257 @@
.PurchaseModal {
.RenewalRHS {
position: sticky;
display: flex;
width: 25%;
flex-direction: column;
padding-top: 80px;
.RenewalCard {
max-width: 353px;
padding: 28px 32px;
border: 1px solid var(--light-8-center-channel-text, rgba(61, 60, 64, 0.08));
background: var(--light-center-channel-bg, #fff);
border-radius: 4px;
box-shadow: 0 2px 3px 0 rgba(0, 0, 0, 0.08);
.SeatsCalculator {
padding: 0;
.SeatsCalculator__seats-item.SeatsCalculator__seats-item--input {
padding: 0;
}
}
.RenewalSummary {
width: 332px;
padding: 28px 32px;
border: 1px solid rgba(var(--sys-center-channel-color-rgb), 0.08);
margin-left: 20px;
background-color: var(--sys-center-channel-bg);
border-radius: 4px;
box-shadow: 0 2px 3px rgba(0, 0, 0, 0.08);
}
.RenewalSummary__noBillingHistory {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
}
.RenewalSummary__noBillingHistory-title {
margin-top: 32px;
font-size: 16px;
font-weight: 600;
line-height: 24px;
}
.RenewalSummary__noBillingHistory-message {
margin-top: 8px;
color: var(--sys-center-channel-color);
font-size: 14px;
line-height: 20px;
}
.RenewalSummary__noBillingHistory-link {
margin-top: 16px;
margin-bottom: 24px;
color: var(--sys-button-bg);
font-size: 12px;
font-weight: 600;
}
.RenewalSummary__lastInvoice {
color: var(--sys-center-channel-color);
hr {
border-color: rgba(var(--sys-center-channel-color-rgb), 0.32);
margin: 12px 0;
}
}
.RenewalSummary__lastInvoice-header {
display: flex;
align-items: center;
}
.RenewalSummary__lastInvoice-headerTitle {
font-size: 20px;
font-weight: 600;
line-height: 28px;
}
.BillingSummary__lastInvoice-headerStatus {
display: flex;
align-items: center;
margin-left: auto;
font-size: 12px;
font-weight: 600;
line-height: 16px;
&.paid {
color: var(--sys-online-indicator);
}
&.failed {
color: var(--sys-error-text);
}
&.pending {
color: #f58b00;
}
svg {
width: 16px;
height: 16px;
margin-left: 4px;
font-size: 14.4px;
&::before {
margin: 0 auto;
}
}
span {
margin-left: 4px;
}
}
.RenewalSummary__upcomingInvoice-due-date {
margin-top: 8px;
color: var(--light-72-center-channel-text, rgba(61, 60, 64, 0.72));
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px;
}
.RenewalSummary__lastInvoice-productName {
margin-top: 32px;
font-size: 16px;
font-weight: 600;
line-height: 24px;
}
.RenewalSummary__upcomingInvoice-charge {
display: flex;
margin: 12px 0;
color: var(--center-channel-color);
font-size: 12px;
font-weight: 400;
line-height: 20px;
&.total {
font-size: 14px;
font-weight: 600;
}
}
.RenewalSummary__upcomingInvoice-hasMoreItems {
display: flex;
align-items: center;
color: rgba(var(--center-channel-color-rgb), 0.72);
font-size: 14px;
font-weight: 400;
.RenewalSummary__upcomingInvoice-chargeDescription {
&:hover {
cursor: pointer;
text-decoration: underline;
}
}
}
.RenewalSummary__upcomingInvoice-ellipses {
display: flex;
align-items: center;
align-self: stretch;
justify-content: center;
fill: #d9d9d9;
}
.RenewalSummary__lastInvoice-chargeAmount {
margin-left: auto;
}
.RenewalSummary__lastInvoice-partialCharges {
color: rgba(var(--sys-center-channel-color-rgb), 0.56);
font-size: 12px;
line-height: 16px;
& + .RenewalSummary__lastInvoice-charge {
margin-top: 4px;
}
}
.RenewalSummary__lastInvoice-download {
margin-top: 32px;
margin-bottom: 16px;
}
.RenewalSummary__upcomingInvoice-renew-button {
display: flex;
width: 100%;
align-items: center;
justify-content: center;
padding: 11px 20px;
background: var(--sys-button-bg);
border-radius: 4px;
color: var(--sys-button-color);
&:hover:not(:disabled) {
background: linear-gradient(0deg, rgba(var(--sys-center-channel-color-rgb), 0.16), rgba(var(--sys-center-channel-color-rgb), 0.16)), var(--sys-button-bg);
color: var(--sys-button-color);
cursor: pointer;
text-decoration: none;
}
&:disabled {
background: rgba(var(--sys-center-channel-color-rgb), 0.08);
color: rgba(var(--sys-center-channel-color-rgb), 0.32);
cursor: not-allowed;
}
>span {
margin-left: 6px;
font-size: 14px;
font-weight: 600;
line-height: 14px;
}
}
.RenewalSummary__upcomingInvoice-viewInvoiceLink {
display: block;
width: 100%;
height: 16px;
margin-bottom: 12px;
background: none;
color: var(--link-color, #1c58d9);
font-size: 12px;
font-weight: 600;
line-height: 9.5px;
text-align: center;
}
.RenewalSummary__upcomingInvoice-viewInvoice {
display: block;
width: 100%;
background: none;
color: var(--link-color, #1c58d9);
font-size: 12px;
font-weight: 600;
line-height: 9.5px;
text-align: center;
}
.RenewalSummary__disclaimer {
margin-top: 16px;
color: rgba(var(--center-channel-color-rgb), 0.72);
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 16px;
}
}
}
}

View File

@@ -0,0 +1,318 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage, FormattedNumber, FormattedDate} from 'react-intl';
import {useDispatch} from 'react-redux';
import type {Invoice, InvoiceLineItem, Product} from '@mattermost/types/cloud';
import {Client4} from 'mattermost-redux/client';
import {trackEvent} from 'actions/telemetry_actions';
import {openModal} from 'actions/views/modals';
import {getPaymentStatus} from 'components/admin_console/billing/billing_summary/billing_summary';
import CloudInvoicePreview from 'components/cloud_invoice_preview';
import OverlayTrigger from 'components/overlay_trigger';
import type {Seats} from 'components/seats_calculator';
import SeatsCalculator from 'components/seats_calculator';
import Tooltip from 'components/tooltip';
import EllipsisHorizontalIcon from 'components/widgets/icons/ellipsis_h_icon';
import {BillingSchemes, ModalIdentifiers, TELEMETRY_CATEGORIES, CloudLinks} from 'utils/constants';
import './renewal_card.scss';
type RenewalCardProps = {
invoice: Invoice;
product?: Product;
hasMore?: number;
fullCharges: InvoiceLineItem[];
partialCharges: InvoiceLineItem[];
seats: Seats;
existingUsers: number;
onSeatChange: (seats: Seats) => void;
buttonDisabled?: boolean;
onButtonClick?: () => void;
};
export default function RenewalCard({invoice, product, hasMore, fullCharges, partialCharges, seats, onSeatChange, existingUsers, buttonDisabled, onButtonClick}: RenewalCardProps) {
const dispatch = useDispatch();
const openInvoicePreview = () => {
dispatch(
openModal({
modalId: ModalIdentifiers.CLOUD_INVOICE_PREVIEW,
dialogType: CloudInvoicePreview,
dialogProps: {
url: Client4.getInvoicePdfUrl(invoice.id),
},
}),
);
};
const seeHowBillingWorks = (
e: React.MouseEvent<HTMLAnchorElement, MouseEvent>,
) => {
e.preventDefault();
trackEvent(
TELEMETRY_CATEGORIES.CLOUD_ADMIN,
'click_see_how_billing_works',
);
window.open(CloudLinks.DELINQUENCY_DOCS, '_blank');
};
return (
<div className='RenewalRHS'>
<div className='RenewalCard'>
<div className='RenewalSummary__lastInvoice-header'>
<div className='RenewalSummary__lastInvoice-headerTitle'>
<FormattedMessage
id='admin.billing.subscription.invoice.next'
defaultMessage='Next Invoice'
/>
</div>
{getPaymentStatus(invoice.status)}
</div>
<div className='RenewalSummary__upcomingInvoice-due-date'>
<FormattedMessage
id={'cloud.renewal.tobepaid'}
defaultMessage={'To be paid on {date}'}
values={{
date: (
<FormattedDate
value={new Date(invoice.period_start)}
month='short'
year='numeric'
day='numeric'
timeZone='UTC'
/>
),
}}
/>
</div>
<div className='RenewalSummary__lastInvoice-productName'>
{product?.name}
</div>
<hr style={{marginTop: '12px'}}/>
<SeatsCalculator
price={product!.price_per_seat}
seats={seats}
onChange={(seats: Seats) => {
onSeatChange(seats);
}}
isCloud={true}
existingUsers={existingUsers}
excludeTotal={true}
/>
{fullCharges.map((charge: any) => (
<div
key={charge.price_id}
className='RenewalSummary__upcomingInvoice-charge'
>
<div className='RenewalSummary__upcomingInvoice-chargeDescription'>
<>
<FormattedNumber
value={charge.price_per_unit / 100.0}
// eslint-disable-next-line react/style-prop-object
style='currency'
currency='USD'
/>
<FormattedMessage
id='admin.billing.subscriptions.billing_summary.lastInvoice.seatCount'
defaultMessage=' x {seats} seats'
values={{seats: charge.quantity}}
/>
{(' ')}
{'('}
<FormattedDate
value={new Date(charge.period_start * 1000)}
month='numeric'
year='numeric'
day='numeric'
timeZone='UTC'
/>
{')'}
</>
</div>
<div className='RenewalSummary__lastInvoice-chargeAmount'>
<FormattedNumber
value={charge.total / 100.0}
// eslint-disable-next-line react/style-prop-object
style='currency'
currency='USD'
/>
</div>
</div>
))}
{Boolean(hasMore) && (
<div
className='RenewalSummary__upcomingInvoice-hasMoreItems'
>
<div
onClick={openInvoicePreview}
className='RenewalSummary__upcomingInvoice-chargeDescription'
>
{product?.billing_scheme === BillingSchemes.FLAT_FEE ? (
<FormattedMessage
id='admin.billing.subscriptions.billing_summary.lastInvoice.monthlyFlatFee'
defaultMessage='Monthly Flat Fee'
/>
) : (
<>
<FormattedMessage
id='cloud.renewal.andMoreItems'
defaultMessage='+ {count} more items'
values={{count: hasMore}}
/>
</>
)}
</div>
</div>
)}
{Boolean(partialCharges.length) && (
<>
<div className='RenewalSummary__lastInvoice-partialCharges'>
<FormattedMessage
id='admin.billing.subscriptions.billing_summary.lastInvoice.partialCharges'
defaultMessage='Partial charges'
/>
<OverlayTrigger
delayShow={500}
placement='bottom'
overlay={
<Tooltip
id='BillingSubscriptions__seatOverageTooltip'
className='BillingSubscriptions__tooltip BillingSubscriptions__tooltip-right'
positionLeft={390}
>
<div className='BillingSubscriptions__tooltipTitle'>
<FormattedMessage
id='admin.billing.subscriptions.billing_summary.lastInvoice.whatArePartialCharges'
defaultMessage='What are partial charges?'
/>
</div>
<div className='BillingSubscriptions__tooltipMessage'>
<FormattedMessage
id='admin.billing.subscriptions.billing_summary.lastInvoice.whatArePartialCharges.message'
defaultMessage='Users who have not been enabled for the full duration of the month are charged at a prorated monthly rate.'
/>
</div>
</Tooltip>
}
>
<i className='icon-information-outline'/>
</OverlayTrigger>
</div>
{partialCharges.map((charge: any) => (
<div
key={charge.price_id}
className='RenewalSummary__lastInvoice-charge'
>
<div className='RenewalSummary__lastInvoice-chargeDescription'>
<FormattedMessage
id='admin.billing.subscriptions.billing_summary.lastInvoice.seatCountPartial'
defaultMessage='{seats} seats'
values={{seats: charge.quantity}}
/>
</div>
<div className='RenewalSummary__lastInvoice-chargeAmount'>
<FormattedNumber
value={charge.total / 100.0}
// eslint-disable-next-line react/style-prop-object
style='currency'
currency='USD'
/>
</div>
</div>
))}
</>
)}
{Boolean(hasMore) && (
<div className='RenewalSummary__upcomingInvoice-ellipses'>
<EllipsisHorizontalIcon width={'40px'}/>
</div>
)}
{Boolean(invoice.tax) && (
<div className='RenewalSummary__lastInvoice-charge'>
<div className='RenewalSummary__lastInvoice-chargeDescription'>
<FormattedMessage
id='admin.billing.subscriptions.billing_summary.lastInvoice.taxes'
defaultMessage='Taxes'
/>
</div>
<div className='RenewalSummary__lastInvoice-chargeAmount'>
<FormattedNumber
value={invoice.tax / 100.0}
// eslint-disable-next-line react/style-prop-object
style='currency'
currency='USD'
/>
</div>
</div>
)}
<button
onClick={openInvoicePreview}
className='RenewalSummary__upcomingInvoice-viewInvoiceLink'
>
<FormattedMessage
id='cloud.renewal.viewInvoice'
defaultMessage='View Invoice'
/>
</button>
<hr style={{marginTop: '0'}}/>
<div className='RenewalSummary__upcomingInvoice-charge total'>
<div className='RenewalSummary__upcomingInvoice-chargeDescription'>
<FormattedMessage
id='admin.billing.subscriptions.billing_summary.lastInvoice.total'
defaultMessage='Total'
/>
</div>
<div className='RenewalSummary__lastInvoice-chargeAmount'>
<FormattedNumber
value={fullCharges.reduce((sum: number, item) => sum + item.total, 0) / 100.0}
// eslint-disable-next-line react/style-prop-object
style='currency'
currency='USD'
/>
</div>
</div>
<button
onClick={onButtonClick}
className='RenewalSummary__upcomingInvoice-renew-button'
disabled={Boolean(buttonDisabled) || Boolean(seats.error)}
>
<FormattedMessage
id='cloud.renewal.renew'
defaultMessage='Renew'
/>
</button>
<div className='RenewalSummary__disclaimer'>
<FormattedMessage
defaultMessage={
'Your bill is calculated at the end of the billing cycle based on the number of enabled users. {seeHowBillingWorks}'
}
id={
'cloud_delinquency.cc_modal.disclaimer_with_upgrade_info'
}
values={{
seeHowBillingWorks: (
<a onClick={seeHowBillingWorks}>
<FormattedMessage
defaultMessage={'See how billing works.'}
id={
'admin.billing.subscription.howItWorks'
}
/>
</a>
),
}}
/>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,42 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Product} from '@mattermost/types/cloud';
import {CloudProducts, RecurringIntervals} from 'utils/constants';
/**
*
* @param products Record<string, Product> | undefined - the list of current cloud products
* @param productId String - a valid product id used to find a particular product in the dictionary
* @param productSku String - the sku value of the product of type either cloud-starter | cloud-professional | cloud-enterprise
* @returns Product
*/
export function findProductInDictionary(products: Record<string, Product> | undefined, productId?: string | null, productSku?: string, productRecurringInterval?: string): Product | null {
if (!products) {
return null;
}
const keys = Object.keys(products);
if (!keys.length) {
return null;
}
if (!productId && !productSku) {
return products[keys[0]];
}
let currentProduct = products[keys[0]];
if (keys.length > 1) {
// here find the product by the provided id or name, otherwise return the one with Professional in the name
keys.forEach((key) => {
if (productId && products[key].id === productId) {
currentProduct = products[key];
} else if (productSku && products[key].sku === productSku && products[key].recurring_interval === productRecurringInterval) {
currentProduct = products[key];
}
});
}
return currentProduct;
}
export function getSelectedProduct(yearlyProducts: Record<string, Product>) {
return findProductInDictionary(yearlyProducts, null, CloudProducts.PROFESSIONAL, RecurringIntervals.YEAR);
}

View File

@@ -98,7 +98,6 @@ exports[`components/Root Routes Should mount public product routes 1`] = `
<GlobalHeader />
<CloudEffectsWrapper />
<withRouter(Connect(TeamSidebar)) />
<DelinquencyModalController />
<Switch>
<Route
key="productwithpublic-public"

View File

@@ -86,7 +86,6 @@ const LazyCreateTeam = React.lazy(() => import('components/create_team'));
const LazyMfa = React.lazy(() => import('components/mfa/mfa_controller'));
const LazyPreparingWorkspace = React.lazy(() => import('components/preparing_workspace'));
const LazyTeamController = React.lazy(() => import('components/team_controller'));
const LazyDelinquencyModalController = React.lazy(() => import('components/delinquency_modal'));
const LazyOnBoardingTaskList = React.lazy(() => import('components/onboarding_tasklist'));
const CreateTeam = makeAsyncComponent('CreateTeam', LazyCreateTeam);
@@ -107,7 +106,6 @@ const Authorize = makeAsyncComponent('Authorize', LazyAuthorize);
const Mfa = makeAsyncComponent('Mfa', LazyMfa);
const PreparingWorkspace = makeAsyncComponent('PreparingWorkspace', LazyPreparingWorkspace);
const TeamController = makeAsyncComponent('TeamController', LazyTeamController);
const DelinquencyModalController = makeAsyncComponent('DelinquencyModalController', LazyDelinquencyModalController);
const OnBoardingTaskList = makeAsyncComponent('OnboardingTaskList', LazyOnBoardingTaskList);
type LoggedInRouteProps = {
@@ -583,7 +581,6 @@ export default class Root extends React.PureComponent<Props, State> {
<GlobalHeader/>
<CloudEffects/>
<TeamSidebar/>
<DelinquencyModalController/>
<Switch>
{this.props.products?.filter((product) => Boolean(product.publicComponent)).map((product) => (
<Route

View File

@@ -20,6 +20,7 @@ interface Props {
existingUsers: number;
isCloud: boolean;
onChange: (seats: Seats) => void;
excludeTotal?: boolean;
}
export interface Seats {
@@ -210,31 +211,35 @@ export default function SeatsCalculator(props: Props) {
</div>
</div>
</div>
<div className='SeatsCalculator__seats-item'>
<div className='SeatsCalculator__seats-label'>
<FormattedMessage
id='self_hosted_signup.line_item_subtotal'
defaultMessage='{num} seats × 12 mo.'
values={{
num: props.seats.quantity || '0',
}}
/>
</div>
<div className='SeatsCalculator__seats-value'>
{total}
</div>
</div>
<div className='SeatsCalculator__total'>
<div className='SeatsCalculator__total-label'>
<FormattedMessage
id='self_hosted_signup.total'
defaultMessage='Total'
/>
</div>
<div className='SeatsCalculator__total-value'>
{total}
</div>
</div>
{!props.excludeTotal && (
<>
<div className='SeatsCalculator__seats-item'>
<div className='SeatsCalculator__seats-label'>
<FormattedMessage
id='self_hosted_signup.line_item_subtotal'
defaultMessage='{num} seats × 12 mo.'
values={{
num: props.seats.quantity || '0',
}}
/>
</div>
<div className='SeatsCalculator__seats-value'>
{total}
</div>
</div>
<div className='SeatsCalculator__total'>
<div className='SeatsCalculator__total-label'>
<FormattedMessage
id='self_hosted_signup.total'
defaultMessage='Total'
/>
</div>
<div className='SeatsCalculator__total-value'>
{total}
</div>
</div>
</>
)}
</div>
</div>

View File

@@ -4,13 +4,18 @@
import React from 'react';
import {useIntl} from 'react-intl';
export default function EllipsisHorizontalIcon(props: React.HTMLAttributes<HTMLSpanElement>) {
type Props = React.HTMLAttributes<HTMLSpanElement> & {
width?: string;
height?: string;
};
export default function EllipsisHorizontalIcon(props: Props) {
const {formatMessage} = useIntl();
return (
<span {...props}>
<svg
width='24px'
height='24px'
width={props.width || '24px'}
height={props.width || '24px'}
viewBox='0 0 24 24'
role='img'
aria-label={formatMessage({id: 'generic_icons.elipsisHorizontalIcon', defaultMessage: 'Ellipsis Horizontal Icon'})}

View File

@@ -387,9 +387,6 @@
"admin.billing.subscription.privateCloudCard.upgradeNow": "Upgrade Now",
"admin.billing.subscription.proratedPayment.substitle": "Thank you for upgrading to {selectedProductName}. Check your workspace in a few minutes to access all the plan's features. You'll be charged a prorated amount for your {currentProductName} plan and {selectedProductName} plan based on the number of days left in the billing cycle and number of users you have.",
"admin.billing.subscription.proratedPayment.title": "You are now subscribed to {selectedProductName}",
"admin.billing.subscription.proratedPayment.tooltipText": "If you upgrade to {selectedProductName} from Cloud Free mid-month, you will be charged a prorated amount for both plans.",
"admin.billing.subscription.proratedPayment.tooltipTitle": "Prorated Payments",
"admin.billing.subscription.proratedPaymentBegins": "Prorated payment begins: {beginDate}. ",
"admin.billing.subscription.providePaymentDetails": "Provide your payment details",
"admin.billing.subscription.returnToTeam": "Return to {team}",
"admin.billing.subscription.stateprovince": "State/Province",
@@ -404,6 +401,7 @@
"admin.billing.subscription.viewBilling": "View Billing",
"admin.billing.subscriptions.billing_summary.explore_enterprise": "Explore Enterprise features",
"admin.billing.subscriptions.billing_summary.explore_enterprise.cta": "View all features",
"admin.billing.subscriptions.billing_summary.lastInvoice.approved": "Approved",
"admin.billing.subscriptions.billing_summary.lastInvoice.failed": "Failed",
"admin.billing.subscriptions.billing_summary.lastInvoice.monthlyFlatFee": "Monthly Flat Fee",
"admin.billing.subscriptions.billing_summary.lastInvoice.paid": "Paid",
@@ -3188,38 +3186,22 @@
"cloud_billing.nudge_to_paid.learn_more": "Upgrade",
"cloud_billing.nudge_to_paid.title": "Upgrade to paid plan to keep your workspace",
"cloud_billing.nudge_to_paid.view_plans": "View plans",
"cloud_billing.nudge_to_yearly.announcement_bar": "Monthly billing will be discontinued in {days} days . Switch to annual billing",
"cloud_billing.nudge_to_yearly.contact_sales": "Contact sales",
"cloud_billing.nudge_to_yearly.description": "Monthly billing will be discontinued on {date}. To keep your workspace, switch to annual billing.",
"cloud_billing.nudge_to_yearly.learn_more": "Learn more",
"cloud_billing.nudge_to_yearly.title": "Action required: Switch to annual billing to keep your workspace.",
"cloud_billing.nudge_to_yearly.update_billing": "Update billing",
"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.cc_modal.card.reactivate": "Reactivate",
"cloud_delinquency.cc_modal.card.totalOwed": "Total owed",
"cloud_delinquency.cc_modal.card.viewBreakdown": "View breakdown",
"cloud_delinquency.cc_modal.disclaimer": "When you reactivate your subscription, you'll be billed the total outstanding amount immediately. Your bill is calculated at the end of the billing cycle based on the number of active users. {seeHowBillingWorks}",
"cloud_delinquency.cc_modal.disclaimer_with_upgrade_info": "When you reactivate your subscription, you'll be billed the total outstanding amount immediately. You'll also be billed {cost} immediately for a 1 year subscription based on your current active user count of {users} users. {seeHowBillingWorks}",
"cloud_delinquency.modal.re_activate_plan": "Reactivate {planName}",
"cloud_delinquency.modal.stay_on_freemium": "Stay on Mattermost Free",
"cloud_delinquency.modal.stay_on_freemium_close": "Close",
"cloud_delinquency.modal.update_billing": "Update billing",
"cloud_delinquency.modal.workspace_downgraded": "Your workspace has been downgraded",
"cloud_delinquency.modal.workspace_downgraded_description": "Due to payment issues on your {paidPlan}, your workspace has been downgraded to the free plan. To access {paidPlan} features again, update your billing information now.",
"cloud_delinquency.modal.workspace_downgraded_freemium": "Cloud Free is restricted to 10,000 message history and 1GB file storage.",
"cloud_delinquency.modal.workspace_downgraded_freemium_limits": "Free plan limits",
"cloud_delinquency.modal.workspace_downgraded_freemium_title": "You now have data limits on your plan",
"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_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",
"cloud.fetch_error": "Error fetching billing data. Please try again later.",
"cloud.fetch_error.retry": "Retry",
"cloud.invoice_pdf_preview.download": "<downloadLink>Download</downloadLink> this page for your records",
"cloud.renewal.andMoreItems": "+ {count} more items",
"cloud.renewal.renew": "Renew",
"cloud.renewal.tobepaid": "To be paid on {date}",
"cloud.renewal.viewInvoice": "View Invoice",
"cloud.startTrial.modal.btn": "Start trial",
"collapsed_reply_threads_modal.confirm": "Got it",
"collapsed_reply_threads_modal.description": "Threads have been revamped to help you create organized conversation around specific messages. Now, channels will appear less cluttered as replies are collapsed under the original message, and all the conversations you're following are available in your **Threads** view. Take the tour to see what's new.",

View File

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

View File

@@ -1,12 +1,26 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Product, CloudCustomer, Limits} from '@mattermost/types/cloud';
import type {CloudCustomer, InvoiceLineItem} from '@mattermost/types/cloud';
import {trackEvent} from 'actions/telemetry_actions';
import {CloudProducts, CloudLinks} from 'utils/constants';
import {hasSomeLimits} from 'utils/limits';
import {CloudLinks} from 'utils/constants';
export function buildInvoiceSummaryPropsFromLineItems(lineItems: InvoiceLineItem[]) {
let fullCharges = lineItems.filter((item) => item.type === 'full');
const partialCharges = lineItems.filter((item) => item.type === 'partial');
if (!partialCharges.length && !fullCharges.length) {
fullCharges = lineItems;
}
let hasMoreLineItems = 0;
if (fullCharges.length > 5) {
hasMoreLineItems = fullCharges.length - 5;
fullCharges = fullCharges.slice(0, 5);
}
return {partialCharges, fullCharges, hasMore: hasMoreLineItems};
}
export function isCustomerCardExpired(customer?: CloudCustomer): boolean {
if (!customer) {
@@ -31,10 +45,6 @@ export function openExternalPricingLink() {
window.open(CloudLinks.PRICING, '_blank');
}
export function isCloudFreePlan(product: Product | undefined, limits: Limits): boolean {
return product?.sku === CloudProducts.STARTER && hasSomeLimits(limits);
}
export const FREEMIUM_TO_ENTERPRISE_TRIAL_LENGTH_DAYS = 30;
export function daysToExpiration(expirationDate: number): number {

View File

@@ -187,6 +187,8 @@ export type InvoiceLineItem = {
description: string;
type: typeof InvoiceLineItemType[keyof typeof InvoiceLineItemType];
metadata: Record<string, string>;
period_start: number;
period_end: number;
}
export type Limits = {