diff --git a/src/libs/API/parameters/MoveIOUReportToPolicyAndInviteSubmitterParams.ts b/src/libs/API/parameters/MoveIOUReportToPolicyAndInviteSubmitterParams.ts new file mode 100644 index 000000000000..4868b9d60ab8 --- /dev/null +++ b/src/libs/API/parameters/MoveIOUReportToPolicyAndInviteSubmitterParams.ts @@ -0,0 +1,9 @@ +type MoveIOUReportToPolicyAndInviteSubmitterParams = { + iouReportID: string; + policyID: string; + policyExpenseChatReportID: string; + policyExpenseCreatedReportActionID: string; + changePolicyReportActionID: string; +}; + +export default MoveIOUReportToPolicyAndInviteSubmitterParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index 847d02b92777..f29a730cbfda 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -382,6 +382,7 @@ export type {default as GetCorpayOnboardingFieldsParams} from './GetCorpayOnboar export type {SaveCorpayOnboardingCompanyDetailsParams} from './SaveCorpayOnboardingCompanyDetailsParams'; export type {default as AcceptSpotnanaTermsParams} from './AcceptSpotnanaTermsParams'; export type {default as SaveCorpayOnboardingBeneficialOwnerParams} from './SaveCorpayOnboardingBeneficialOwnerParams'; +export type {default as MoveIOUReportToPolicyAndInviteSubmitterParams} from './MoveIOUReportToPolicyAndInviteSubmitterParams'; export type {default as MoveIOUReportToExistingPolicyParams} from './MoveIOUReportToExistingPolicyParams'; export type {default as ChangeReportPolicyParams} from './ChangeReportPolicyParams'; export type {default as ResetBankAccountSetupParams} from './ResetBankAccountSetupParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index 240af7b7fe82..f34587063268 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -145,6 +145,7 @@ const WRITE_COMMANDS = { CREATE_WORKSPACE: 'CreateWorkspace', CREATE_WORKSPACE_FROM_IOU_PAYMENT: 'CreateWorkspaceFromIOUPayment', SET_WORKSPACE_CATEGORIES_ENABLED: 'SetWorkspaceCategoriesEnabled', + MOVE_IOU_REPORT_TO_POLICY_AND_INVITE_SUBMITTER: 'MoveIOUReportToPolicyAndInviteSubmitter', SET_POLICY_TAGS_ENABLED: 'SetPolicyTagsEnabled', CREATE_WORKSPACE_CATEGORIES: 'CreateWorkspaceCategories', IMPORT_CATEGORIES_SPREADSHEET: 'ImportCategoriesSpreadsheet', @@ -600,6 +601,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.CREATE_WORKSPACE_FROM_IOU_PAYMENT]: Parameters.CreateWorkspaceFromIOUPaymentParams; [WRITE_COMMANDS.MOVE_IOU_REPORT_TO_EXISTING_POLICY]: Parameters.MoveIOUReportToExistingPolicyParams; [WRITE_COMMANDS.SET_WORKSPACE_CATEGORIES_ENABLED]: Parameters.SetWorkspaceCategoriesEnabledParams; + [WRITE_COMMANDS.MOVE_IOU_REPORT_TO_POLICY_AND_INVITE_SUBMITTER]: Parameters.MoveIOUReportToPolicyAndInviteSubmitterParams; [WRITE_COMMANDS.CREATE_WORKSPACE_CATEGORIES]: Parameters.CreateWorkspaceCategoriesParams; [WRITE_COMMANDS.IMPORT_CATEGORIES_SPREADSHEET]: Parameters.ImportCategoriesSpreadsheetParams; [WRITE_COMMANDS.IMPORT_PER_DIEM_RATES]: Parameters.ImportPerDiemRatesParams; diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 55956fa08a10..09497fecc86b 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -9495,6 +9495,7 @@ export { buildOptimisticModifiedExpenseReportAction, buildOptimisticMoneyRequestEntities, buildOptimisticMovedReportAction, + buildOptimisticChangePolicyReportAction, buildOptimisticMovedTrackedExpenseModifiedReportAction, buildOptimisticRenamedRoomReportAction, buildOptimisticRoomDescriptionUpdatedReportAction, @@ -9817,7 +9818,6 @@ export { isSelectedManagerMcTest, isTestTransactionReport, getReportSubtitlePrefix, - buildOptimisticChangePolicyReportAction, getPolicyChangeMessage, getExpenseReportStateAndStatus, buildOptimisticResolvedDuplicatesReportAction, diff --git a/src/libs/actions/Policy/Member.ts b/src/libs/actions/Policy/Member.ts index 9b1a5c931331..65d7a6467c70 100644 --- a/src/libs/actions/Policy/Member.ts +++ b/src/libs/actions/Policy/Member.ts @@ -1248,6 +1248,7 @@ export { importPolicyMembers, downloadMembersCSV, clearInviteDraft, + buildRoomMembersOnyxData, openPolicyMemberProfilePage, }; diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 7bdabcf4b4e2..6216edeca412 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -3,7 +3,7 @@ import {format as timezoneFormat, toZonedTime} from 'date-fns-tz'; import {Str} from 'expensify-common'; import isEmpty from 'lodash/isEmpty'; import {DeviceEventEmitter, InteractionManager, Linking} from 'react-native'; -import type {NullishDeep, OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-native-onyx'; +import type {NullishDeep, OnyxCollection, OnyxCollectionInputValue, OnyxEntry, OnyxUpdate} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import type {PartialDeep, ValueOf} from 'type-fest'; import type {Emoji} from '@assets/emojis/types'; @@ -29,6 +29,7 @@ import type { MarkAsExportedParams, MarkAsUnreadParams, MoveIOUReportToExistingPolicyParams, + MoveIOUReportToPolicyAndInviteSubmitterParams, OpenReportParams, OpenRoomMembersPageParams, ReadNewestActionParams, @@ -86,7 +87,7 @@ import {rand64} from '@libs/NumberUtils'; import Parser from '@libs/Parser'; import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; import * as PhoneNumber from '@libs/PhoneNumber'; -import {extractPolicyIDFromPath, getPolicy} from '@libs/PolicyUtils'; +import {extractPolicyIDFromPath, getDefaultApprover, getPolicy, isPolicyAdmin as isPolicyAdminPolicyUtils, isPolicyMember} from '@libs/PolicyUtils'; import processReportIDDeeplink from '@libs/processReportIDDeeplink'; import Pusher from '@libs/Pusher'; import type {UserIsLeavingRoomEvent, UserIsTypingEvent} from '@libs/Pusher/types'; @@ -113,6 +114,7 @@ import { findSelfDMReportID, formatReportLastMessageText, generateReportID, + getAllPolicyReports, getChatByParticipants, getChildReportNotificationPreference, getDefaultNotificationPreferenceForReport, @@ -168,6 +170,7 @@ import type { PersonalDetails, PersonalDetailsList, Policy, + PolicyEmployee, PolicyReportField, QuickAction, RecentlyUsedReportFields, @@ -186,6 +189,8 @@ import {clearByKey} from './CachedPDFPaths'; import {setDownload} from './Download'; import {close} from './Modal'; import navigateFromNotification from './navigateFromNotification'; +import {buildRoomMembersOnyxData} from './Policy/Member'; +import {createPolicyExpenseChats} from './Policy/Policy'; import { createUpdateCommentMatcher, resolveCommentDeletionConflicts, @@ -387,6 +392,37 @@ Onyx.connect({ callback: (value) => (nvpDismissedProductTraining = value), }); +const allPolicies: OnyxCollection = {}; +Onyx.connect({ + key: ONYXKEYS.COLLECTION.POLICY, + callback: (val, key) => { + if (!key) { + return; + } + if (val === null || val === undefined) { + // If we are deleting a policy, we have to check every report linked to that policy + // and unset the draft indicator (pencil icon) alongside removing any draft comments. Clearing these values will keep the newly archived chats from being displayed in the LHN. + // More info: https://github.com/Expensify/App/issues/14260 + const policyID = key.replace(ONYXKEYS.COLLECTION.POLICY, ''); + const policyReports = getAllPolicyReports(policyID); + const cleanUpSetQueries: Record<`${typeof ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${string}` | `${typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${string}`, null> = {}; + policyReports.forEach((policyReport) => { + if (!policyReport) { + return; + } + const {reportID} = policyReport; + cleanUpSetQueries[`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`] = null; + cleanUpSetQueries[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${reportID}`] = null; + }); + Onyx.multiSet(cleanUpSetQueries); + delete allPolicies[key]; + return; + } + + allPolicies[key] = val; + }, +}); + let environmentURL: string; Environment.getEnvironmentURL().then((url: string) => (environmentURL = url)); @@ -5083,6 +5119,242 @@ function moveIOUReportToPolicy(reportID: string, policyID: string) { API.write(WRITE_COMMANDS.MOVE_IOU_REPORT_TO_EXISTING_POLICY, parameters, {optimisticData, successData, failureData}); } +/** + * Moves an IOU report to a policy by converting it to an expense report + * @param reportID - The ID of the IOU report to move + * @param policyID - The ID of the policy to move the report to + */ +function moveIOUReportToPolicyAndInviteSubmitter(reportID: string, policyID: string) { + const iouReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; + const policy = getPolicy(policyID); + + if (!policy || !iouReport) { + return; + } + + const isPolicyAdmin = isPolicyAdminPolicyUtils(policy); + const submitterAccountID = iouReport.ownerAccountID; + const submitterEmail = PersonalDetailsUtils.getLoginByAccountID(submitterAccountID ?? CONST.DEFAULT_NUMBER_ID); + const submitterLogin = PhoneNumber.addSMSDomainIfPhoneNumber(submitterEmail); + + // This flow only works for admins moving an IOU report to a policy where the submitter is NOT yet a member of the policy + if (!isPolicyAdmin || !isIOUReportUsingReport(iouReport) || !submitterAccountID || !submitterEmail || isPolicyMember(submitterLogin, policyID)) { + return; + } + + // We only allow moving IOU report to a policy if it doesn't have requests from multiple users, as we do not want to create negative amount expenses + if (ReportActionsUtils.hasRequestFromCurrentAccount(reportID, iouReport.managerID ?? CONST.DEFAULT_NUMBER_ID)) { + return; + } + + const optimisticData: OnyxUpdate[] = []; + const successData: OnyxUpdate[] = []; + const failureData: OnyxUpdate[] = []; + + // Optimistically add the submitter to the workspace and create a workspace chat for them + const policyKey = `${ONYXKEYS.COLLECTION.POLICY}${policyID}` as const; + const invitedEmailsToAccountIDs: InvitedEmailsToAccountIDs = { + [submitterEmail]: submitterAccountID, + }; + + // Set up new member optimistic data + const role = CONST.POLICY.ROLE.USER; + + // Get personal details onyx data (similar to addMembersToWorkspace) + const {newAccountIDs, newLogins} = PersonalDetailsUtils.getNewAccountIDsAndLogins([submitterLogin], [submitterAccountID]); + const newPersonalDetailsOnyxData = PersonalDetailsUtils.getPersonalDetailsOnyxDataForOptimisticUsers(newLogins, newAccountIDs); + + // Build announce room members data for the new member + const announceRoomMembers = buildRoomMembersOnyxData(CONST.REPORT.CHAT_TYPE.POLICY_ANNOUNCE, policyID, [submitterAccountID]); + + // Create policy expense chat for the submitter + const policyExpenseChats = createPolicyExpenseChats(policyID, invitedEmailsToAccountIDs); + const optimisticPolicyExpenseChatReportID = policyExpenseChats.reportCreationData[submitterEmail].reportID; + const optimisticPolicyExpenseChatCreatedReportActionID = policyExpenseChats.reportCreationData[submitterEmail].reportActionID; + + // Set up optimistic member state + const optimisticMembersState: OnyxCollectionInputValue = { + [submitterLogin]: { + role, + email: submitterLogin, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + submitsTo: getDefaultApprover(allPolicies?.[policyKey]), + }, + }; + + const successMembersState: OnyxCollectionInputValue = { + [submitterLogin]: {pendingAction: null}, + }; + + const failureMembersState: OnyxCollectionInputValue = { + [submitterLogin]: { + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('workspace.people.error.genericAdd'), + }, + }; + + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: policyKey, + value: { + employeeList: optimisticMembersState, + }, + }); + + successData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: policyKey, + value: { + employeeList: successMembersState, + }, + }); + + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: policyKey, + value: { + employeeList: failureMembersState, + }, + }); + + optimisticData.push(...newPersonalDetailsOnyxData.optimisticData, ...policyExpenseChats.onyxOptimisticData, ...announceRoomMembers.optimisticData); + successData.push(...newPersonalDetailsOnyxData.finallyData, ...policyExpenseChats.onyxSuccessData, ...announceRoomMembers.successData); + failureData.push(...policyExpenseChats.onyxFailureData, ...announceRoomMembers.failureData); + + // Next we need to convert the IOU report to Expense report. + // We need to change: + // - report type + // - change the sign of the report total + // - update its policyID and policyName + // - update the chatReportID to point to the workspace chat if the policy has policy expense chat enabled + const expenseReport = { + ...iouReport, + chatReportID: optimisticPolicyExpenseChatReportID, + policyID, + policyName: policy.name, + parentReportID: optimisticPolicyExpenseChatReportID, + type: CONST.REPORT.TYPE.EXPENSE, + total: -(iouReport?.total ?? 0), + }; + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, + value: expenseReport, + }); + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, + value: iouReport, + }); + + // The expense report transactions need to have the amount reversed to negative values + const reportTransactions = getReportTransactions(reportID); + + // For performance reasons, we are going to compose a merge collection data for transactions + const transactionsOptimisticData: Record = {}; + const transactionFailureData: Record = {}; + reportTransactions.forEach((transaction) => { + transactionsOptimisticData[`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`] = { + ...transaction, + amount: -transaction.amount, + modifiedAmount: transaction.modifiedAmount ? -transaction.modifiedAmount : 0, + }; + + transactionFailureData[`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`] = transaction; + }); + + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE_COLLECTION, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}`, + value: transactionsOptimisticData, + }); + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE_COLLECTION, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}`, + value: transactionFailureData, + }); + + // We need to move the report preview action from the DM to the workspace chat. + const oldChatReportID = iouReport.chatReportID; + const reportPreviewActionID = iouReport.parentReportActionID; + const reportPreview = !!oldChatReportID && !!reportPreviewActionID ? allReportActions?.[oldChatReportID]?.[reportPreviewActionID] : undefined; + + if (reportPreview?.reportActionID) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${oldChatReportID}`, + value: {[reportPreview.reportActionID]: null}, + }); + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${oldChatReportID}`, + value: {[reportPreview.reportActionID]: reportPreview}, + }); + + // Add the reportPreview action to workspace chat + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${optimisticPolicyExpenseChatReportID}`, + value: {[reportPreview.reportActionID]: {...reportPreview, created: DateUtils.getDBTime()}}, + }); + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${optimisticPolicyExpenseChatReportID}`, + value: {[reportPreview.reportActionID]: null}, + }); + } + + // Create the CHANGE_POLICY report action and add it to the expense report which indicates to the user where the report has been moved + const changePolicyReportAction = buildOptimisticChangePolicyReportAction(iouReport.policyID, policyID, true); + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, + value: {[changePolicyReportAction.reportActionID]: changePolicyReportAction}, + }); + successData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, + value: { + [changePolicyReportAction.reportActionID]: { + ...changePolicyReportAction, + pendingAction: null, + }, + }, + }); + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, + value: {[changePolicyReportAction.reportActionID]: null}, + }); + + // To optimistically remove the GBR from the DM we need to update the hasOutstandingChildRequest param to false + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${oldChatReportID}`, + value: { + hasOutstandingChildRequest: false, + iouReportID: null, + }, + }); + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${oldChatReportID}`, + value: { + hasOutstandingChildRequest: true, + iouReportID: reportID, + }, + }); + + const parameters: MoveIOUReportToPolicyAndInviteSubmitterParams = { + iouReportID: reportID, + policyID, + policyExpenseChatReportID: optimisticPolicyExpenseChatReportID ?? String(CONST.DEFAULT_NUMBER_ID), + policyExpenseCreatedReportActionID: optimisticPolicyExpenseChatCreatedReportActionID ?? String(CONST.DEFAULT_NUMBER_ID), + changePolicyReportActionID: changePolicyReportAction.reportActionID, + }; + + API.write(WRITE_COMMANDS.MOVE_IOU_REPORT_TO_POLICY_AND_INVITE_SUBMITTER, parameters, {optimisticData, successData, failureData}); +} + /** * Dismisses the change report policy educational modal so that it doesn't show up again. */ @@ -5431,6 +5703,7 @@ export { saveReportDraft, prepareOnboardingOnyxData, moveIOUReportToPolicy, + moveIOUReportToPolicyAndInviteSubmitter, dismissChangePolicyModal, changeReportPolicy, }; diff --git a/src/pages/ReportChangeWorkspacePage.tsx b/src/pages/ReportChangeWorkspacePage.tsx index 804588aed916..e6bd240f803b 100644 --- a/src/pages/ReportChangeWorkspacePage.tsx +++ b/src/pages/ReportChangeWorkspacePage.tsx @@ -11,11 +11,12 @@ import useNetwork from '@hooks/useNetwork'; import useThemeStyles from '@hooks/useThemeStyles'; import type {WorkspaceListItem} from '@hooks/useWorkspaceList'; import useWorkspaceList from '@hooks/useWorkspaceList'; -import {changeReportPolicy, moveIOUReportToPolicy} from '@libs/actions/Report'; +import {changeReportPolicy, moveIOUReportToPolicy, moveIOUReportToPolicyAndInviteSubmitter} from '@libs/actions/Report'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {ReportChangeWorkspaceNavigatorParamList} from '@libs/Navigation/types'; -import {isPolicyMember, isWorkspaceEligibleForReportChange} from '@libs/PolicyUtils'; +import {getLoginByAccountID} from '@libs/PersonalDetailsUtils'; +import {getPolicy, isPolicyAdmin, isPolicyMember, isWorkspaceEligibleForReportChange} from '@libs/PolicyUtils'; import {isIOUReport, isMoneyRequestReport, isMoneyRequestReportPendingDeletion} from '@libs/ReportUtils'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -45,13 +46,15 @@ function ReportChangeWorkspacePage({report}: ReportChangeWorkspacePageProps) { return; } Navigation.goBack(ROUTES.REPORT_WITH_ID.getRoute(reportID)); - if (isIOUReport(reportID) && isPolicyMember(currentUserLogin, policyID)) { + if (isIOUReport(reportID) && isPolicyAdmin(getPolicy(policyID)) && report.ownerAccountID && !isPolicyMember(getLoginByAccountID(report.ownerAccountID), policyID)) { + moveIOUReportToPolicyAndInviteSubmitter(reportID, policyID); + } else if (isIOUReport(reportID) && isPolicyMember(currentUserLogin, policyID)) { moveIOUReportToPolicy(reportID, policyID); } else { changeReportPolicy(reportID, policyID); } }, - [currentUserLogin, reportID], + [currentUserLogin, report, reportID], ); const {sections, shouldShowNoResultsFoundMessage, shouldShowSearchInput} = useWorkspaceList({