diff --git a/src/components/KYCWall/BaseKYCWall.tsx b/src/components/KYCWall/BaseKYCWall.tsx index 39ac82542792d..531ef2b4a3932 100644 --- a/src/components/KYCWall/BaseKYCWall.tsx +++ b/src/components/KYCWall/BaseKYCWall.tsx @@ -4,6 +4,7 @@ import {Dimensions} from 'react-native'; import type {EmitterSubscription, View} from 'react-native'; import AddPaymentMethodMenu from '@components/AddPaymentMethodMenu'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; +import useAllPolicyExpenseChatReportActions from '@hooks/useAllPolicyExpenseChatReportActions'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; @@ -74,6 +75,7 @@ function KYCWall({ const personalDetails = usePersonalDetails(); const employeeEmail = personalDetails?.[iouReport?.ownerAccountID ?? CONST.DEFAULT_NUMBER_ID]?.login ?? ''; const reportTransactions = useReportTransactions(iouReport?.reportID); + const {filteredReportActions} = useAllPolicyExpenseChatReportActions(); const anchorRef = useRef(null); const transferBalanceButtonRef = useRef(null); @@ -139,7 +141,7 @@ function KYCWall({ if (iouReport && isIOUReport(iouReport)) { const adminPolicy = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${policy?.id}`]; if (adminPolicy) { - const inviteResult = moveIOUReportToPolicyAndInviteSubmitter(iouReport, adminPolicy, formatPhoneNumber, reportTransactions); + const inviteResult = moveIOUReportToPolicyAndInviteSubmitter(iouReport, adminPolicy, formatPhoneNumber, filteredReportActions, reportTransactions); if (inviteResult?.policyExpenseChatReportID) { setNavigationActionToMicrotaskQueue(() => { Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(inviteResult.policyExpenseChatReportID)); @@ -214,6 +216,7 @@ function KYCWall({ introSelected, formatPhoneNumber, reportTransactions, + filteredReportActions, lastPaymentMethod, isSelfTourViewed, betas, diff --git a/src/hooks/useAllPolicyExpenseChatReportActions.ts b/src/hooks/useAllPolicyExpenseChatReportActions.ts new file mode 100644 index 0000000000000..c09945a1d79cb --- /dev/null +++ b/src/hooks/useAllPolicyExpenseChatReportActions.ts @@ -0,0 +1,54 @@ +import {useCallback, useMemo} from 'react'; +import type {OnyxCollection} from 'react-native-onyx'; +import {isPolicyExpenseChat, isThread} from '@libs/ReportUtils'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Report, ReportActions} from '@src/types/onyx'; +import mapOnyxCollectionItems from '@src/utils/mapOnyxCollectionItems'; +import useOnyx from './useOnyx'; + +const policyExpenseChatReportsSelector = (reports: OnyxCollection) => { + return mapOnyxCollectionItems(reports, (report) => { + if (!report || !isPolicyExpenseChat(report) || isThread(report)) { + return undefined; + } + return report; + }); +}; + +/** + * Returns all reports that satisfy isPolicyExpenseChat && !isThread (via selector), + * along with reportActions filtered to only those matching reports. + */ +function useAllPolicyExpenseChatReportActions() { + const [policyExpenseChatReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT, {selector: policyExpenseChatReportsSelector}); + + const policyExpenseChatReportIDs = useMemo(() => { + const ids = new Set(); + for (const report of Object.values(policyExpenseChatReports ?? {})) { + if (report?.reportID) { + ids.add(report.reportID); + } + } + return ids; + }, [policyExpenseChatReports]); + + const reportActionsSelector = useCallback( + (reportActions: OnyxCollection) => { + const result: NonNullable> = {}; + for (const [key, actions] of Object.entries(reportActions ?? {})) { + const reportID = key.replace(ONYXKEYS.COLLECTION.REPORT_ACTIONS, ''); + if (policyExpenseChatReportIDs.has(reportID)) { + result[key] = actions; + } + } + return result; + }, + [policyExpenseChatReportIDs], + ); + + const [filteredReportActions] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS, {selector: reportActionsSelector}, [reportActionsSelector]); + + return {policyExpenseChatReports, filteredReportActions}; +} + +export default useAllPolicyExpenseChatReportActions; diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index 8c63c3f9097fc..ea8f049a4b311 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -59,6 +59,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {BillingGraceEndPeriod, Policy, Report, SearchResults, Transaction, TransactionViolations} from '@src/types/onyx'; import type DeepValueOf from '@src/types/utils/DeepValueOf'; +import useAllPolicyExpenseChatReportActions from './useAllPolicyExpenseChatReportActions'; import useAllTransactions from './useAllTransactions'; import useBulkPayOptions from './useBulkPayOptions'; import useConfirmModal from './useConfirmModal'; @@ -110,6 +111,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { const allTransactions = useAllTransactions(); const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); const [allReportActions] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS); + const {filteredReportActions: policyExpenseChatReportActions} = useAllPolicyExpenseChatReportActions(); const [allReportNameValuePairs] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS); const selfDMReport = useSelfDMReport(); const [lastPaymentMethods] = useOnyx(ONYXKEYS.NVP_LAST_PAYMENT_METHOD); @@ -631,7 +633,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { ); return; } - const invite = moveIOUReportToPolicyAndInviteSubmitter(itemReport, adminPolicy, formatPhoneNumber, reportTransactions); + const invite = moveIOUReportToPolicyAndInviteSubmitter(itemReport, adminPolicy, formatPhoneNumber, policyExpenseChatReportActions, reportTransactions); if (!invite?.policyExpenseChatReportID) { moveIOUReportToPolicy(itemReport, adminPolicy, false, reportTransactions); } @@ -680,23 +682,24 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { }); }, [ - clearSelectedTransactions, hash, isOffline, - lastPaymentMethods, + isDelegateAccessRestricted, selectedReports, selectedTransactions, + userBillingGracePeriodEnds, + ownerBillingGracePeriodEnd, + amountOwed, policies, - formatPhoneNumber, - policyIDsWithVBBA, - isDelegateAccessRestricted, showDelegateNoAccessModal, + allReports, personalPolicyID, + lastPaymentMethods, allTransactions, - allReports, - userBillingGracePeriodEnds, - amountOwed, - ownerBillingGracePeriodEnd, + policyIDsWithVBBA, + policyExpenseChatReportActions, + formatPhoneNumber, + clearSelectedTransactions, ], ); diff --git a/src/libs/actions/Policy/Member.ts b/src/libs/actions/Policy/Member.ts index e77277022878f..0765f0a001c6d 100644 --- a/src/libs/actions/Policy/Member.ts +++ b/src/libs/actions/Policy/Member.ts @@ -869,7 +869,8 @@ function buildAddMembersToWorkspaceOnyxData( const announceRoomChat = optimisticAnnounceChat.announceChatData; // create onyx data for policy expense chats for each new member - const membersChats = createPolicyExpenseChats(policyID, invitedEmailsToAccountIDs, undefined, policyExpenseChatNotificationPreference); + // TODO: Update to include reportActionsList later (https://github.com/Expensify/App/issues/66578) + const membersChats = createPolicyExpenseChats(policyID, invitedEmailsToAccountIDs, undefined, undefined, policyExpenseChatNotificationPreference); const optimisticMembersState: OnyxCollectionInputValue = {}; const successMembersState: OnyxCollectionInputValue = {}; diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index 5975ed6324c43..f88e36accf150 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -1536,6 +1536,8 @@ function verifySetupIntentAndRequestPolicyOwnerChange(policyID: string) { function createPolicyExpenseChats( policyID: string, invitedEmailsToAccountIDs: InvitedEmailsToAccountIDs, + // TODO: Remove optional (?) once all is updated (https://github.com/Expensify/App/issues/66578) + reportActionsList?: OnyxCollection, hasOutstandingChildRequest = false, notificationPreference: NotificationPreference = CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN, ): WorkspaceMembersChats { @@ -1575,7 +1577,7 @@ function createPolicyExpenseChats( }, }); const currentTime = DateUtils.getDBTime(); - const reportActions = deprecatedAllReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${oldChat.reportID}`] ?? {}; + const reportActions = (reportActionsList ?? deprecatedAllReportActions)?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${oldChat.reportID}`] ?? {}; for (const action of Object.values(reportActions)) { if (action.actionName !== CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW) { continue; @@ -2934,7 +2936,13 @@ function buildPolicyData(options: BuildPolicyDataOptions): OnyxData, policy: Policy, formatPhoneNumber: LocaleContextProps['formatPhoneNumber'], + reportActions: OnyxCollection, reportTransactions: Transaction[] = [], ): {policyExpenseChatReportID?: string} | undefined { if (!policy || !iouReport) { @@ -6179,7 +6180,7 @@ function moveIOUReportToPolicyAndInviteSubmitter( const announceRoomMembers = buildRoomMembersOnyxData(CONST.REPORT.CHAT_TYPE.POLICY_ANNOUNCE, policyID, [submitterAccountID]); // Create policy expense chat for the submitter - const policyExpenseChats = createPolicyExpenseChats(policyID, invitedEmailsToAccountIDs); + const policyExpenseChats = createPolicyExpenseChats(policyID, invitedEmailsToAccountIDs, reportActions); const optimisticPolicyExpenseChatReportID = policyExpenseChats.reportCreationData[submitterEmail].reportID; const optimisticPolicyExpenseChatCreatedReportActionID = policyExpenseChats.reportCreationData[submitterEmail].reportActionID; diff --git a/src/pages/ReportChangeWorkspacePage.tsx b/src/pages/ReportChangeWorkspacePage.tsx index b6610d3eefae0..1b055fcf981c9 100644 --- a/src/pages/ReportChangeWorkspacePage.tsx +++ b/src/pages/ReportChangeWorkspacePage.tsx @@ -7,6 +7,7 @@ import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; import type {WorkspaceListItemType} from '@components/SelectionList/ListItem/types'; import UserListItem from '@components/SelectionList/ListItem/UserListItem'; +import useAllPolicyExpenseChatReportActions from '@hooks/useAllPolicyExpenseChatReportActions'; import useDebouncedState from '@hooks/useDebouncedState'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; @@ -74,6 +75,7 @@ function ReportChangeWorkspacePage({report, route}: ReportChangeWorkspacePagePro const hasViolations = hasViolationsReportUtils(report?.reportID, transactionViolations, session?.accountID ?? CONST.DEFAULT_NUMBER_ID, session?.email ?? ''); const [ownerBillingGracePeriodEnd] = useOnyx(ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END); const [userBillingGracePeriods] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_USER_BILLING_GRACE_PERIOD_END); + const {filteredReportActions} = useAllPolicyExpenseChatReportActions(); const selectPolicy = useCallback( (policyID?: string) => { @@ -88,7 +90,7 @@ function ReportChangeWorkspacePage({report, route}: ReportChangeWorkspacePagePro const {backTo} = route.params; Navigation.goBack(backTo); if (isIOUReport(reportID)) { - const invite = moveIOUReportToPolicyAndInviteSubmitter(report, policy, formatPhoneNumber, reportTransactions); + const invite = moveIOUReportToPolicyAndInviteSubmitter(report, policy, formatPhoneNumber, filteredReportActions, reportTransactions); if (!invite?.policyExpenseChatReportID) { moveIOUReportToPolicy(report, policy, false, reportTransactions); } @@ -134,6 +136,7 @@ function ReportChangeWorkspacePage({report, route}: ReportChangeWorkspacePagePro parentReport, formatPhoneNumber, reportTransactions, + filteredReportActions, isReportLastVisibleArchived, session?.accountID, session?.email, diff --git a/tests/actions/ReportTest.ts b/tests/actions/ReportTest.ts index 3c11ac984d9e6..629d065469880 100644 --- a/tests/actions/ReportTest.ts +++ b/tests/actions/ReportTest.ts @@ -3523,7 +3523,7 @@ describe('actions/Report', () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); // When moving iou to a workspace and invite the submitter - Report.moveIOUReportToPolicyAndInviteSubmitter(iouReport, policy, (phone: string) => phone); + Report.moveIOUReportToPolicyAndInviteSubmitter(iouReport, policy, (phone: string) => phone, {}); await waitForBatchedUpdates(); // Then MOVED report action should be added to the expense report @@ -3590,7 +3590,7 @@ describe('actions/Report', () => { // Call moveIOUReportToPolicyAndInviteSubmitter const formatPhoneNumber = (phoneNumber: string) => phoneNumber; - Report.moveIOUReportToPolicyAndInviteSubmitter(iouReport, policy, formatPhoneNumber); + Report.moveIOUReportToPolicyAndInviteSubmitter(iouReport, policy, formatPhoneNumber, {}); await waitForBatchedUpdates(); // Simulate network failure @@ -3617,6 +3617,98 @@ describe('actions/Report', () => { // Cleanup mockFetch.succeed?.(); }); + + it('should negate transaction amounts when reportTransactions are provided', async () => { + const ownerAccountID = 1; + const ownerEmail = 'owner@gmail.com'; + const transactionID = 'txn123'; + const iouReport: OnyxTypes.Report = { + ...createRandomReport(1, undefined), + type: CONST.REPORT.TYPE.IOU, + ownerAccountID, + total: 5000, + }; + const policy: OnyxTypes.Policy = {...createRandomPolicy(1), role: CONST.POLICY.ROLE.ADMIN}; + const transaction: OnyxTypes.Transaction = { + ...createRandomTransaction(1), + transactionID, + reportID: iouReport.reportID, + amount: 5000, + modifiedAmount: 6000, + }; + + await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, { + [ownerAccountID]: { + login: ownerEmail, + }, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`, iouReport); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, transaction); + + // When moving IOU to a workspace with reportTransactions + Report.moveIOUReportToPolicyAndInviteSubmitter(iouReport, policy, (phone: string) => phone, {}, [transaction]); + await waitForBatchedUpdates(); + + // Then the transaction amounts should be negated optimistically + const updatedTransaction = await new Promise>((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + callback: (val) => { + resolve(val); + Onyx.disconnect(connection); + }, + }); + }); + expect(updatedTransaction?.amount).toBe(-5000); + expect(updatedTransaction?.modifiedAmount).toBe(-6000); + }); + + it('should convert IOU report to expense report with correct policyID when reportTransactions are provided', async () => { + const ownerAccountID = 1; + const ownerEmail = 'owner@gmail.com'; + const transactionID = 'txn456'; + const iouReport: OnyxTypes.Report = { + ...createRandomReport(1, undefined), + type: CONST.REPORT.TYPE.IOU, + ownerAccountID, + total: 3000, + }; + const policy: OnyxTypes.Policy = {...createRandomPolicy(1), role: CONST.POLICY.ROLE.ADMIN}; + const transaction: OnyxTypes.Transaction = { + ...createRandomTransaction(1), + transactionID, + reportID: iouReport.reportID, + amount: 3000, + }; + + await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, { + [ownerAccountID]: { + login: ownerEmail, + }, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`, iouReport); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, transaction); + + // When moving IOU to a workspace with transactions + Report.moveIOUReportToPolicyAndInviteSubmitter(iouReport, policy, (phone: string) => phone, {}, [transaction]); + await waitForBatchedUpdates(); + + // Then the report should be converted to an expense report with the new policyID + const updatedReport = await new Promise>((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`, + callback: (val) => { + resolve(val); + Onyx.disconnect(connection); + }, + }); + }); + expect(updatedReport?.type).toBe(CONST.REPORT.TYPE.EXPENSE); + expect(updatedReport?.policyID).toBe(policy.id); + expect(updatedReport?.total).toBe(-3000); + }); }); describe('buildOptimisticChangePolicyData', () => {