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} )} -