From b40cd113fa4f0d8efd57ebb7e2bac017fb79e882 Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Fri, 24 Mar 2023 14:48:47 -0400 Subject: [PATCH] Migrate to mono-repo --- .../channels/src/actions/hosted_customer.tsx | 88 ++- .../enterprise_edition.scss | 25 +- .../enterprise_edition_left_panel.test.tsx | 86 ++- .../enterprise_edition_left_panel.tsx | 61 ++- .../common/hooks/useCanSelfHostedExpand.ts | 46 ++ .../useControlSelfHostedExpansionModal.ts | 93 ++++ .../useControlSelfHostedPurchaseModal.ts | 2 + .../purchase_in_progress_modal/index.test.tsx | 20 +- .../purchase_in_progress_modal/index.tsx | 4 +- .../self_hosted_expansion_modal/constants.tsx | 4 + .../error_page.scss | 3 + .../error_page.tsx | 70 +++ .../expansion_card.scss | 140 +++++ .../expansion_card.tsx | 268 ++++++++++ .../index.test.tsx | 418 +++++++++++++++ .../self_hosted_expansion_modal/index.tsx | 503 ++++++++++++++++++ .../self_hosted_expansion_modal.scss | 178 +++++++ .../success_page.scss | 20 + .../success_page.tsx | 77 +++ .../self_hosted_purchase_modal/index.tsx | 13 +- webapp/channels/src/utils/constants.tsx | 4 + webapp/channels/src/utils/hosted_customer.ts | 11 + webapp/platform/client/src/client4.ts | 8 + webapp/platform/types/src/hosted_customer.ts | 4 + 24 files changed, 2111 insertions(+), 35 deletions(-) create mode 100644 webapp/channels/src/components/common/hooks/useCanSelfHostedExpand.ts create mode 100644 webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts create mode 100644 webapp/channels/src/components/self_hosted_expansion_modal/constants.tsx create mode 100644 webapp/channels/src/components/self_hosted_expansion_modal/error_page.scss create mode 100644 webapp/channels/src/components/self_hosted_expansion_modal/error_page.tsx create mode 100644 webapp/channels/src/components/self_hosted_expansion_modal/expansion_card.scss create mode 100644 webapp/channels/src/components/self_hosted_expansion_modal/expansion_card.tsx create mode 100644 webapp/channels/src/components/self_hosted_expansion_modal/index.test.tsx create mode 100644 webapp/channels/src/components/self_hosted_expansion_modal/index.tsx create mode 100644 webapp/channels/src/components/self_hosted_expansion_modal/self_hosted_expansion_modal.scss create mode 100644 webapp/channels/src/components/self_hosted_expansion_modal/success_page.scss create mode 100644 webapp/channels/src/components/self_hosted_expansion_modal/success_page.tsx diff --git a/webapp/channels/src/actions/hosted_customer.tsx b/webapp/channels/src/actions/hosted_customer.tsx index 81d02c63fd..9ae2d350ba 100644 --- a/webapp/channels/src/actions/hosted_customer.tsx +++ b/webapp/channels/src/actions/hosted_customer.tsx @@ -6,7 +6,7 @@ import {Stripe} from '@stripe/stripe-js'; import {getCode} from 'country-list'; import {CreateSubscriptionRequest} from '@mattermost/types/cloud'; -import {SelfHostedSignupProgress} from '@mattermost/types/hosted_customer'; +import {SelfHostedExpansionRequest, SelfHostedSignupProgress} from '@mattermost/types/hosted_customer'; import {ValueOf} from '@mattermost/types/utilities'; import {Client4} from 'mattermost-redux/client'; @@ -198,3 +198,89 @@ export function getTrueUpReviewStatus(): ActionFunc { onRequest: HostedCustomerTypes.TRUE_UP_REVIEW_STATUS_REQUEST, }); } + +export function confirmSelfHostedExpansion( + stripe: Stripe, + stripeSetupIntent: StripeSetupIntent, + isDevMode: boolean, + billingDetails: BillingDetails, + initialProgress: ValueOf, + expansionRequest: SelfHostedExpansionRequest, +): ActionFunc { + return async (dispatch: DispatchFunc) => { + const cardSetupFunction = getConfirmCardSetup(isDevMode); + const confirmCardSetup = cardSetupFunction(stripe.confirmCardSetup); + + const shouldConfirmCard = selfHostedNeedsConfirmation(initialProgress); + if (shouldConfirmCard) { + const result = await confirmCardSetup( + stripeSetupIntent.client_secret, + { + payment_method: { + card: billingDetails.card, + billing_details: { + name: billingDetails.name, + address: { + line1: billingDetails.address, + line2: billingDetails.address2, + city: billingDetails.city, + state: billingDetails.state, + country: getCode(billingDetails.country), + postal_code: billingDetails.postalCode, + }, + }, + }, + }, + ); + + if (!result) { + return {data: false, error: 'failed to confirm card with Stripe'}; + } + + const {setupIntent, error: stripeError} = result; + + if (stripeError) { + if (stripeError.code === STRIPE_UNEXPECTED_STATE && stripeError.message === STRIPE_ALREADY_SUCCEEDED && stripeError.setup_intent?.status === 'succeeded') { + dispatch({ + type: HostedCustomerTypes.RECEIVED_SELF_HOSTED_SIGNUP_PROGRESS, + data: SelfHostedSignupProgress.CONFIRMED_INTENT, + }); + } else { + return {data: false, error: stripeError.message || 'Stripe failed to confirm payment method'}; + } + } else { + if (setupIntent === null || setupIntent === undefined) { + return {data: false, error: 'Stripe did not return successful setup intent'}; + } + + if (setupIntent.status !== 'succeeded') { + return {data: false, error: `Stripe setup intent status was: ${setupIntent.status}`}; + } + dispatch({ + type: HostedCustomerTypes.RECEIVED_SELF_HOSTED_SIGNUP_PROGRESS, + data: SelfHostedSignupProgress.CONFIRMED_INTENT, + }); + } + } + + let confirmResult; + try { + confirmResult = await Client4.confirmSelfHostedExpansion(stripeSetupIntent.id, expansionRequest); + dispatch({ + type: HostedCustomerTypes.RECEIVED_SELF_HOSTED_SIGNUP_PROGRESS, + data: confirmResult.progress, + }); + } catch (error) { + // eslint-disable-next-line no-console + console.error(error); + + // unprocessable entity, e.g. failed export compliance + if (error.status_code === 422) { + return {data: false, error: error.status_code}; + } + return {data: false, error}; + } + + return {data: confirmResult.license}; + }; +} diff --git a/webapp/channels/src/components/admin_console/license_settings/enterprise_edition/enterprise_edition.scss b/webapp/channels/src/components/admin_console/license_settings/enterprise_edition/enterprise_edition.scss index 881616d1cf..69ab2d6e1b 100644 --- a/webapp/channels/src/components/admin_console/license_settings/enterprise_edition/enterprise_edition.scss +++ b/webapp/channels/src/components/admin_console/license_settings/enterprise_edition/enterprise_edition.scss @@ -104,7 +104,7 @@ .license-details-top { display: flex; - justify-content: flex-start; + justify-content: space-between; font-size: 14px; font-weight: 700; line-height: 24px; @@ -114,10 +114,11 @@ color: #3f4350; } - span.expiration-days { - margin-left: auto; - color: var(--denim-status-online); + .add-seats-button { + border-radius: 4px; + font-family: 'Open Sans', sans-serif; font-size: 12px; + font-weight: 600; } } @@ -157,6 +158,20 @@ font-weight: 600; } } + + span.expiration-days { + margin-left: 8px; + font-size: 14px; + font-weight: 600; + + &-warning { + color: var(--sys-away-indicator); + } + + &-danger { + color: var(--dnd-indicator); + } + } } .add-new-licence-btn { @@ -194,4 +209,4 @@ } } } -} +} \ No newline at end of file diff --git a/webapp/channels/src/components/admin_console/license_settings/enterprise_edition/enterprise_edition_left_panel.test.tsx b/webapp/channels/src/components/admin_console/license_settings/enterprise_edition/enterprise_edition_left_panel.test.tsx index ef6a3d387f..551af99212 100644 --- a/webapp/channels/src/components/admin_console/license_settings/enterprise_edition/enterprise_edition_left_panel.test.tsx +++ b/webapp/channels/src/components/admin_console/license_settings/enterprise_edition/enterprise_edition_left_panel.test.tsx @@ -6,15 +6,20 @@ import {screen} from '@testing-library/react'; import {Provider} from 'react-redux'; +import moment from 'moment-timezone'; + import {mountWithIntl} from 'tests/helpers/intl-test-helper'; import {renderWithIntl} from 'tests/react_testing_utils'; -import {OverActiveUserLimits} from 'utils/constants'; +import {OverActiveUserLimits, SelfHostedProducts} from 'utils/constants'; +import {TestHelper} from 'utils/test_helper'; import {General} from 'mattermost-redux/constants'; import {DeepPartial} from '@mattermost/types/utilities'; import {GlobalState} from '@mattermost/types/store'; import mockStore from 'tests/test_store'; +import * as useCanSelfHostedExpand from 'components/common/hooks/useCanSelfHostedExpand'; + import EnterpriseEditionLeftPanel, {EnterpriseEditionProps} from './enterprise_edition_left_panel'; describe('components/admin_console/license_settings/enterprise_edition/enterprise_edition_left_panel', () => { @@ -26,7 +31,7 @@ describe('components/admin_console/license_settings/enterprise_edition/enterpris SkuShortName: 'Enterprise', Name: 'LicenseName', Company: 'Mattermost Inc.', - Users: '1000000', + Users: '1000', }; const initialState: DeepPartial = { @@ -45,10 +50,36 @@ describe('components/admin_console/license_settings/enterprise_edition/enterpris }, general: { license, + config: { + BuildEnterpriseReady: 'true', + }, }, preferences: { myPreferences: {}, }, + admin: { + config: { + ServiceSettings: { + SelfHostedExpansion: true, + }, + }, + }, + cloud: { + subscription: undefined, + }, + hostedCustomer: { + products: { + products: { + prod_professional: TestHelper.getProductMock({ + id: 'prod_professional', + name: 'Professional', + sku: SelfHostedProducts.PROFESSIONAL, + price_per_seat: 7.5, + }), + }, + productsLoaded: true, + }, + }, }, }; @@ -80,12 +111,12 @@ describe('components/admin_console/license_settings/enterprise_edition/enterpris const item = wrapper.find('.item-element').filterWhere((n) => { return n.children().length === 2 && - n.childAt(0).type() === 'span' && - !n.childAt(0).text().includes('ACTIVE') && - n.childAt(0).text().includes('USERS'); + n.childAt(0).type() === 'span' && + !n.childAt(0).text().includes('ACTIVE') && + n.childAt(0).text().includes('USERS'); }); - expect(item.text()).toContain('1,000,000'); + expect(item.text()).toContain('1,000'); }); test('should not add any class if active users is lower than the minimal', async () => { @@ -146,4 +177,47 @@ describe('components/admin_console/license_settings/enterprise_edition/enterpris expect(screen.getByText('ACTIVE USERS:')).not.toHaveClass('legend--warning-over-seats-purchased'); expect(screen.getByText('ACTIVE USERS:')).toHaveClass('legend--over-seats-purchased'); }); + + test('should add warning class to days expired indicator when there are more than 5 days until expiry', async () => { + license.ExpiresAt = moment().add(6, 'days').valueOf().toString(); + const store = await mockStore(initialState); + renderWithIntl( + + + , + ); + + expect(screen.getByText('Expires in 6 days')).toHaveClass('expiration-days-warning'); + }); + + test('should add danger class to days expired indicator when there are at least 5 days until expiry', async () => { + license.ExpiresAt = moment().add(5, 'days').valueOf().toString(); + const store = await mockStore(initialState); + renderWithIntl( + + + , + ); + + expect(screen.getByText('Expires in 5 days')).toHaveClass('expiration-days-danger'); + }); + + test('should display add seats button when there are more than 60 days until expiry and self hosted expansion is available', async () => { + license.ExpiresAt = moment().add(61, 'days').valueOf().toString(); + const store = await mockStore(initialState); + jest.spyOn(useCanSelfHostedExpand, 'default').mockImplementation(() => true); + renderWithIntl( + + + , + ); + + expect(screen.getByText('+ Add seats')).toBeVisible(); + }); }); diff --git a/webapp/channels/src/components/admin_console/license_settings/enterprise_edition/enterprise_edition_left_panel.tsx b/webapp/channels/src/components/admin_console/license_settings/enterprise_edition/enterprise_edition_left_panel.tsx index c3f37bfd93..0d4a422a27 100644 --- a/webapp/channels/src/components/admin_console/license_settings/enterprise_edition/enterprise_edition_left_panel.tsx +++ b/webapp/channels/src/components/admin_console/license_settings/enterprise_edition/enterprise_edition_left_panel.tsx @@ -4,6 +4,7 @@ import React, {RefObject, useEffect, useState} from 'react'; import classNames from 'classnames'; import {FormattedDate, FormattedMessage, FormattedNumber, FormattedTime, useIntl} from 'react-intl'; +import {useSelector} from 'react-redux'; import Tag from 'components/widgets/tag/tag'; @@ -15,9 +16,16 @@ import {getRemainingDaysFromFutureTimestamp, toTitleCase} from 'utils/utils'; import {FileTypes} from 'utils/constants'; import {getSkuDisplayName} from 'utils/subscription'; import {calculateOverageUserActivated} from 'utils/overage_team'; +import {getConfig} from 'mattermost-redux/selectors/entities/admin'; import './enterprise_edition.scss'; import useOpenPricingModal from 'components/common/hooks/useOpenPricingModal'; +import useCanSelfHostedExpand from 'components/common/hooks/useCanSelfHostedExpand'; +import {getExpandSeatsLink} from 'selectors/cloud'; +import useControlSelfHostedExpansionModal from 'components/common/hooks/useControlSelfHostedExpansionModal'; + +const DAYS_UNTIL_EXPIRY_WARNING_DISPLAY_THRESHOLD = 30; +const DAYS_UNTIL_EXPIRY_DANGER_DISPLAY_THRESHOLD = 5; export interface EnterpriseEditionProps { openEELicenseModal: () => void; @@ -47,10 +55,12 @@ const EnterpriseEditionLeftPanel = ({ const {formatMessage} = useIntl(); const [unsanitizedLicense, setUnsanitizedLicense] = useState(license); const openPricingModal = useOpenPricingModal(); + const canExpand = useCanSelfHostedExpand(); + const selfHostedExpansionModal = useControlSelfHostedExpansionModal({trackingLocation: 'license_settings_add_seats'}); + const expandableLink = useSelector(getExpandSeatsLink); useEffect(() => { async function fetchUnSanitizedLicense() { - // This solves this the issue reported here: https://mattermost.atlassian.net/browse/MM-42906 try { const unsanitizedL = await Client4.getClientLicenseOld(); setUnsanitizedLicense(unsanitizedL); @@ -63,6 +73,7 @@ const EnterpriseEditionLeftPanel = ({ const skuName = getSkuDisplayName(unsanitizedLicense.SkuShortName, unsanitizedLicense.IsGovSku === 'true'); const expirationDays = getRemainingDaysFromFutureTimestamp(parseInt(unsanitizedLicense.ExpiresAt, 10)); + const isSelfHostedExpansionEnabled = useSelector(getConfig)?.ServiceSettings?.SelfHostedExpansion; const viewPlansButton = ( } { @@ -134,6 +159,7 @@ const EnterpriseEditionLeftPanel = ({ fileInputRef, handleChange, statsActiveUsers, + expirationDays, ) } @@ -162,7 +188,7 @@ const EnterpriseEditionLeftPanel = ({ type LegendValues = 'START DATE:' | 'EXPIRES:' | 'USERS:' | 'ACTIVE USERS:' | 'EDITION:' | 'LICENSE ISSUED:' | 'NAME:' | 'COMPANY / ORG:' -const renderLicenseValues = (activeUsers: number, seatsPurchased: number) => ({legend, value}: {legend: LegendValues; value: string | JSX.Element | null}, index: number): React.ReactNode => { +const renderLicenseValues = (activeUsers: number, seatsPurchased: number, expirationDays: number) => ({legend, value}: {legend: LegendValues; value: string | JSX.Element | null}, index: number): React.ReactNode => { if (legend === 'ACTIVE USERS:') { const {isBetween5PercerntAnd10PercentPurchasedSeats, isOver10PercerntPurchasedSeats} = calculateOverageUserActivated({activeUsers, seatsPurchased}); return ( @@ -186,6 +212,26 @@ const renderLicenseValues = (activeUsers: number, seatsPurchased: number) => ({l >{value} ); + } else if (legend === 'EXPIRES:') { + return ( +
+ {legend} + {value} + {(expirationDays <= DAYS_UNTIL_EXPIRY_WARNING_DISPLAY_THRESHOLD) && + + {`Expires in ${expirationDays} day${expirationDays > 1 ? 's' : ''}`} + + } +
+ ); } return ( @@ -209,6 +255,7 @@ const renderLicenseContent = ( fileInputRef: RefObject, handleChange: () => void, statsActiveUsers: number, + expirationDays: number, ) => { // Note: DO NOT LOCALISE THESE STRINGS. Legally we can not since the license is in English. @@ -246,7 +293,7 @@ const renderLicenseContent = ( return (
- {licenseValues.map(renderLicenseValues(statsActiveUsers, parseInt(license.Users, 10)))} + {licenseValues.map(renderLicenseValues(statsActiveUsers, parseInt(license.Users, 10), expirationDays))}
{renderAddNewLicenseButton(fileInputRef, handleChange)} {renderRemoveButton(handleRemove, isDisabled, removing)} diff --git a/webapp/channels/src/components/common/hooks/useCanSelfHostedExpand.ts b/webapp/channels/src/components/common/hooks/useCanSelfHostedExpand.ts new file mode 100644 index 0000000000..93f2d2092e --- /dev/null +++ b/webapp/channels/src/components/common/hooks/useCanSelfHostedExpand.ts @@ -0,0 +1,46 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {useEffect, useState} from 'react'; +import {useSelector} from 'react-redux'; + +import {Client4} from 'mattermost-redux/client'; +import {getConfig, getLicense} from 'mattermost-redux/selectors/entities/general'; +import {getSubscriptionProduct} from 'mattermost-redux/selectors/entities/cloud'; +import {BillingSchemes, SelfHostedProducts} from 'utils/constants'; + +import {isCloudLicense} from 'utils/license_utils'; + +import {findSelfHostedProductBySku} from 'utils/hosted_customer'; + +import useGetSelfHostedProducts from './useGetSelfHostedProducts'; + +export default function useCanSelfHostedExpand() { + // NOTE: This is a basic implementation to get things up and running, more details to come later. + const [expansionAvailable, setExpansionAvailable] = useState(false); + const config = useSelector(getConfig); + const isEnterpriseReady = config.BuildEnterpriseReady === 'true'; + const isSalesServeOnly = useSelector(getSubscriptionProduct)?.billing_scheme === BillingSchemes.SALES_SERVE; + const license = useSelector(getLicense); + const isCloud = isCloudLicense(license); + const [products] = useGetSelfHostedProducts(); + const currentProduct = findSelfHostedProductBySku(products, license.SkuShortName); + + // Self Hosted Products never contains a product for starter, additional check is done out of caution. + const isSelfHostedStarter = currentProduct === null || currentProduct?.sku === SelfHostedProducts.STARTER; + + useEffect(() => { + if (!isEnterpriseReady) { + return; + } + Client4.getLicenseSelfServeStatus(). + then((res) => { + setExpansionAvailable(res.is_expandable ?? false); + }). + catch(() => { + setExpansionAvailable(false); + }); + }, [isEnterpriseReady]); + + return !isCloud && !isSelfHostedStarter && !isSalesServeOnly && expansionAvailable; +} diff --git a/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts b/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts new file mode 100644 index 0000000000..acca8853e0 --- /dev/null +++ b/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts @@ -0,0 +1,93 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {useMemo} from 'react'; +import {useDispatch, useSelector} from 'react-redux'; + +import {trackEvent} from 'actions/telemetry_actions'; +import {openModal} from 'actions/views/modals'; +import {ModalIdentifiers, TELEMETRY_CATEGORIES} from 'utils/constants'; +import PurchaseInProgressModal from 'components/purchase_in_progress_modal'; +import {Client4} from 'mattermost-redux/client'; +import {getCurrentUser} from 'mattermost-redux/selectors/entities/common'; +import {HostedCustomerTypes} from 'mattermost-redux/action_types'; + +import {STORAGE_KEY_EXPANSION_IN_PROGRESS} from 'components/self_hosted_expansion_modal/constants'; +import SelfHostedExpansionModal from 'components/self_hosted_expansion_modal'; + +import {useControlModal, ControlModal} from './useControlModal'; + +interface HookOptions{ + onClick?: () => void; + trackingLocation: string; +} + +export default function useControlSelfHostedExpansionModal(options: HookOptions): ControlModal { + const dispatch = useDispatch(); + const currentUser = useSelector(getCurrentUser); + const controlModal = useControlModal({ + modalId: ModalIdentifiers.SELF_HOSTED_EXPANSION, + dialogType: SelfHostedExpansionModal, + }); + + return useMemo(() => { + return { + ...controlModal, + open: async () => { + const purchaseInProgress = localStorage.getItem(STORAGE_KEY_EXPANSION_IN_PROGRESS) === 'true'; + + // check if user already has an open purchase modal in current browser. + if (purchaseInProgress) { + // User within the same browser session + // is already trying to purchase. Notify them of this + // and request the exit that purchase flow before attempting again. + dispatch(openModal({ + modalId: ModalIdentifiers.PURCHASE_IN_PROGRESS, + dialogType: PurchaseInProgressModal, + dialogProps: { + purchaserEmail: currentUser.email, + storageKey: STORAGE_KEY_EXPANSION_IN_PROGRESS, + }, + })); + return; + } + + trackEvent(TELEMETRY_CATEGORIES.SELF_HOSTED_EXPANSION, 'click_open_expansion_modal', { + callerInfo: options.trackingLocation, + }); + + if (options.onClick) { + options.onClick(); + } + + try { + const result = await Client4.bootstrapSelfHostedSignup(); + + if (result.email !== currentUser.email) { + // Token already exists and was created by another admin. + // Notify user of this and do not allow them to try to expand concurrently. + dispatch(openModal({ + modalId: ModalIdentifiers.EXPANSION_IN_PROGRESS, + dialogType: PurchaseInProgressModal, + dialogProps: { + purchaserEmail: result.email, + storageKey: STORAGE_KEY_EXPANSION_IN_PROGRESS, + }, + })); + return; + } + + dispatch({ + type: HostedCustomerTypes.RECEIVED_SELF_HOSTED_SIGNUP_PROGRESS, + data: result.progress, + }); + + controlModal.open(); + } catch (e) { + // eslint-disable-next-line no-console + console.error('error bootstrapping self hosted purchase modal', e); + } + }, + }; + }, [controlModal, options.onClick, options.trackingLocation]); +} diff --git a/webapp/channels/src/components/common/hooks/useControlSelfHostedPurchaseModal.ts b/webapp/channels/src/components/common/hooks/useControlSelfHostedPurchaseModal.ts index d6e3d1cdec..1cbd91372b 100644 --- a/webapp/channels/src/components/common/hooks/useControlSelfHostedPurchaseModal.ts +++ b/webapp/channels/src/components/common/hooks/useControlSelfHostedPurchaseModal.ts @@ -63,6 +63,7 @@ export default function useControlSelfHostedPurchaseModal(options: HookOptions): dialogType: PurchaseInProgressModal, dialogProps: { purchaserEmail: currentUser.email, + storageKey: STORAGE_KEY_PURCHASE_IN_PROGRESS, }, })); return; @@ -86,6 +87,7 @@ export default function useControlSelfHostedPurchaseModal(options: HookOptions): dialogType: PurchaseInProgressModal, dialogProps: { purchaserEmail: result.email, + storageKey: STORAGE_KEY_PURCHASE_IN_PROGRESS, }, })); return; diff --git a/webapp/channels/src/components/purchase_in_progress_modal/index.test.tsx b/webapp/channels/src/components/purchase_in_progress_modal/index.test.tsx index 268d8dbb90..fc1b87159e 100644 --- a/webapp/channels/src/components/purchase_in_progress_modal/index.test.tsx +++ b/webapp/channels/src/components/purchase_in_progress_modal/index.test.tsx @@ -11,6 +11,8 @@ import {GlobalState} from 'types/store'; import {TestHelper as TH} from 'utils/test_helper'; import {Client4} from 'mattermost-redux/client'; +import {STORAGE_KEY_PURCHASE_IN_PROGRESS} from 'components/self_hosted_purchase_modal/constants'; + import PurchaseInProgressModal from './'; jest.mock('mattermost-redux/client', () => { @@ -56,13 +58,27 @@ describe('PurchaseInProgressModal', () => { it('when purchaser and user emails are different, user is instructed to wait', () => { const stateOverride: DeepPartial = JSON.parse(JSON.stringify(initialState)); stateOverride.entities!.users!.currentUserId = 'otherUserId'; - renderWithIntlAndStore(
, stateOverride); + renderWithIntlAndStore( +
+ +
, stateOverride, + ); screen.getByText('@UserAdmin is currently attempting to purchase a paid license.'); }); it('when purchaser and user emails are same, allows user to reset purchase flow', () => { - renderWithIntlAndStore(
, initialState); + renderWithIntlAndStore( +
+ +
, initialState, + ); expect(Client4.bootstrapSelfHostedSignup).not.toHaveBeenCalled(); screen.getByText('Reset purchase flow').click(); diff --git a/webapp/channels/src/components/purchase_in_progress_modal/index.tsx b/webapp/channels/src/components/purchase_in_progress_modal/index.tsx index 1a7cf3be80..2e0483a401 100644 --- a/webapp/channels/src/components/purchase_in_progress_modal/index.tsx +++ b/webapp/channels/src/components/purchase_in_progress_modal/index.tsx @@ -13,13 +13,13 @@ import {Client4} from 'mattermost-redux/client'; import CreditCardSvg from 'components/common/svg_images_components/credit_card_svg'; import {useControlPurchaseInProgressModal} from 'components/common/hooks/useControlModal'; -import {STORAGE_KEY_PURCHASE_IN_PROGRESS} from 'components/self_hosted_purchase_modal/constants'; import './index.scss'; import {GlobalState} from '@mattermost/types/store'; interface Props { purchaserEmail: string; + storageKey: string; } export default function PurchaseInProgressModal(props: Props) { @@ -64,7 +64,7 @@ export default function PurchaseInProgressModal(props: Props) { ); genericModalProps.handleConfirm = () => { - localStorage.removeItem(STORAGE_KEY_PURCHASE_IN_PROGRESS); + localStorage.removeItem(props.storageKey); Client4.bootstrapSelfHostedSignup(true); close(); }; diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/constants.tsx b/webapp/channels/src/components/self_hosted_expansion_modal/constants.tsx new file mode 100644 index 0000000000..83c13f3567 --- /dev/null +++ b/webapp/channels/src/components/self_hosted_expansion_modal/constants.tsx @@ -0,0 +1,4 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +export const STORAGE_KEY_EXPANSION_IN_PROGRESS = 'EXPANSION_IN_PROGRESS'; diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/error_page.scss b/webapp/channels/src/components/self_hosted_expansion_modal/error_page.scss new file mode 100644 index 0000000000..9a25362e9d --- /dev/null +++ b/webapp/channels/src/components/self_hosted_expansion_modal/error_page.scss @@ -0,0 +1,3 @@ +.self_hosted_expansion_failed { + margin-top: 163px; +} diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/error_page.tsx b/webapp/channels/src/components/self_hosted_expansion_modal/error_page.tsx new file mode 100644 index 0000000000..76b9af34d4 --- /dev/null +++ b/webapp/channels/src/components/self_hosted_expansion_modal/error_page.tsx @@ -0,0 +1,70 @@ +// 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 {getCloudContactUsLink, InquiryType} from 'selectors/cloud'; + +import PaymentFailedSvg from 'components/common/svg_images_components/payment_failed_svg'; +import IconMessage from 'components/purchase_modal/icon_message'; + +import './error_page.scss'; + +export default function SelfHostedExpansionErrorPage() { + const contactSupportLink = useSelector(getCloudContactUsLink)(InquiryType.Technical); + + const formattedTitle = ( + + ); + + const formattedButtonText = ( + + ); + + const formattedSubtitle = ( + + ); + + const tertiaryButtonText = ( + + ); + + const icon = ( + + ); + + return ( +
+ { + //TODO: Open self hosted expansion modal + }} + formattedTertiaryButonText={tertiaryButtonText} + tertiaryButtonHandler={() => window.open(contactSupportLink, '_blank', 'noreferrer')} + /> +
+ ); +} diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/expansion_card.scss b/webapp/channels/src/components/self_hosted_expansion_modal/expansion_card.scss new file mode 100644 index 0000000000..79efc3e325 --- /dev/null +++ b/webapp/channels/src/components/self_hosted_expansion_modal/expansion_card.scss @@ -0,0 +1,140 @@ +.SelfHostedExpansionRHSCard { + display: flex; + max-width: 280px; + flex-direction: column; + + &__Content { + padding: 24px; + border: 1px solid; + border-color: rgba(var(--sys-denim-center-channel-text-rgb), 0.16); + border-radius: 4px; + } + + &__RHSCardTitle { + display: block; + margin-bottom: 12px; + color: rgba(var(--sys-denim-center-channel-text-rgb), 0.72); + font-family: 'Open Sans'; + font-size: 14px; + font-weight: 600; + text-align: center; + text-transform: capitalize; + } + + .seatsInput { + width: 73px; + margin-left: auto; + font-family: 'Open Sans'; + font-size: 14px; + font-weight: 400; + + input[type="number"] { + text-align: right; + } + + input[type="number"]::-webkit-inner-spin-button, + input[type="number"]::-webkit-outer-spin-button { + margin: 0; + -webkit-appearance: none; + } + } + + &__PlanDetails { + display: flex; + flex-direction: column; + text-align: center; + + .planName { + color: rgba(var(--sys-denim-center-channel-text-rgb), 0.72); + font-family: 'Metropolis'; + font-size: 20px; + font-weight: 400; + text-transform: capitalize; + } + + .usage { + color: rgba(var(--sys-denim-center-channel-text-rgb), 0.56); + font-family: 'Open Sans'; + font-size: 12px; + font-weight: 600; + + :first-child { + text-transform: uppercase; + } + } + } + + hr { + width: 90%; + height: 2px; + background-color: rgba(var(--sys-denim-center-channel-text-rgb), 0.16); + } + + &__seatInput, + &__cost_breakdown { + display: grid; + font-weight: 400; + gap: 10px; + grid-template-columns: repeat(2, 1fr); + + .costPerUser > span:first-child { + font-family: 'Open Sans'; + font-size: 14px; + } + + .costPerUser > span:last-child { + color: rgba(var(--sys-denim-center-channel-text-rgb), 0.72); + font-family: 'Open Sans'; + font-size: 12px; + } + + .totalCost { + width: 141px; + } + + .totalCost > span:first-child { + color: var(--sys-denim-center-channel-text); + font-family: 'Open Sans'; + font-size: 14px; + font-weight: 700; + } + + .totalCost > span:last-child { + color: rgba(var(--sys-denim-center-channel-text-rgb), 0.72); + font-family: 'Open Sans'; + font-size: 12px; + } + + .costAmount { + margin-right: 0; + margin-left: auto; + font-weight: 700; + } + } + + &__AddSeatsWarning { + display: block; + width: 100%; + height: 35px; + margin-bottom: 15px; + color: var(--dnd-indicator); + font-family: 'Open Sans'; + font-size: 12px; + font-weight: 600; + text-align: right; + } + + &__CompletePurchaseButton { + width: 100%; + margin-top: 10px; + margin-bottom: 10px; + border-radius: 4px; + } + + &__ChargedTodayDisclaimer { + color: rgba(var(--sys-denim-center-channel-text-rgb), 0.72); + font-family: 'Open Sans'; + font-size: 12px; + font-weight: 400; + } +} diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/expansion_card.tsx b/webapp/channels/src/components/self_hosted_expansion_modal/expansion_card.tsx new file mode 100644 index 0000000000..d79d6b66fc --- /dev/null +++ b/webapp/channels/src/components/self_hosted_expansion_modal/expansion_card.tsx @@ -0,0 +1,268 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {OutlinedInput} from '@mui/material'; + +import moment from 'moment-timezone'; +import React, {Fragment, useState} from 'react'; +import {FormattedMessage} from 'react-intl'; +import {useSelector} from 'react-redux'; + +import {getLicense} from 'mattermost-redux/selectors/entities/general'; +import {DocLinks, RecurringIntervals} from 'utils/constants'; +import WarningIcon from 'components/widgets/icons/fa_warning_icon'; + +import './expansion_card.scss'; +import useGetSelfHostedProducts from 'components/common/hooks/useGetSelfHostedProducts'; +import {findSelfHostedProductBySku} from 'utils/hosted_customer'; +import ExternalLink from 'components/external_link'; + +const MONTHS_IN_YEAR = 12; +const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000; +const MAX_TRANSACTION_VALUE = 1_000_000 - 1; + +interface Props { + canSubmit: boolean; + licensedSeats: number; + initialSeats: number; + submit: () => void; + updateSeats: (seats: number) => void; +} + +export default function SelfHostedExpansionCard(props: Props) { + const license = useSelector(getLicense); + const startsAt = moment(parseInt(license.StartsAt, 10)).format('MMM. D, YYYY'); + const endsAt = moment(parseInt(license.ExpiresAt, 10)).format('MMM. D, YYYY'); + const [additionalSeats, setAdditionalSeats] = useState(props.initialSeats); + const [overMaxSeats, setOverMaxSeats] = useState(false); + const licenseExpiry = parseInt(license.ExpiresAt, 10); + const invalidAdditionalSeats = additionalSeats === 0 || isNaN(additionalSeats); + const [products] = useGetSelfHostedProducts(); + const currentProduct = findSelfHostedProductBySku(products, license.SkuShortName); + + const getMonthsUntilExpiry = () => { + const now = new Date(); + return Math.ceil((licenseExpiry - now.getTime()) / MILLISECONDS_PER_DAY / 30); + }; + + const getMonthlyPrice = () => { + if (currentProduct === null) { + return 0; + } + + if (currentProduct?.recurring_interval === RecurringIntervals.MONTH) { + return currentProduct.price_per_seat; + } + + const costPerMonth = (currentProduct.price_per_seat / MONTHS_IN_YEAR); + + // Only display 2 decimal places if the cost per month is not evenly divisible over 12 months. + if (!Number.isInteger(costPerMonth)) { + // Keep the return value as a number. + return costPerMonth; + } + + return costPerMonth; + }; + + const getCostPerUser = () => { + if (isNaN(additionalSeats)) { + return 0; + } + const monthlyPrice = getMonthlyPrice(); + const monthsUntilExpiry = getMonthsUntilExpiry(); + return monthlyPrice * monthsUntilExpiry; + }; + + const getTotal = () => { + if (isNaN(additionalSeats)) { + return 0; + } + const monthlyPrice = getMonthlyPrice(); + const monthsUntilExpiry = getMonthsUntilExpiry(); + return additionalSeats * monthlyPrice * monthsUntilExpiry; + }; + + // Finds the maximum number of additional seats that is possible, taking into account + // the stripe transaction limit. The maximum number of seats will follow the formula: + // (StripeTransaction Limit - (Current_Seats * Price Per Seat)) / price_per_seat + const getMaximumAdditionalSeats = () => { + if (currentProduct === null) { + return 0; + } + + let recurringCost = 0; + + // if monthly + if (currentProduct.recurring_interval === RecurringIntervals.MONTH) { + recurringCost = getMonthlyPrice(); + } else { // if yearly + recurringCost = currentProduct.price_per_seat; + } + + const currentPaymentPrice = recurringCost * props.licensedSeats; + const remainingTransactionLimit = MAX_TRANSACTION_VALUE - currentPaymentPrice; + const remainingSeats = Math.floor(remainingTransactionLimit / recurringCost); + return Math.max(0, remainingSeats); + }; + + const maxAdditionalSeats = getMaximumAdditionalSeats(); + + const handleNewSeatsInputChange = (e: React.ChangeEvent) => { + setOverMaxSeats(false); + + const requestedSeats = parseInt(e.target.value, 10); + + const overMaxAdditionalSeats = requestedSeats > maxAdditionalSeats; + setOverMaxSeats(overMaxAdditionalSeats); + + const finalSeatCount = overMaxAdditionalSeats ? maxAdditionalSeats : requestedSeats; + setAdditionalSeats(finalSeatCount); + + props.updateSeats(finalSeatCount); + }; + + return ( +
+
+ +
+
+
+ {license.SkuShortName} +
+ +
+ +
+
+
+
+ + +
+
+ {invalidAdditionalSeats && !overMaxSeats && + , + }} + /> + } + {overMaxSeats && maxAdditionalSeats > 0 && + , + }} + /> + } + {maxAdditionalSeats === 0 && + , + warningIcon: , + }} + /> + } +
+
+
+ +
+ +
+
+ {'$' + getCostPerUser().toFixed(2)} +
+
+ +
+ +
+ + {'$' + getTotal().toFixed(2)} + +
+ +
+ ( + +
+ + {text} + +
+ ), + }} + /> +
+
+
+ ); +} diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/index.test.tsx b/webapp/channels/src/components/self_hosted_expansion_modal/index.test.tsx new file mode 100644 index 0000000000..05f6386302 --- /dev/null +++ b/webapp/channels/src/components/self_hosted_expansion_modal/index.test.tsx @@ -0,0 +1,418 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import {screen, fireEvent} from '@testing-library/react'; + +import {GlobalState} from 'types/store'; + +import {SelfHostedSignupForm, SelfHostedSignupProgress} from '@mattermost/types/hosted_customer'; + +import {renderWithIntlAndStore} from 'tests/react_testing_utils'; +import {TestHelper as TH} from 'utils/test_helper'; +import {SelfHostedProducts, ModalIdentifiers} from 'utils/constants'; + +import {DeepPartial} from '@mattermost/types/utilities'; + +import SelfHostedExpansionModal, {makeInitialState, canSubmit, FormState} from './'; + +interface MockCardInputProps { + onCardInputChange: (event: {complete: boolean}) => void; + forwardedRef: React.MutableRefObject; +} + +// number borrowed from stripe +const successCardNumber = '4242424242424242'; +function MockCardInput(props: MockCardInputProps) { + props.forwardedRef.current = { + getCard: () => ({}), + }; + return ( + ) => { + if (e.target.value === successCardNumber) { + props.onCardInputChange({complete: true}); + } + }} + /> + ); +} + +jest.mock('components/payment_form/card_input', () => { + const original = jest.requireActual('components/payment_form/card_input'); + return { + ...original, + __esModule: true, + default: MockCardInput, + }; +}); + +jest.mock('components/self_hosted_purchase_modal/stripe_provider', () => { + return function(props: {children: React.ReactNode | React.ReactNodeArray}) { + return props.children; + }; +}); + +jest.mock('components/common/hooks/useLoadStripe', () => { + return function() { + return {current: { + stripe: {}, + + }}; + }; +}); + +const mockCreatedIntent = SelfHostedSignupProgress.CREATED_INTENT; +const mockCreatedLicense = SelfHostedSignupProgress.CREATED_LICENSE; +const failOrg = 'failorg'; + +const existingUsers = 10; + +const mockProfessionalProduct = TH.getProductMock({ + id: 'prod_professional', + name: 'Professional', + sku: SelfHostedProducts.PROFESSIONAL, + price_per_seat: 7.5, +}); + +jest.mock('mattermost-redux/client', () => { + const original = jest.requireActual('mattermost-redux/client'); + return { + __esModule: true, + ...original, + Client4: { + ...original.Client4, + pageVisited: jest.fn(), + setAcceptLanguage: jest.fn(), + trackEvent: jest.fn(), + createCustomerSelfHostedSignup: (form: SelfHostedSignupForm) => { + if (form.organization === failOrg) { + throw new Error('error creating customer'); + } + return Promise.resolve({ + progress: mockCreatedIntent, + }); + }, + confirmSelfHostedSignup: () => Promise.resolve({ + progress: mockCreatedLicense, + license: {Users: existingUsers * 2}, + }), + getClientLicenseOld: () => Promise.resolve({ + data: {Sku: 'Enterprise'}, + }), + }, + }; +}); + +jest.mock('components/payment_form/stripe', () => { + const original = jest.requireActual('components/payment_form/stripe'); + return { + __esModule: true, + ...original, + getConfirmCardSetup: () => () => () => ({setupIntent: {status: 'succeeded'}, error: null}), + }; +}); + +jest.mock('utils/hosted_customer', () => { + const original = jest.requireActual('utils/hosted_customer'); + return { + __esModule: true, + ...original, + findSelfHostedProductBySku: () => { + return mockProfessionalProduct; + }, + }; +}); + +const productName = SelfHostedProducts.PROFESSIONAL; + +const initialState: DeepPartial = { + views: { + modals: { + modalState: { + [ModalIdentifiers.SELF_HOSTED_EXPANSION]: { + open: true, + }, + }, + }, + }, + storage: { + storage: {}, + }, + entities: { + admin: { + analytics: { + TOTAL_USERS: existingUsers, + }, + }, + teams: { + currentTeamId: '', + }, + preferences: { + myPreferences: { + theme: {}, + }, + }, + general: { + config: { + EnableDeveloper: 'false', + }, + license: { + Sku: productName, + Users: '50', + }, + }, + cloud: { + subscription: {}, + }, + users: { + currentUserId: 'adminUserId', + profiles: { + adminUserId: TH.getUserMock({ + id: 'adminUserId', + roles: 'admin', + first_name: 'first', + last_name: 'admin', + }), + otherUserId: TH.getUserMock({ + id: 'otherUserId', + roles: '', + first_name: '', + last_name: '', + }), + }, + filteredStats: { + total_users_count: 100, + }, + }, + hostedCustomer: { + products: { + productsLoaded: true, + products: { + prod_professional: mockProfessionalProduct, + }, + }, + signupProgress: SelfHostedSignupProgress.START, + }, + }, +}; + +const valueEvent = (value: any) => ({target: {value}}); +function changeByPlaceholder(sel: string, val: any) { + fireEvent.change(screen.getByPlaceholderText(sel), valueEvent(val)); +} + +function selectDropdownValue(testId: string, value: string) { + fireEvent.change(screen.getByTestId(testId).querySelector('input') as any, valueEvent(value)); + fireEvent.click(screen.getByTestId(testId).querySelector('.DropDown__option--is-focused') as any); +} + +function changeByTestId(testId: string, value: string) { + fireEvent.change(screen.getByTestId(testId).querySelector('input') as any, valueEvent(value)); +} + +interface PurchaseForm { + card: string; + org: string; + name: string; + country: string; + address: string; + city: string; + state: string; + zip: string; + seats: string; +} + +const defaultSuccessForm: PurchaseForm = { + card: successCardNumber, + org: 'My org', + name: 'The Cardholder', + country: 'United States of America', + address: '123 Main Street', + city: 'Minneapolis', + state: 'MN', + zip: '55423', + seats: '10', +}; + +function fillForm(form: PurchaseForm) { + changeByPlaceholder('Card number', form.card); + changeByPlaceholder('Organization Name', form.org); + changeByPlaceholder('Name on Card', form.name); + selectDropdownValue('selfHostedExpansionCountrySelector', form.country); + changeByPlaceholder('Address', form.address); + changeByPlaceholder('City', form.city); + selectDropdownValue('selfHostedExpansionStateSelector', form.state); + changeByPlaceholder('Zip/Postal Code', form.zip); + changeByTestId('seatsInput', form.seats); + + expect(document.getElementsByClassName('SelfHostedExpansionRHSCard__AddSeatsWarning')[0] as HTMLElement).toBeEnabled(); + + // not changing the license seats number, + // because it is expected to be pre-filled with the correct number of seats. + + const completeButton = screen.getByText('Complete purchase'); + + if (form === defaultSuccessForm) { + expect(completeButton).toBeEnabled(); + } + + return completeButton; +} + +describe('SelfHostedExpansionModal', () => { + it('renders the form', () => { + renderWithIntlAndStore(
, initialState); + + screen.getByText('Provide your payment details'); + screen.getByText('Add new seats'); + screen.getByText('Contact Sales'); + screen.getByText('Cost per user', {exact: false}); + + // screen.getByText(productName, {normalizer: (val) => {return val.charAt(0).toUpperCase() + val.slice(1)}}); + screen.getByText('Your credit card will be charged today.'); + screen.getByText('See how billing works', {exact: false}); + }); + + it('filling the form enables expansion', () => { + renderWithIntlAndStore(
, initialState); + expect(screen.getByText('Complete purchase')).toBeDisabled(); + fillForm(defaultSuccessForm); + }); + + it('disables expansion if too few seats or no seats entered', () => { + renderWithIntlAndStore(
, initialState); + fillForm(defaultSuccessForm); + + // 0 seats entered. + const tooFewSeats = 0; + fireEvent.change(screen.getByTestId('seatsInput').querySelector('input') as HTMLElement, valueEvent(tooFewSeats.toString())); + expect(screen.getByText('Complete purchase')).toBeDisabled(); + expect(screen.getByText('You must add a seat to continue')).toBeVisible(); + + // No seats value entered. + fireEvent.change(screen.getByTestId('seatsInput').querySelector('input') as HTMLElement, undefined); + expect(screen.getByText('Complete purchase')).toBeDisabled(); + expect(screen.getByText('You must add a seat to continue')).toBeVisible(); + }); + + // it('happy path submit shows success screen', async () => { + // renderWithIntlAndStore(
, initialState); + // expect(screen.getByText('Complete purchase')).toBeDisabled(); + // const upgradeButton = fillForm(defaultSuccessForm); + + // upgradeButton.click(); + // await waitFor(() => expect(screen.getByText(`You're now subscribed to ${productName}`)).toBeTruthy(), {timeout: 1234}); + // }); + + // it('sad path submit shows error screen', async () => { + // renderWithIntlAndStore(
, initialState); + // expect(screen.getByText('Complete purchase')).toBeDisabled(); + // fillForm(defaultSuccessForm); + // changeByPlaceholder('Organization Name', failOrg); + + // const upgradeButton = screen.getByText('Complete purchase'); + // expect(upgradeButton).toBeEnabled(); + // upgradeButton.click(); + // await waitFor(() => expect(screen.getByText('Sorry, the payment verification failed')).toBeTruthy(), {timeout: 1234}); + // }); +}); + +describe('SelfHostedExpansionModal :: canSubmit', () => { + function makeHappyPathState(): FormState { + return { + address: 'string', + address2: 'string', + city: 'string', + state: 'string', + country: 'string', + postalCode: '12345', + cardName: 'string', + organization: 'string', + cardFilled: true, + seats: 1, + submitting: false, + succeeded: false, + progressBar: 0, + error: '', + }; + } + it('if submitting, can not submit again', () => { + const state = makeHappyPathState(); + state.submitting = true; + expect(canSubmit(state, SelfHostedSignupProgress.CREATED_LICENSE)).toBe(false); + }); + + it('if created license, can submit', () => { + const state = makeInitialState(1); + state.submitting = false; + expect(canSubmit(state, SelfHostedSignupProgress.CREATED_LICENSE)).toBe(true); + }); + + it('if paid, can submit', () => { + const state = makeInitialState(1); + state.submitting = false; + expect(canSubmit(state, SelfHostedSignupProgress.PAID)).toBe(true); + }); + + // TODO: Needed? + it('if created subscription, can submit', () => { + const state = makeInitialState(1); + state.submitting = false; + expect(canSubmit(state, SelfHostedSignupProgress.CREATED_SUBSCRIPTION)).toBe(true); + }); + + it('if all details filled and card has not been confirmed, can submit', () => { + const state = makeHappyPathState(); + expect(canSubmit(state, SelfHostedSignupProgress.START)).toBe(true); + expect(canSubmit(state, SelfHostedSignupProgress.CREATED_CUSTOMER)).toBe(true); + expect(canSubmit(state, SelfHostedSignupProgress.CREATED_INTENT)).toBe(true); + }); + + it('if card name missing and card has not been confirmed, can not submit', () => { + const state = makeHappyPathState(); + state.cardName = ''; + expect(canSubmit(state, SelfHostedSignupProgress.START)).toBe(false); + expect(canSubmit(state, SelfHostedSignupProgress.CREATED_CUSTOMER)).toBe(false); + expect(canSubmit(state, SelfHostedSignupProgress.CREATED_INTENT)).toBe(false); + }); + + it('if card number missing and card has not been confirmed, can not submit', () => { + const state = makeHappyPathState(); + state.cardFilled = false; + expect(canSubmit(state, SelfHostedSignupProgress.START)).toBe(false); + expect(canSubmit(state, SelfHostedSignupProgress.CREATED_CUSTOMER)).toBe(false); + expect(canSubmit(state, SelfHostedSignupProgress.CREATED_INTENT)).toBe(false); + }); + + it('if address not filled and card has not been confirmed, can not submit', () => { + const state = makeHappyPathState(); + state.address = ''; + expect(canSubmit(state, SelfHostedSignupProgress.START)).toBe(false); + expect(canSubmit(state, SelfHostedSignupProgress.CREATED_CUSTOMER)).toBe(false); + expect(canSubmit(state, SelfHostedSignupProgress.CREATED_INTENT)).toBe(false); + }); + + it('if seats not valid and card has not been confirmed, can not submit', () => { + const state = makeHappyPathState(); + state.seats = 0; + expect(canSubmit(state, SelfHostedSignupProgress.START)).toBe(false); + expect(canSubmit(state, SelfHostedSignupProgress.CREATED_CUSTOMER)).toBe(false); + expect(canSubmit(state, SelfHostedSignupProgress.CREATED_INTENT)).toBe(false); + }); + + it('if card confirmed, card not required for submission', () => { + const state = makeHappyPathState(); + state.cardFilled = false; + state.cardName = ''; + expect(canSubmit(state, SelfHostedSignupProgress.CONFIRMED_INTENT)).toBe(true); + }); + + it('if passed unknown progress status, can not submit', () => { + const state = makeHappyPathState(); + expect(canSubmit(state, 'unknown status' as any)).toBe(false); + }); +}); diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx b/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx new file mode 100644 index 0000000000..914d464877 --- /dev/null +++ b/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx @@ -0,0 +1,503 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useEffect, useRef, useState} from 'react'; + +import {useIntl} from 'react-intl'; + +import {useDispatch, useSelector} from 'react-redux'; + +import {StripeCardElementChangeEvent} from '@stripe/stripe-js'; + +import UpgradeSvg from 'components/common/svg_images_components/upgrade_svg'; +import RootPortal from 'components/root_portal'; +import ContactSalesLink from 'components/self_hosted_purchase_modal/contact_sales_link'; + +import useLoadStripe from 'components/common/hooks/useLoadStripe'; +import CardInput, {CardInputType} from 'components/payment_form/card_input'; +import FullScreenModal from 'components/widgets/modals/full_screen_modal'; +import Input from 'components/widgets/inputs/input/input'; + +import BackgroundSvg from 'components/common/svg_images_components/background_svg'; +import {COUNTRIES} from 'utils/countries'; +import StateSelector from 'components/payment_form/state_selector'; +import {getTheme} from 'mattermost-redux/selectors/entities/preferences'; +import DropdownInput from 'components/dropdown_input'; +import StripeProvider from '../self_hosted_purchase_modal/stripe_provider'; + +import {closeModal} from 'actions/views/modals'; +import {ModalIdentifiers, TELEMETRY_CATEGORIES} from 'utils/constants'; +import {getLicense} from 'mattermost-redux/selectors/entities/general'; +import {getCurrentUser, getFilteredUsersStats} from 'mattermost-redux/selectors/entities/users'; +import {pageVisited} from 'actions/telemetry_actions'; + +import {Client4} from 'mattermost-redux/client'; +import {HostedCustomerTypes} from 'mattermost-redux/action_types'; +import {getSelfHostedSignupProgress} from 'mattermost-redux/selectors/entities/hosted_customer'; +import {inferNames} from 'utils/hosted_customer'; +import {SelfHostedSignupCustomerResponse, SelfHostedSignupProgress} from '@mattermost/types/hosted_customer'; +import {isDevModeEnabled} from 'selectors/general'; +import {getLicenseConfig} from 'mattermost-redux/actions/general'; +import {confirmSelfHostedExpansion} from 'actions/hosted_customer'; +import {DispatchFunc} from 'mattermost-redux/types/actions'; +import {ValueOf} from '@mattermost/types/utilities'; + +import SelfHostedExpansionCard from './expansion_card'; + +import './self_hosted_expansion_modal.scss'; + +import {STORAGE_KEY_EXPANSION_IN_PROGRESS} from './constants'; + +export interface FormState { + address: string; + address2: string; + city: string; + state: string; + country: string; + postalCode: string; + cardName: string; + organization: string; + cardFilled: boolean; + seats: number; + submitting: boolean; + succeeded: boolean; + progressBar: number; + error: string; +} + +export function makeInitialState(seats: number): FormState { + return { + address: '', + address2: '', + city: '', + state: '', + country: '', + postalCode: '', + cardName: '', + organization: '', + cardFilled: false, + seats, + submitting: false, + succeeded: false, + progressBar: 0, + error: '', + }; +} + +export function canSubmit(formState: FormState, progress: ValueOf) { + if (formState.submitting) { + return false; + } + + const validAddress = Boolean( + formState.organization && + formState.address && + formState.city && + formState.state && + formState.postalCode && + formState.country, + ); + const validCard = Boolean( + formState.cardName && + formState.cardFilled, + ); + const validSeats = formState.seats > 0; + + switch (progress) { + case SelfHostedSignupProgress.PAID: + case SelfHostedSignupProgress.CREATED_LICENSE: + case SelfHostedSignupProgress.CREATED_SUBSCRIPTION: + return true; + case SelfHostedSignupProgress.CONFIRMED_INTENT: { + return Boolean( + validAddress && + validSeats, + ); + } + case SelfHostedSignupProgress.START: + case SelfHostedSignupProgress.CREATED_CUSTOMER: + case SelfHostedSignupProgress.CREATED_INTENT: + return Boolean( + validCard && + validAddress && + validSeats, + ); + default: { + return false; + } + } +} + +export default function SelfHostedExpansionModal() { + const dispatch = useDispatch(); + const intl = useIntl(); + const cardRef = useRef(null); + const theme = useSelector(getTheme); + const progress = useSelector(getSelfHostedSignupProgress); + const user = useSelector(getCurrentUser); + const isDevMode = useSelector(isDevModeEnabled); + + const license = useSelector(getLicense); + const licensedSeats = parseInt(license.Users, 10); + const activeUsers = useSelector(getFilteredUsersStats)?.total_users_count || 0; + const [additionalSeats, setAdditionalSeats] = useState(activeUsers <= licensedSeats ? 1 : activeUsers - licensedSeats); + + const [stripeLoadHint, setStripeLoadHint] = useState(Math.random()); + const stripeRef = useLoadStripe(stripeLoadHint); + + const initialState = makeInitialState(additionalSeats); + const [formState, setFormState] = useState(initialState); + const [show] = useState(true); + + const title = intl.formatMessage({ + id: 'self_hosted_expansion.expansion_modal.title', + defaultMessage: 'Provide your payment details', + }); + + const canSubmitForm = canSubmit(formState, progress); + + const submit = async () => { + let submitProgress = progress; + let signupCustomerResult: SelfHostedSignupCustomerResponse | null = null; + try { + const [firstName, lastName] = inferNames(user, formState.cardName); + + signupCustomerResult = await Client4.createCustomerSelfHostedSignup({ + first_name: firstName, + last_name: lastName, + billing_address: { + city: formState.city, + country: formState.country, + line1: formState.address, + line2: formState.address2, + postal_code: formState.postalCode, + state: formState.state, + }, + organization: formState.organization, + }); + } catch { + setFormState({...formState, error: 'Failed to submit payment information'}); + return; + } + + if (signupCustomerResult === null) { + setStripeLoadHint(Math.random()); + setFormState({...formState, submitting: false}); + return; + } + + if (progress === SelfHostedSignupProgress.START || progress === SelfHostedSignupProgress.CREATED_CUSTOMER) { + dispatch({ + type: HostedCustomerTypes.RECEIVED_SELF_HOSTED_SIGNUP_PROGRESS, + data: signupCustomerResult.progress, + }); + submitProgress = signupCustomerResult.progress; + } + if (stripeRef.current === null) { + setStripeLoadHint(Math.random()); + setFormState({...formState, submitting: false}); + return; + } + + try { + const card = cardRef.current?.getCard(); + if (!card) { + const message = 'Failed to get card when it was expected'; + // eslint-disable-next-line no-console + console.error(message); + setFormState({...formState, error: message}); + return; + } + const finished = await dispatch(confirmSelfHostedExpansion( + stripeRef.current, + { + id: signupCustomerResult.setup_intent_id, + client_secret: signupCustomerResult.setup_intent_secret, + }, + isDevMode, + { + address: formState.address, + address2: formState.address2, + city: formState.city, + state: formState.state, + country: formState.country, + postalCode: formState.postalCode, + name: formState.cardName, + card, + }, + submitProgress, + { + seats: formState.seats, + }, + )); + + if (finished.data) { + setFormState({...formState, succeeded: true}); + + dispatch({ + type: HostedCustomerTypes.RECEIVED_SELF_HOSTED_SIGNUP_PROGRESS, + data: SelfHostedSignupProgress.CREATED_LICENSE, + }); + + // Reload license in background. + // Needed if this was completed while on the Edition and License page. + dispatch(getLicenseConfig()); + } else if (finished.error) { + let errorData = finished.error; + if (finished.error === 422) { + errorData = finished.error.toString(); + } + setFormState({...formState, error: errorData}); + return; + } + setFormState({...formState, submitting: false}); + } catch (e) { + // eslint-disable-next-line no-console + console.error('could not complete setup', e); + setFormState({...formState, error: 'unable to complete signup'}); + } + }; + + useEffect(() => { + pageVisited( + TELEMETRY_CATEGORIES.SELF_HOSTED_EXPANSION, + 'pageview_self_hosted_expansion', + ); + + localStorage.setItem(STORAGE_KEY_EXPANSION_IN_PROGRESS, 'true'); + return () => { + localStorage.removeItem(STORAGE_KEY_EXPANSION_IN_PROGRESS); + }; + }, []); + + const resetToken = () => { + try { + Client4.bootstrapSelfHostedSignup(true). + then((data) => { + dispatch({ + type: HostedCustomerTypes.RECEIVED_SELF_HOSTED_SIGNUP_PROGRESS, + data: data.progress, + }); + }); + } catch { + // swallow error ok here + } + }; + + return ( + + + { + dispatch(closeModal(ModalIdentifiers.SELF_HOSTED_EXPANSION)); + resetToken(); + }} + > +
+
+
+

