diff --git a/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts b/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts index 721fb0dc8596..8998ad42ef4b 100644 --- a/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts +++ b/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts @@ -2,7 +2,7 @@ import type {OnyxEntry} from 'react-native-onyx'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; import {isValidPerDiemExpenseAmount} from '@libs/actions/IOU/PerDiem'; import {getIsMissingAttendeesViolation} from '@libs/AttendeeUtils'; -import {validateAmount} from '@libs/MoneyRequestUtils'; +import {isValidMoneyRequestAmount, validateAmount} from '@libs/MoneyRequestUtils'; import type {getTagLists as getTagListsFn} from '@libs/PolicyUtils'; import {isAttendeeTrackingEnabled} from '@libs/PolicyUtils'; import {hasEnabledTags, hasMatchingTag} from '@libs/TagsOptionsListUtils'; @@ -157,10 +157,12 @@ function useConfirmationValidation({ } const firstParticipant = transaction?.participants?.at(0); - const isP2P = !!(firstParticipant?.accountID && !firstParticipant?.isPolicyExpenseChat); + const isSelfDM = !!firstParticipant?.isSelfDM; + const isP2P = !!(firstParticipant?.accountID && !firstParticipant?.isPolicyExpenseChat && !isSelfDM); - // P2P manual submit: $0 is invalid unless scan/time/distance (same guard as legacy inline confirm). - if (!isScanRequestUtil(transaction) && !isTimeRequest && !isDistanceRequest && iouAmount === 0 && isP2P) { + // Zero or invalid amounts are blocked for invoice, pay, split, and P2P submit/request flows. + // Scan, time, distance, and per-diem requests have their own amount rules below. + if (!isScanRequestUtil(transaction) && !isTimeRequest && !isDistanceRequest && !isPerDiemRequest && !isValidMoneyRequestAmount(iouAmount, iouType, true, isP2P, isSelfDM)) { return {errorKey: 'common.error.invalidAmount'}; } if (isNewManualExpenseFlowEnabled && !transaction?.isAmountSet) { diff --git a/src/components/MoneyRequestConfirmationList/sections/AmountField.tsx b/src/components/MoneyRequestConfirmationList/sections/AmountField.tsx index be2d37bce6d2..b42321280aa8 100644 --- a/src/components/MoneyRequestConfirmationList/sections/AmountField.tsx +++ b/src/components/MoneyRequestConfirmationList/sections/AmountField.tsx @@ -13,6 +13,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {clearMoneyRequestAmount, getMoneyRequestParticipantsFromReport, setMoneyRequestAmount} from '@libs/actions/IOU/MoneyRequest'; import {convertToBackendAmount, convertToFrontendAmountAsString, getLocalizedCurrencySymbol} from '@libs/CurrencyUtils'; import {calculateAmount} from '@libs/IOUUtils'; +import {isValidMoneyRequestAmount} from '@libs/MoneyRequestUtils'; import Navigation from '@libs/Navigation/Navigation'; import {shouldEnableNegative} from '@libs/ReportUtils'; import {isAmountMissing} from '@libs/TransactionUtils'; @@ -88,9 +89,10 @@ function AmountField({ const isAmountFieldDisabled = didConfirm || isReadOnly || shouldShowTimeRequestFields || isDistanceRequest; const firstParticipant = transaction?.participants?.at(0); - const isP2P = isNewManualExpenseFlowEnabled - ? isParticipantP2P(getMoneyRequestParticipantsFromReport(report, currentUserPersonalDetails.accountID).at(0)) - : !!(firstParticipant?.accountID && !firstParticipant?.isPolicyExpenseChat); + const participantForAmountValidation = + isNewManualExpenseFlowEnabled && firstParticipant ? firstParticipant : getMoneyRequestParticipantsFromReport(report, currentUserPersonalDetails.accountID).at(0); + const isSelfDMParticipant = !!participantForAmountValidation?.isSelfDM; + const isP2P = isParticipantP2P(participantForAmountValidation); const shouldShowAmountRequiredError = formError === 'common.error.fieldRequired'; const shouldShowAmountInvalidError = formError === 'common.error.invalidAmount'; @@ -236,7 +238,7 @@ function AmountField({ return; } - const isInlineAmountInvalid = parsedAmount === 0 && isP2P; + const isInlineAmountInvalid = !isDistanceRequest && !shouldShowTimeRequestFields && !isValidMoneyRequestAmount(parsedAmount, iouType, allowNegative, isP2P, isSelfDMParticipant); if (isInlineAmountInvalid && shouldDisplayFieldError) { setFormError('common.error.invalidAmount'); diff --git a/src/libs/MoneyRequestUtils.ts b/src/libs/MoneyRequestUtils.ts index f03c66d22add..27ba924f844d 100644 --- a/src/libs/MoneyRequestUtils.ts +++ b/src/libs/MoneyRequestUtils.ts @@ -136,8 +136,9 @@ const nonZeroMoneyRequestTypes = new Set>([CONST. * @param allowNegative - Whether negative amounts are allowed * @param isIOUReport - Whether this is an IOU report (zero amounts not allowed) * @param isP2P - Whether this is a peer-to-peer transaction + * @param isSelfDM - Whether the expense is being sent to the user's self DM */ -function isValidMoneyRequestAmount(amount: number | undefined, iouType: ValueOf, allowNegative = true, isP2P = false): boolean { +function isValidMoneyRequestAmount(amount: number | undefined, iouType: ValueOf, allowNegative = true, isP2P = false, isSelfDM = false): boolean { if (amount === undefined || amount === null || Number.isNaN(amount)) { return false; } @@ -148,7 +149,7 @@ function isValidMoneyRequestAmount(amount: number | undefined, iouType: ValueOf< const absoluteAmount = Math.abs(amount); - if ((iouType === CONST.IOU.TYPE.REQUEST || iouType === CONST.IOU.TYPE.SUBMIT) && isP2P) { + if ((iouType === CONST.IOU.TYPE.REQUEST || iouType === CONST.IOU.TYPE.SUBMIT) && isP2P && !isSelfDM) { return absoluteAmount >= 1; } diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx index ad1da43a3e92..3d15a57b2f00 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx @@ -75,6 +75,7 @@ import { } from '@libs/TransactionUtils'; import {getIOURequestPolicyID, getMoneyRequestParticipantsFromReport, setMoneyRequestParticipants, setMoneyRequestParticipantsFromReport} from '@userActions/IOU/MoneyRequest'; import {setMoneyRequestReceipt} from '@userActions/IOU/Receipt'; +import {setTransactionReport} from '@userActions/Transaction'; import {removeDraftTransaction, replaceDefaultDraftTransaction} from '@userActions/TransactionEdit'; import CONST from '@src/CONST'; import type {IOUType} from '@src/CONST'; @@ -114,6 +115,7 @@ function IOURequestStepConfirmation({ report: reportReal, reportDraft, route, + navigation, transaction: initialTransaction, isLoadingTransaction, shouldHideHeader = false, @@ -343,12 +345,28 @@ function IOURequestStepConfirmation({ if (!activeTransactionID) { return; } + + const firstParticipant = participantsList.at(0); + if (isNewManualExpenseFlowEnabled && firstParticipant?.isSelfDM && selfDMReport?.reportID && iouType !== CONST.IOU.TYPE.SPLIT) { + for (const draftTransaction of transactions) { + const selfDMParticipants = getMoneyRequestParticipantsFromReport(selfDMReport, currentUserPersonalDetails.accountID).map((participant) => ({ + ...participant, + iouType: CONST.IOU.TYPE.TRACK, + })); + setMoneyRequestParticipants(draftTransaction.transactionID, selfDMParticipants); + setTransactionReport(draftTransaction.transactionID, {reportID: CONST.REPORT.UNREPORTED_REPORT_ID}, true); + } + Navigation.setParams({iouType: CONST.IOU.TYPE.TRACK, reportID: selfDMReport.reportID}, route.key, navigation.getState()?.key); + closeParticipantPicker(); + return; + } + setMoneyRequestParticipants(activeTransactionID, participantsList); if (participantsList.length > 0) { closeParticipantPicker(); } }, - [activeTransactionID, closeParticipantPicker], + [activeTransactionID, closeParticipantPicker, currentUserPersonalDetails.accountID, isNewManualExpenseFlowEnabled, iouType, navigation, route.key, selfDMReport, transactions], ); useEffect(() => { diff --git a/tests/unit/MoneyRequestUtilsTest.ts b/tests/unit/MoneyRequestUtilsTest.ts index 13decab07708..a935d90420c1 100644 --- a/tests/unit/MoneyRequestUtilsTest.ts +++ b/tests/unit/MoneyRequestUtilsTest.ts @@ -217,6 +217,11 @@ describe('ReportActionsUtils', () => { expect(isValidMoneyRequestAmount(0, CONST.IOU.TYPE.SUBMIT, allowNegative, isP2P)).toBe(false); }); + it('should allow zero amount for self DM', () => { + expect(isValidMoneyRequestAmount(0, CONST.IOU.TYPE.SUBMIT, allowNegative, isP2P, true)).toBe(true); + expect(isValidMoneyRequestAmount(0, CONST.IOU.TYPE.REQUEST, allowNegative, isP2P, true)).toBe(true); + }); + it('should return true for amounts >= 1 cent', () => { expect(isValidMoneyRequestAmount(1, CONST.IOU.TYPE.REQUEST, allowNegative, isP2P)).toBe(true); expect(isValidMoneyRequestAmount(100, CONST.IOU.TYPE.REQUEST, allowNegative, isP2P)).toBe(true); diff --git a/tests/unit/hooks/useConfirmationValidation.test.ts b/tests/unit/hooks/useConfirmationValidation.test.ts index 66bcb590ce82..267888bd5dc2 100644 --- a/tests/unit/hooks/useConfirmationValidation.test.ts +++ b/tests/unit/hooks/useConfirmationValidation.test.ts @@ -121,6 +121,40 @@ describe('useConfirmationValidation', () => { expect(result.current.validate()).toEqual({errorKey: 'iou.error.invalidAmount'}); }); + it('returns invalidAmount for invoice with zero amount', () => { + const {result} = renderHook(() => + useConfirmationValidation({ + ...baseParams, + iouType: CONST.IOU.TYPE.INVOICE, + iouAmount: 0, + transaction: { + transactionID: 'txn1', + amount: 0, + comment: {}, + participants: [{accountID: 2, isPolicyExpenseChat: true}], + } as unknown as OnyxTypes.Transaction, + }), + ); + expect(result.current.validate()).toEqual({errorKey: 'common.error.invalidAmount'}); + }); + + it('allows zero amount for self DM submit expense', () => { + const {result} = renderHook(() => + useConfirmationValidation({ + ...baseParams, + iouAmount: 0, + transaction: { + transactionID: 'txn1', + amount: 0, + isAmountSet: true, + comment: {}, + participants: [{accountID: 0, reportID: 'self-dm', isSelfDM: true, selected: true}], + } as unknown as OnyxTypes.Transaction, + }), + ); + expect(result.current.validate()).toEqual({errorKey: null}); + }); + it('returns errorKey: null on successful non-PAY validation', () => { const {result} = renderHook(() => useConfirmationValidation(baseParams)); expect(result.current.validate()).toEqual({errorKey: null});