diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 0dae8c858939..7fc2df016aed 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -503,6 +503,19 @@ const ONYXKEYS = { /** Information about loading states while talking with AI sales */ TALK_TO_AI_SALES: 'talkToAISales', + /** Set when we are loading bill when downgrade */ + IS_LOADING_BILL_WHEN_DOWNGRADE: 'isLoadingBillWhenDowngrade', + + /** + * Determines whether billing is required when the user downgrades their plan. + * If true, the "Pay & Downgrade" RHP will be displayed to guide the user + * through the payment process before downgrading. + */ + SHOULD_BILL_WHEN_DOWNGRADING: 'shouldBillWhenDowngrading', + + /** Billing receipt details */ + BILLING_RECEIPT_DETAILS: 'billingReceiptDetails', + /** Collection Keys */ COLLECTION: { DOWNLOAD: 'download_', @@ -1124,6 +1137,9 @@ type OnyxValuesMapping = { [ONYXKEYS.CORPAY_ONBOARDING_FIELDS]: OnyxTypes.CorpayOnboardingFields; [ONYXKEYS.LAST_FULL_RECONNECT_TIME]: string; [ONYXKEYS.TRAVEL_PROVISIONING]: OnyxTypes.TravelProvisioning; + [ONYXKEYS.IS_LOADING_BILL_WHEN_DOWNGRADE]: boolean | undefined; + [ONYXKEYS.SHOULD_BILL_WHEN_DOWNGRADING]: boolean | undefined; + [ONYXKEYS.BILLING_RECEIPT_DETAILS]: OnyxTypes.BillingReceiptDetails; [ONYXKEYS.NVP_SIDE_PANEL]: OnyxTypes.SidePanel; }; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 1aced37e8fd0..59ac92a724d6 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -1330,6 +1330,10 @@ const ROUTES = { getRoute: (policyID?: string, backTo?: string) => getUrlWithBackToParam(policyID ? (`settings/workspaces/${policyID}/downgrade/` as const) : (`settings/workspaces/downgrade` as const), backTo), }, + WORKSPACE_PAY_AND_DOWNGRADE: { + route: 'settings/workspaces/pay-and-downgrade/', + getRoute: (backTo?: string) => getUrlWithBackToParam(`settings/workspaces/pay-and-downgrade` as const, backTo), + }, WORKSPACE_CATEGORIES_SETTINGS: { route: 'settings/workspaces/:policyID/categories/settings', getRoute: (policyID: string) => `settings/workspaces/${policyID}/categories/settings` as const, diff --git a/src/SCREENS.ts b/src/SCREENS.ts index aa8d07ff7147..6f9d59f2616a 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -593,6 +593,7 @@ const SCREENS = { DISTANCE_RATE_TAX_RATE_EDIT: 'Distance_Rate_Tax_Rate_Edit', UPGRADE: 'Workspace_Upgrade', DOWNGRADE: 'Workspace_Downgrade', + PAY_AND_DOWNGRADE: 'Workspace_Pay_And_Downgrade', RULES: 'Policy_Rules', RULES_CUSTOM_NAME: 'Rules_Custom_Name', RULES_AUTO_APPROVE_REPORTS_UNDER: 'Rules_Auto_Approve_Reports_Under', diff --git a/src/components/PopoverMenu.tsx b/src/components/PopoverMenu.tsx index 4e5c9c6994fc..d5444441e138 100644 --- a/src/components/PopoverMenu.tsx +++ b/src/components/PopoverMenu.tsx @@ -62,6 +62,9 @@ type PopoverMenuItem = MenuItemProps & { key?: string; + /** Whether to keep the modal open after clicking on the menu item */ + shouldKeepModalOpen?: boolean; + /** Test identifier used to find elements in unit and e2e tests */ testID?: string; }; diff --git a/src/components/ThreeDotsMenu/index.tsx b/src/components/ThreeDotsMenu/index.tsx index e87ada85863f..3c5957d209ac 100644 --- a/src/components/ThreeDotsMenu/index.tsx +++ b/src/components/ThreeDotsMenu/index.tsx @@ -1,9 +1,10 @@ -import React, {useEffect, useRef, useState} from 'react'; +import React, {useEffect, useImperativeHandle, useRef, useState} from 'react'; import {View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import {getButtonRole} from '@components/Button/utils'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; +import type {PopoverMenuItem} from '@components/PopoverMenu'; import PopoverMenu from '@components/PopoverMenu'; import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; import EducationalTooltip from '@components/Tooltip/EducationalTooltip'; @@ -38,8 +39,9 @@ function ThreeDotsMenu({ renderProductTrainingTooltipContent, shouldShowProductTrainingTooltip = false, isNested = false, + threeDotsMenuRef, }: ThreeDotsMenuProps) { - const [modal] = useOnyx(ONYXKEYS.MODAL); + const [modal] = useOnyx(ONYXKEYS.MODAL, {canBeMissing: true}); const theme = useTheme(); const styles = useThemeStyles(); @@ -53,10 +55,18 @@ function ThreeDotsMenu({ setPopupMenuVisible(true); }; - const hidePopoverMenu = () => { + const hidePopoverMenu = (selectedItem?: PopoverMenuItem) => { + if (selectedItem && selectedItem.shouldKeepModalOpen) { + return; + } setPopupMenuVisible(false); }; + useImperativeHandle(threeDotsMenuRef, () => ({ + isPopupMenuVisible, + hidePopoverMenu, + })); + useEffect(() => { if (!isBehindModal || !isPopupMenuVisible) { return; diff --git a/src/components/ThreeDotsMenu/types.ts b/src/components/ThreeDotsMenu/types.ts index 3e5dd58495b5..9195de6cae80 100644 --- a/src/components/ThreeDotsMenu/types.ts +++ b/src/components/ThreeDotsMenu/types.ts @@ -47,6 +47,9 @@ type ThreeDotsMenuProps = { /** Is the menu nested? This prop is used to omit html warning when we are nesting a button inside another button */ isNested?: boolean; + + /** Ref to the menu */ + threeDotsMenuRef?: React.RefObject<{hidePopoverMenu: () => void; isPopupMenuVisible: boolean}>; }; type ThreeDotsMenuWithOptionalAnchorProps = diff --git a/src/hooks/usePayAndDowngrade.ts b/src/hooks/usePayAndDowngrade.ts new file mode 100644 index 000000000000..d414cd8b3197 --- /dev/null +++ b/src/hooks/usePayAndDowngrade.ts @@ -0,0 +1,33 @@ +import {useEffect, useRef} from 'react'; +import {useOnyx} from 'react-native-onyx'; +import Navigation from '@libs/Navigation/Navigation'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; + +function usePayAndDowngrade(setIsDeleteModalOpen: (value: boolean) => void) { + const [isLoadingBill] = useOnyx(ONYXKEYS.IS_LOADING_BILL_WHEN_DOWNGRADE, {canBeMissing: true}); + const [shouldBillWhenDowngrading] = useOnyx(ONYXKEYS.SHOULD_BILL_WHEN_DOWNGRADING, {canBeMissing: true}); + const isDeletingPaidWorkspaceRef = useRef(false); + + const setIsDeletingPaidWorkspace = (value: boolean) => { + isDeletingPaidWorkspaceRef.current = value; + }; + + useEffect(() => { + if (!isDeletingPaidWorkspaceRef.current || isLoadingBill) { + return; + } + + if (!shouldBillWhenDowngrading) { + setIsDeleteModalOpen(true); + } else { + Navigation.navigate(ROUTES.WORKSPACE_PAY_AND_DOWNGRADE.getRoute(Navigation.getActiveRoute())); + } + + isDeletingPaidWorkspaceRef.current = false; + }, [isLoadingBill, shouldBillWhenDowngrading, setIsDeleteModalOpen]); + + return {setIsDeletingPaidWorkspace, isLoadingBill}; +} + +export default usePayAndDowngrade; diff --git a/src/languages/en.ts b/src/languages/en.ts index f93fad6dee7b..83d8d19f17b4 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -4800,6 +4800,15 @@ const translations = { gotIt: 'Got it, thanks', }, }, + payAndDowngrade: { + title: 'Pay & downgrade', + headline: 'Your final payment', + description1: 'Your final bill for this subscription will be', + description2: ({date}: DateParams) => `See your breakdown below for ${date}:`, + subscription: + 'This will end your subscription with Expensify, delete your remaining workspace and all members will lose access moving forward. If you want to remove just yourself, have another admin take over billing, and at that point, you can remove yourself from this workspace.', + genericFailureMessage: 'An error occurred while paying your bill. Please try again.', + }, restrictedAction: { restricted: 'Restricted', actionsAreCurrentlyRestricted: ({workspaceName}: ActionsAreCurrentlyRestricted) => `Actions on the ${workspaceName} workspace are currently restricted`, diff --git a/src/languages/es.ts b/src/languages/es.ts index fceb99f1317c..076f5b87667d 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -4869,6 +4869,15 @@ const translations = { gotIt: 'Entendido, gracias.', }, }, + payAndDowngrade: { + title: 'Pagar y bajar de categoría', + headline: 'Tu pago final', + description1: 'Tu factura final por esta suscripción será', + description2: ({date}: DateParams) => `Consulta el desglose a continuación para ${date}:`, + subscription: + 'Esto finalizará tu suscripción a Expensify, eliminará tu espacio de trabajo restante y todos los miembros perderán acceso de ahora en adelante. Si solo deseas eliminarte a ti mismo, haz que otro administrador se encargue de la facturación, y en ese momento podrás salir de este espacio de trabajo.', + genericFailureMessage: 'Ocurrió un error al pagar tu factura. Por favor, inténtalo de nuevo.', + }, restrictedAction: { restricted: 'Restringido', actionsAreCurrentlyRestricted: ({workspaceName}: ActionsAreCurrentlyRestricted) => `Las acciones en el espacio de trabajo ${workspaceName} están actualmente restringidas`, diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index 37dfccd5f790..fa7adf8b152b 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -467,6 +467,7 @@ const WRITE_COMMANDS = { CHANGE_REPORT_POLICY: 'ChangeReportPolicy', CHANGE_TRANSACTIONS_REPORT: 'ChangeTransactionsReport', SEND_RECAP_IN_ADMINS_ROOM: 'SendRecapInAdminsRoom', + PAY_AND_DOWNGRADE: 'PayAndDowngrade', COMPLETE_CONCIERGE_CALL: 'CompleteConciergeCall', } as const; @@ -952,6 +953,8 @@ type WriteCommandParameters = { // Change report policy [WRITE_COMMANDS.CHANGE_REPORT_POLICY]: Parameters.ChangeReportPolicyParams; + [WRITE_COMMANDS.PAY_AND_DOWNGRADE]: null; + // Change transaction report [WRITE_COMMANDS.CHANGE_TRANSACTIONS_REPORT]: Parameters.ChangeTransactionsReportParams; }; @@ -1023,6 +1026,7 @@ const READ_COMMANDS = { GET_CORPAY_ONBOARDING_FIELDS: 'GetCorpayOnboardingFields', OPEN_WORKSPACE_PLAN_PAGE: 'OpenWorkspacePlanPage', OPEN_SECURITY_SETTINGS_PAGE: 'OpenSecuritySettingsPage', + CALCULATE_BILL_NEW_DOT: 'CalculateBillNewDot', } as const; type ReadCommand = ValueOf; @@ -1094,6 +1098,7 @@ type ReadCommandParameters = { [READ_COMMANDS.GET_CORPAY_ONBOARDING_FIELDS]: Parameters.GetCorpayOnboardingFieldsParams; [READ_COMMANDS.OPEN_WORKSPACE_PLAN_PAGE]: Parameters.OpenWorkspacePlanPageParams; [READ_COMMANDS.OPEN_SECURITY_SETTINGS_PAGE]: null; + [READ_COMMANDS.CALCULATE_BILL_NEW_DOT]: null; }; const SIDE_EFFECT_REQUEST_COMMANDS = { diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index 104fc9e18d06..de1403db63f6 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -311,6 +311,7 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/workspace/categories/ImportedCategoriesPage').default, [SCREENS.WORKSPACE.UPGRADE]: () => require('../../../../pages/workspace/upgrade/WorkspaceUpgradePage').default, [SCREENS.WORKSPACE.DOWNGRADE]: () => require('../../../../pages/workspace/downgrade/WorkspaceDowngradePage').default, + [SCREENS.WORKSPACE.PAY_AND_DOWNGRADE]: () => require('../../../../pages/workspace/downgrade/PayAndDowngradePage').default, [SCREENS.WORKSPACE.MEMBER_DETAILS]: () => require('../../../../pages/workspace/members/WorkspaceMemberDetailsPage').default, [SCREENS.WORKSPACE.MEMBER_NEW_CARD]: () => require('../../../../pages/workspace/members/WorkspaceMemberNewCardPage').default, [SCREENS.WORKSPACE.OWNER_CHANGE_CHECK]: () => require('@pages/workspace/members/WorkspaceOwnerChangeWrapperPage').default, diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index b412681b34ad..af02135b3547 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -682,6 +682,9 @@ const config: LinkingOptions['config'] = { [SCREENS.WORKSPACE.DOWNGRADE]: { path: ROUTES.WORKSPACE_DOWNGRADE.route, }, + [SCREENS.WORKSPACE.PAY_AND_DOWNGRADE]: { + path: ROUTES.WORKSPACE_PAY_AND_DOWNGRADE.route, + }, [SCREENS.WORKSPACE.CATEGORIES_SETTINGS]: { path: ROUTES.WORKSPACE_CATEGORIES_SETTINGS.route, }, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 06db845c3bf3..5dbe8eeb1c1e 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -237,6 +237,9 @@ type SettingsNavigatorParamList = { policyID?: string; backTo?: Routes; }; + [SCREENS.WORKSPACE.PAY_AND_DOWNGRADE]: { + policyID?: string; + }; [SCREENS.WORKSPACE.CATEGORIES_SETTINGS]: { policyID: string; backTo?: Routes; diff --git a/src/libs/SubscriptionUtils.ts b/src/libs/SubscriptionUtils.ts index 0ff9eb66b5dd..2b7d88f143b1 100644 --- a/src/libs/SubscriptionUtils.ts +++ b/src/libs/SubscriptionUtils.ts @@ -177,6 +177,15 @@ Onyx.connect({ waitForCollectionCallback: true, }); +// Indicates if downgrading the current subscription plan is allowed for the user. +let canDowngrade = false; +Onyx.connect({ + key: ONYXKEYS.ACCOUNT, + callback: (val) => { + canDowngrade = val?.canDowngrade ?? false; + }, +}); + /** * @returns The date when the grace period ends. */ @@ -569,6 +578,10 @@ function shouldRestrictUserBillableActions(policyID: string): boolean { return false; } +function shouldCalculateBillNewDot(): boolean { + return canDowngrade && getOwnedPaidPolicies(allPolicies, currentUserAccountID).length === 1; +} + function checkIfHasTeam2025Pricing() { if (hasManualTeamPricing2025) { return true; @@ -676,6 +689,7 @@ export { shouldShowPreTrialBillingBanner, shouldShowDiscountBanner, getEarlyDiscountInfo, + shouldCalculateBillNewDot, getSubscriptionPlanInfo, getSubscriptionPrice, }; diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index 79640a101bb6..b07c654bf2fa 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -5080,6 +5080,78 @@ function getAssignedSupportData(policyID: string) { API.read(READ_COMMANDS.GET_ASSIGNED_SUPPORT_DATA, parameters); } +/** + * Call the API to calculate the bill for the new dot + */ +function calculateBillNewDot() { + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.IS_LOADING_BILL_WHEN_DOWNGRADE, + value: true, + }, + ]; + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.IS_LOADING_BILL_WHEN_DOWNGRADE, + value: false, + }, + ]; + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.IS_LOADING_BILL_WHEN_DOWNGRADE, + value: false, + }, + ]; + API.read(READ_COMMANDS.CALCULATE_BILL_NEW_DOT, null, { + optimisticData, + successData, + failureData, + }); +} + +/** + * Call the API to pay and downgrade + */ +function payAndDowngrade() { + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.BILLING_RECEIPT_DETAILS, + value: { + errors: null, + isLoading: true, + }, + }, + ]; + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.BILLING_RECEIPT_DETAILS, + value: { + isLoading: false, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.BILLING_RECEIPT_DETAILS, + value: { + isLoading: false, + }, + }, + ]; + API.write(WRITE_COMMANDS.PAY_AND_DOWNGRADE, null, {optimisticData, successData, failureData}); +} + +function clearBillingReceiptDetailsErrors() { + Onyx.merge(ONYXKEYS.BILLING_RECEIPT_DETAILS, {errors: null}); +} + export { leaveWorkspace, addBillingCardAndRequestPolicyOwnerChange, @@ -5180,6 +5252,9 @@ export { updateDefaultPolicy, getAssignedSupportData, downgradeToTeam, + calculateBillNewDot, + payAndDowngrade, + clearBillingReceiptDetailsErrors, clearQuickbooksOnlineAutoSyncErrorField, updateLastAccessedWorkspaceSwitcher, }; diff --git a/src/pages/workspace/WorkspaceOverviewPage.tsx b/src/pages/workspace/WorkspaceOverviewPage.tsx index 357839e23609..1eff3ad66cfa 100644 --- a/src/pages/workspace/WorkspaceOverviewPage.tsx +++ b/src/pages/workspace/WorkspaceOverviewPage.tsx @@ -15,12 +15,14 @@ import Section from '@components/Section'; import useActiveWorkspace from '@hooks/useActiveWorkspace'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; +import usePayAndDowngrade from '@hooks/usePayAndDowngrade'; import usePermissions from '@hooks/usePermissions'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeIllustrations from '@hooks/useThemeIllustrations'; import useThemeStyles from '@hooks/useThemeStyles'; import {clearInviteDraft} from '@libs/actions/Policy/Member'; import { + calculateBillNewDot, clearAvatarErrors, clearPolicyErrorField, deleteWorkspace, @@ -39,6 +41,7 @@ import type {WorkspaceSplitNavigatorParamList} from '@libs/Navigation/types'; import {getUserFriendlyWorkspaceType, isPolicyAdmin as isPolicyAdminPolicyUtils, isPolicyOwner} from '@libs/PolicyUtils'; import {getDefaultWorkspaceAvatar} from '@libs/ReportUtils'; import StringUtils from '@libs/StringUtils'; +import {shouldCalculateBillNewDot} from '@libs/SubscriptionUtils'; import {getFullSizeAvatar} from '@libs/UserUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -60,8 +63,8 @@ function WorkspaceOverviewPage({policyDraft, policy: policyProp, route}: Workspa const {activeWorkspaceID, setActiveWorkspaceID} = useActiveWorkspace(); const backTo = route.params.backTo; - const [currencyList = {}] = useOnyx(ONYXKEYS.CURRENCY_LIST); - const [currentUserAccountID = -1] = useOnyx(ONYXKEYS.SESSION, {selector: (session) => session?.accountID}); + const [currencyList = {}] = useOnyx(ONYXKEYS.CURRENCY_LIST, {canBeMissing: true}); + const [currentUserAccountID = -1] = useOnyx(ONYXKEYS.SESSION, {selector: (session) => session?.accountID, canBeMissing: true}); // When we create a new workspace, the policy prop will be empty on the first render. Therefore, we have to use policyDraft until policy has been set in Onyx. const policy = policyDraft?.id ? policyDraft : policyProp; @@ -72,8 +75,8 @@ function WorkspaceOverviewPage({policyDraft, policy: policyProp, route}: Workspa // We need this to update translation for deleting a workspace when it has third party card feeds or expensify card assigned. const workspaceAccountID = policy?.workspaceAccountID ?? CONST.DEFAULT_NUMBER_ID; - const [cardFeeds] = useOnyx(`${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${workspaceAccountID}`); - const [cardsList] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${workspaceAccountID}_${CONST.EXPENSIFY_CARD.BANK}`, {selector: filterInactiveCards}); + const [cardFeeds] = useOnyx(`${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${workspaceAccountID}`, {canBeMissing: true}); + const [cardsList] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${workspaceAccountID}_${CONST.EXPENSIFY_CARD.BANK}`, {selector: filterInactiveCards, canBeMissing: true}); const hasCardFeedOrExpensifyCard = // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing !isEmptyObject(cardFeeds) || !isEmptyObject(cardsList) || ((policy?.areExpensifyCardsEnabled || policy?.areCompanyCardsEnabled) && policy?.workspaceAccountID); @@ -163,6 +166,8 @@ function WorkspaceOverviewPage({policyDraft, policy: policyProp, route}: Workspa const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const {setIsDeletingPaidWorkspace, isLoadingBill} = usePayAndDowngrade(setIsDeleteModalOpen); + const confirmDeleteAndHideModal = useCallback(() => { if (!policy?.id || !policyName) { return; @@ -179,6 +184,16 @@ function WorkspaceOverviewPage({policyDraft, policy: policyProp, route}: Workspa } }, [policy?.id, policyName, activeWorkspaceID, setActiveWorkspaceID]); + const onDeleteWorkspace = useCallback(() => { + if (shouldCalculateBillNewDot()) { + setIsDeletingPaidWorkspace(true); + calculateBillNewDot(); + return; + } + + setIsDeleteModalOpen(true); + }, [setIsDeletingPaidWorkspace]); + return ( setIsDeleteModalOpen(true)} + onPress={onDeleteWorkspace} icon={Expensicons.Trashcan} + isLoading={isLoadingBill} + iconStyles={isLoadingBill ? styles.opacity0 : undefined} /> )} diff --git a/src/pages/workspace/WorkspacesListPage.tsx b/src/pages/workspace/WorkspacesListPage.tsx index e5590b1f3750..e84abe7c5686 100755 --- a/src/pages/workspace/WorkspacesListPage.tsx +++ b/src/pages/workspace/WorkspacesListPage.tsx @@ -27,11 +27,13 @@ import useActiveWorkspace from '@hooks/useActiveWorkspace'; import useHandleBackButton from '@hooks/useHandleBackButton'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; +import usePayAndDowngrade from '@hooks/usePayAndDowngrade'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {isConnectionInProgress} from '@libs/actions/connections'; import { + calculateBillNewDot, clearDeleteWorkspaceError, clearErrors, deleteWorkspace, @@ -50,6 +52,7 @@ import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigat import type {SettingsSplitNavigatorParamList} from '@libs/Navigation/types'; import {getPolicy, getPolicyBrickRoadIndicatorStatus, isPolicyAdmin, shouldShowPolicy} from '@libs/PolicyUtils'; import {getDefaultWorkspaceAvatar} from '@libs/ReportUtils'; +import {shouldCalculateBillNewDot as shouldCalculateBillNewDotFn} from '@libs/SubscriptionUtils'; import type {AvatarSource} from '@libs/UserUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -126,25 +129,30 @@ function WorkspacesListPage() { const {isOffline} = useNetwork(); const {activeWorkspaceID, setActiveWorkspaceID} = useActiveWorkspace(); const {shouldUseNarrowLayout, isMediumScreenWidth} = useResponsiveLayout(); - const [allConnectionSyncProgresses] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS); - const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); - const [reimbursementAccount] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT); - const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); - const [session] = useOnyx(ONYXKEYS.SESSION); - const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID); - const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP); + const [allConnectionSyncProgresses] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS, {canBeMissing: true}); + const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {canBeMissing: true}); + const [reimbursementAccount] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {canBeMissing: true}); + const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT, {canBeMissing: true}); + const [session] = useOnyx(ONYXKEYS.SESSION, {canBeMissing: true}); + const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID, {canBeMissing: true}); + const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP, {canBeMissing: true}); + const shouldShowLoadingIndicator = isLoadingApp && !isOffline; const route = useRoute>(); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [policyIDToDelete, setPolicyIDToDelete] = useState(); const [policyNameToDelete, setPolicyNameToDelete] = useState(); + const {setIsDeletingPaidWorkspace, isLoadingBill} = usePayAndDowngrade(setIsDeleteModalOpen); + + const [loadingSpinnerIconIndex, setLoadingSpinnerIconIndex] = useState(null); + const isLessThanMediumScreen = isMediumScreenWidth || shouldUseNarrowLayout; // We need this to update translation for deleting a workspace when it has third party card feeds or expensify card assigned. const workspaceAccountID = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyIDToDelete}`]?.workspaceAccountID ?? CONST.DEFAULT_NUMBER_ID; - const [cardFeeds] = useOnyx(`${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${workspaceAccountID}`); - const [cardsList] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${workspaceAccountID}_${CONST.EXPENSIFY_CARD.BANK}`, {selector: filterInactiveCards}); + const [cardFeeds] = useOnyx(`${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${workspaceAccountID}`, {canBeMissing: true}); + const [cardsList] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${workspaceAccountID}_${CONST.EXPENSIFY_CARD.BANK}`, {selector: filterInactiveCards, canBeMissing: true}); const policyToDelete = getPolicy(policyIDToDelete); const hasCardFeedOrExpensifyCard = !isEmptyObject(cardFeeds) || @@ -173,6 +181,12 @@ function WorkspacesListPage() { } }; + const shouldCalculateBillNewDot = shouldCalculateBillNewDotFn(); + + const resetLoadingSpinnerIconIndex = useCallback(() => { + setLoadingSpinnerIconIndex(null); + }, []); + /** * Gets the menu item for each workspace */ @@ -196,16 +210,31 @@ function WorkspacesListPage() { threeDotsMenuItems.push({ icon: Expensicons.Trashcan, text: translate('workspace.common.delete'), + shouldShowLoadingSpinnerIcon: loadingSpinnerIconIndex === index, onSelected: () => { + if (loadingSpinnerIconIndex !== null) { + return; + } + if (isSupportalAction) { setIsSupportalActionRestrictedModalOpen(true); return; } + setPolicyIDToDelete(item.policyID); setPolicyNameToDelete(item.title); + + if (shouldCalculateBillNewDot) { + setIsDeletingPaidWorkspace(true); + calculateBillNewDot(); + setLoadingSpinnerIconIndex(index); + return; + } + setIsDeleteModalOpen(true); }, - shouldCallAfterModalHide: true, + shouldKeepModalOpen: shouldCalculateBillNewDot, + shouldCallAfterModalHide: !shouldCalculateBillNewDot, }); } @@ -272,6 +301,8 @@ function WorkspacesListPage() { shouldDisableThreeDotsMenu={item.disabled} style={[item.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE ? styles.offlineFeedback.deleted : {}]} isDefault={isDefault} + isLoadingBill={isLoadingBill} + resetLoadingSpinnerIconIndex={resetLoadingSpinnerIconIndex} /> )} @@ -290,6 +321,11 @@ function WorkspacesListPage() { session?.email, activePolicyID, isSupportalAction, + setIsDeletingPaidWorkspace, + isLoadingBill, + shouldCalculateBillNewDot, + loadingSpinnerIconIndex, + resetLoadingSpinnerIconIndex, ], ); diff --git a/src/pages/workspace/WorkspacesListRow.tsx b/src/pages/workspace/WorkspacesListRow.tsx index 3f1c5e2c03b2..bdb1db36c670 100644 --- a/src/pages/workspace/WorkspacesListRow.tsx +++ b/src/pages/workspace/WorkspacesListRow.tsx @@ -1,5 +1,5 @@ import {Str} from 'expensify-common'; -import React, {useCallback, useRef} from 'react'; +import React, {useCallback, useEffect, useRef} from 'react'; import {View} from 'react-native'; import type {StyleProp, ViewStyle} from 'react-native'; import type {ValueOf} from 'type-fest'; @@ -70,6 +70,12 @@ type WorkspacesListRowProps = WithCurrentUserPersonalDetailsProps & { /** is policy defualt */ isDefault?: boolean; + + /** Whether the bill is loading */ + isLoadingBill?: boolean; + + /** Function to reset loading spinner icon index */ + resetLoadingSpinnerIconIndex?: () => void; }; type BrickRoadIndicatorIconProps = { @@ -114,6 +120,8 @@ function WorkspacesListRow({ isJoinRequestPending, policyID, isDefault, + isLoadingBill, + resetLoadingSpinnerIconIndex, }: WorkspacesListRowProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -121,6 +129,19 @@ function WorkspacesListRow({ const {shouldUseNarrowLayout} = useResponsiveLayout(); const ownerDetails = ownerAccountID && getPersonalDetailsByIDs({accountIDs: [ownerAccountID], currentUserAccountID: currentUserPersonalDetails.accountID}).at(0); + const threeDotsMenuRef = useRef<{hidePopoverMenu: () => void; isPopupMenuVisible: boolean}>(null); + + useEffect(() => { + if (isLoadingBill) { + return; + } + resetLoadingSpinnerIconIndex?.(); + + if (!threeDotsMenuRef.current?.isPopupMenuVisible) { + return; + } + threeDotsMenuRef?.current?.hidePopoverMenu(); + }, [isLoadingBill, resetLoadingSpinnerIconIndex]); const calculateAndSetThreeDotsMenuPosition = useCallback(() => { if (shouldUseNarrowLayout) { @@ -187,6 +208,7 @@ function WorkspacesListRow({ shouldOverlay disabled={shouldDisableThreeDotsMenu} isNested + threeDotsMenuRef={threeDotsMenuRef} /> diff --git a/src/pages/workspace/downgrade/PayAndDowngradePage.tsx b/src/pages/workspace/downgrade/PayAndDowngradePage.tsx new file mode 100644 index 000000000000..4ab9b2bb0026 --- /dev/null +++ b/src/pages/workspace/downgrade/PayAndDowngradePage.tsx @@ -0,0 +1,133 @@ +import React, {useEffect, useMemo} from 'react'; +import {View} from 'react-native'; +import {useOnyx} from 'react-native-onyx'; +import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; +import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView'; +import Button from '@components/Button'; +import FixedFooter from '@components/FixedFooter'; +import FormHelpMessage from '@components/FormHelpMessage'; +import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import RenderHTML from '@components/RenderHTML'; +import ScreenWrapper from '@components/ScreenWrapper'; +import ScrollView from '@components/ScrollView'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import usePrevious from '@hooks/usePrevious'; +import useThemeStyles from '@hooks/useThemeStyles'; +import Navigation from '@libs/Navigation/Navigation'; +import {clearBillingReceiptDetailsErrors, payAndDowngrade} from '@src/libs/actions/Policy/Policy'; +import ONYXKEYS from '@src/ONYXKEYS'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; + +type BillingItem = { + key: string; + value: string; + isTotal: boolean; +}; + +function PayAndDowngradePage() { + const styles = useThemeStyles(); + + const {translate} = useLocalize(); + + const [billingDetails, metadata] = useOnyx(ONYXKEYS.BILLING_RECEIPT_DETAILS, {canBeMissing: true}); + const prevIsLoading = usePrevious(billingDetails?.isLoading); + + const errorMessage = billingDetails?.errors; + + const items: BillingItem[] = useMemo(() => { + if (isEmptyObject(billingDetails)) { + return []; + } + const results = [...billingDetails.receiptsWithoutDiscount, ...billingDetails.discounts].map((item) => { + return { + key: item.description, + value: item.formattedAmount, + isTotal: false, + }; + }); + + results.push({ + key: translate('common.total'), + value: billingDetails.formattedSubtotal, + isTotal: true, + }); + return results; + }, [billingDetails, translate]); + + useEffect(() => { + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + if (billingDetails?.isLoading || !prevIsLoading || billingDetails?.errors) { + return; + } + Navigation.dismissModal(); + }, [billingDetails?.isLoading, prevIsLoading, billingDetails?.errors]); + + useEffect(() => { + clearBillingReceiptDetailsErrors(); + }, []); + + if (isLoadingOnyxValue(metadata)) { + return ; + } + + return ( + + + + + + {translate('workspace.payAndDowngrade.headline')} + + {translate('workspace.payAndDowngrade.description1')} {billingDetails?.formattedSubtotal} + + + {translate('workspace.payAndDowngrade.description2', { + date: billingDetails?.billingMonth ?? '', + })} + + + + {items.map((item) => ( + + {!item.isTotal ? : {item.key}} + {item.value} + + ))} + + {translate('workspace.payAndDowngrade.subscription')} + + + {!!errorMessage && ( + + + + )} +