{title}

+ +
{'Questions?'}
+ +
+
+
+ + {intl.formatMessage({ + id: 'payment_form.credit_card', + defaultMessage: 'Credit Card', + })} + +
+ { + setFormState({...formState, cardFilled: event.complete}); + }} + theme={theme} + /> +
+
+ ) => { + setFormState({...formState, organization: e.target.value}); + }} + placeholder={intl.formatMessage({ + id: 'self_hosted_signup.organization', + defaultMessage: 'Organization Name', + })} + required={true} + /> +
+
+ ) => { + setFormState({...formState, cardName: e.target.value}); + }} + placeholder={intl.formatMessage({ + id: 'payment_form.name_on_card', + defaultMessage: 'Name on Card', + })} + required={true} + /> +
+ + {intl.formatMessage({ + id: 'payment_form.billing_address', + defaultMessage: 'Billing address', + })} + + { + setFormState({...formState, country: option.value}); + }} + value={ + formState.country ? {value: formState.country, label: formState.country} : undefined + } + options={COUNTRIES.map((country) => ({ + value: country.name, + label: country.name, + }))} + legend={intl.formatMessage({ + id: 'payment_form.country', + defaultMessage: 'Country', + })} + placeholder={intl.formatMessage({ + id: 'payment_form.country', + defaultMessage: 'Country', + })} + name={'billing_dropdown'} + /> +
+ ) => { + setFormState({...formState, address: e.target.value}); + }} + placeholder={intl.formatMessage({ + id: 'payment_form.address', + defaultMessage: 'Address', + })} + required={true} + /> +
+
+ ) => { + setFormState({...formState, address2: e.target.value}); + }} + placeholder={intl.formatMessage({ + id: 'payment_form.address_2', + defaultMessage: 'Address 2', + })} + /> +
+
+ ) => { + setFormState({...formState, city: e.target.value}); + }} + placeholder={intl.formatMessage({ + id: 'payment_form.city', + defaultMessage: 'City', + })} + required={true} + /> +
+
+
+ { + setFormState({...formState, state}); + }} + /> +
+
+ ) => { + setFormState({...formState, postalCode: e.target.value}); + }} + placeholder={intl.formatMessage({ + id: 'payment_form.zipcode', + defaultMessage: 'Zip/Postal Code', + })} + required={true} + /> +
+
+
+
+
+ { + setFormState({...formState, seats}); + setAdditionalSeats(seats); + }} + canSubmit={canSubmitForm} + submit={submit} + licensedSeats={licensedSeats} + initialSeats={additionalSeats} + /> +
+
+ {/* {((formState.succeeded || progress === SelfHostedSignupProgress.CREATED_LICENSE) && hasLicense) && !formState.error && !formState.submitting && ( + + )} + {formState.submitting && ( + + )} + {formState.error && ( + + )} */} +
+ +
+
+
+
+
+ ); +} diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/self_hosted_expansion_modal.scss b/webapp/channels/src/components/self_hosted_expansion_modal/self_hosted_expansion_modal.scss new file mode 100644 index 0000000000..beb6c32e08 --- /dev/null +++ b/webapp/channels/src/components/self_hosted_expansion_modal/self_hosted_expansion_modal.scss @@ -0,0 +1,178 @@ +.SelfHostedExpansionModal { + height: 100%; + + .form-view { + display: flex; + overflow: hidden; + width: 100%; + height: 100%; + flex-direction: row; + flex-grow: 1; + flex-wrap: wrap; + align-content: top; + justify-content: center; + padding: 77px 107px; + color: var(--center-channel-color); + font-family: "Open Sans"; + font-size: 16px; + font-weight: 600; + + .title { + font-size: 22px; + font-weight: 600; + } + + .form { + padding: 0 96px; + margin: 0 auto; + + .form-row { + display: flex; + width: 100%; + margin-bottom: 24px; + } + + .form-row-third-1 { + width: 66%; + max-width: 288px; + margin-right: 16px; + + .DropdownInput { + z-index: 99999; + margin-top: 0; + } + } + + .DropdownInput { + position: relative; + z-index: 999999; + height: 36px; + margin-bottom: 24px; + + .Input_fieldset { + height: 43px; + } + } + + .form-row-third-2 { + width: 34%; + max-width: 144px; + } + + .section-title { + margin-bottom: 24px; + color: rgba(var(--center-channel-color-rgb), 0.72); + font-size: 16px; + font-weight: 600; + text-align: left; + } + + .Input_fieldset { + height: 40px; + padding: 2px 1px; + background: var(--center-channel-bg); + + .Input { + height: 32px; + background: inherit; + } + + .Input_wrapper { + margin: 0; + } + } + } + + >.lhs { + width: 25%; + } + + >.center { + width: 50%; + } + + >.rhs { + position: sticky; + display: flex; + width: 25%; + flex-direction: column; + align-items: center; + } + + .submitting, + .success, + .failed { + display: flex; + overflow: hidden; + width: 100%; + height: 100%; + flex-direction: row; + flex-grow: 1; + flex-wrap: wrap; + align-content: center; + justify-content: center; + padding: 77px 107px; + color: var(--center-channel-color); + font-family: "Open Sans"; + font-size: 16px; + font-weight: 600; + + .IconMessage .content .IconMessage-link { + margin-left: 0; + } + } + + .background-svg { + position: absolute; + z-index: -1; + top: 0; + width: 100%; + height: 100%; + + >div { + position: absolute; + top: 0; + left: 0; + } + } + + .self-hosted-agreed-terms { + label { + display: flex; + align-items: flex-start; + justify-content: flex-start; + } + + input[type=checkbox] { + margin-right: 12px; + } + + font-size: 16px; + } + } + + @media (max-width: 1020px) { + .SelfHostedExpansionModal { + .form-view { + >.lhs { + display: none; + } + + >.center { + width: 66%; + } + + >.rhs { + width: 33%; + } + } + } + } + + .FullScreenModal { + .close-x { + top: 12px; + right: 12px; + } + } +} diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/success_page.scss b/webapp/channels/src/components/self_hosted_expansion_modal/success_page.scss new file mode 100644 index 0000000000..7b4bab61f8 --- /dev/null +++ b/webapp/channels/src/components/self_hosted_expansion_modal/success_page.scss @@ -0,0 +1,20 @@ +.SelfHostedPurchaseModal__success { + display: flex; + overflow: hidden; + width: 100%; + height: 100%; + flex-direction: row; + flex-grow: 1; + flex-wrap: wrap; + align-content: center; + justify-content: center; + padding: 77px 107px; + color: var(--center-channel-color); + font-family: "Open Sans"; + font-size: 16px; + font-weight: 600; +} + +.self_hosted_expansion_success { + margin-top: 163px; +} diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/success_page.tsx b/webapp/channels/src/components/self_hosted_expansion_modal/success_page.tsx new file mode 100644 index 0000000000..cda885fa0d --- /dev/null +++ b/webapp/channels/src/components/self_hosted_expansion_modal/success_page.tsx @@ -0,0 +1,77 @@ +// 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 {NavLink} from 'react-router-dom'; + +import {useDispatch} from 'react-redux'; + +import IconMessage from 'components/purchase_modal/icon_message'; +import PaymentSuccessStandardSvg from 'components/common/svg_images_components/payment_success_standard_svg'; +import {ConsolePages, ModalIdentifiers} from 'utils/constants'; +import BackgroundSvg from 'components/common/svg_images_components/background_svg'; +import {closeModal} from 'actions/views/modals'; + +import './success_page.scss'; + +export default function SelfHostedExpansionSuccessPage() { + const dispatch = useDispatch(); + const titleText = ( + + ); + + const formattedSubtitleText = ( + Billing section of the system console.'} + values={{ + billing: (billingText: React.ReactNode) => ( + + {billingText} + + ), + }} + /> + ); + + const formattedButtonText = ( + + ); + + const icon = ( + + ); + + return ( +
+ dispatch(closeModal(ModalIdentifiers.SUCCESS_MODAL))} + /> +
+ +
+
+ ); +} + diff --git a/webapp/channels/src/components/self_hosted_purchase_modal/index.tsx b/webapp/channels/src/components/self_hosted_purchase_modal/index.tsx index 03bfbccddc..3f032a0dcc 100644 --- a/webapp/channels/src/components/self_hosted_purchase_modal/index.tsx +++ b/webapp/channels/src/components/self_hosted_purchase_modal/index.tsx @@ -27,6 +27,7 @@ import {isModalOpen} from 'selectors/views/modals'; import {isDevModeEnabled} from 'selectors/general'; import {COUNTRIES} from 'utils/countries'; +import {inferNames} from 'utils/hosted_customer'; import { ModalIdentifiers, @@ -49,7 +50,6 @@ import useControlSelfHostedPurchaseModal from 'components/common/hooks/useContro import useFetchStandardAnalytics from 'components/common/hooks/useFetchStandardAnalytics'; import {ValueOf} from '@mattermost/types/utilities'; -import {UserProfile} from '@mattermost/types/users'; import { SelfHostedSignupProgress, SelfHostedSignupCustomerResponse, @@ -270,17 +270,6 @@ interface FakeProgress { intervalId?: NodeJS.Timeout; } -function inferNames(user: UserProfile, cardName: string): [string, string] { - if (user.first_name) { - return [user.first_name, user.last_name]; - } - const names = cardName.split(' '); - if (cardName.length === 2) { - return [names[0], names[1]]; - } - return [names[0], names.slice(1).join(' ')]; -} - export default function SelfHostedPurchaseModal(props: Props) { useFetchStandardAnalytics(); useNoEscape(); diff --git a/webapp/channels/src/utils/constants.tsx b/webapp/channels/src/utils/constants.tsx index d3426a4395..0d1ddb8d70 100644 --- a/webapp/channels/src/utils/constants.tsx +++ b/webapp/channels/src/utils/constants.tsx @@ -459,6 +459,7 @@ export const ModalIdentifiers = { DELETE_WORKSPACE_RESULT: 'delete_workspace_result', SCREENING_IN_PROGRESS: 'screening_in_progress', CONFIRM_SWITCH_TO_YEARLY: 'confirm_switch_to_yearly', + SELF_HOSTED_EXPANSION: 'self_hosted_expansion', }; export const UserStatuses = { @@ -738,6 +739,7 @@ export const TELEMETRY_CATEGORIES = { CLOUD_PURCHASING: 'cloud_purchasing', CLOUD_PRICING: 'cloud_pricing', SELF_HOSTED_PURCHASING: 'self_hosted_purchasing', + SELF_HOSTED_EXPANSION: 'self_hosted_expansion', CLOUD_ADMIN: 'cloud_admin', CLOUD_DELINQUENCY: 'cloud_delinquency', SELF_HOSTED_ADMIN: 'self_hosted_admin', @@ -1068,6 +1070,7 @@ export const CloudLinks = { SELF_HOSTED_SIGNUP: 'https://customers.mattermost.com/signup', DELINQUENCY_DOCS: 'https://docs.mattermost.com/about/cloud-subscriptions.html#failed-or-late-payments', SELF_HOSTED_PRICING: 'https://mattermost.com/pricing/#self-hosted', + SELF_HOSTED_BILLING: 'https://docs.mattermost.com/manage/self-hosted-billing.html', }; export const HostedCustomerLinks = { @@ -1998,6 +2001,7 @@ export const ConsolePages = { WEB_SERVER: '/admin_console/environment/web_server', PUSH_NOTIFICATION_CENTER: '/admin_console/environment/push_notification_server', SMTP: '/admin_console/environment/smtp', + BILLING_HISTORY: 'admin_console/billing/billing_history', }; export const WindowSizes = { diff --git a/webapp/channels/src/utils/hosted_customer.ts b/webapp/channels/src/utils/hosted_customer.ts index 130bba706c..6ea29269f7 100644 --- a/webapp/channels/src/utils/hosted_customer.ts +++ b/webapp/channels/src/utils/hosted_customer.ts @@ -2,6 +2,7 @@ // See LICENSE.txt for license information. import {Product} from '@mattermost/types/cloud'; +import {UserProfile} from '@mattermost/types/users'; // find a self-hosted product based on its SKU // This function should not be used for cloud products, because there are @@ -17,3 +18,13 @@ export const findSelfHostedProductBySku = (products: Record, sk return matches[0]; }; +export const inferNames = (user: UserProfile, cardName: string): [string, string] => { + if (user.first_name) { + return [user.first_name, user.last_name]; + } + const names = cardName.split(' '); + if (cardName.length === 2) { + return [names[0], names[1]]; + } + return [names[0], names.slice(1).join(' ')]; +}; diff --git a/webapp/platform/client/src/client4.ts b/webapp/platform/client/src/client4.ts index 56e2f552f1..8aca0c6326 100644 --- a/webapp/platform/client/src/client4.ts +++ b/webapp/platform/client/src/client4.ts @@ -32,6 +32,7 @@ import { SelfHostedSignupCustomerResponse, SelfHostedSignupSuccessResponse, SelfHostedSignupBootstrapResponse, + SelfHostedExpansionRequest, } from '@mattermost/types/hosted_customer'; import {ChannelCategory, OrderedChannelCategories} from '@mattermost/types/channel_categories'; @@ -3892,6 +3893,13 @@ export default class Client4 { ); }; + confirmSelfHostedExpansion = (setupIntentId: string, expandRequest: SelfHostedExpansionRequest) => { + return this.doFetch( + `${this.getHostedCustomerRoute()}/confirm?expand=true`, + {method: 'post', body: JSON.stringify({stripe_setup_intent_id: setupIntentId, subscription: expandRequest})}, + ); + } + createPaymentMethod = async () => { return this.doFetch( `${this.getCloudRoute()}/payment`, diff --git a/webapp/platform/types/src/hosted_customer.ts b/webapp/platform/types/src/hosted_customer.ts index d81ef15227..28f04fd561 100644 --- a/webapp/platform/types/src/hosted_customer.ts +++ b/webapp/platform/types/src/hosted_customer.ts @@ -74,3 +74,7 @@ export interface TrueUpReviewProfileReducer extends TrueUpReviewProfile { export interface TrueUpReviewStatusReducer extends TrueUpReviewStatus { getRequestState: RequestState; } + +export interface SelfHostedExpansionRequest { + seats: number; +}