Skip to content

Commit 17704a9

Browse files
authored
Merge pull request #59070 from dukenv0307/feat/58603
2 parents 9721e97 + a1b7c49 commit 17704a9

File tree

21 files changed

+456
-19
lines changed

21 files changed

+456
-19
lines changed

src/ONYXKEYS.ts

+16
Original file line numberDiff line numberDiff line change
@@ -503,6 +503,19 @@ const ONYXKEYS = {
503503
/** Information about loading states while talking with AI sales */
504504
TALK_TO_AI_SALES: 'talkToAISales',
505505

506+
/** Set when we are loading bill when downgrade */
507+
IS_LOADING_BILL_WHEN_DOWNGRADE: 'isLoadingBillWhenDowngrade',
508+
509+
/**
510+
* Determines whether billing is required when the user downgrades their plan.
511+
* If true, the "Pay & Downgrade" RHP will be displayed to guide the user
512+
* through the payment process before downgrading.
513+
*/
514+
SHOULD_BILL_WHEN_DOWNGRADING: 'shouldBillWhenDowngrading',
515+
516+
/** Billing receipt details */
517+
BILLING_RECEIPT_DETAILS: 'billingReceiptDetails',
518+
506519
/** Collection Keys */
507520
COLLECTION: {
508521
DOWNLOAD: 'download_',
@@ -1124,6 +1137,9 @@ type OnyxValuesMapping = {
11241137
[ONYXKEYS.CORPAY_ONBOARDING_FIELDS]: OnyxTypes.CorpayOnboardingFields;
11251138
[ONYXKEYS.LAST_FULL_RECONNECT_TIME]: string;
11261139
[ONYXKEYS.TRAVEL_PROVISIONING]: OnyxTypes.TravelProvisioning;
1140+
[ONYXKEYS.IS_LOADING_BILL_WHEN_DOWNGRADE]: boolean | undefined;
1141+
[ONYXKEYS.SHOULD_BILL_WHEN_DOWNGRADING]: boolean | undefined;
1142+
[ONYXKEYS.BILLING_RECEIPT_DETAILS]: OnyxTypes.BillingReceiptDetails;
11271143
[ONYXKEYS.NVP_SIDE_PANEL]: OnyxTypes.SidePanel;
11281144
};
11291145

src/ROUTES.ts

+4
Original file line numberDiff line numberDiff line change
@@ -1330,6 +1330,10 @@ const ROUTES = {
13301330
getRoute: (policyID?: string, backTo?: string) =>
13311331
getUrlWithBackToParam(policyID ? (`settings/workspaces/${policyID}/downgrade/` as const) : (`settings/workspaces/downgrade` as const), backTo),
13321332
},
1333+
WORKSPACE_PAY_AND_DOWNGRADE: {
1334+
route: 'settings/workspaces/pay-and-downgrade/',
1335+
getRoute: (backTo?: string) => getUrlWithBackToParam(`settings/workspaces/pay-and-downgrade` as const, backTo),
1336+
},
13331337
WORKSPACE_CATEGORIES_SETTINGS: {
13341338
route: 'settings/workspaces/:policyID/categories/settings',
13351339
getRoute: (policyID: string) => `settings/workspaces/${policyID}/categories/settings` as const,

src/SCREENS.ts

+1
Original file line numberDiff line numberDiff line change
@@ -593,6 +593,7 @@ const SCREENS = {
593593
DISTANCE_RATE_TAX_RATE_EDIT: 'Distance_Rate_Tax_Rate_Edit',
594594
UPGRADE: 'Workspace_Upgrade',
595595
DOWNGRADE: 'Workspace_Downgrade',
596+
PAY_AND_DOWNGRADE: 'Workspace_Pay_And_Downgrade',
596597
RULES: 'Policy_Rules',
597598
RULES_CUSTOM_NAME: 'Rules_Custom_Name',
598599
RULES_AUTO_APPROVE_REPORTS_UNDER: 'Rules_Auto_Approve_Reports_Under',

src/components/PopoverMenu.tsx

+3
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,9 @@ type PopoverMenuItem = MenuItemProps & {
6262

6363
key?: string;
6464

65+
/** Whether to keep the modal open after clicking on the menu item */
66+
shouldKeepModalOpen?: boolean;
67+
6568
/** Test identifier used to find elements in unit and e2e tests */
6669
testID?: string;
6770
};

src/components/ThreeDotsMenu/index.tsx

+13-3
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
import React, {useEffect, useRef, useState} from 'react';
1+
import React, {useEffect, useImperativeHandle, useRef, useState} from 'react';
22
import {View} from 'react-native';
33
import {useOnyx} from 'react-native-onyx';
44
import {getButtonRole} from '@components/Button/utils';
55
import Icon from '@components/Icon';
66
import * as Expensicons from '@components/Icon/Expensicons';
7+
import type {PopoverMenuItem} from '@components/PopoverMenu';
78
import PopoverMenu from '@components/PopoverMenu';
89
import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback';
910
import EducationalTooltip from '@components/Tooltip/EducationalTooltip';
@@ -38,8 +39,9 @@ function ThreeDotsMenu({
3839
renderProductTrainingTooltipContent,
3940
shouldShowProductTrainingTooltip = false,
4041
isNested = false,
42+
threeDotsMenuRef,
4143
}: ThreeDotsMenuProps) {
42-
const [modal] = useOnyx(ONYXKEYS.MODAL);
44+
const [modal] = useOnyx(ONYXKEYS.MODAL, {canBeMissing: true});
4345

4446
const theme = useTheme();
4547
const styles = useThemeStyles();
@@ -53,10 +55,18 @@ function ThreeDotsMenu({
5355
setPopupMenuVisible(true);
5456
};
5557

56-
const hidePopoverMenu = () => {
58+
const hidePopoverMenu = (selectedItem?: PopoverMenuItem) => {
59+
if (selectedItem && selectedItem.shouldKeepModalOpen) {
60+
return;
61+
}
5762
setPopupMenuVisible(false);
5863
};
5964

65+
useImperativeHandle(threeDotsMenuRef, () => ({
66+
isPopupMenuVisible,
67+
hidePopoverMenu,
68+
}));
69+
6070
useEffect(() => {
6171
if (!isBehindModal || !isPopupMenuVisible) {
6272
return;

src/components/ThreeDotsMenu/types.ts

+3
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@ type ThreeDotsMenuProps = {
4747

4848
/** Is the menu nested? This prop is used to omit html warning when we are nesting a button inside another button */
4949
isNested?: boolean;
50+
51+
/** Ref to the menu */
52+
threeDotsMenuRef?: React.RefObject<{hidePopoverMenu: () => void; isPopupMenuVisible: boolean}>;
5053
};
5154

5255
type ThreeDotsMenuWithOptionalAnchorProps =

src/hooks/usePayAndDowngrade.ts

+33
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import {useEffect, useRef} from 'react';
2+
import {useOnyx} from 'react-native-onyx';
3+
import Navigation from '@libs/Navigation/Navigation';
4+
import ONYXKEYS from '@src/ONYXKEYS';
5+
import ROUTES from '@src/ROUTES';
6+
7+
function usePayAndDowngrade(setIsDeleteModalOpen: (value: boolean) => void) {
8+
const [isLoadingBill] = useOnyx(ONYXKEYS.IS_LOADING_BILL_WHEN_DOWNGRADE, {canBeMissing: true});
9+
const [shouldBillWhenDowngrading] = useOnyx(ONYXKEYS.SHOULD_BILL_WHEN_DOWNGRADING, {canBeMissing: true});
10+
const isDeletingPaidWorkspaceRef = useRef(false);
11+
12+
const setIsDeletingPaidWorkspace = (value: boolean) => {
13+
isDeletingPaidWorkspaceRef.current = value;
14+
};
15+
16+
useEffect(() => {
17+
if (!isDeletingPaidWorkspaceRef.current || isLoadingBill) {
18+
return;
19+
}
20+
21+
if (!shouldBillWhenDowngrading) {
22+
setIsDeleteModalOpen(true);
23+
} else {
24+
Navigation.navigate(ROUTES.WORKSPACE_PAY_AND_DOWNGRADE.getRoute(Navigation.getActiveRoute()));
25+
}
26+
27+
isDeletingPaidWorkspaceRef.current = false;
28+
}, [isLoadingBill, shouldBillWhenDowngrading, setIsDeleteModalOpen]);
29+
30+
return {setIsDeletingPaidWorkspace, isLoadingBill};
31+
}
32+
33+
export default usePayAndDowngrade;

src/languages/en.ts

+9
Original file line numberDiff line numberDiff line change
@@ -4803,6 +4803,15 @@ const translations = {
48034803
gotIt: 'Got it, thanks',
48044804
},
48054805
},
4806+
payAndDowngrade: {
4807+
title: 'Pay & downgrade',
4808+
headline: 'Your final payment',
4809+
description1: 'Your final bill for this subscription will be',
4810+
description2: ({date}: DateParams) => `See your breakdown below for ${date}:`,
4811+
subscription:
4812+
'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.',
4813+
genericFailureMessage: 'An error occurred while paying your bill. Please try again.',
4814+
},
48064815
restrictedAction: {
48074816
restricted: 'Restricted',
48084817
actionsAreCurrentlyRestricted: ({workspaceName}: ActionsAreCurrentlyRestricted) => `Actions on the ${workspaceName} workspace are currently restricted`,

src/languages/es.ts

+9
Original file line numberDiff line numberDiff line change
@@ -4872,6 +4872,15 @@ const translations = {
48724872
gotIt: 'Entendido, gracias.',
48734873
},
48744874
},
4875+
payAndDowngrade: {
4876+
title: 'Pagar y bajar de categoría',
4877+
headline: 'Tu pago final',
4878+
description1: 'Tu factura final por esta suscripción será',
4879+
description2: ({date}: DateParams) => `Consulta el desglose a continuación para ${date}:`,
4880+
subscription:
4881+
'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.',
4882+
genericFailureMessage: 'Ocurrió un error al pagar tu factura. Por favor, inténtalo de nuevo.',
4883+
},
48754884
restrictedAction: {
48764885
restricted: 'Restringido',
48774886
actionsAreCurrentlyRestricted: ({workspaceName}: ActionsAreCurrentlyRestricted) => `Las acciones en el espacio de trabajo ${workspaceName} están actualmente restringidas`,

src/libs/API/types.ts

+5
Original file line numberDiff line numberDiff line change
@@ -467,6 +467,7 @@ const WRITE_COMMANDS = {
467467
CHANGE_REPORT_POLICY: 'ChangeReportPolicy',
468468
CHANGE_TRANSACTIONS_REPORT: 'ChangeTransactionsReport',
469469
SEND_RECAP_IN_ADMINS_ROOM: 'SendRecapInAdminsRoom',
470+
PAY_AND_DOWNGRADE: 'PayAndDowngrade',
470471
COMPLETE_CONCIERGE_CALL: 'CompleteConciergeCall',
471472
FINISH_CORPAY_BANK_ACCOUNT_ONBOARDING: 'FinishCorpayBankAccountOnboarding',
472473
} as const;
@@ -954,6 +955,8 @@ type WriteCommandParameters = {
954955
// Change report policy
955956
[WRITE_COMMANDS.CHANGE_REPORT_POLICY]: Parameters.ChangeReportPolicyParams;
956957

958+
[WRITE_COMMANDS.PAY_AND_DOWNGRADE]: null;
959+
957960
// Change transaction report
958961
[WRITE_COMMANDS.CHANGE_TRANSACTIONS_REPORT]: Parameters.ChangeTransactionsReportParams;
959962
};
@@ -1025,6 +1028,7 @@ const READ_COMMANDS = {
10251028
GET_CORPAY_ONBOARDING_FIELDS: 'GetCorpayOnboardingFields',
10261029
OPEN_WORKSPACE_PLAN_PAGE: 'OpenWorkspacePlanPage',
10271030
OPEN_SECURITY_SETTINGS_PAGE: 'OpenSecuritySettingsPage',
1031+
CALCULATE_BILL_NEW_DOT: 'CalculateBillNewDot',
10281032
} as const;
10291033

10301034
type ReadCommand = ValueOf<typeof READ_COMMANDS>;
@@ -1096,6 +1100,7 @@ type ReadCommandParameters = {
10961100
[READ_COMMANDS.GET_CORPAY_ONBOARDING_FIELDS]: Parameters.GetCorpayOnboardingFieldsParams;
10971101
[READ_COMMANDS.OPEN_WORKSPACE_PLAN_PAGE]: Parameters.OpenWorkspacePlanPageParams;
10981102
[READ_COMMANDS.OPEN_SECURITY_SETTINGS_PAGE]: null;
1103+
[READ_COMMANDS.CALCULATE_BILL_NEW_DOT]: null;
10991104
};
11001105

11011106
const SIDE_EFFECT_REQUEST_COMMANDS = {

src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx

+1
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,7 @@ const SettingsModalStackNavigator = createModalStackNavigator<SettingsNavigatorP
311311
[SCREENS.WORKSPACE.CATEGORIES_IMPORTED]: () => require<ReactComponentModule>('../../../../pages/workspace/categories/ImportedCategoriesPage').default,
312312
[SCREENS.WORKSPACE.UPGRADE]: () => require<ReactComponentModule>('../../../../pages/workspace/upgrade/WorkspaceUpgradePage').default,
313313
[SCREENS.WORKSPACE.DOWNGRADE]: () => require<ReactComponentModule>('../../../../pages/workspace/downgrade/WorkspaceDowngradePage').default,
314+
[SCREENS.WORKSPACE.PAY_AND_DOWNGRADE]: () => require<ReactComponentModule>('../../../../pages/workspace/downgrade/PayAndDowngradePage').default,
314315
[SCREENS.WORKSPACE.MEMBER_DETAILS]: () => require<ReactComponentModule>('../../../../pages/workspace/members/WorkspaceMemberDetailsPage').default,
315316
[SCREENS.WORKSPACE.MEMBER_NEW_CARD]: () => require<ReactComponentModule>('../../../../pages/workspace/members/WorkspaceMemberNewCardPage').default,
316317
[SCREENS.WORKSPACE.OWNER_CHANGE_CHECK]: () => require<ReactComponentModule>('@pages/workspace/members/WorkspaceOwnerChangeWrapperPage').default,

src/libs/Navigation/linkingConfig/config.ts

+3
Original file line numberDiff line numberDiff line change
@@ -682,6 +682,9 @@ const config: LinkingOptions<RootNavigatorParamList>['config'] = {
682682
[SCREENS.WORKSPACE.DOWNGRADE]: {
683683
path: ROUTES.WORKSPACE_DOWNGRADE.route,
684684
},
685+
[SCREENS.WORKSPACE.PAY_AND_DOWNGRADE]: {
686+
path: ROUTES.WORKSPACE_PAY_AND_DOWNGRADE.route,
687+
},
685688
[SCREENS.WORKSPACE.CATEGORIES_SETTINGS]: {
686689
path: ROUTES.WORKSPACE_CATEGORIES_SETTINGS.route,
687690
},

src/libs/Navigation/types.ts

+3
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,9 @@ type SettingsNavigatorParamList = {
237237
policyID?: string;
238238
backTo?: Routes;
239239
};
240+
[SCREENS.WORKSPACE.PAY_AND_DOWNGRADE]: {
241+
policyID?: string;
242+
};
240243
[SCREENS.WORKSPACE.CATEGORIES_SETTINGS]: {
241244
policyID: string;
242245
backTo?: Routes;

src/libs/SubscriptionUtils.ts

+14
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,15 @@ Onyx.connect({
177177
waitForCollectionCallback: true,
178178
});
179179

180+
// Indicates if downgrading the current subscription plan is allowed for the user.
181+
let canDowngrade = false;
182+
Onyx.connect({
183+
key: ONYXKEYS.ACCOUNT,
184+
callback: (val) => {
185+
canDowngrade = val?.canDowngrade ?? false;
186+
},
187+
});
188+
180189
/**
181190
* @returns The date when the grace period ends.
182191
*/
@@ -569,6 +578,10 @@ function shouldRestrictUserBillableActions(policyID: string): boolean {
569578
return false;
570579
}
571580

581+
function shouldCalculateBillNewDot(): boolean {
582+
return canDowngrade && getOwnedPaidPolicies(allPolicies, currentUserAccountID).length === 1;
583+
}
584+
572585
function checkIfHasTeam2025Pricing() {
573586
if (hasManualTeamPricing2025) {
574587
return true;
@@ -676,6 +689,7 @@ export {
676689
shouldShowPreTrialBillingBanner,
677690
shouldShowDiscountBanner,
678691
getEarlyDiscountInfo,
692+
shouldCalculateBillNewDot,
679693
getSubscriptionPlanInfo,
680694
getSubscriptionPrice,
681695
};

src/libs/actions/Policy/Policy.ts

+75
Original file line numberDiff line numberDiff line change
@@ -5080,6 +5080,78 @@ function getAssignedSupportData(policyID: string) {
50805080
API.read(READ_COMMANDS.GET_ASSIGNED_SUPPORT_DATA, parameters);
50815081
}
50825082

5083+
/**
5084+
* Call the API to calculate the bill for the new dot
5085+
*/
5086+
function calculateBillNewDot() {
5087+
const optimisticData: OnyxUpdate[] = [
5088+
{
5089+
onyxMethod: Onyx.METHOD.MERGE,
5090+
key: ONYXKEYS.IS_LOADING_BILL_WHEN_DOWNGRADE,
5091+
value: true,
5092+
},
5093+
];
5094+
const successData: OnyxUpdate[] = [
5095+
{
5096+
onyxMethod: Onyx.METHOD.MERGE,
5097+
key: ONYXKEYS.IS_LOADING_BILL_WHEN_DOWNGRADE,
5098+
value: false,
5099+
},
5100+
];
5101+
const failureData: OnyxUpdate[] = [
5102+
{
5103+
onyxMethod: Onyx.METHOD.MERGE,
5104+
key: ONYXKEYS.IS_LOADING_BILL_WHEN_DOWNGRADE,
5105+
value: false,
5106+
},
5107+
];
5108+
API.read(READ_COMMANDS.CALCULATE_BILL_NEW_DOT, null, {
5109+
optimisticData,
5110+
successData,
5111+
failureData,
5112+
});
5113+
}
5114+
5115+
/**
5116+
* Call the API to pay and downgrade
5117+
*/
5118+
function payAndDowngrade() {
5119+
const optimisticData: OnyxUpdate[] = [
5120+
{
5121+
onyxMethod: Onyx.METHOD.MERGE,
5122+
key: ONYXKEYS.BILLING_RECEIPT_DETAILS,
5123+
value: {
5124+
errors: null,
5125+
isLoading: true,
5126+
},
5127+
},
5128+
];
5129+
const successData: OnyxUpdate[] = [
5130+
{
5131+
onyxMethod: Onyx.METHOD.MERGE,
5132+
key: ONYXKEYS.BILLING_RECEIPT_DETAILS,
5133+
value: {
5134+
isLoading: false,
5135+
},
5136+
},
5137+
];
5138+
5139+
const failureData: OnyxUpdate[] = [
5140+
{
5141+
onyxMethod: Onyx.METHOD.MERGE,
5142+
key: ONYXKEYS.BILLING_RECEIPT_DETAILS,
5143+
value: {
5144+
isLoading: false,
5145+
},
5146+
},
5147+
];
5148+
API.write(WRITE_COMMANDS.PAY_AND_DOWNGRADE, null, {optimisticData, successData, failureData});
5149+
}
5150+
5151+
function clearBillingReceiptDetailsErrors() {
5152+
Onyx.merge(ONYXKEYS.BILLING_RECEIPT_DETAILS, {errors: null});
5153+
}
5154+
50835155
export {
50845156
leaveWorkspace,
50855157
addBillingCardAndRequestPolicyOwnerChange,
@@ -5180,6 +5252,9 @@ export {
51805252
updateDefaultPolicy,
51815253
getAssignedSupportData,
51825254
downgradeToTeam,
5255+
calculateBillNewDot,
5256+
payAndDowngrade,
5257+
clearBillingReceiptDetailsErrors,
51835258
clearQuickbooksOnlineAutoSyncErrorField,
51845259
updateLastAccessedWorkspaceSwitcher,
51855260
};

0 commit comments

Comments
 (0)