diff --git a/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts b/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts index 721fb0dc8596..2200f4f79543 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,15 +157,28 @@ function useConfirmationValidation({ } const firstParticipant = transaction?.participants?.at(0); - const isP2P = !!(firstParticipant?.accountID && !firstParticipant?.isPolicyExpenseChat); + const isP2P = !!(firstParticipant?.accountID && !firstParticipant?.isPolicyExpenseChat && !firstParticipant?.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) { return {errorKey: 'common.error.invalidAmount'}; } - if (isNewManualExpenseFlowEnabled && !transaction?.isAmountSet) { + // isAmountSet only applies to manual expenses — scan, per diem, distance, and time set amount programmatically. + if (isNewManualExpenseFlowEnabled && transaction?.iouRequestType === CONST.IOU.REQUEST_TYPE.MANUAL && !transaction?.isAmountSet) { return {errorKey: 'common.error.fieldRequired'}; } + if ( + isNewManualExpenseFlowEnabled && + transaction?.iouRequestType === CONST.IOU.REQUEST_TYPE.MANUAL && + transaction?.isAmountSet && + !isScanRequestUtil(transaction) && + !isTimeRequest && + !isDistanceRequest && + !isEditingSplitBill && + !isValidMoneyRequestAmount(iouAmount, iouType, true, isP2P) + ) { + return {errorKey: 'common.error.invalidAmount'}; + } const merchantValue = iouMerchant ?? ''; const {isValid: isMerchantLengthValid} = isValidInputLength(merchantValue, CONST.MERCHANT_NAME_MAX_BYTES); diff --git a/src/components/MoneyRequestConfirmationList/sections/AmountField.tsx b/src/components/MoneyRequestConfirmationList/sections/AmountField.tsx index 4c184af063a1..53bda7b0d340 100644 --- a/src/components/MoneyRequestConfirmationList/sections/AmountField.tsx +++ b/src/components/MoneyRequestConfirmationList/sections/AmountField.tsx @@ -109,8 +109,10 @@ function AmountField({ const decimals = getCurrencyDecimals(effectiveCurrency); // In the new manual expense flow the amount field starts empty (transaction.amount defaults to 0 before the user // touches it). Once the user explicitly sets an amount – including 0 – isAmountSet becomes true and we show the - // real value. This avoids showing "$0.00" as a pre-filled default. - const transactionAmount = isNewManualExpenseFlowEnabled && !transactionSlice?.isAmountSet ? '' : convertToFrontendAmountAsString(amount, decimals); + // real value. This avoids showing "$0.00" as a pre-filled default. Scan and other non-manual flows populate + // amount programmatically and never set isAmountSet. + const shouldShowEmptyAmount = isNewManualExpenseFlowEnabled && !transactionSlice?.isAmountSet && transactionSlice?.iouRequestType === CONST.IOU.REQUEST_TYPE.MANUAL; + const transactionAmount = shouldShowEmptyAmount ? '' : convertToFrontendAmountAsString(amount, decimals); const allowNegative = shouldEnableNegative(report, policy, iouType, transactionSlice?.participants); // `autoFocus` on our TextInput only runs on mount. Closing and reopening the RHP often keeps the same mounted diff --git a/src/pages/iou/request/step/IOURequestStepAmount.tsx b/src/pages/iou/request/step/IOURequestStepAmount.tsx index ceab6f331c13..382d57f00765 100644 --- a/src/pages/iou/request/step/IOURequestStepAmount.tsx +++ b/src/pages/iou/request/step/IOURequestStepAmount.tsx @@ -570,8 +570,8 @@ function IOURequestStepAmount({ /** * Check if the participant is a P2P chat */ -function isParticipantP2P(participant: {accountID?: number; isPolicyExpenseChat?: boolean} | undefined): boolean { - return !!(participant?.accountID && !participant.isPolicyExpenseChat); +function isParticipantP2P(participant: {accountID?: number; isPolicyExpenseChat?: boolean; isSelfDM?: boolean} | undefined): boolean { + return !!(participant?.accountID && !participant.isPolicyExpenseChat && !participant.isSelfDM); } const IOURequestStepAmountWithWritableReportOrNotFound = withWritableReportOrNotFound(IOURequestStepAmount, true); diff --git a/tests/actions/IOU/MoneyRequestTest.ts b/tests/actions/IOU/MoneyRequestTest.ts index f53fa62a378c..dc9f8ec0eaa8 100644 --- a/tests/actions/IOU/MoneyRequestTest.ts +++ b/tests/actions/IOU/MoneyRequestTest.ts @@ -1,7 +1,14 @@ import type {OnyxEntry} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import type {MoneyRequestStepScanParticipantsFlowParams} from '@libs/actions/IOU/MoneyRequest'; -import {createTransaction, getMoneyRequestParticipantOptions, handleMoneyRequestStepDistanceNavigation, handleMoneyRequestStepScanParticipants} from '@libs/actions/IOU/MoneyRequest'; +import { + clearMoneyRequestAmount, + createTransaction, + getMoneyRequestParticipantOptions, + handleMoneyRequestStepDistanceNavigation, + handleMoneyRequestStepScanParticipants, + setMoneyRequestAmount, +} from '@libs/actions/IOU/MoneyRequest'; import getCurrentPosition from '@libs/getCurrentPosition'; import {GeolocationErrorCode} from '@libs/getCurrentPosition/getCurrentPosition.types'; import Navigation from '@libs/Navigation/Navigation'; @@ -1753,4 +1760,53 @@ describe('MoneyRequest', () => { await Onyx.clear(); }); }); + + describe('setMoneyRequestAmount and clearMoneyRequestAmount', () => { + const transactionID = 'amount-test-txn'; + + beforeEach(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, { + transactionID, + amount: 0, + currency: 'USD', + comment: {}, + iouRequestType: CONST.IOU.REQUEST_TYPE.MANUAL, + }); + }); + + afterEach(async () => { + await Onyx.clear(); + }); + + it('sets isAmountSet to true when the user enters an amount', async () => { + setMoneyRequestAmount(transactionID, 1500, 'USD'); + await waitForBatchedUpdates(); + + const transaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`); + expect(transaction?.amount).toBe(1500); + expect(transaction?.currency).toBe('USD'); + expect(transaction?.isAmountSet).toBe(true); + }); + + it('allows explicitly setting zero as a valid amount via isAmountSet', async () => { + setMoneyRequestAmount(transactionID, 0, 'USD'); + await waitForBatchedUpdates(); + + const transaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`); + expect(transaction?.amount).toBe(0); + expect(transaction?.isAmountSet).toBe(true); + }); + + it('clears isAmountSet when the user deletes the amount input', async () => { + setMoneyRequestAmount(transactionID, 2500, 'USD'); + await waitForBatchedUpdates(); + + clearMoneyRequestAmount(transactionID); + await waitForBatchedUpdates(); + + const transaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`); + expect(transaction?.amount).toBe(0); + expect(transaction?.isAmountSet).toBe(false); + }); + }); }); diff --git a/tests/unit/IOURequestStepAmountTest.ts b/tests/unit/IOURequestStepAmountTest.ts index c58746732de9..ced8c610f79f 100644 --- a/tests/unit/IOURequestStepAmountTest.ts +++ b/tests/unit/IOURequestStepAmountTest.ts @@ -61,5 +61,15 @@ describe('IOURequestStepAmount', () => { expect(isParticipantP2P(participant)).toBe(true); }); + + it('should return false for self-DM participant', () => { + const participant = { + accountID: 123, + isPolicyExpenseChat: false, + isSelfDM: true, + }; + + expect(isParticipantP2P(participant)).toBe(false); + }); }); }); diff --git a/tests/unit/hooks/useConfirmationValidation.test.ts b/tests/unit/hooks/useConfirmationValidation.test.ts index 66bcb590ce82..6f979ae6f2f1 100644 --- a/tests/unit/hooks/useConfirmationValidation.test.ts +++ b/tests/unit/hooks/useConfirmationValidation.test.ts @@ -12,8 +12,40 @@ jest.mock('@hooks/useCurrencyList', () => ({ }), })); +const TRANSACTION_ID = 'txn1'; +const REPORT_ID = 'report1'; + +const P2P_PARTICIPANT = {accountID: 2, isPolicyExpenseChat: false} as Participant; +const POLICY_EXPENSE_CHAT_PARTICIPANT = {accountID: 0, isPolicyExpenseChat: true, policyID: 'policy1'} as Participant; +const SELF_DM_PARTICIPANT = {accountID: 1, isPolicyExpenseChat: false, isSelfDM: true} as Participant; + +const IOU_TYPES_WITH_STANDARD_VALIDATION = [CONST.IOU.TYPE.SUBMIT, CONST.IOU.TYPE.CREATE, CONST.IOU.TYPE.TRACK, CONST.IOU.TYPE.SPLIT, CONST.IOU.TYPE.INVOICE] as const; + +type ValidationParamsOverrides = Omit, 'transaction'>; + +function createTransactionBase(overrides: Partial = {}): OnyxTypes.Transaction { + return { + transactionID: TRANSACTION_ID, + reportID: REPORT_ID, + amount: 0, + currency: 'USD', + merchant: 'Coffee Shop', + created: '2025-01-15', + comment: {}, + ...overrides, + }; +} + +function createManualTransaction(participants: Participant[], overrides: Partial = {}): OnyxTypes.Transaction { + return createTransactionBase({ + iouRequestType: CONST.IOU.REQUEST_TYPE.MANUAL, + participants, + ...overrides, + }); +} + const baseParams = { - transaction: {transactionID: 'txn1', comment: {}, amount: 100} as unknown as OnyxTypes.Transaction, + transaction: createTransactionBase({amount: 100, participants: [P2P_PARTICIPANT]}), transactionReport: undefined, transactionID: 'txn1', iouType: CONST.IOU.TYPE.SUBMIT, @@ -26,7 +58,7 @@ const baseParams = { policyTags: undefined, policyTagLists: [], policyCategories: undefined, - selectedParticipants: [{accountID: 1}] as unknown as Participant[], + selectedParticipants: [P2P_PARTICIPANT], currentUserPersonalDetails: {accountID: 1} as CurrentUserPersonalDetails, isEditingSplitBill: false, isMerchantRequired: false, @@ -42,6 +74,21 @@ const baseParams = { isNewManualExpenseFlowEnabled: false, } satisfies UseConfirmationValidationParams; +function createValidationParamsForParticipant( + participant: Participant, + overrides: ValidationParamsOverrides = {}, + transactionOverrides: Partial = {}, +): UseConfirmationValidationParams { + const participants = transactionOverrides.participants ?? [participant]; + + return { + ...baseParams, + ...overrides, + selectedParticipants: overrides.selectedParticipants ?? participants, + transaction: createManualTransaction(participants, transactionOverrides), + }; +} + describe('useConfirmationValidation', () => { it('returns null when routeError is set', () => { const {result} = renderHook(() => useConfirmationValidation({...baseParams, routeError: 'route error'})); @@ -91,7 +138,7 @@ describe('useConfirmationValidation', () => { useConfirmationValidation({ ...baseParams, isPerDiemRequest: true, - transaction: {transactionID: 'txn1', amount: 100, comment: {customUnit: {subRates: []}}} as unknown as OnyxTypes.Transaction, + transaction: createTransactionBase({amount: 100, comment: {customUnit: {subRates: []}}}), }), ); expect(result.current.validate()).toEqual({errorKey: 'iou.error.invalidSubrateLength'}); @@ -114,7 +161,7 @@ describe('useConfirmationValidation', () => { ...baseParams, isEditingSplitBill: true, iouAmount: 0, - transaction: {transactionID: 'txn1', amount: 100, merchant: 'Coffee', comment: {}} as unknown as OnyxTypes.Transaction, + transaction: createTransactionBase({amount: 100, merchant: 'Coffee'}), transactionReport: {type: CONST.REPORT.TYPE.IOU} as unknown as OnyxTypes.Report, }), ); @@ -126,6 +173,53 @@ describe('useConfirmationValidation', () => { expect(result.current.validate()).toEqual({errorKey: null}); }); + it('returns fieldRequired for manual expense when amount is not set in new manual expense flow with a policy expense chat participant', () => { + const {result} = renderHook(() => + useConfirmationValidation( + createValidationParamsForParticipant( + POLICY_EXPENSE_CHAT_PARTICIPANT, + { + isNewManualExpenseFlowEnabled: true, + iouAmount: 0, + }, + {isAmountSet: false}, + ), + ), + ); + expect(result.current.validate()).toEqual({errorKey: 'common.error.fieldRequired'}); + }); + + it('does not return fieldRequired for scan expense when amount is not set in new manual expense flow', () => { + const {result} = renderHook(() => + useConfirmationValidation({ + ...baseParams, + isNewManualExpenseFlowEnabled: true, + transaction: createTransactionBase({ + amount: 1000, + iouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, + receipt: {source: 'https://example.com/receipt.jpg'}, + }), + }), + ); + expect(result.current.validate()).toEqual({errorKey: null}); + }); + + it('does not return fieldRequired for per diem expense when amount is not set in new manual expense flow', () => { + const {result} = renderHook(() => + useConfirmationValidation({ + ...baseParams, + isNewManualExpenseFlowEnabled: true, + isPerDiemRequest: true, + transaction: createTransactionBase({ + amount: 5000, + iouRequestType: CONST.IOU.REQUEST_TYPE.PER_DIEM, + comment: {customUnit: {subRates: [{id: 'rate1', name: 'Breakfast', quantity: 1, rate: 5000}]}}, + }), + }), + ); + expect(result.current.validate()).toEqual({errorKey: null}); + }); + it('returns null for PAY type without payment method', () => { const {result} = renderHook(() => useConfirmationValidation({...baseParams, iouType: CONST.IOU.TYPE.PAY})); expect(result.current.validate()).toBeNull(); @@ -135,4 +229,455 @@ describe('useConfirmationValidation', () => { const {result} = renderHook(() => useConfirmationValidation({...baseParams, iouType: CONST.IOU.TYPE.PAY})); expect(result.current.validate(CONST.IOU.PAYMENT_TYPE.ELSEWHERE)).toEqual({errorKey: null}); }); + + describe('amount validation — new manual expense flow (isAmountSet)', () => { + const newManualFlowParams = { + isNewManualExpenseFlowEnabled: true, + iouAmount: 0, + }; + + describe('policy expense chat participant (workspace submit/create)', () => { + it.each(IOU_TYPES_WITH_STANDARD_VALIDATION)('returns fieldRequired for unset manual amount when iouType is %s', (iouType) => { + const {result} = renderHook(() => + useConfirmationValidation( + createValidationParamsForParticipant( + POLICY_EXPENSE_CHAT_PARTICIPANT, + { + ...newManualFlowParams, + iouType, + }, + {isAmountSet: false}, + ), + ), + ); + expect(result.current.validate()).toEqual({errorKey: 'common.error.fieldRequired'}); + }); + + it('returns fieldRequired for PAY with unset manual amount before payment method is checked', () => { + const {result} = renderHook(() => + useConfirmationValidation( + createValidationParamsForParticipant( + POLICY_EXPENSE_CHAT_PARTICIPANT, + { + ...newManualFlowParams, + iouType: CONST.IOU.TYPE.PAY, + }, + {isAmountSet: false}, + ), + ), + ); + expect(result.current.validate()).toEqual({errorKey: 'common.error.fieldRequired'}); + expect(result.current.validate(CONST.IOU.PAYMENT_TYPE.ELSEWHERE)).toEqual({errorKey: 'common.error.fieldRequired'}); + }); + + it('returns errorKey: null when manual amount is explicitly set to zero for submit', () => { + const {result} = renderHook(() => + useConfirmationValidation( + createValidationParamsForParticipant(POLICY_EXPENSE_CHAT_PARTICIPANT, {...newManualFlowParams, iouType: CONST.IOU.TYPE.SUBMIT}, {amount: 0, isAmountSet: true}), + ), + ); + expect(result.current.validate()).toEqual({errorKey: null}); + }); + + it('returns invalidAmount when manual amount is explicitly set to zero for invoice', () => { + const {result} = renderHook(() => + useConfirmationValidation( + createValidationParamsForParticipant(POLICY_EXPENSE_CHAT_PARTICIPANT, {...newManualFlowParams, iouType: CONST.IOU.TYPE.INVOICE}, {amount: 0, isAmountSet: true}), + ), + ); + expect(result.current.validate()).toEqual({errorKey: 'common.error.invalidAmount'}); + }); + }); + + describe('P2P participant', () => { + it.each([CONST.IOU.TYPE.SUBMIT, CONST.IOU.TYPE.CREATE, CONST.IOU.TYPE.PAY, CONST.IOU.TYPE.SPLIT])( + 'returns invalidAmount (not fieldRequired) for unset manual amount when iouType is %s', + (iouType) => { + const {result} = renderHook(() => + useConfirmationValidation( + createValidationParamsForParticipant( + P2P_PARTICIPANT, + { + ...newManualFlowParams, + iouType, + }, + {isAmountSet: false}, + ), + ), + ); + expect(result.current.validate()).toEqual({errorKey: 'common.error.invalidAmount'}); + }, + ); + + it('returns errorKey: null when manual amount is explicitly set to a positive value', () => { + const {result} = renderHook(() => + useConfirmationValidation( + createValidationParamsForParticipant( + P2P_PARTICIPANT, + { + ...newManualFlowParams, + iouAmount: 2500, + }, + {amount: 2500, isAmountSet: true}, + ), + ), + ); + expect(result.current.validate()).toEqual({errorKey: null}); + }); + + it('returns invalidAmount when manual amount is explicitly set to zero', () => { + const {result} = renderHook(() => useConfirmationValidation(createValidationParamsForParticipant(P2P_PARTICIPANT, newManualFlowParams, {amount: 0, isAmountSet: true}))); + expect(result.current.validate()).toEqual({errorKey: 'common.error.invalidAmount'}); + }); + + it('returns invalidAmount when manual amount is explicitly set to zero for invoice', () => { + const {result} = renderHook(() => + useConfirmationValidation( + createValidationParamsForParticipant(P2P_PARTICIPANT, {...newManualFlowParams, iouType: CONST.IOU.TYPE.INVOICE}, {amount: 0, isAmountSet: true}), + ), + ); + expect(result.current.validate()).toEqual({errorKey: 'common.error.invalidAmount'}); + }); + }); + + describe('self-DM participant', () => { + it('returns fieldRequired for unset manual amount', () => { + const {result} = renderHook(() => useConfirmationValidation(createValidationParamsForParticipant(SELF_DM_PARTICIPANT, newManualFlowParams, {isAmountSet: false}))); + expect(result.current.validate()).toEqual({errorKey: 'common.error.fieldRequired'}); + }); + + it('returns errorKey: null when manual amount is explicitly set to zero', () => { + const {result} = renderHook(() => useConfirmationValidation(createValidationParamsForParticipant(SELF_DM_PARTICIPANT, newManualFlowParams, {amount: 0, isAmountSet: true}))); + expect(result.current.validate()).toEqual({errorKey: null}); + }); + }); + + it('does not return fieldRequired when the new manual expense flow beta is disabled', () => { + const {result} = renderHook(() => + useConfirmationValidation( + createValidationParamsForParticipant( + P2P_PARTICIPANT, + { + isNewManualExpenseFlowEnabled: false, + iouAmount: 0, + }, + {isAmountSet: false}, + ), + ), + ); + expect(result.current.validate()).toEqual({errorKey: 'common.error.invalidAmount'}); + }); + }); + + describe('amount validation — P2P zero amount guard', () => { + it('returns invalidAmount for P2P manual submit with zero amount when flow is disabled', () => { + const {result} = renderHook(() => useConfirmationValidation(createValidationParamsForParticipant(P2P_PARTICIPANT, {iouAmount: 0}, {amount: 0, isAmountSet: true}))); + expect(result.current.validate()).toEqual({errorKey: 'common.error.invalidAmount'}); + }); + + it('returns errorKey: null for policy expense chat participant with zero amount', () => { + const {result} = renderHook(() => + useConfirmationValidation(createValidationParamsForParticipant(POLICY_EXPENSE_CHAT_PARTICIPANT, {iouAmount: 0}, {amount: 0, isAmountSet: true})), + ); + expect(result.current.validate()).toEqual({errorKey: null}); + }); + + it('returns errorKey: null for P2P scan request with zero amount', () => { + const {result} = renderHook(() => + useConfirmationValidation({ + ...baseParams, + iouAmount: 0, + transaction: createTransactionBase({ + amount: 0, + iouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, + receipt: {source: 'https://example.com/receipt.jpg'}, + participants: [P2P_PARTICIPANT], + }), + selectedParticipants: [P2P_PARTICIPANT], + }), + ); + expect(result.current.validate()).toEqual({errorKey: null}); + }); + + it('returns errorKey: null for P2P distance request with zero amount', () => { + const {result} = renderHook(() => + useConfirmationValidation({ + ...baseParams, + iouAmount: 0, + isDistanceRequest: true, + transaction: createTransactionBase({ + amount: 0, + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE, + participants: [P2P_PARTICIPANT], + comment: {type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT}, + }), + selectedParticipants: [P2P_PARTICIPANT], + }), + ); + expect(result.current.validate()).toEqual({errorKey: null}); + }); + + it('returns errorKey: null for P2P time request with zero amount', () => { + const {result} = renderHook(() => + useConfirmationValidation({ + ...baseParams, + iouAmount: 0, + isTimeRequest: true, + transaction: createTransactionBase({ + amount: 0, + iouRequestType: CONST.IOU.REQUEST_TYPE.TIME, + participants: [P2P_PARTICIPANT], + comment: {type: CONST.TRANSACTION.TYPE.TIME, units: {count: 1, rate: 0}}, + }), + selectedParticipants: [P2P_PARTICIPANT], + }), + ); + expect(result.current.validate()).toEqual({errorKey: null}); + }); + }); + + describe('amount validation — programmatic request types (scan, distance, time, per diem)', () => { + const newManualFlowParams = { + ...baseParams, + isNewManualExpenseFlowEnabled: true, + }; + + it('does not return fieldRequired for scan expense when amount is not set', () => { + const {result} = renderHook(() => + useConfirmationValidation({ + ...newManualFlowParams, + transaction: createTransactionBase({ + amount: 1000, + iouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, + receipt: {source: 'https://example.com/receipt.jpg'}, + participants: [POLICY_EXPENSE_CHAT_PARTICIPANT], + }), + selectedParticipants: [POLICY_EXPENSE_CHAT_PARTICIPANT], + }), + ); + expect(result.current.validate()).toEqual({errorKey: null}); + }); + + it('does not return fieldRequired for distance expense when amount is not set', () => { + const {result} = renderHook(() => + useConfirmationValidation({ + ...newManualFlowParams, + iouAmount: 5000, + isDistanceRequest: true, + transaction: createTransactionBase({ + amount: 5000, + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE, + participants: [POLICY_EXPENSE_CHAT_PARTICIPANT], + comment: {type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT}, + }), + selectedParticipants: [POLICY_EXPENSE_CHAT_PARTICIPANT], + }), + ); + expect(result.current.validate()).toEqual({errorKey: null}); + }); + + it('does not return fieldRequired for time expense when amount is not set', () => { + const {result} = renderHook(() => + useConfirmationValidation({ + ...newManualFlowParams, + iouAmount: 3600, + isTimeRequest: true, + transaction: createTransactionBase({ + amount: 3600, + iouRequestType: CONST.IOU.REQUEST_TYPE.TIME, + participants: [P2P_PARTICIPANT], + comment: {type: CONST.TRANSACTION.TYPE.TIME, units: {count: 1, rate: 3600}}, + }), + selectedParticipants: [P2P_PARTICIPANT], + }), + ); + expect(result.current.validate()).toEqual({errorKey: null}); + }); + + it('does not return fieldRequired for per diem expense when amount is not set', () => { + const {result} = renderHook(() => + useConfirmationValidation({ + ...newManualFlowParams, + isPerDiemRequest: true, + transaction: createTransactionBase({ + amount: 5000, + iouRequestType: CONST.IOU.REQUEST_TYPE.PER_DIEM, + participants: [POLICY_EXPENSE_CHAT_PARTICIPANT], + comment: {customUnit: {subRates: [{id: 'rate1', name: 'Breakfast', quantity: 1, rate: 5000}]}}, + }), + selectedParticipants: [POLICY_EXPENSE_CHAT_PARTICIPANT], + }), + ); + expect(result.current.validate()).toEqual({errorKey: null}); + }); + + it('returns invalidAmount when distance amount exceeds validateAmount max length', () => { + const {result} = renderHook(() => + useConfirmationValidation({ + ...baseParams, + isDistanceRequest: true, + iouAmount: 123456789012345, + transaction: createTransactionBase({ + amount: 123456789012345, + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE, + participants: [POLICY_EXPENSE_CHAT_PARTICIPANT], + comment: {type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT}, + }), + }), + ); + expect(result.current.validate()).toEqual({errorKey: 'common.error.invalidAmount'}); + }); + + it('returns errorKey: null for distance request below max safe amount when route is pending', () => { + const {result} = renderHook(() => + useConfirmationValidation({ + ...baseParams, + isDistanceRequest: true, + isDistanceRequestWithPendingRoute: true, + iouAmount: CONST.IOU.MAX_SAFE_AMOUNT, + transaction: createTransactionBase({ + amount: CONST.IOU.MAX_SAFE_AMOUNT, + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE, + participants: [POLICY_EXPENSE_CHAT_PARTICIPANT], + comment: {type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT}, + }), + }), + ); + expect(result.current.validate()).toEqual({errorKey: null}); + }); + + it('returns amountTooLargeError for time expense with an invalid amount', () => { + const {result} = renderHook(() => + useConfirmationValidation({ + ...baseParams, + isTimeRequest: true, + iouAmount: CONST.IOU.MAX_SAFE_AMOUNT + 1, + transaction: createTransactionBase({ + amount: CONST.IOU.MAX_SAFE_AMOUNT + 1, + iouRequestType: CONST.IOU.REQUEST_TYPE.TIME, + participants: [P2P_PARTICIPANT], + comment: {type: CONST.TRANSACTION.TYPE.TIME, units: {count: 1, rate: CONST.IOU.MAX_SAFE_AMOUNT + 1}}, + }), + }), + ); + expect(result.current.validate()).toEqual({errorKey: 'iou.timeTracking.amountTooLargeError'}); + }); + + it('returns invalidQuantity for per diem expense with an invalid computed amount', () => { + const {result} = renderHook(() => + useConfirmationValidation({ + ...baseParams, + isPerDiemRequest: true, + transaction: createTransactionBase({ + amount: 0, + iouRequestType: CONST.IOU.REQUEST_TYPE.PER_DIEM, + participants: [POLICY_EXPENSE_CHAT_PARTICIPANT], + comment: { + customUnit: { + subRates: [{id: 'rate1', name: 'Breakfast', quantity: 100000, rate: 12345678}], + }, + }, + }), + }), + ); + expect(result.current.validate()).toEqual({errorKey: 'iou.error.invalidQuantity'}); + }); + }); + + describe('amount validation — split bill editing', () => { + it('returns invalidAmount when editing a split bill with zero amount and required fields otherwise filled', () => { + const splitParticipants = [P2P_PARTICIPANT, {accountID: 3, isPolicyExpenseChat: false} as Participant]; + const {result} = renderHook(() => + useConfirmationValidation({ + ...baseParams, + isEditingSplitBill: true, + iouAmount: 0, + selectedParticipants: splitParticipants, + transaction: createTransactionBase({ + amount: 100, + merchant: 'Coffee', + participants: splitParticipants, + }), + transactionReport: {type: CONST.REPORT.TYPE.IOU} as unknown as OnyxTypes.Report, + }), + ); + // P2P zero-amount guard runs before the split-bill-specific invalidAmount check. + expect(result.current.validate()).toEqual({errorKey: 'common.error.invalidAmount'}); + }); + + it('returns invalidAmount for split IOU type with unset manual amount and P2P participants', () => { + const splitParticipants = [P2P_PARTICIPANT, {accountID: 3, isPolicyExpenseChat: false} as Participant]; + const {result} = renderHook(() => + useConfirmationValidation( + createValidationParamsForParticipant( + P2P_PARTICIPANT, + { + iouType: CONST.IOU.TYPE.SPLIT, + isNewManualExpenseFlowEnabled: true, + iouAmount: 0, + selectedParticipants: splitParticipants, + }, + {isAmountSet: false, participants: splitParticipants}, + ), + ), + ); + expect(result.current.validate()).toEqual({errorKey: 'common.error.invalidAmount'}); + }); + + it('returns fieldRequired for split IOU type with unset manual amount and a policy expense chat participant', () => { + const {result} = renderHook(() => + useConfirmationValidation( + createValidationParamsForParticipant( + POLICY_EXPENSE_CHAT_PARTICIPANT, + { + iouType: CONST.IOU.TYPE.SPLIT, + isNewManualExpenseFlowEnabled: true, + iouAmount: 0, + }, + {isAmountSet: false}, + ), + ), + ); + expect(result.current.validate()).toEqual({errorKey: 'common.error.fieldRequired'}); + }); + + it('returns invalidAmount for split IOU type with zero amount and isAmountSet true', () => { + const splitParticipants = [POLICY_EXPENSE_CHAT_PARTICIPANT, {accountID: 3, isPolicyExpenseChat: false} as Participant]; + const {result} = renderHook(() => + useConfirmationValidation( + createValidationParamsForParticipant( + POLICY_EXPENSE_CHAT_PARTICIPANT, + { + iouType: CONST.IOU.TYPE.SPLIT, + isNewManualExpenseFlowEnabled: true, + iouAmount: 0, + selectedParticipants: splitParticipants, + }, + {amount: 0, isAmountSet: true, participants: splitParticipants}, + ), + ), + ); + expect(result.current.validate()).toEqual({errorKey: 'common.error.invalidAmount'}); + }); + + it('returns iou.error.invalidAmount when editing split bill with zero amount and isAmountSet true', () => { + const splitParticipants = [POLICY_EXPENSE_CHAT_PARTICIPANT, {accountID: 3, isPolicyExpenseChat: false} as Participant]; + const {result} = renderHook(() => + useConfirmationValidation({ + ...baseParams, + isEditingSplitBill: true, + iouAmount: 0, + selectedParticipants: splitParticipants, + transaction: createTransactionBase({ + amount: 100, + isAmountSet: true, + merchant: 'Coffee', + participants: splitParticipants, + }), + transactionReport: {type: CONST.REPORT.TYPE.IOU} as unknown as OnyxTypes.Report, + }), + ); + expect(result.current.validate()).toEqual({errorKey: 'iou.error.invalidAmount'}); + }); + }); });