diff --git a/assets/images/money-waving.svg b/assets/images/money-waving.svg
new file mode 100644
index 000000000000..5242e31092a0
--- /dev/null
+++ b/assets/images/money-waving.svg
@@ -0,0 +1,81 @@
+
diff --git a/src/CONST.ts b/src/CONST.ts
index 955ddda76741..d5ad5584126c 100755
--- a/src/CONST.ts
+++ b/src/CONST.ts
@@ -1502,6 +1502,15 @@ const CONST = {
DISABLE: 'disable',
ENABLE: 'enable',
},
+ OWNERSHIP_ERRORS: {
+ NO_BILLING_CARD: 'noBillingCard',
+ AMOUNT_OWED: 'amountOwed',
+ HAS_FAILED_SETTLEMENTS: 'hasFailedSettlements',
+ OWNER_OWES_AMOUNT: 'ownerOwesAmount',
+ SUBSCRIPTION: 'subscription',
+ DUPLICATE_SUBSCRIPTION: 'duplicateSubscription',
+ FAILED_TO_CLEAR_BALANCE: 'failedToClearBalance',
+ },
TAX_RATES_BULK_ACTION_TYPES: {
DELETE: 'delete',
DISABLE: 'disable',
diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts
index d3fab1b9fcde..b38e191a2a98 100755
--- a/src/ONYXKEYS.ts
+++ b/src/ONYXKEYS.ts
@@ -290,6 +290,9 @@ const ONYXKEYS = {
// Paths of PDF file that has been cached during one session
CACHED_PDF_PATHS: 'cachedPDFPaths',
+ /** Holds the checks used while transferring the ownership of the workspace */
+ POLICY_OWNERSHIP_CHANGE_CHECKS: 'policyOwnershipChangeChecks',
+
/** Collection Keys */
COLLECTION: {
DOWNLOAD: 'download_',
@@ -620,6 +623,7 @@ type OnyxValuesMapping = {
[ONYXKEYS.LOGS]: Record;
[ONYXKEYS.SHOULD_STORE_LOGS]: boolean;
[ONYXKEYS.CACHED_PDF_PATHS]: Record;
+ [ONYXKEYS.POLICY_OWNERSHIP_CHANGE_CHECKS]: Record;
[ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE]: OnyxTypes.QuickAction;
};
diff --git a/src/ROUTES.ts b/src/ROUTES.ts
index 6302e0ee4683..23bb2ee845ad 100644
--- a/src/ROUTES.ts
+++ b/src/ROUTES.ts
@@ -612,6 +612,19 @@ const ROUTES = {
route: 'settings/workspaces/:policyID/members/:accountID/role-selection',
getRoute: (policyID: string, accountID: number, backTo?: string) => getUrlWithBackToParam(`settings/workspaces/${policyID}/members/${accountID}/role-selection`, backTo),
},
+ WORKSPACE_OWNER_CHANGE_SUCCESS: {
+ route: 'settings/workspaces/:policyID/change-owner/:accountID/success',
+ getRoute: (policyID: string, accountID: number) => `settings/workspaces/${policyID}/change-owner/${accountID}/success` as const,
+ },
+ WORKSPACE_OWNER_CHANGE_ERROR: {
+ route: 'settings/workspaces/:policyID/change-owner/:accountID/failure',
+ getRoute: (policyID: string, accountID: number) => `settings/workspaces/${policyID}/change-owner/${accountID}/failure` as const,
+ },
+ WORKSPACE_OWNER_CHANGE_CHECK: {
+ route: 'settings/workspaces/:policyID/change-owner/:accountID/:error',
+ getRoute: (policyID: string, accountID: number, error: ValueOf) =>
+ `settings/workspaces/${policyID}/change-owner/${accountID}/${error}` as const,
+ },
WORKSPACE_TAX_CREATE: {
route: 'settings/workspaces/:policyID/taxes/new',
getRoute: (policyID: string) => `settings/workspaces/${policyID}/taxes/new` as const,
diff --git a/src/SCREENS.ts b/src/SCREENS.ts
index 82fef0383918..ffb18391c980 100644
--- a/src/SCREENS.ts
+++ b/src/SCREENS.ts
@@ -242,6 +242,9 @@ const SCREENS = {
MORE_FEATURES: 'Workspace_More_Features',
MEMBER_DETAILS: 'Workspace_Member_Details',
MEMBER_DETAILS_ROLE_SELECTION: 'Workspace_Member_Details_Role_Selection',
+ OWNER_CHANGE_CHECK: 'Workspace_Owner_Change_Check',
+ OWNER_CHANGE_SUCCESS: 'Workspace_Owner_Change_Success',
+ OWNER_CHANGE_ERROR: 'Workspace_Owner_Change_Error',
DISTANCE_RATES: 'Distance_Rates',
CREATE_DISTANCE_RATE: 'Create_Distance_Rate',
DISTANCE_RATES_SETTINGS: 'Distance_Rates_Settings',
diff --git a/src/components/ConfirmationPage.tsx b/src/components/ConfirmationPage.tsx
index 4c2fb46dccf8..d1a73b7933fe 100644
--- a/src/components/ConfirmationPage.tsx
+++ b/src/components/ConfirmationPage.tsx
@@ -1,4 +1,5 @@
import React from 'react';
+import type {TextStyle} from 'react-native';
import {View} from 'react-native';
import useThemeStyles from '@hooks/useThemeStyles';
import Button from './Button';
@@ -26,9 +27,24 @@ type ConfirmationPageProps = {
/** Whether we should show a confirmation button */
shouldShowButton?: boolean;
+
+ /** Additional style for the heading */
+ headingStyle?: TextStyle;
+
+ /** Additional style for the description */
+ descriptionStyle?: TextStyle;
};
-function ConfirmationPage({animation = LottieAnimations.Fireworks, heading, description, buttonText = '', onButtonPress = () => {}, shouldShowButton = false}: ConfirmationPageProps) {
+function ConfirmationPage({
+ animation = LottieAnimations.Fireworks,
+ heading,
+ description,
+ buttonText = '',
+ onButtonPress = () => {},
+ shouldShowButton = false,
+ headingStyle,
+ descriptionStyle,
+}: ConfirmationPageProps) {
const styles = useThemeStyles();
return (
@@ -40,8 +56,8 @@ function ConfirmationPage({animation = LottieAnimations.Fireworks, heading, desc
loop
style={styles.confirmationAnimation}
/>
- {heading}
- {description}
+ {heading}
+ {description}
{shouldShowButton && (
diff --git a/src/components/Icon/Expensicons.ts b/src/components/Icon/Expensicons.ts
index 7116ba2aab67..5191d2012b05 100644
--- a/src/components/Icon/Expensicons.ts
+++ b/src/components/Icon/Expensicons.ts
@@ -100,6 +100,7 @@ import Menu from '@assets/images/menu.svg';
import Meter from '@assets/images/meter.svg';
import MoneyBag from '@assets/images/money-bag.svg';
import MoneyCircle from '@assets/images/money-circle.svg';
+import MoneyWaving from '@assets/images/money-waving.svg';
import Monitor from '@assets/images/monitor.svg';
import Mute from '@assets/images/mute.svg';
import NewWindow from '@assets/images/new-window.svg';
@@ -257,6 +258,7 @@ export {
Megaphone,
MoneyBag,
MoneyCircle,
+ MoneyWaving,
Monitor,
Mute,
ExpensifyLogoNew,
diff --git a/src/components/Section/index.tsx b/src/components/Section/index.tsx
index 470e81727984..93a2e91639a6 100644
--- a/src/components/Section/index.tsx
+++ b/src/components/Section/index.tsx
@@ -18,6 +18,7 @@ import IconSection from './IconSection';
const CARD_LAYOUT = {
ICON_ON_TOP: 'iconOnTop',
+ ICON_ON_LEFT: 'iconOnLeft',
ICON_ON_RIGHT: 'iconOnRight',
} as const;
@@ -121,6 +122,12 @@ function Section({
)}
+ {cardLayout === CARD_LAYOUT.ICON_ON_LEFT && (
+
+ )}
{title}
diff --git a/src/languages/en.ts b/src/languages/en.ts
index 358a3ce61d4f..6a2a0ed45054 100755
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -312,6 +312,7 @@ export default {
update: 'Update',
member: 'Member',
role: 'Role',
+ currency: 'Currency',
},
location: {
useCurrent: 'Use current location',
@@ -1949,6 +1950,7 @@ export default {
removeMemberButtonTitle: 'Remove from workspace',
removeMemberPrompt: ({memberName}) => `Are you sure you want to remove ${memberName}`,
removeMemberTitle: 'Remove member',
+ transferOwner: 'Transfer owner',
makeMember: 'Make member',
makeAdmin: 'Make admin',
selectAll: 'Select all',
@@ -2097,6 +2099,49 @@ export default {
updateCurrencyPrompt: 'It looks like your Workspace is currently set to a different currency than USD. Please click the button below to update your currency to USD now.',
updateToUSD: 'Update to USD',
},
+ changeOwner: {
+ changeOwnerPageTitle: 'Transfer owner',
+ addPaymentCardTitle: 'Enter your payment card to transfer ownership',
+ addPaymentCardButtonText: 'Accept terms & add payment card',
+ addPaymentCardReadAndAcceptTextPart1: 'Read and accept',
+ addPaymentCardReadAndAcceptTextPart2: 'policy to add your card',
+ addPaymentCardTerms: 'terms',
+ addPaymentCardPrivacy: 'privacy',
+ addPaymentCardAnd: '&',
+ addPaymentCardPciCompliant: 'PCI-DSS compliant',
+ addPaymentCardBankLevelEncrypt: 'Bank level encryption',
+ addPaymentCardRedundant: 'Redundant infrastructure',
+ addPaymentCardLearnMore: 'Learn more about our',
+ addPaymentCardSecurity: 'security',
+ amountOwedTitle: 'Outstanding balance',
+ amountOwedButtonText: 'OK',
+ amountOwedText: 'This account has an outstanding balance from a previous month.\n\nDo you want to clear balance and take over billing of this workspace?',
+ ownerOwesAmountTitle: 'Outstanding balance',
+ ownerOwesAmountButtonText: 'Transfer balance',
+ ownerOwesAmountText: ({email, amount}) =>
+ `The account owning this workspace (${email}) has an outstanding balance from a previous month.\n\nDo you want to transfer this amount (${amount}) in order to take over billing for this workspace? Your payment card will be charged immediately.`,
+ subscriptionTitle: 'Take over annual subscription',
+ subscriptionButtonText: 'Transfer subscription',
+ subscriptionText: ({usersCount, finalCount}) =>
+ `Taking over this workspace will merge its associated annual subscription with your current subscription. This will increase your subscription size by ${usersCount} users making your new subscription size ${finalCount}. Would you like to continue?`,
+ duplicateSubscriptionTitle: 'Duplicate subscription alert',
+ duplicateSubscriptionButtonText: 'Continue',
+ duplicateSubscriptionText: ({email, workspaceName}) =>
+ `It looks like you may be trying to take over billing for ${email}'s workspaces, but to do that, you need to be an admin on all their workspaces first.\n\nClick "Continue" if you only want to take over billing for the workspace ${workspaceName}.\n\nIf you want to take over billing for their entire subscription, please have them add you as an admin to all their workspaces first before taking over billing.`,
+ hasFailedSettlementsTitle: 'Cannot transfer ownership',
+ hasFailedSettlementsButtonText: 'Got it',
+ hasFailedSettlementsText: ({email}) =>
+ `You cannot take over billing because ${email} has an overdue expensify Expensify Card settlement. Please advise them to reach out to concierge@expensify.com to resolve the issue. Then, you can take over billing for this workspace.`,
+ failedToClearBalanceTitle: 'Failed to clear balance',
+ failedToClearBalanceButtonText: 'OK',
+ failedToClearBalanceText: 'We were unable to clear the balance. Please try again later.',
+ successTitle: 'Woohoo! All set.',
+ successDescription: "You're now the owner if this workspace.",
+ errorTitle: 'Oops! Not so fast...',
+ errorDescriptionPartOne: 'There was a problem transferring ownership of this workspace. Try again, or',
+ errorDescriptionPartTwo: 'reach out to Concierge',
+ errorDescriptionPartThree: 'for help.',
+ },
},
getAssistancePage: {
title: 'Get assistance',
diff --git a/src/languages/es.ts b/src/languages/es.ts
index 16d0748eed1d..8167633c2d64 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -302,6 +302,7 @@ export default {
update: 'Actualizar',
member: 'Miembro',
role: 'Role',
+ currency: 'Divisa',
},
location: {
useCurrent: 'Usar ubicación actual',
@@ -1976,6 +1977,7 @@ export default {
removeMemberButtonTitle: 'Quitar del espacio de trabajo',
removeMemberPrompt: ({memberName}) => `¿Estás seguro de que deseas eliminar a ${memberName}`,
removeMemberTitle: 'Eliminar miembro',
+ transferOwner: 'Transferir la propiedad',
makeMember: 'Hacer miembro',
makeAdmin: 'Hacer administrador',
selectAll: 'Seleccionar todo',
@@ -2126,6 +2128,50 @@ export default {
'Parece que tu espacio de trabajo está configurado actualmente en una moneda diferente a USD. Por favor, haz clic en el botón de abajo para actualizar tu moneda a USD ahora.',
updateToUSD: 'Actualizar a USD',
},
+ changeOwner: {
+ changeOwnerPageTitle: 'Dueño de la transferencia',
+ addPaymentCardTitle: 'Ingrese su tarjeta de pago para transferir la propiedad',
+ addPaymentCardButtonText: 'Aceptar términos y agregar tarjeta de pago',
+ addPaymentCardReadAndAcceptTextPart1: 'Lea y acepte',
+ addPaymentCardReadAndAcceptTextPart2: 'para agregar su tarjeta',
+ addPaymentCardTerms: 'los términos',
+ addPaymentCardPrivacy: 'la política de privacidad',
+ addPaymentCardAnd: 'y',
+ addPaymentCardPciCompliant: 'PCI-DSS obediente',
+ addPaymentCardBankLevelEncrypt: 'Cifrado a nivel bancario',
+ addPaymentCardRedundant: 'Infraestructura redundante',
+ addPaymentCardLearnMore: 'Conozca más sobre nuestra',
+ addPaymentCardSecurity: 'seguridad',
+ // TODO: add spanish translations below
+ amountOwedTitle: 'Saldo pendiente',
+ amountOwedButtonText: 'OK',
+ amountOwedText: 'Esta cuenta tiene un saldo pendiente de un mes anterior.\n\n¿Quiere liquidar el saldo y hacerse cargo de la facturación de este espacio de trabajo?',
+ ownerOwesAmountTitle: 'Saldo pendiente',
+ ownerOwesAmountButtonText: 'Transferir saldo',
+ ownerOwesAmountText: ({email, amount}) =>
+ `La cuenta propietaria de este espacio de trabajo (${email}) tiene un saldo pendiente de un mes anterior.\n\n¿Desea transferir este monto (${amount}) para hacerse cargo de la facturación de este espacio de trabajo? Su tarjeta de pago se cargará inmediatamente.`,
+ subscriptionTitle: 'Asumir la suscripción anual',
+ subscriptionButtonText: 'Transferir suscripción',
+ subscriptionText: ({usersCount, finalCount}) =>
+ `Al hacerse cargo de este espacio de trabajo se fusionará su suscripción anual asociada con su suscripción actual. Esto aumentará el tamaño de su suscripción en ${usersCount} usuarios, lo que hará que su nuevo tamaño de suscripción sea ${finalCount}. ¿Te gustaria continuar?`,
+ duplicateSubscriptionTitle: 'Alerta de suscripción duplicada',
+ duplicateSubscriptionButtonText: 'Continuar',
+ duplicateSubscriptionText: ({email, workspaceName}) =>
+ `Parece que estás intentando hacerte cargo de la facturación de los espacios de trabajo de ${email}, pero para hacerlo, primero debes ser administrador de todos sus espacios de trabajo.\n\nHaz clic en "Continuar" si solo quieres tomar sobrefacturación para el espacio de trabajo ${workspaceName}.\n\nSi desea hacerse cargo de la facturación de toda su suscripción, pídales que lo agreguen como administrador a todos sus espacios de trabajo antes de hacerse cargo de la facturación.`,
+ hasFailedSettlementsTitle: 'No se puede transferir la propiedad',
+ hasFailedSettlementsButtonText: 'Entiendo',
+ hasFailedSettlementsText: ({email}) =>
+ `No puede hacerse cargo de la facturación porque ${email} tiene una liquidación vencida de la tarjeta Expensify. Avíseles que se comuniquen con concierge@expensify.com para resolver el problema. Luego, podrá hacerse cargo de la facturación de este espacio de trabajo.`,
+ failedToClearBalanceTitle: 'Failed to clear balance',
+ failedToClearBalanceButtonText: 'OK',
+ failedToClearBalanceText: 'We were unable to clear the balance. Please try again later.',
+ successTitle: '¡Guau! Todo listo.',
+ successDescription: 'Ahora eres el propietario de este espacio de trabajo.',
+ errorTitle: '¡Ups! No tan rapido...',
+ errorDescriptionPartOne: 'Hubo un problema al transferir la propiedad de este espacio de trabajo. Inténtalo de nuevo, o',
+ errorDescriptionPartTwo: 'contacta con el conserje',
+ errorDescriptionPartThree: 'por ayuda.',
+ },
},
getAssistancePage: {
title: 'Obtener ayuda',
diff --git a/src/libs/API/parameters/AddBillingCardAndRequestWorkspaceOwnerChangeParams.ts b/src/libs/API/parameters/AddBillingCardAndRequestWorkspaceOwnerChangeParams.ts
new file mode 100644
index 000000000000..459c89f572f7
--- /dev/null
+++ b/src/libs/API/parameters/AddBillingCardAndRequestWorkspaceOwnerChangeParams.ts
@@ -0,0 +1,12 @@
+type AddBillingCardAndRequestWorkspaceOwnerChangeParams = {
+ policyID: string;
+ cardNumber: string;
+ cardYear: string;
+ cardMonth: string;
+ cardCVV: string;
+ addressName: string;
+ addressZip: string;
+ currency: string;
+};
+
+export default AddBillingCardAndRequestWorkspaceOwnerChangeParams;
diff --git a/src/libs/API/parameters/RequestWorkspaceOwnerChangeParams.ts b/src/libs/API/parameters/RequestWorkspaceOwnerChangeParams.ts
new file mode 100644
index 000000000000..076b11e7fab6
--- /dev/null
+++ b/src/libs/API/parameters/RequestWorkspaceOwnerChangeParams.ts
@@ -0,0 +1,7 @@
+import type {PolicyOwnershipChangeChecks} from '@src/types/onyx';
+
+type RequestWorkspaceOwnerChangeParams = PolicyOwnershipChangeChecks & {
+ policyID: string;
+};
+
+export default RequestWorkspaceOwnerChangeParams;
diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts
index 1895c2426e1a..87d9e2265568 100644
--- a/src/libs/API/parameters/index.ts
+++ b/src/libs/API/parameters/index.ts
@@ -187,6 +187,8 @@ export type {default as UpdatePolicyDistanceRateValueParams} from './UpdatePolic
export type {default as SetPolicyDistanceRatesEnabledParams} from './SetPolicyDistanceRatesEnabledParams';
export type {default as DeletePolicyDistanceRatesParams} from './DeletePolicyDistanceRatesParams';
export type {default as CreatePolicyTagsParams} from './CreatePolicyTagsParams';
+export type {default as RequestWorkspaceOwnerChangeParams} from './RequestWorkspaceOwnerChangeParams';
+export type {default as AddBillingCardAndRequestWorkspaceOwnerChangeParams} from './AddBillingCardAndRequestWorkspaceOwnerChangeParams';
export type {default as SetPolicyTaxesEnabledParams} from './SetPolicyTaxesEnabledParams';
export type {default as DeletePolicyTaxesParams} from './DeletePolicyTaxesParams';
export type {default as UpdatePolicyTaxValueParams} from './UpdatePolicyTaxValueParams';
diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts
index 9d6e6b3929b8..fd84e65c028e 100644
--- a/src/libs/API/types.ts
+++ b/src/libs/API/types.ts
@@ -188,6 +188,8 @@ const WRITE_COMMANDS = {
UPDATE_POLICY_TAX_VALUE: 'UpdatePolicyTaxValue',
RENAME_POLICY_TAX: 'RenamePolicyTax',
CREATE_POLICY_DISTANCE_RATE: 'CreatePolicyDistanceRate',
+ REQUEST_WORKSPACE_OWNER_CHANGE: 'RequestWorkspaceOwnerChange',
+ ADD_BILLING_CARD_AND_REQUEST_WORKSPACE_OWNER_CHANGE: 'AddBillingCardAndRequestPolicyOwnerChange',
SET_POLICY_DISTANCE_RATES_UNIT: 'SetPolicyDistanceRatesUnit',
SET_POLICY_DISTANCE_RATES_DEFAULT_CATEGORY: 'SetPolicyDistanceRatesDefaultCategory',
UPDATE_POLICY_DISTANCE_RATE_VALUE: 'UpdatePolicyDistanceRateValue',
@@ -378,6 +380,8 @@ type WriteCommandParameters = {
[WRITE_COMMANDS.DELETE_POLICY_TAXES]: Parameters.DeletePolicyTaxesParams;
[WRITE_COMMANDS.UPDATE_POLICY_TAX_VALUE]: Parameters.UpdatePolicyTaxValueParams;
[WRITE_COMMANDS.CREATE_POLICY_DISTANCE_RATE]: Parameters.CreatePolicyDistanceRateParams;
+ [WRITE_COMMANDS.REQUEST_WORKSPACE_OWNER_CHANGE]: Parameters.RequestWorkspaceOwnerChangeParams;
+ [WRITE_COMMANDS.ADD_BILLING_CARD_AND_REQUEST_WORKSPACE_OWNER_CHANGE]: Parameters.AddBillingCardAndRequestWorkspaceOwnerChangeParams;
[WRITE_COMMANDS.RENAME_POLICY_TAX]: Parameters.RenamePolicyTaxParams;
[WRITE_COMMANDS.SET_POLICY_DISTANCE_RATES_UNIT]: Parameters.SetPolicyDistanceRatesUnitParams;
[WRITE_COMMANDS.SET_POLICY_DISTANCE_RATES_DEFAULT_CATEGORY]: Parameters.SetPolicyDistanceRatesDefaultCategoryParams;
diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx
index bc14f346c3f9..1946c969cdad 100644
--- a/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx
+++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx
@@ -260,6 +260,9 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../pages/workspace/categories/WorkspaceCategoriesSettingsPage').default as React.ComponentType,
[SCREENS.WORKSPACE.MEMBER_DETAILS]: () => require('../../../pages/workspace/members/WorkspaceMemberDetailsPage').default as React.ComponentType,
[SCREENS.WORKSPACE.MEMBER_DETAILS_ROLE_SELECTION]: () => require('../../../pages/workspace/members/WorkspaceMemberDetailsRoleSelectionPage').default as React.ComponentType,
+ [SCREENS.WORKSPACE.OWNER_CHANGE_CHECK]: () => require('@pages/workspace/members/WorkspaceOwnerChangeWrapperPage').default as React.ComponentType,
+ [SCREENS.WORKSPACE.OWNER_CHANGE_SUCCESS]: () => require('../../../pages/workspace/members/WorkspaceOwnerChangeSuccessPage').default as React.ComponentType,
+ [SCREENS.WORKSPACE.OWNER_CHANGE_ERROR]: () => require('../../../pages/workspace/members/WorkspaceOwnerChangeErrorPage').default as React.ComponentType,
[SCREENS.WORKSPACE.CATEGORY_CREATE]: () => require('../../../pages/workspace/categories/CreateCategoryPage').default as React.ComponentType,
[SCREENS.WORKSPACE.CATEGORY_EDIT]: () => require('../../../pages/workspace/categories/EditCategoryPage').default as React.ComponentType,
[SCREENS.WORKSPACE.CREATE_DISTANCE_RATE]: () => require('../../../pages/workspace/distanceRates/CreateDistanceRatePage').default as React.ComponentType,
diff --git a/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts b/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts
index 35b129e8b0c0..1247933701a8 100755
--- a/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts
+++ b/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts
@@ -4,7 +4,15 @@ import SCREENS from '@src/SCREENS';
const FULL_SCREEN_TO_RHP_MAPPING: Partial> = {
[SCREENS.WORKSPACE.PROFILE]: [SCREENS.WORKSPACE.NAME, SCREENS.WORKSPACE.CURRENCY, SCREENS.WORKSPACE.DESCRIPTION, SCREENS.WORKSPACE.SHARE],
[SCREENS.WORKSPACE.REIMBURSE]: [SCREENS.WORKSPACE.RATE_AND_UNIT, SCREENS.WORKSPACE.RATE_AND_UNIT_RATE, SCREENS.WORKSPACE.RATE_AND_UNIT_UNIT],
- [SCREENS.WORKSPACE.MEMBERS]: [SCREENS.WORKSPACE.INVITE, SCREENS.WORKSPACE.INVITE_MESSAGE, SCREENS.WORKSPACE.MEMBER_DETAILS, SCREENS.WORKSPACE.MEMBER_DETAILS_ROLE_SELECTION],
+ [SCREENS.WORKSPACE.MEMBERS]: [
+ SCREENS.WORKSPACE.INVITE,
+ SCREENS.WORKSPACE.INVITE_MESSAGE,
+ SCREENS.WORKSPACE.MEMBER_DETAILS,
+ SCREENS.WORKSPACE.MEMBER_DETAILS_ROLE_SELECTION,
+ SCREENS.WORKSPACE.OWNER_CHANGE_CHECK,
+ SCREENS.WORKSPACE.OWNER_CHANGE_SUCCESS,
+ SCREENS.WORKSPACE.OWNER_CHANGE_ERROR,
+ ],
[SCREENS.WORKSPACE.WORKFLOWS]: [
SCREENS.WORKSPACE.WORKFLOWS_APPROVER,
SCREENS.WORKSPACE.WORKFLOWS_AUTO_REPORTING_FREQUENCY,
diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts
index b8b9280bc576..c9c5d47a2df3 100644
--- a/src/libs/Navigation/linkingConfig/config.ts
+++ b/src/libs/Navigation/linkingConfig/config.ts
@@ -280,6 +280,15 @@ const config: LinkingOptions['config'] = {
[SCREENS.WORKSPACE.MEMBER_DETAILS_ROLE_SELECTION]: {
path: ROUTES.WORKSPACE_MEMBER_ROLE_SELECTION.route,
},
+ [SCREENS.WORKSPACE.OWNER_CHANGE_SUCCESS]: {
+ path: ROUTES.WORKSPACE_OWNER_CHANGE_SUCCESS.route,
+ },
+ [SCREENS.WORKSPACE.OWNER_CHANGE_ERROR]: {
+ path: ROUTES.WORKSPACE_OWNER_CHANGE_ERROR.route,
+ },
+ [SCREENS.WORKSPACE.OWNER_CHANGE_CHECK]: {
+ path: ROUTES.WORKSPACE_OWNER_CHANGE_CHECK.route,
+ },
[SCREENS.WORKSPACE.CATEGORY_CREATE]: {
path: ROUTES.WORKSPACE_CATEGORY_CREATE.route,
},
diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts
index 3f85aec3a560..60b2ed63ab49 100644
--- a/src/libs/Navigation/types.ts
+++ b/src/libs/Navigation/types.ts
@@ -227,6 +227,19 @@ type SettingsNavigatorParamList = {
accountID: string;
backTo: Routes;
};
+ [SCREENS.WORKSPACE.OWNER_CHANGE_SUCCESS]: {
+ policyID: string;
+ accountID: number;
+ };
+ [SCREENS.WORKSPACE.OWNER_CHANGE_ERROR]: {
+ policyID: string;
+ accountID: number;
+ };
+ [SCREENS.WORKSPACE.OWNER_CHANGE_CHECK]: {
+ policyID: string;
+ accountID: number;
+ error: ValueOf;
+ };
[SCREENS.WORKSPACE.CREATE_DISTANCE_RATE]: {
policyID: string;
};
diff --git a/src/libs/WorkspacesSettingsUtils.ts b/src/libs/WorkspacesSettingsUtils.ts
index 653667f9ad7b..00e8a96e56b4 100644
--- a/src/libs/WorkspacesSettingsUtils.ts
+++ b/src/libs/WorkspacesSettingsUtils.ts
@@ -6,6 +6,8 @@ import type {TranslationPaths} from '@src/languages/types';
import ONYXKEYS from '@src/ONYXKEYS';
import type {Policy, PolicyMembers, ReimbursementAccount, Report} from '@src/types/onyx';
import type {Unit} from '@src/types/onyx/Policy';
+import * as CurrencyUtils from './CurrencyUtils';
+import type {Phrase, PhraseParameters} from './Localize';
import * as OptionsListUtils from './OptionsListUtils';
import {hasCustomUnitsError, hasPolicyError, hasPolicyMemberError, hasTaxRateError} from './PolicyUtils';
import * as ReportActionsUtils from './ReportActionsUtils';
@@ -214,6 +216,77 @@ function getUnitTranslationKey(unit: Unit): TranslationPaths {
return unitTranslationKeysStrategy[unit];
}
+/**
+ * @param error workspace change owner error
+ * @param translate translation function
+ * @param policy policy object
+ * @param accountLogin account login/email
+ * @returns ownership change checks page display text's
+ */
+function getOwnershipChecksDisplayText(
+ error: ValueOf,
+ translate: (phraseKey: TKey, ...phraseParameters: PhraseParameters>) => string,
+ policy: OnyxEntry,
+ accountLogin: string | undefined,
+) {
+ let title;
+ let text;
+ let buttonText;
+
+ const changeOwner = policy?.errorFields?.changeOwner;
+ const subscription = changeOwner?.subscription as unknown as {ownerUserCount: number; totalUserCount: number};
+ const ownerOwesAmount = changeOwner?.ownerOwesAmount as unknown as {ownerEmail: string; amount: number; currency: string};
+
+ switch (error) {
+ case CONST.POLICY.OWNERSHIP_ERRORS.AMOUNT_OWED:
+ title = translate('workspace.changeOwner.amountOwedTitle');
+ text = translate('workspace.changeOwner.amountOwedText');
+ buttonText = translate('workspace.changeOwner.amountOwedButtonText');
+ break;
+ case CONST.POLICY.OWNERSHIP_ERRORS.OWNER_OWES_AMOUNT:
+ title = translate('workspace.changeOwner.ownerOwesAmountTitle');
+ text = translate('workspace.changeOwner.ownerOwesAmountText', {
+ email: ownerOwesAmount?.ownerEmail,
+ amount: CurrencyUtils.convertAmountToDisplayString(ownerOwesAmount?.amount, ownerOwesAmount?.currency),
+ });
+ buttonText = translate('workspace.changeOwner.ownerOwesAmountButtonText');
+ break;
+ case CONST.POLICY.OWNERSHIP_ERRORS.SUBSCRIPTION:
+ title = translate('workspace.changeOwner.subscriptionTitle');
+ text = translate('workspace.changeOwner.subscriptionText', {
+ usersCount: subscription?.ownerUserCount,
+ finalCount: subscription?.totalUserCount,
+ });
+ buttonText = translate('workspace.changeOwner.subscriptionButtonText');
+ break;
+ case CONST.POLICY.OWNERSHIP_ERRORS.DUPLICATE_SUBSCRIPTION:
+ title = translate('workspace.changeOwner.duplicateSubscriptionTitle');
+ text = translate('workspace.changeOwner.duplicateSubscriptionText', {
+ email: changeOwner?.duplicateSubscription,
+ workspaceName: policy?.name,
+ });
+ buttonText = translate('workspace.changeOwner.duplicateSubscriptionButtonText');
+ break;
+ case CONST.POLICY.OWNERSHIP_ERRORS.HAS_FAILED_SETTLEMENTS:
+ title = translate('workspace.changeOwner.hasFailedSettlementsTitle');
+ text = translate('workspace.changeOwner.hasFailedSettlementsText', {email: accountLogin});
+ buttonText = translate('workspace.changeOwner.hasFailedSettlementsButtonText');
+ break;
+ case CONST.POLICY.OWNERSHIP_ERRORS.FAILED_TO_CLEAR_BALANCE:
+ title = translate('workspace.changeOwner.failedToClearBalanceTitle');
+ text = translate('workspace.changeOwner.failedToClearBalanceText');
+ buttonText = translate('workspace.changeOwner.failedToClearBalanceButtonText');
+ break;
+ default:
+ title = '';
+ text = '';
+ buttonText = '';
+ break;
+ }
+
+ return {title, text, buttonText};
+}
+
export {
getBrickRoadForPolicy,
getWorkspacesBrickRoads,
@@ -223,5 +296,6 @@ export {
hasWorkspaceSettingsRBR,
getChatTabBrickRoad,
getUnitTranslationKey,
+ getOwnershipChecksDisplayText,
};
export type {BrickRoad};
diff --git a/src/libs/actions/Policy.ts b/src/libs/actions/Policy.ts
index 332c145c58c4..3efba3e54dcd 100644
--- a/src/libs/actions/Policy.ts
+++ b/src/libs/actions/Policy.ts
@@ -9,6 +9,7 @@ import Onyx from 'react-native-onyx';
import type {ValueOf} from 'type-fest';
import * as API from '@libs/API';
import type {
+ AddBillingCardAndRequestWorkspaceOwnerChangeParams,
AddMembersToWorkspaceParams,
CreatePolicyDistanceRateParams,
CreateWorkspaceFromIOUPaymentParams,
@@ -35,6 +36,7 @@ import type {
OpenWorkspaceMembersPageParams,
OpenWorkspaceParams,
OpenWorkspaceReimburseViewParams,
+ RequestWorkspaceOwnerChangeParams,
SetPolicyDistanceRatesDefaultCategoryParams,
SetPolicyDistanceRatesEnabledParams,
SetPolicyDistanceRatesUnitParams,
@@ -75,6 +77,7 @@ import type {
PolicyCategories,
PolicyCategory,
PolicyMember,
+ PolicyOwnershipChangeChecks,
PolicyTag,
PolicyTagList,
PolicyTags,
@@ -249,6 +252,14 @@ Onyx.connect({
callback: (val) => (allPolicyCategories = val),
});
+let policyOwnershipChecks: Record;
+Onyx.connect({
+ key: ONYXKEYS.POLICY_OWNERSHIP_CHANGE_CHECKS,
+ callback: (value) => {
+ policyOwnershipChecks = value ?? {};
+ },
+});
+
/**
* Stores in Onyx the policy ID of the last workspace that was accessed by the user
*/
@@ -1004,6 +1015,149 @@ function updateWorkspaceMembersRole(policyID: string, accountIDs: number[], newR
API.write(WRITE_COMMANDS.UPDATE_WORKSPACE_MEMBERS_ROLE, params, {optimisticData, successData, failureData});
}
+function requestWorkspaceOwnerChange(policyID: string) {
+ const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`] ?? ({} as Policy);
+ const ownershipChecks = {...policyOwnershipChecks?.[policyID]} ?? {};
+
+ const changeOwnerErrors = Object.keys(policy?.errorFields?.changeOwner ?? {});
+
+ if (changeOwnerErrors && changeOwnerErrors.length > 0) {
+ const currentError = changeOwnerErrors[0];
+ if (currentError === CONST.POLICY.OWNERSHIP_ERRORS.AMOUNT_OWED) {
+ ownershipChecks.shouldClearOutstandingBalance = true;
+ }
+
+ if (currentError === CONST.POLICY.OWNERSHIP_ERRORS.OWNER_OWES_AMOUNT) {
+ ownershipChecks.shouldTransferAmountOwed = true;
+ }
+
+ if (currentError === CONST.POLICY.OWNERSHIP_ERRORS.SUBSCRIPTION) {
+ ownershipChecks.shouldTransferSubscription = true;
+ }
+
+ if (currentError === CONST.POLICY.OWNERSHIP_ERRORS.DUPLICATE_SUBSCRIPTION) {
+ ownershipChecks.shouldTransferSingleSubscription = true;
+ }
+
+ Onyx.merge(ONYXKEYS.POLICY_OWNERSHIP_CHANGE_CHECKS, {
+ [policyID]: ownershipChecks,
+ });
+ }
+
+ const optimisticData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ errorFields: null,
+ isLoading: true,
+ isChangeOwnerSuccessful: false,
+ isChangeOwnerFailed: false,
+ },
+ },
+ ];
+
+ const successData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ isLoading: false,
+ isChangeOwnerSuccessful: true,
+ isChangeOwnerFailed: false,
+ },
+ },
+ ];
+
+ const failureData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ isLoading: false,
+ isChangeOwnerSuccessful: false,
+ isChangeOwnerFailed: true,
+ },
+ },
+ ];
+
+ const params: RequestWorkspaceOwnerChangeParams = {
+ policyID,
+ ...ownershipChecks,
+ };
+
+ API.write(WRITE_COMMANDS.REQUEST_WORKSPACE_OWNER_CHANGE, params, {optimisticData, successData, failureData});
+}
+
+function clearWorkspaceOwnerChangeFlow(policyID: string) {
+ Onyx.merge(ONYXKEYS.POLICY_OWNERSHIP_CHANGE_CHECKS, null);
+ Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {
+ errorFields: null,
+ isLoading: false,
+ isChangeOwnerSuccessful: false,
+ isChangeOwnerFailed: false,
+ });
+}
+
+function addBillingCardAndRequestPolicyOwnerChange(
+ policyID: string,
+ cardData: {
+ cardNumber: string;
+ cardYear: string;
+ cardMonth: string;
+ cardCVV: string;
+ addressName: string;
+ addressZip: string;
+ currency: string;
+ },
+) {
+ const {cardNumber, cardYear, cardMonth, cardCVV, addressName, addressZip, currency} = cardData;
+
+ const optimisticData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ errorFields: null,
+ isLoading: true,
+ },
+ },
+ ];
+
+ const successData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ isLoading: false,
+ },
+ },
+ ];
+
+ const failureData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {
+ isLoading: false,
+ },
+ },
+ ];
+
+ const params: AddBillingCardAndRequestWorkspaceOwnerChangeParams = {
+ policyID,
+ cardNumber,
+ cardYear,
+ cardMonth,
+ cardCVV,
+ addressName,
+ addressZip,
+ currency,
+ };
+
+ API.write(WRITE_COMMANDS.ADD_BILLING_CARD_AND_REQUEST_WORKSPACE_OWNER_CHANGE, params, {optimisticData, successData, failureData});
+}
+
/**
* Optimistically create a chat for each member of the workspace, creates both optimistic and success data for onyx.
*
@@ -4600,6 +4754,9 @@ function setForeignCurrencyDefault(policyID: string, taxCode: string) {
export {
removeMembers,
updateWorkspaceMembersRole,
+ requestWorkspaceOwnerChange,
+ clearWorkspaceOwnerChangeFlow,
+ addBillingCardAndRequestPolicyOwnerChange,
addMembersToWorkspace,
isAdminOfFreePolicy,
hasActiveChatEnabledPolicies,
diff --git a/src/pages/workspace/WorkspaceMembersPage.tsx b/src/pages/workspace/WorkspaceMembersPage.tsx
index cebcc38aeae4..953910bb2462 100644
--- a/src/pages/workspace/WorkspaceMembersPage.tsx
+++ b/src/pages/workspace/WorkspaceMembersPage.tsx
@@ -108,6 +108,8 @@ function WorkspaceMembersPage({
const selectionListRef = useRef(null);
const isFocused = useIsFocused();
+ const policyID = route.params.policyID;
+
/**
* Get filtered personalDetails list with current policyMembers
*/
@@ -220,7 +222,7 @@ function WorkspaceMembersPage({
* Add or remove all users passed from the selectedEmployees list
*/
const toggleAllUsers = (memberList: MemberOption[]) => {
- const enabledAccounts = memberList.filter((member) => !member.isDisabled);
+ const enabledAccounts = memberList.filter((member) => !member.isDisabled && !member.isDisabledCheckbox);
const everyoneSelected = enabledAccounts.every((member) => selectedEmployees.includes(member.accountID));
if (everyoneSelected) {
@@ -282,9 +284,10 @@ function WorkspaceMembersPage({
return;
}
+ Policy.clearWorkspaceOwnerChangeFlow(policyID);
Navigation.navigate(ROUTES.WORKSPACE_MEMBER_DETAILS.getRoute(route.params.policyID, item.accountID, Navigation.getActiveRoute()));
},
- [isPolicyAdmin, policy, route.params.policyID],
+ [isPolicyAdmin, policy, policyID, route.params.policyID],
);
/**
@@ -310,7 +313,6 @@ function WorkspaceMembersPage({
);
const policyOwner = policy?.owner;
const currentUserLogin = currentUserPersonalDetails.login;
- const policyID = route.params.policyID;
const invitedPrimaryToSecondaryLogins = invertObject(policy?.primaryLoginsInvited ?? {});
@@ -359,12 +361,8 @@ function WorkspaceMembersPage({
keyForList: accountIDKey,
accountID,
isSelected,
- isDisabled:
- isPolicyAdmin &&
- (accountID === session?.accountID ||
- accountID === policy?.ownerAccountID ||
- policyMember.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE ||
- !isEmptyObject(policyMember.errors)),
+ isDisabledCheckbox: !(isPolicyAdmin && accountID !== policy?.ownerAccountID && accountID !== session?.accountID),
+ isDisabled: isPolicyAdmin && (policyMember.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || !isEmptyObject(policyMember.errors)),
text: formatPhoneNumber(PersonalDetailsUtils.getDisplayNameOrDefault(details)),
alternateText: formatPhoneNumber(details?.login ?? ''),
rightElement: roleBadge,
diff --git a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx
index bb7f4d6a03aa..4199bb5f432d 100644
--- a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx
+++ b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx
@@ -1,8 +1,9 @@
import type {StackScreenProps} from '@react-navigation/stack';
-import React, {useCallback} from 'react';
+import React, {useCallback, useEffect} from 'react';
import {View} from 'react-native';
import type {OnyxEntry} from 'react-native-onyx';
import {withOnyx} from 'react-native-onyx';
+import type {ValueOf} from 'type-fest';
import Avatar from '@components/Avatar';
import Button from '@components/Button';
import ConfirmModal from '@components/ConfirmModal';
@@ -13,7 +14,10 @@ import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription';
import OfflineWithFeedback from '@components/OfflineWithFeedback';
import ScreenWrapper from '@components/ScreenWrapper';
import Text from '@components/Text';
+import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
import useLocalize from '@hooks/useLocalize';
+import useNetwork from '@hooks/useNetwork';
+import useStyleUtils from '@hooks/useStyleUtils';
import useThemeStyles from '@hooks/useThemeStyles';
import * as UserUtils from '@libs/UserUtils';
import Navigation from '@navigation/Navigation';
@@ -39,7 +43,10 @@ type WorkspaceMemberDetailsPageProps = WithPolicyAndFullscreenLoadingProps & Wor
function WorkspaceMemberDetailsPage({personalDetails, policyMembers, policy, route}: WorkspaceMemberDetailsPageProps) {
const styles = useThemeStyles();
+ const {isOffline} = useNetwork();
const {translate} = useLocalize();
+ const StyleUtils = useStyleUtils();
+ const currentUserPersonalDetails = useCurrentUserPersonalDetails();
const [isRemoveMemberConfirmModalVisible, setIsRemoveMemberConfirmModalVisible] = React.useState(false);
@@ -52,24 +59,46 @@ function WorkspaceMemberDetailsPage({personalDetails, policyMembers, policy, rou
const avatar = details.avatar ?? UserUtils.getDefaultAvatar();
const fallbackIcon = details.fallbackIcon ?? '';
const displayName = details.displayName ?? '';
+ const isSelectedMemberOwner = policy?.owner === details.login;
+ const isSelectedMemberCurrentUser = accountID === currentUserPersonalDetails?.accountID;
+ const isCurrentUserAdmin = policyMembers?.[currentUserPersonalDetails?.accountID]?.role === CONST.POLICY.ROLE.ADMIN;
+ const isCurrentUserOwner = policy?.owner === currentUserPersonalDetails?.login;
+
+ useEffect(() => {
+ if (!policy?.errorFields?.changeOwner && policy?.isChangeOwnerSuccessful) {
+ return;
+ }
+
+ const changeOwnerErrors = Object.keys(policy?.errorFields?.changeOwner ?? {});
+
+ if (changeOwnerErrors && changeOwnerErrors.length > 0) {
+ Navigation.navigate(ROUTES.WORKSPACE_OWNER_CHANGE_CHECK.getRoute(policyID, accountID, changeOwnerErrors[0] as ValueOf));
+ }
+ }, [accountID, policy?.errorFields?.changeOwner, policy?.isChangeOwnerSuccessful, policyID]);
const askForConfirmationToRemove = () => {
setIsRemoveMemberConfirmModalVisible(true);
};
const removeUser = useCallback(() => {
- Policy.removeMembers([accountID], route.params.policyID);
+ Policy.removeMembers([accountID], policyID);
setIsRemoveMemberConfirmModalVisible(false);
Navigation.goBack(backTo);
- }, [accountID, backTo, route.params.policyID]);
+ }, [accountID, backTo, policyID]);
const navigateToProfile = useCallback(() => {
Navigation.navigate(ROUTES.PROFILE.getRoute(accountID, Navigation.getActiveRoute()));
}, [accountID]);
const openRoleSelectionModal = useCallback(() => {
- Navigation.navigate(ROUTES.WORKSPACE_MEMBER_ROLE_SELECTION.getRoute(route.params.policyID, accountID, Navigation.getActiveRoute()));
- }, [accountID, route.params.policyID]);
+ Navigation.navigate(ROUTES.WORKSPACE_MEMBER_ROLE_SELECTION.getRoute(policyID, accountID, Navigation.getActiveRoute()));
+ }, [accountID, policyID]);
+
+ const startChangeOwnershipFlow = useCallback(() => {
+ Policy.clearWorkspaceOwnerChangeFlow(policyID);
+ Policy.requestWorkspaceOwnerChange(policyID);
+ Navigation.navigate(ROUTES.WORKSPACE_OWNER_CHANGE_CHECK.getRoute(policyID, accountID, 'amountOwed' as ValueOf));
+ }, [accountID, policyID]);
return (
@@ -99,13 +128,27 @@ function WorkspaceMemberDetailsPage({personalDetails, policyMembers, policy, rou
{displayName}
)}
-
+ {isSelectedMemberOwner && isCurrentUserAdmin && !isCurrentUserOwner ? (
+
+ ) : (
+
+ )}
;
+};
+
+type WorkspaceOwnerChangeCheckProps = WorkspaceOwnerChangeCheckOnyxProps & {
+ /** The policy */
+ policy: OnyxEntry;
+
+ /** The accountID */
+ accountID: number;
+
+ /** The error code */
+ error: ValueOf;
+};
+
+function WorkspaceOwnerChangeCheck({personalDetails, policy, accountID, error}: WorkspaceOwnerChangeCheckProps) {
+ const styles = useThemeStyles();
+ const {translate} = useLocalize();
+
+ const policyID = policy?.id ?? '';
+
+ const confirm = useCallback(() => {
+ if (error === CONST.POLICY.OWNERSHIP_ERRORS.HAS_FAILED_SETTLEMENTS || error === CONST.POLICY.OWNERSHIP_ERRORS.FAILED_TO_CLEAR_BALANCE) {
+ // cannot transfer ownership if there are failed settlements, or we cannot clear the balance
+ PolicyActions.clearWorkspaceOwnerChangeFlow(policyID);
+ Navigation.navigate(ROUTES.WORKSPACE_MEMBER_DETAILS.getRoute(policyID, accountID));
+ return;
+ }
+
+ PolicyActions.requestWorkspaceOwnerChange(policyID);
+ }, [accountID, error, policyID]);
+
+ const {title, text, buttonText} = WorkspaceSettingsUtils.getOwnershipChecksDisplayText(error, translate, policy, personalDetails?.[accountID]?.login);
+
+ return (
+ <>
+ {title}
+ {text}
+
+ >
+ );
+}
+
+WorkspaceOwnerChangeCheck.displayName = 'WorkspaceOwnerChangeCheckPage';
+
+export default withOnyx({
+ personalDetails: {
+ key: ONYXKEYS.PERSONAL_DETAILS_LIST,
+ },
+})(WorkspaceOwnerChangeCheck);
diff --git a/src/pages/workspace/members/WorkspaceOwnerChangeErrorPage.tsx b/src/pages/workspace/members/WorkspaceOwnerChangeErrorPage.tsx
new file mode 100644
index 000000000000..81c43f6ab818
--- /dev/null
+++ b/src/pages/workspace/members/WorkspaceOwnerChangeErrorPage.tsx
@@ -0,0 +1,78 @@
+import type {StackScreenProps} from '@react-navigation/stack';
+import React, {useCallback} from 'react';
+import {View} from 'react-native';
+import Button from '@components/Button';
+import FixedFooter from '@components/FixedFooter';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import Icon from '@components/Icon';
+import * as Expensicons from '@components/Icon/Expensicons';
+import ScreenWrapper from '@components/ScreenWrapper';
+import Text from '@components/Text';
+import TextLink from '@components/TextLink';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
+import Navigation from '@navigation/Navigation';
+import type {SettingsNavigatorParamList} from '@navigation/types';
+import AdminPolicyAccessOrNotFoundWrapper from '@pages/workspace/AdminPolicyAccessOrNotFoundWrapper';
+import PaidPolicyAccessOrNotFoundWrapper from '@pages/workspace/PaidPolicyAccessOrNotFoundWrapper';
+import * as PolicyActions from '@userActions/Policy';
+import CONST from '@src/CONST';
+import ROUTES from '@src/ROUTES';
+import type SCREENS from '@src/SCREENS';
+
+type WorkspaceOwnerChangeSuccessPageProps = StackScreenProps;
+
+function WorkspaceOwnerChangeErrorPage({route}: WorkspaceOwnerChangeSuccessPageProps) {
+ const styles = useThemeStyles();
+ const {translate} = useLocalize();
+
+ const accountID = Number(route.params.accountID) ?? 0;
+ const policyID = route.params.policyID;
+
+ const closePage = useCallback(() => {
+ PolicyActions.clearWorkspaceOwnerChangeFlow(policyID);
+ Navigation.navigate(ROUTES.WORKSPACE_MEMBER_DETAILS.getRoute(policyID, accountID));
+ }, [accountID, policyID]);
+
+ return (
+
+
+
+
+
+
+ {translate('workspace.changeOwner.errorTitle')}
+
+ {translate('workspace.changeOwner.errorDescriptionPartOne')}{' '}
+ {translate('workspace.changeOwner.errorDescriptionPartTwo')}{' '}
+ {translate('workspace.changeOwner.errorDescriptionPartThree')}
+
+
+
+
+
+
+
+
+ );
+}
+
+WorkspaceOwnerChangeErrorPage.displayName = 'WorkspaceOwnerChangeErrorPage';
+
+export default WorkspaceOwnerChangeErrorPage;
diff --git a/src/pages/workspace/members/WorkspaceOwnerChangeSuccessPage.tsx b/src/pages/workspace/members/WorkspaceOwnerChangeSuccessPage.tsx
new file mode 100644
index 000000000000..856a852037b4
--- /dev/null
+++ b/src/pages/workspace/members/WorkspaceOwnerChangeSuccessPage.tsx
@@ -0,0 +1,56 @@
+import type {StackScreenProps} from '@react-navigation/stack';
+import React, {useCallback} from 'react';
+import ConfirmationPage from '@components/ConfirmationPage';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import LottieAnimations from '@components/LottieAnimations';
+import ScreenWrapper from '@components/ScreenWrapper';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
+import Navigation from '@navigation/Navigation';
+import type {SettingsNavigatorParamList} from '@navigation/types';
+import AdminPolicyAccessOrNotFoundWrapper from '@pages/workspace/AdminPolicyAccessOrNotFoundWrapper';
+import PaidPolicyAccessOrNotFoundWrapper from '@pages/workspace/PaidPolicyAccessOrNotFoundWrapper';
+import * as PolicyActions from '@userActions/Policy';
+import ROUTES from '@src/ROUTES';
+import type SCREENS from '@src/SCREENS';
+
+type WorkspaceOwnerChangeSuccessPageProps = StackScreenProps;
+
+function WorkspaceOwnerChangeSuccessPage({route}: WorkspaceOwnerChangeSuccessPageProps) {
+ const styles = useThemeStyles();
+ const {translate} = useLocalize();
+
+ const accountID = Number(route.params.accountID) ?? 0;
+ const policyID = route.params.policyID;
+
+ const closePage = useCallback(() => {
+ PolicyActions.clearWorkspaceOwnerChangeFlow(policyID);
+ Navigation.navigate(ROUTES.WORKSPACE_MEMBER_DETAILS.getRoute(policyID, accountID));
+ }, [accountID, policyID]);
+
+ return (
+
+
+
+
+
+
+
+
+ );
+}
+
+WorkspaceOwnerChangeSuccessPage.displayName = 'WorkspaceOwnerChangeSuccessPage';
+
+export default WorkspaceOwnerChangeSuccessPage;
diff --git a/src/pages/workspace/members/WorkspaceOwnerChangeWrapperPage.tsx b/src/pages/workspace/members/WorkspaceOwnerChangeWrapperPage.tsx
new file mode 100644
index 000000000000..d166173a23df
--- /dev/null
+++ b/src/pages/workspace/members/WorkspaceOwnerChangeWrapperPage.tsx
@@ -0,0 +1,100 @@
+import type {StackScreenProps} from '@react-navigation/stack';
+import React, {useEffect, useState} from 'react';
+import {View} from 'react-native';
+import type {ValueOf} from 'type-fest';
+import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import ScreenWrapper from '@components/ScreenWrapper';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
+import Navigation from '@navigation/Navigation';
+import type {SettingsNavigatorParamList} from '@navigation/types';
+import AdminPolicyAccessOrNotFoundWrapper from '@pages/workspace/AdminPolicyAccessOrNotFoundWrapper';
+import PaidPolicyAccessOrNotFoundWrapper from '@pages/workspace/PaidPolicyAccessOrNotFoundWrapper';
+import withPolicy from '@pages/workspace/withPolicy';
+import type {WithPolicyOnyxProps} from '@pages/workspace/withPolicy';
+import * as PolicyActions from '@userActions/Policy';
+import CONST from '@src/CONST';
+import ROUTES from '@src/ROUTES';
+import type SCREENS from '@src/SCREENS';
+import WorkspaceOwnerChangeCheck from './WorkspaceOwnerChangeCheck';
+import WorkspaceOwnerPaymentCardForm from './WorkspaceOwnerPaymentCardForm';
+
+type WorkspaceOwnerChangeWrapperPageProps = WithPolicyOnyxProps & StackScreenProps;
+
+function WorkspaceOwnerChangeWrapperPage({route, policy}: WorkspaceOwnerChangeWrapperPageProps) {
+ const styles = useThemeStyles();
+ const {translate} = useLocalize();
+ const [isTransitioning, setIsTransitioning] = useState(false);
+
+ const policyID = route.params.policyID;
+ const accountID = route.params.accountID;
+ const error = route.params.error;
+
+ useEffect(() => {
+ setIsTransitioning(true);
+ }, []);
+
+ useEffect(() => {
+ if (!policy || policy?.isLoading) {
+ return;
+ }
+
+ if (!policy.errorFields && policy.isChangeOwnerFailed) {
+ // there are some errors but not related to change owner flow - show an error page
+ Navigation.navigate(ROUTES.WORKSPACE_OWNER_CHANGE_ERROR.getRoute(policyID, accountID));
+ return;
+ }
+
+ if (!policy?.errorFields?.changeOwner && policy?.isChangeOwnerSuccessful) {
+ // no errors - show a success page
+ Navigation.navigate(ROUTES.WORKSPACE_OWNER_CHANGE_SUCCESS.getRoute(policyID, accountID));
+ return;
+ }
+
+ const changeOwnerErrors = Object.keys(policy?.errorFields?.changeOwner ?? {});
+
+ if (changeOwnerErrors && changeOwnerErrors.length > 0 && changeOwnerErrors[0] !== CONST.POLICY.OWNERSHIP_ERRORS.NO_BILLING_CARD) {
+ Navigation.navigate(ROUTES.WORKSPACE_OWNER_CHANGE_CHECK.getRoute(policyID, accountID, changeOwnerErrors[0] as ValueOf));
+ }
+ }, [accountID, policy, policy?.errorFields?.changeOwner, policyID]);
+
+ return (
+
+
+ {
+ setIsTransitioning(false);
+ }}
+ >
+ {
+ PolicyActions.clearWorkspaceOwnerChangeFlow(policyID);
+ Navigation.navigate(ROUTES.WORKSPACE_MEMBER_DETAILS.getRoute(policyID, accountID));
+ }}
+ />
+
+ {(policy?.isLoading ?? isTransitioning) && }
+ {!policy?.isLoading &&
+ !isTransitioning &&
+ (error === CONST.POLICY.OWNERSHIP_ERRORS.NO_BILLING_CARD ? (
+
+ ) : (
+
+ ))}
+
+
+
+
+ );
+}
+
+WorkspaceOwnerChangeWrapperPage.displayName = 'WorkspaceOwnerChangeWrapperPage';
+
+export default withPolicy(WorkspaceOwnerChangeWrapperPage);
diff --git a/src/pages/workspace/members/WorkspaceOwnerPaymentCardCurrencyModal.tsx b/src/pages/workspace/members/WorkspaceOwnerPaymentCardCurrencyModal.tsx
new file mode 100644
index 000000000000..8d6eb4b2b943
--- /dev/null
+++ b/src/pages/workspace/members/WorkspaceOwnerPaymentCardCurrencyModal.tsx
@@ -0,0 +1,85 @@
+import React, {useMemo} from 'react';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import Modal from '@components/Modal';
+import ScreenWrapper from '@components/ScreenWrapper';
+import SelectionList from '@components/SelectionList';
+import RadioListItem from '@components/SelectionList/RadioListItem';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
+import CONST from '@src/CONST';
+
+type WorkspaceOwnerPaymentCardCurrencyModalProps = {
+ /** Whether the modal is visible */
+ isVisible: boolean;
+
+ /** The list of years to render */
+ currencies: Array;
+
+ /** Currently selected year */
+ currentCurrency: keyof typeof CONST.CURRENCY;
+
+ /** Function to call when the user selects a year */
+ onCurrencyChange?: (currency: keyof typeof CONST.CURRENCY) => void;
+
+ /** Function to call when the user closes the year picker */
+ onClose?: () => void;
+};
+
+function WorkspaceOwnerPaymentCardCurrencyModal({isVisible, currencies, currentCurrency = CONST.CURRENCY.USD, onCurrencyChange, onClose}: WorkspaceOwnerPaymentCardCurrencyModalProps) {
+ const styles = useThemeStyles();
+ const {translate} = useLocalize();
+ const {sections} = useMemo(
+ () => ({
+ sections: [
+ {
+ data: currencies.map((currency) => ({
+ text: currency,
+ value: currency,
+ isSelected: currency === currentCurrency,
+ })),
+ indexOffset: 0,
+ },
+ ],
+ }),
+ [currencies, currentCurrency],
+ );
+
+ return (
+ onClose?.()}
+ onModalHide={onClose}
+ hideModalContentWhileAnimating
+ useNativeDriver
+ >
+
+
+ {
+ onCurrencyChange?.(option.value);
+ }}
+ initiallyFocusedOptionKey={currentCurrency}
+ showScrollIndicator
+ shouldStopPropagation
+ shouldUseDynamicMaxToRenderPerBatch
+ ListItem={RadioListItem}
+ />
+
+
+ );
+}
+
+WorkspaceOwnerPaymentCardCurrencyModal.displayName = 'WorkspaceOwnerPaymentCardCurrencyModal';
+
+export default WorkspaceOwnerPaymentCardCurrencyModal;
diff --git a/src/pages/workspace/members/WorkspaceOwnerPaymentCardForm.tsx b/src/pages/workspace/members/WorkspaceOwnerPaymentCardForm.tsx
new file mode 100644
index 000000000000..7044034a5a0d
--- /dev/null
+++ b/src/pages/workspace/members/WorkspaceOwnerPaymentCardForm.tsx
@@ -0,0 +1,276 @@
+import React, {useCallback, useEffect, useRef, useState} from 'react';
+import {View} from 'react-native';
+import type {OnyxEntry} from 'react-native-onyx';
+import FormProvider from '@components/Form/FormProvider';
+import InputWrapper from '@components/Form/InputWrapper';
+import type {FormInputErrors, FormOnyxValues} from '@components/Form/types';
+import Hoverable from '@components/Hoverable';
+import Icon from '@components/Icon';
+import * as Expensicons from '@components/Icon/Expensicons';
+import * as Illustrations from '@components/Icon/Illustrations';
+import type {AnimatedTextInputRef} from '@components/RNTextInput';
+import Section, {CARD_LAYOUT} from '@components/Section';
+import Text from '@components/Text';
+import TextInput from '@components/TextInput';
+import TextLink from '@components/TextLink';
+import useLocalize from '@hooks/useLocalize';
+import useTheme from '@hooks/useTheme';
+import useThemeStyles from '@hooks/useThemeStyles';
+import * as CardUtils from '@libs/CardUtils';
+import * as ValidationUtils from '@libs/ValidationUtils';
+import * as PaymentMethods from '@userActions/PaymentMethods';
+import * as PolicyActions from '@userActions/Policy';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import INPUT_IDS from '@src/types/form/AddDebitCardForm';
+import type * as OnyxTypes from '@src/types/onyx';
+import WorkspaceOwnerPaymentCardCurrencyModal from './WorkspaceOwnerPaymentCardCurrencyModal';
+
+type WorkspaceOwnerPaymentCardFormProps = {
+ /** The policy */
+ policy: OnyxEntry;
+};
+
+const REQUIRED_FIELDS = [INPUT_IDS.NAME_ON_CARD, INPUT_IDS.CARD_NUMBER, INPUT_IDS.EXPIRATION_DATE, INPUT_IDS.ADDRESS_STREET, INPUT_IDS.SECURITY_CODE, INPUT_IDS.ADDRESS_ZIP_CODE];
+
+function WorkspaceOwnerPaymentCardForm({policy}: WorkspaceOwnerPaymentCardFormProps) {
+ const styles = useThemeStyles();
+ const theme = useTheme();
+ const {translate} = useLocalize();
+
+ const cardNumberRef = useRef(null);
+
+ const [isCurrencyModalVisible, setIsCurrencyModalVisible] = useState(false);
+ const [currency, setCurrency] = useState(CONST.CURRENCY.USD);
+
+ const policyID = policy?.id ?? '';
+
+ useEffect(
+ () => {
+ PaymentMethods.clearDebitCardFormErrorAndSubmit();
+
+ return () => {
+ PaymentMethods.clearDebitCardFormErrorAndSubmit();
+ };
+ },
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [],
+ );
+
+ const validate = (formValues: FormOnyxValues): FormInputErrors => {
+ const errors = ValidationUtils.getFieldRequiredErrors(formValues, REQUIRED_FIELDS);
+
+ if (formValues.nameOnCard && !ValidationUtils.isValidLegalName(formValues.nameOnCard)) {
+ errors.nameOnCard = 'addDebitCardPage.error.invalidName';
+ }
+
+ if (formValues.cardNumber && !ValidationUtils.isValidDebitCard(formValues.cardNumber.replace(/ /g, ''))) {
+ errors.cardNumber = 'addDebitCardPage.error.debitCardNumber';
+ }
+
+ if (formValues.expirationDate && !ValidationUtils.isValidExpirationDate(formValues.expirationDate)) {
+ errors.expirationDate = 'addDebitCardPage.error.expirationDate';
+ }
+
+ if (formValues.securityCode && !ValidationUtils.isValidSecurityCode(formValues.securityCode)) {
+ errors.securityCode = 'addDebitCardPage.error.securityCode';
+ }
+
+ if (formValues.addressStreet && !ValidationUtils.isValidAddress(formValues.addressStreet)) {
+ errors.addressStreet = 'addDebitCardPage.error.addressStreet';
+ }
+
+ if (formValues.addressZipCode && !ValidationUtils.isValidZipCode(formValues.addressZipCode)) {
+ errors.addressZipCode = 'addDebitCardPage.error.addressZipCode';
+ }
+
+ return errors;
+ };
+
+ const addPaymentCard = useCallback(
+ (values: FormOnyxValues) => {
+ const cardData = {
+ cardNumber: values.cardNumber,
+ cardMonth: CardUtils.getMonthFromExpirationDateString(values.expirationDate),
+ cardYear: CardUtils.getYearFromExpirationDateString(values.expirationDate),
+ cardCVV: values.securityCode,
+ addressName: values.nameOnCard,
+ addressZip: values.addressZipCode,
+ currency: CONST.CURRENCY.USD,
+ };
+
+ PolicyActions.addBillingCardAndRequestPolicyOwnerChange(policyID, cardData);
+ },
+ [policyID],
+ );
+
+ const showCurrenciesModal = useCallback(() => {
+ setIsCurrencyModalVisible(true);
+ }, []);
+
+ const changeCurrency = useCallback((newCurrency: keyof typeof CONST.CURRENCY) => {
+ setCurrency(newCurrency);
+ setIsCurrencyModalVisible(false);
+ }, []);
+
+ return (
+ <>
+ {translate('workspace.changeOwner.addPaymentCardTitle')}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {(isHovered) => (
+
+ )}
+
+
+
+
+ }
+ currentCurrency={currency}
+ onCurrencyChange={changeCurrency}
+ onClose={() => setIsCurrencyModalVisible(false)}
+ />
+
+
+ {translate('workspace.changeOwner.addPaymentCardReadAndAcceptTextPart1')}{' '}
+
+ {translate('workspace.changeOwner.addPaymentCardTerms')}
+ {' '}
+ {translate('workspace.changeOwner.addPaymentCardAnd')}{' '}
+
+ {translate('workspace.changeOwner.addPaymentCardPrivacy')}
+ {' '}
+ {translate('workspace.changeOwner.addPaymentCardReadAndAcceptTextPart2')}
+
+
+
+
+
+ {translate('workspace.changeOwner.addPaymentCardPciCompliant')}
+
+
+
+ {translate('workspace.changeOwner.addPaymentCardBankLevelEncrypt')}
+
+
+
+ {translate('workspace.changeOwner.addPaymentCardRedundant')}
+
+
+
+ {translate('workspace.changeOwner.addPaymentCardLearnMore')}{' '}
+
+ {translate('workspace.changeOwner.addPaymentCardSecurity')}
+
+ .
+
+
+
+ >
+ );
+}
+
+WorkspaceOwnerPaymentCardForm.displayName = 'WorkspaceOwnerPaymentCardForm';
+
+export default WorkspaceOwnerPaymentCardForm;
diff --git a/src/types/onyx/Policy.ts b/src/types/onyx/Policy.ts
index 5165fa2ee128..72eaf61a2b2f 100644
--- a/src/types/onyx/Policy.ts
+++ b/src/types/onyx/Policy.ts
@@ -417,6 +417,15 @@ type Policy = OnyxCommon.OnyxValueWithOfflineFeedback<
/** Whether the Connections feature is enabled */
areConnectionsEnabled?: boolean;
+
+ /** Indicates if the Policy is in loading state */
+ isLoading?: boolean;
+
+ /** Indicates if the Policy ownership change is successful */
+ isChangeOwnerSuccessful?: boolean;
+
+ /** Indicates if the Policy ownership change is failed */
+ isChangeOwnerFailed?: boolean;
} & Partial,
'generalSettings' | 'addWorkspaceRoom'
>;
diff --git a/src/types/onyx/PolicyOwnershipChangeChecks.ts b/src/types/onyx/PolicyOwnershipChangeChecks.ts
new file mode 100644
index 000000000000..8033cffdee3c
--- /dev/null
+++ b/src/types/onyx/PolicyOwnershipChangeChecks.ts
@@ -0,0 +1,8 @@
+type PolicyOwnershipChangeChecks = {
+ shouldClearOutstandingBalance?: boolean;
+ shouldTransferAmountOwed?: boolean;
+ shouldTransferSubscription?: boolean;
+ shouldTransferSingleSubscription?: boolean;
+};
+
+export default PolicyOwnershipChangeChecks;
diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts
index de40dd4cf02f..53ba93648742 100644
--- a/src/types/onyx/index.ts
+++ b/src/types/onyx/index.ts
@@ -39,6 +39,7 @@ import type {PolicyCategories, PolicyCategory} from './PolicyCategory';
import type PolicyJoinMember from './PolicyJoinMember';
import type {PolicyMembers} from './PolicyMember';
import type PolicyMember from './PolicyMember';
+import type PolicyOwnershipChangeChecks from './PolicyOwnershipChangeChecks';
import type {PolicyTag, PolicyTagList, PolicyTags} from './PolicyTag';
import type PreferredTheme from './PreferredTheme';
import type PriorityMode from './PriorityMode';
@@ -115,6 +116,7 @@ export type {
PolicyCategory,
PolicyMember,
PolicyMembers,
+ PolicyOwnershipChangeChecks,
PolicyTag,
PolicyTags,
PolicyTagList,