diff --git a/src/components/ParticipantPicker.tsx b/src/components/ParticipantPicker.tsx index 91a864c405b5..42cc0f24e27d 100644 --- a/src/components/ParticipantPicker.tsx +++ b/src/components/ParticipantPicker.tsx @@ -27,6 +27,9 @@ type ParticipantPickerProps = { /** Whether the IOU is workspaces only */ isWorkspacesOnly?: boolean; + /** Whether to exclude P2P recipients (and the invite-by-email option) from the list. Used for negative amounts, which P2P chats don't support. */ + shouldExcludeP2P?: boolean; + /** Callback fired when participants are updated */ onParticipantsAdded: (value: Participant[]) => void; @@ -47,6 +50,7 @@ function ParticipantPicker({ isPerDiemRequest = false, isTimeRequest = false, isWorkspacesOnly = false, + shouldExcludeP2P = false, onParticipantsAdded, onFinish, isVisible = true, @@ -67,6 +71,7 @@ function ParticipantPicker({ isPerDiemRequest={isPerDiemRequest} isTimeRequest={isTimeRequest} isWorkspacesOnly={isWorkspacesOnly} + shouldExcludeP2P={shouldExcludeP2P} onRestrictedParticipantSelected={onClose} initiallySelectedReportID={selectedParticipant?.reportID} shouldMoveSelectedToTop diff --git a/src/hooks/useParticipantSubmission.ts b/src/hooks/useParticipantSubmission.ts index 4ffc3ebba138..5a97f7608bd0 100644 --- a/src/hooks/useParticipantSubmission.ts +++ b/src/hooks/useParticipantSubmission.ts @@ -4,6 +4,7 @@ import {setTransactionReport} from '@libs/actions/Transaction'; import {READ_COMMANDS} from '@libs/API/types'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; import HttpUtils from '@libs/HttpUtils'; +import {isParticipantP2P} from '@libs/IOUUtils'; import Navigation from '@libs/Navigation/Navigation'; import {isGroupPolicy} from '@libs/PolicyUtils'; import {findSelfDMReportID, generateReportID, isInvoiceRoomWithID} from '@libs/ReportUtils'; @@ -208,12 +209,23 @@ function useParticipantSubmission({ }); }; + // P2P chats don't support negative amounts. When a negative amount was entered before a participant was + // selected (e.g. "Submit it to someone" from a self DM track expense), assigning it to a P2P participant + // would fail at submit, so keep the expense on the self DM as a track expense — mirroring the guard in the + // confirmation step's handleParticipantsAdded. This doesn't apply to an expense already bound to a policy + // expense chat, which must stay on that workspace. + const shouldKeepNegativeExpenseOnSelfDM = (firstParticipant: Participant | undefined) => { + const currentTransaction = dataRef.current.initialTransaction; + const isTransactionOnPolicyExpenseChat = currentTransaction?.participants?.some((participant) => participant?.isPolicyExpenseChat); + return (currentTransaction?.amount ?? 0) < 0 && !isTransactionOnPolicyExpenseChat && isParticipantP2P(firstParticipant); + }; + const addParticipant = (val: Participant[]) => { HttpUtils.cancelPendingRequests(READ_COMMANDS.SEARCH_FOR_REPORTS); const firstParticipant = val.at(0); - if (firstParticipant?.isSelfDM && !isSplitRequest) { + if ((firstParticipant?.isSelfDM || shouldKeepNegativeExpenseOnSelfDM(firstParticipant)) && !isSplitRequest) { trackExpense(); return; } @@ -301,6 +313,15 @@ function useParticipantSubmission({ // (last-rendered value from dataRef) because the Onyx write from addParticipant may not have // caused a re-render yet by the time goToNextStep is called. const effectiveParticipants = nextParticipants ?? currentParticipants; + + // ParticipantSearchResults.addSingleParticipant fires onFinish (this callback) right after onParticipantsAdded + // for any non-self row. When the negative-amount P2P fallback in addParticipant already kept the expense on the + // self DM as a track expense (and queued that navigation), skip the submit navigation here so it doesn't + // override the track flow and land the user on a submit confirmation for the self-DM draft. + if (!isSplitRequest && shouldKeepNegativeExpenseOnSelfDM(effectiveParticipants?.at(0))) { + return; + } + const isPolicyExpenseChat = effectiveParticipants?.some((participant) => participant.isPolicyExpenseChat); if (iouType === CONST.IOU.TYPE.SPLIT && !isPolicyExpenseChat && splitTransaction?.amount && splitTransaction?.currency) { const participantAccountIDs = effectiveParticipants?.map((participant) => participant.accountID) as number[]; diff --git a/src/pages/iou/request/MoneyRequestParticipantsSelector.tsx b/src/pages/iou/request/MoneyRequestParticipantsSelector.tsx index 325d2d7949aa..7660258ce05d 100644 --- a/src/pages/iou/request/MoneyRequestParticipantsSelector.tsx +++ b/src/pages/iou/request/MoneyRequestParticipantsSelector.tsx @@ -35,6 +35,9 @@ type MoneyRequestParticipantsSelectorProps = { /** Whether this is a transaction from a credit card import */ isTransactionFromCreditCardImport?: boolean; + /** Whether to exclude P2P recipients (and the invite-by-email option) from the list. Used for negative amounts, which P2P chats don't support. */ + shouldExcludeP2P?: boolean; + /** Report ID of a pre-selected participant whose selection state can't be derived from the participants array (e.g. self DM with accountID 0) */ initiallySelectedReportID?: string; @@ -63,6 +66,7 @@ function MoneyRequestParticipantsSelector({ isTimeRequest = false, isWorkspacesOnly = false, isTransactionFromCreditCardImport = false, + shouldExcludeP2P = false, initiallySelectedReportID, shouldMoveSelectedToTop = false, onRestrictedParticipantSelected, @@ -92,6 +96,7 @@ function MoneyRequestParticipantsSelector({ isTimeRequest={isTimeRequest} isNative={isNative} isTransactionFromCreditCardImport={isTransactionFromCreditCardImport} + shouldExcludeP2P={shouldExcludeP2P} selectionListRef={selectionListRef} textInputAutoFocus={textInputAutoFocus} setTextInputAutoFocus={setTextInputAutoFocus} diff --git a/src/pages/iou/request/ParticipantSearchResults.tsx b/src/pages/iou/request/ParticipantSearchResults.tsx index 997abdceacd4..c9ab2f18ce7c 100644 --- a/src/pages/iou/request/ParticipantSearchResults.tsx +++ b/src/pages/iou/request/ParticipantSearchResults.tsx @@ -78,6 +78,9 @@ type ParticipantSearchResultsProps = { /** Whether this is a transaction from a credit card import */ isTransactionFromCreditCardImport: boolean; + /** Whether to exclude P2P recipients (and the invite-by-email option) from the list. Used for negative amounts, which P2P chats don't support. */ + shouldExcludeP2P?: boolean; + /** Forwarded ref for the SelectionList — used by the parent's useImperativeHandle */ selectionListRef: Ref; @@ -112,6 +115,7 @@ function ParticipantSearchResults({ isTimeRequest, isNative, isTransactionFromCreditCardImport, + shouldExcludeP2P = false, selectionListRef, textInputAutoFocus, setTextInputAutoFocus, @@ -166,7 +170,7 @@ function ParticipantSearchResults({ excludeLogins: CONST.EXPENSIFY_EMAILS_OBJECT, includeOwnedWorkspaceChats: iouType === CONST.IOU.TYPE.SUBMIT || iouType === CONST.IOU.TYPE.CREATE || iouType === CONST.IOU.TYPE.SPLIT || iouType === CONST.IOU.TYPE.TRACK, excludeNonAdminWorkspaces: action === CONST.IOU.ACTION.SHARE, - includeP2P: !isCategorizeOrShareAction && !isPerDiemRequest && !isTimeRequest && !isTransactionFromCreditCardImport, + includeP2P: !isCategorizeOrShareAction && !isPerDiemRequest && !isTimeRequest && !isTransactionFromCreditCardImport && !shouldExcludeP2P, includeInvoiceRooms: iouType === CONST.IOU.TYPE.INVOICE, action, shouldSeparateSelfDMChat: iouType !== CONST.IOU.TYPE.INVOICE, @@ -217,7 +221,7 @@ function ParticipantSearchResults({ const {searchTerm, debouncedSearchTerm, setSearchTerm, availableOptions, selectedOptions, toggleSelection, areOptionsInitialized, onListEndReached, contactState} = useSearchSelector({ selectionMode: isIOUSplit ? CONST.SEARCH_SELECTOR.SELECTION_MODE_MULTI : CONST.SEARCH_SELECTOR.SELECTION_MODE_SINGLE, searchContext: CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_GENERAL, - includeUserToInvite: !isCategorizeOrShareAction && !isPerDiemRequest && !isTimeRequest && !isTransactionFromCreditCardImport, + includeUserToInvite: !isCategorizeOrShareAction && !isPerDiemRequest && !isTimeRequest && !isTransactionFromCreditCardImport && !shouldExcludeP2P, excludeLogins: CONST.EXPENSIFY_EMAILS_OBJECT, includeRecentReports: true, maxRecentReportsToShow: CONST.IOU.MAX_RECENT_REPORTS_TO_SHOW, diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx index 72afa59cd25f..6cd0980a5ae0 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx @@ -41,6 +41,7 @@ import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import { getIsWorkspacesOnlyForTransaction, isMovingTransactionFromTrackExpense as isMovingTransactionFromTrackExpenseIOUUtils, + isParticipantP2P, navigateToStartMoneyRequestStep, resolveOptimisticChatReportID, resolveReportForMoneyRequest, @@ -312,7 +313,17 @@ function IOURequestStepConfirmation({ if (!activeTransactionID) { return; } - if (participantsList.at(0)?.isSelfDM) { + const selectedParticipant = participantsList.at(0); + // P2P chats don't support negative amounts. When a negative amount was entered before a participant + // was selected (e.g. "Submit it to someone" from a self DM), assigning it to a P2P participant would + // fail at submit, so keep the expense on the self DM (its default) instead of assigning the P2P + // participant, stopping the user at selection rather than at submit. This only applies while the + // expense is still on the self DM — a negative expense already bound to a policy expense chat (e.g. + // global create auto-assigned the default workspace) must stay on that workspace rather than being + // silently converted into a personal track expense. + const isTransactionOnPolicyExpenseChat = transaction?.participants?.some((participant) => participant?.isPolicyExpenseChat); + const shouldKeepOnSelfDM = !!selectedParticipant?.isSelfDM || ((transaction?.amount ?? 0) < 0 && !isTransactionOnPolicyExpenseChat && isParticipantP2P(selectedParticipant)); + if (shouldKeepOnSelfDM) { setMoneyRequestParticipantsFromReport(activeTransactionID, selfDMReport, currentUserPersonalDetails.accountID); setTransactionReport(activeTransactionID, {reportID: CONST.REPORT.UNREPORTED_REPORT_ID}, true); navigation.setParams({iouType: CONST.IOU.TYPE.TRACK}); @@ -330,7 +341,7 @@ function IOURequestStepConfirmation({ closeParticipantPicker(); } }, - [activeTransactionID, closeParticipantPicker, currentUserPersonalDetails.accountID, navigation, selfDMReport, iouType, reportID], + [activeTransactionID, closeParticipantPicker, currentUserPersonalDetails.accountID, navigation, selfDMReport, iouType, transaction?.amount, transaction?.participants, reportID], ); useEffect(() => { @@ -902,6 +913,7 @@ function IOURequestStepConfirmation({ isPerDiemRequest={isPerDiemRequest} isTimeRequest={isTimeRequest} isWorkspacesOnly={getIsWorkspacesOnlyForTransaction(transaction, requestType)} + shouldExcludeP2P={(transaction?.amount ?? 0) < 0} onParticipantsAdded={handleParticipantsAdded} onFinish={closeParticipantPicker} isVisible={isParticipantPickerVisible} diff --git a/src/pages/iou/request/step/IOURequestStepParticipants.tsx b/src/pages/iou/request/step/IOURequestStepParticipants.tsx index 8c53277b0837..618288471b78 100644 --- a/src/pages/iou/request/step/IOURequestStepParticipants.tsx +++ b/src/pages/iou/request/step/IOURequestStepParticipants.tsx @@ -149,6 +149,7 @@ function IOURequestStepParticipants({ isTimeRequest={isTime} isWorkspacesOnly={isWorkspacesOnly} isTransactionFromCreditCardImport={isTransactionFromCreditCardImport} + shouldExcludeP2P={(initialTransaction?.amount ?? 0) < 0} initiallySelectedReportID={selectedParticipant?.reportID} shouldMoveSelectedToTop />