diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index 378c4289c4c0..c4c28281c550 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -292,7 +292,7 @@ function MoneyRequestView({ } = getTransactionDetails(transaction, undefined, undefined, allowNegativeAmount, false, currentUserPersonalDetails) ?? {}; const isEmptyMerchant = isInvalidMerchantValue(transactionMerchant); const isDistanceRequest = isDistanceRequestTransactionUtils(transaction); - const isManualDistanceRequest = isManualDistanceRequestTransactionUtils(transaction, !!mergeTransactionID); + const isManualDistanceRequest = isManualDistanceRequestTransactionUtils(transaction); const isGPSDistanceRequest = isGPSDistanceRequestTransactionUtils(transaction); const isOdometerDistanceRequest = isOdometerDistanceRequestTransactionUtils(transaction); const isMapDistanceRequest = isMapDistanceRequestTransactionUtils(transaction) || isDistanceTypeRequest(transaction); diff --git a/src/libs/MergeTransactionUtils.ts b/src/libs/MergeTransactionUtils.ts index 1e5bdf499508..5e89709cd4a4 100644 --- a/src/libs/MergeTransactionUtils.ts +++ b/src/libs/MergeTransactionUtils.ts @@ -396,6 +396,7 @@ function buildMergedTransactionData(targetTransaction: OnyxEntry, m taxAmount: mergeTransaction.taxAmount, taxCode: mergeTransaction.taxCode, taxName: mergeTransaction.taxName, + ...(mergeTransaction.iouRequestType && {iouRequestType: mergeTransaction.iouRequestType}), }; } diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index 2291d39140f0..ac57d6f71708 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -141,53 +141,13 @@ function isDeletedTransaction(transaction: {reportID?: string}): boolean { return transaction.reportID === CONST.REPORT.TRASH_REPORT_ID; } -function hasDistanceCustomUnit(transaction: OnyxEntry | Partial): boolean { - return transaction?.comment?.type === CONST.TRANSACTION.TYPE.CUSTOM_UNIT && transaction?.comment?.customUnit?.name === CONST.CUSTOM_UNITS.NAME_DISTANCE; -} - function isDistanceRequest(transaction: OnyxEntry): boolean { - // This is used during the expense creation flow before the transaction has been saved to the server - if (transaction && Object.hasOwn(transaction, 'iouRequestType')) { - return ( - transaction?.iouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE || - transaction?.iouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE_MAP || - transaction?.iouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE_ODOMETER || - transaction?.iouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE_GPS || - transaction?.iouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE_MANUAL - ); - } - - // This is the case for transaction objects once they have been saved to the server - return hasDistanceCustomUnit(transaction); + const requestType = transaction?.iouRequestType; + return requestType === CONST.IOU.REQUEST_TYPE.DISTANCE || isDistanceExpenseType(requestType); } function isDistanceTypeRequest(transaction: OnyxEntry): boolean { - // This is used during the expense creation flow before the transaction has been saved to the server - if (transaction && Object.hasOwn(transaction, 'iouRequestType')) { - return transaction?.iouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE; - } - - // This is the case for transaction objects once they have been saved to the server - return hasDistanceCustomUnit(transaction); -} - -/** - * todo: Currently there is no way to tell server map transaction object from - * server GPS transaction object, this will be discussed and updated later. - * To fix this temporarily we set keyForList of GPS waypoints to 'gps_start' and 'gps_end' - * and use that to determine if it's a GPS or Map transaction. This should be changed before - * the first GPS release. - */ -function hasGPSWaypoints(transaction: OnyxEntry) { - const waypoints = transaction?.comment?.waypoints; - - if (!waypoints) { - return false; - } - - const waypoint = Object.values(waypoints).at(0); - - return !!waypoint?.keyForList?.startsWith('gps'); + return transaction?.iouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE; } /** @@ -202,90 +162,31 @@ function haveWaypointAddressesChanged(oldWaypoints: WaypointCollection | undefin } function isMapDistanceRequest(transaction: OnyxEntry): boolean { - // This is used during the expense creation flow before the transaction has been saved to the server - if (transaction && Object.hasOwn(transaction, 'iouRequestType')) { - return transaction?.iouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE_MAP; - } - - // This is the case for transaction objects once they have been saved to the server - return hasDistanceCustomUnit(transaction) && !hasGPSWaypoints(transaction); + return transaction?.iouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE_MAP; } function isGPSDistanceRequest(transaction: OnyxEntry): boolean { - // This is used during the expense creation flow before the transaction has been saved to the server - if (transaction && Object.hasOwn(transaction, 'iouRequestType')) { - return transaction?.iouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE_GPS; - } - - // This is the case for transaction objects once they have been saved to the server - return hasGPSWaypoints(transaction); + return transaction?.iouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE_GPS; } -function isManualDistanceRequest(transaction: OnyxEntry, isUpdatedMergeTransaction = false): boolean { - // This is used during the expense creation flow before the transaction has been saved to the server - if (transaction && Object.hasOwn(transaction, 'iouRequestType') && !isUpdatedMergeTransaction) { - return transaction?.iouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE_MANUAL; - } - - // This is the case for transaction objects once they have been saved to the server - // Exclude odometer requests which also have no waypoints but have odometer readings - return ( - hasDistanceCustomUnit(transaction) && - isEmptyObject(transaction?.comment?.waypoints) && - transaction?.comment?.odometerStart === undefined && - transaction?.comment?.odometerEnd === undefined - ); +function isManualDistanceRequest(transaction: OnyxEntry): boolean { + return transaction?.iouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE_MANUAL; } function isOdometerDistanceRequest(transaction: OnyxEntry): boolean { - // This is used during the expense creation flow before the transaction has been saved to the server - if (transaction && Object.hasOwn(transaction, 'iouRequestType')) { - return transaction?.iouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE_ODOMETER; - } - - // This is the case for transaction objects once they have been saved to the server - // Odometer requests have odometerStart and odometerEnd in comment, and no waypoints - return ( - hasDistanceCustomUnit(transaction) && - isEmptyObject(transaction?.comment?.waypoints) && - (transaction?.comment?.odometerStart !== undefined || transaction?.comment?.odometerEnd !== undefined) - ); + return transaction?.iouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE_ODOMETER; } -function isScanRequest(transaction: OnyxEntry | Partial): boolean { - // This is used during the expense creation flow before the transaction has been saved to the server - if (transaction && Object.hasOwn(transaction, 'iouRequestType')) { - return transaction?.iouRequestType === CONST.IOU.REQUEST_TYPE.SCAN; - } - - // Distance requests can have a receipt source (for the map), so we need to exclude them - if (hasDistanceCustomUnit(transaction)) { - return false; - } - - return !!transaction?.receipt?.source && transaction?.amount === 0; +function isScanRequest(transaction: OnyxEntry): boolean { + return transaction?.iouRequestType === CONST.IOU.REQUEST_TYPE.SCAN; } function isPerDiemRequest(transaction: OnyxEntry): boolean { - // This is used during the expense creation flow before the transaction has been saved to the server - if (transaction && Object.hasOwn(transaction, 'iouRequestType')) { - return transaction?.iouRequestType === CONST.IOU.REQUEST_TYPE.PER_DIEM; - } - - // This is the case for transaction objects once they have been saved to the server - const type = transaction?.comment?.type; - const customUnitName = transaction?.comment?.customUnit?.name; - return type === CONST.TRANSACTION.TYPE.CUSTOM_UNIT && customUnitName === CONST.CUSTOM_UNITS.NAME_PER_DIEM_INTERNATIONAL; + return transaction?.iouRequestType === CONST.IOU.REQUEST_TYPE.PER_DIEM; } function isTimeRequest(transaction: OnyxEntry): boolean { - // This is used during the expense creation flow before the transaction has been saved to the server - if (transaction && Object.hasOwn(transaction, 'iouRequestType')) { - return transaction?.iouRequestType === CONST.IOU.REQUEST_TYPE.TIME; - } - - // This is the case for transaction objects once they have been saved to the server - return transaction?.comment?.type === CONST.TRANSACTION.TYPE.TIME; + return transaction?.iouRequestType === CONST.IOU.REQUEST_TYPE.TIME; } function isDistanceExpenseType(requestType: IOURequestType | undefined): requestType is DistanceExpenseType { @@ -302,32 +203,7 @@ function isCorporateCardTransaction(transaction: OnyxEntry): boolea } function getRequestType(transaction: OnyxEntry): IOURequestType { - if (isOdometerDistanceRequest(transaction)) { - return CONST.IOU.REQUEST_TYPE.DISTANCE_ODOMETER; - } - if (isManualDistanceRequest(transaction)) { - return CONST.IOU.REQUEST_TYPE.DISTANCE_MANUAL; - } - if (isMapDistanceRequest(transaction)) { - return CONST.IOU.REQUEST_TYPE.DISTANCE_MAP; - } - if (isDistanceTypeRequest(transaction)) { - return CONST.IOU.REQUEST_TYPE.DISTANCE; - } - if (isScanRequest(transaction)) { - return CONST.IOU.REQUEST_TYPE.SCAN; - } - if (isPerDiemRequest(transaction)) { - return CONST.IOU.REQUEST_TYPE.PER_DIEM; - } - if (isTimeRequest(transaction)) { - return CONST.IOU.REQUEST_TYPE.TIME; - } - if (isGPSDistanceRequest(transaction)) { - return CONST.IOU.REQUEST_TYPE.DISTANCE_GPS; - } - - return CONST.IOU.REQUEST_TYPE.MANUAL; + return transaction?.iouRequestType ?? CONST.IOU.REQUEST_TYPE.MANUAL; } /** @@ -552,7 +428,6 @@ function buildOptimisticTransaction(params: BuildOptimisticTransactionParams): T cardID: existingTransaction?.cardID, cardName: existingTransaction?.cardName, cardNumber: existingTransaction?.cardNumber, - // Use conditional spread to avoid creating the key if it's undefined, which would break lodashHas checks. ...(existingTransaction?.iouRequestType ? {iouRequestType: existingTransaction.iouRequestType} : {}), routes, }; diff --git a/src/libs/actions/IOU/Duplicate.ts b/src/libs/actions/IOU/Duplicate.ts index be49c05add51..66900621c178 100644 --- a/src/libs/actions/IOU/Duplicate.ts +++ b/src/libs/actions/IOU/Duplicate.ts @@ -615,6 +615,15 @@ function buildDuplicateTransactionParams(transaction: OnyxTypes.Transaction, tra return {transactionParams, waypoints}; } +/** + * Returns the request type the duplicate should be created with. SCAN sources become MANUAL because + * `buildDuplicateTransactionParams` strips the receipt — without one, the duplicate cannot be a scan request. + */ +function getDuplicateRequestType(transaction: OnyxTypes.Transaction) { + const sourceRequestType = getRequestType(transaction); + return sourceRequestType === CONST.IOU.REQUEST_TYPE.SCAN ? CONST.IOU.REQUEST_TYPE.MANUAL : sourceRequestType; +} + /** * Routes a duplicate expense to the correct creation function based on transaction type. * Shared between duplicateExpenseTransaction and duplicateReport. @@ -654,7 +663,13 @@ function createExpenseByType({ currentUserLogin: params.currentUserEmailParam, currentUserAccountID: params.currentUserAccountIDParam, existingTransaction: { - ...(params.transactionParams ?? {}), + iouRequestType: getDuplicateRequestType(transaction), + amount: 0, + currency: '', + created: '', + merchant: '', + reportID: '1', + transactionID: '1', comment: { ...transaction.comment, hold: undefined, @@ -662,10 +677,6 @@ function createExpenseByType({ source: undefined, waypoints, }, - iouRequestType: getRequestType(transaction), - modifiedCreated: '', - reportID: '1', - transactionID: '1', }, transactionParams: { ...(params.transactionParams ?? {}), @@ -787,6 +798,15 @@ function duplicateExpenseTransaction({ policyRecentlyUsedCurrencies, quickAction, existingTransactionDraft, + existingTransaction: { + iouRequestType: getDuplicateRequestType(transaction), + amount: 0, + currency: '', + created: '', + merchant: '', + reportID: '1', + transactionID: '1', + }, draftTransactionIDs, isSelfTourViewed, betas, @@ -803,7 +823,13 @@ function duplicateExpenseTransaction({ participant: {accountID: currentUserAccountID, selected: true}, }, existingTransaction: { - ...(params.transactionParams ?? {}), + iouRequestType: getDuplicateRequestType(transaction), + amount: 0, + currency: '', + created: '', + merchant: '', + reportID: '1', + transactionID: '1', comment: { ...transaction.comment, hold: undefined, @@ -811,10 +837,6 @@ function duplicateExpenseTransaction({ source: undefined, waypoints, }, - iouRequestType: getRequestType(transaction), - modifiedCreated: '', - reportID: '1', - transactionID: '1', }, transactionParams: { ...(params.transactionParams ?? {}), @@ -981,6 +1003,15 @@ function duplicateReport({ quickAction, policyRecentlyUsedCurrencies, existingTransactionDraft: undefined, + existingTransaction: { + iouRequestType: getDuplicateRequestType(transaction), + amount: 0, + currency: '', + created: '', + merchant: '', + reportID: '1', + transactionID: '1', + }, draftTransactionIDs, isSelfTourViewed, betas, diff --git a/src/libs/actions/IOU/MoneyRequest.ts b/src/libs/actions/IOU/MoneyRequest.ts index 3b18f612b530..27ed6ad83305 100644 --- a/src/libs/actions/IOU/MoneyRequest.ts +++ b/src/libs/actions/IOU/MoneyRequest.ts @@ -258,6 +258,7 @@ function createTransaction({ taxCode, taxAmount, }, + existingTransaction: transaction, ...(policyParams ?? {}), shouldHandleNavigation: shouldHandleNav, shouldDeferForSearch, @@ -307,6 +308,7 @@ function createTransaction({ quickAction, policyRecentlyUsedCurrencies: policyRecentlyUsedCurrencies ?? [], existingTransactionDraft, + existingTransaction: transaction, draftTransactionIDs, isSelfTourViewed, personalDetails, @@ -734,6 +736,7 @@ function handleMoneyRequestStepDistanceNavigation({ taxCode: distanceTaxCode, taxAmount: distanceTaxAmount, }, + existingTransaction: transaction, shouldHandleNavigation: overrides.shouldHandleNavigation, shouldDeferForSearch: overrides.shouldDeferForSearch, isASAPSubmitBetaEnabled, diff --git a/src/libs/actions/IOU/MoneyRequestBuilder.ts b/src/libs/actions/IOU/MoneyRequestBuilder.ts index e6082701a445..4b28d0e56336 100644 --- a/src/libs/actions/IOU/MoneyRequestBuilder.ts +++ b/src/libs/actions/IOU/MoneyRequestBuilder.ts @@ -1231,13 +1231,7 @@ function getMoneyRequestInformation(moneyRequestInformation: MoneyRequestInforma iouReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${chatReport.iouReportID}`] ?? null; } - const isScanRequest = isScanRequestTransactionUtils({ - amount, - receipt, - ...(existingTransaction && { - iouRequestType: existingTransaction.iouRequestType, - }), - }); + const isScanRequest = isScanRequestTransactionUtils(existingTransaction); const shouldCreateNewMoneyRequestReport = isSplitExpense ? false diff --git a/src/libs/actions/IOU/Split.ts b/src/libs/actions/IOU/Split.ts index 0deb3b4e7cd6..71b630d01157 100644 --- a/src/libs/actions/IOU/Split.ts +++ b/src/libs/actions/IOU/Split.ts @@ -488,6 +488,8 @@ function startSplitBill({ }, }); + splitTransaction.iouRequestType = receipt?.source ? CONST.IOU.REQUEST_TYPE.SCAN : CONST.IOU.REQUEST_TYPE.MANUAL; + const filename = splitTransaction.receipt?.filename; // Note: The created action must be optimistically generated before the IOU action so there's no chance that the created action appears after the IOU action in the chat diff --git a/src/libs/actions/IOU/TrackExpense.ts b/src/libs/actions/IOU/TrackExpense.ts index 458028909a6d..eba325095365 100644 --- a/src/libs/actions/IOU/TrackExpense.ts +++ b/src/libs/actions/IOU/TrackExpense.ts @@ -995,7 +995,7 @@ function getTrackExpenseInformation(params: GetTrackExpenseInformationParams): T } else { iouReport = getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${chatReport.iouReportID}`] ?? null; } - const isScanRequest = isScanRequestTransactionUtils({amount, receipt}); + const isScanRequest = isScanRequestTransactionUtils(existingTransaction); shouldCreateNewMoneyRequestReport = shouldCreateNewMoneyRequestReportReportUtils(iouReport, chatReport, isScanRequest, betas); if (!iouReport || shouldCreateNewMoneyRequestReport) { const reportTransactions = buildMinimalTransactionForFormula(optimisticTransactionID, optimisticExpenseReportID, created, amount, currency, merchant); @@ -1601,7 +1601,7 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouRep quickAction, policyRecentlyUsedCurrencies, existingTransactionDraft, - existingTransaction, + existingTransaction: explicitExistingTransaction, draftTransactionIDs = [], isSelfTourViewed, betas, @@ -1651,8 +1651,8 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouRep const moneyRequestReportID = isMoneyRequestReport ? report?.reportID : ''; const isMovingTransactionFromTrackExpense = isMovingTransactionFromTrackExpenseIOUUtils(action); const existingTransactionID = existingTransactionDraft?.transactionID; - const existingTransactionRef = - action === CONST.IOU.ACTION.SUBMIT ? existingTransactionDraft : (existingTransaction ?? getAllTransactions()[`${ONYXKEYS.COLLECTION.TRANSACTION}${existingTransactionID}`]); + const existingTransaction = + explicitExistingTransaction ?? (action === CONST.IOU.ACTION.SUBMIT ? existingTransactionDraft : getAllTransactions()[`${ONYXKEYS.COLLECTION.TRANSACTION}${existingTransactionID}`]); const retryParams = { ...requestMoneyInformation, @@ -1664,6 +1664,17 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouRep ...requestMoneyInformation.transactionParams, receipt: undefined, }, + existingTransaction: explicitExistingTransaction + ? { + iouRequestType: explicitExistingTransaction.iouRequestType, + amount: 0, + currency: '', + created: '', + merchant: '', + reportID: '1', + transactionID: '1', + } + : undefined, }; const { @@ -1695,7 +1706,7 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouRep transactionParams, moneyRequestReportID, existingTransactionID, - existingTransaction: isDistanceRequestTransactionUtils(existingTransactionRef) ? existingTransactionRef : undefined, + existingTransaction, retryParams, testDriveCommentReportActionID, optimisticChatReportID, @@ -2411,6 +2422,17 @@ function trackExpense(params: CreateTrackExpenseParams) { }, quickAction, isSelfTourViewed, + existingTransaction: existingTransaction + ? { + iouRequestType: existingTransaction.iouRequestType, + amount: 0, + currency: '', + created: '', + merchant: '', + reportID: '1', + transactionID: '1', + } + : undefined, }; const { diff --git a/src/libs/actions/MergeTransaction.ts b/src/libs/actions/MergeTransaction.ts index 6e1ab3e3bdb2..3f72b56bfd9e 100644 --- a/src/libs/actions/MergeTransaction.ts +++ b/src/libs/actions/MergeTransaction.ts @@ -313,7 +313,7 @@ function getOnyxTargetTransactionData({ ...(mergeTransaction.odometerEndImage !== undefined && {odometerEndImage: mergeTransaction.odometerEndImage}), }, routes: mergeTransaction.routes ?? null, - iouRequestType: mergeTransaction.iouRequestType ?? null, + iouRequestType: mergeTransaction.iouRequestType ?? targetTransaction.iouRequestType ?? null, }, }); } diff --git a/src/pages/Share/SubmitDetailsPage.tsx b/src/pages/Share/SubmitDetailsPage.tsx index fcc2aa60b58c..24a80abda871 100644 --- a/src/pages/Share/SubmitDetailsPage.tsx +++ b/src/pages/Share/SubmitDetailsPage.tsx @@ -246,6 +246,7 @@ function SubmitDetailsPage({ isLinkedTrackedExpenseReportArchived, gpsPoint, }, + existingTransaction: transaction, isASAPSubmitBetaEnabled, currentUser: {accountID: currentUserPersonalDetails.accountID, email: currentUserPersonalDetails.login ?? ''}, introSelected, @@ -292,7 +293,7 @@ function SubmitDetailsPage({ policyRecentlyUsedCurrencies: policyRecentlyUsedCurrencies ?? [], quickAction, existingTransactionDraft, - existingTransaction: storedTransaction, + existingTransaction: storedTransaction ?? transaction, draftTransactionIDs, isSelfTourViewed, betas, diff --git a/src/pages/iou/request/step/IOURequestStepAmount.tsx b/src/pages/iou/request/step/IOURequestStepAmount.tsx index ceab6f331c13..3867b413bb4d 100644 --- a/src/pages/iou/request/step/IOURequestStepAmount.tsx +++ b/src/pages/iou/request/step/IOURequestStepAmount.tsx @@ -366,6 +366,7 @@ function IOURequestStepAmount({ merchant: CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT, reimbursable: defaultReimbursable, }, + existingTransaction: storedTransaction ?? transaction, isASAPSubmitBetaEnabled, currentUser: {accountID: currentUserAccountIDParam, email: currentUserEmailParam}, introSelected, diff --git a/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts b/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts index 935961ed204d..636c2e9f48b0 100644 --- a/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts +++ b/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts @@ -384,7 +384,7 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { transactionViolations: transactionViolationsRef.current, policyRecentlyUsedCurrencies, quickAction, - existingTransaction, + existingTransaction: existingTransaction ?? item, existingTransactionDraft, draftTransactionIDs, isSelfTourViewed, @@ -552,6 +552,7 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { accountantParams: { accountant: item.accountant, }, + existingTransaction: item, shouldHandleNavigation: shouldHandleNav && index === transactions.length - 1, shouldDeferForSearch, isASAPSubmitBetaEnabled, diff --git a/src/types/onyx/Transaction.ts b/src/types/onyx/Transaction.ts index d33dd6e280de..2b518b02127c 100644 --- a/src/types/onyx/Transaction.ts +++ b/src/types/onyx/Transaction.ts @@ -511,7 +511,7 @@ type Transaction = OnyxCommon.OnyxValueWithOfflineFeedback< /** The exchange rate of the transaction if the transaction is grouped. Defaults to the exchange rate against the active policy currency if group has no target currency */ groupExchangeRate?: number; - /** Used during the creation flow before the transaction is saved to the server */ + /** The transaction's request type (e.g. manual, scan, distance). */ iouRequestType?: IOURequestType; /** diff --git a/tests/actions/IOU/MoneyRequestTest.ts b/tests/actions/IOU/MoneyRequestTest.ts index f53fa62a378c..60e0d591f8e8 100644 --- a/tests/actions/IOU/MoneyRequestTest.ts +++ b/tests/actions/IOU/MoneyRequestTest.ts @@ -353,6 +353,34 @@ describe('MoneyRequest', () => { ); }); + it('should pass the source transaction as existingTransaction to requestMoney for non-TRACK iouType', () => { + createTransaction({ + ...baseParams, + iouType: CONST.IOU.TYPE.SEND, + allTransactionDrafts: {}, + }); + + expect(TrackExpense.requestMoney).toHaveBeenCalledWith( + expect.objectContaining({ + existingTransaction: fakeTransaction, + }), + ); + }); + + it('should pass the source transaction as existingTransaction to trackExpense for TRACK iouType', () => { + createTransaction({ + ...baseParams, + iouType: CONST.IOU.TYPE.TRACK, + allTransactionDrafts: {}, + }); + + expect(TrackExpense.trackExpense).toHaveBeenCalledWith( + expect.objectContaining({ + existingTransaction: fakeTransaction, + }), + ); + }); + it('should compute draftTransactionIDs from allTransactionDrafts', () => { const draft1 = createRandomTransaction(101); const draft2 = createRandomTransaction(102); @@ -1218,6 +1246,7 @@ describe('MoneyRequest', () => { taxCode: '', taxAmount: 0, }, + existingTransaction: fakeTransaction, shouldHandleNavigation: true, shouldDeferForSearch: false, isASAPSubmitBetaEnabled: baseParams.isASAPSubmitBetaEnabled, diff --git a/tests/actions/IOUTest/DuplicateTest.ts b/tests/actions/IOUTest/DuplicateTest.ts index f0b3181f62b2..5e1ed1da8caa 100644 --- a/tests/actions/IOUTest/DuplicateTest.ts +++ b/tests/actions/IOUTest/DuplicateTest.ts @@ -1197,6 +1197,7 @@ describe('actions/Duplicate', () => { ...mockTransaction, transactionID, amount: AMOUNT_CENTS, + iouRequestType: CONST.IOU.REQUEST_TYPE.TIME, comment: { type: 'time' as const, units: { @@ -1254,6 +1255,106 @@ describe('actions/Duplicate', () => { expect(isTimeRequest(duplicatedTransaction)).toBeTruthy(); }); + it('should duplicate a scan expense as a manual expense when a target workspace is provided', async () => { + const transactionID = 'scan-workspace-1'; + const mockScanExpenseTransaction = { + ...mockTransaction, + transactionID, + amount: mockTransaction.amount * -1, + iouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, + receipt: {source: 'https://example.com/receipt.jpg', state: CONST.IOU.RECEIPT_STATE.OPEN}, + }; + + await Onyx.clear(); + + duplicateExpenseTransaction({ + transaction: mockScanExpenseTransaction, + optimisticChatReportID: mockOptimisticChatReportID, + optimisticIOUReportID: mockOptimisticIOUReportID, + isASAPSubmitBetaEnabled: mockIsASAPSubmitBetaEnabled, + introSelected: undefined, + quickAction: undefined, + policyRecentlyUsedCurrencies: [], + isSelfTourViewed: false, + customUnitPolicyID: '', + targetPolicy: mockPolicy, + targetPolicyCategories: fakePolicyCategories, + targetReport: policyExpenseChat, + existingTransactionDraft: undefined, + draftTransactionIDs: [], + personalDetails: mockPersonalDetails, + betas: [CONST.BETAS.ALL], + recentWaypoints, + targetPolicyTags, + conciergeReportID: undefined, + currentUser: {accountID: RORY_ACCOUNT_ID, email: RORY_EMAIL}, + }); + + await waitForBatchedUpdates(); + + let duplicatedTransaction: OnyxEntry; + await getOnyxData({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (allTransactions) => { + duplicatedTransaction = Object.values(allTransactions ?? {}).find((t) => !!t); + }, + }); + + expect(duplicatedTransaction?.transactionID).not.toBe(transactionID); + expect(duplicatedTransaction?.iouRequestType).toBe(CONST.IOU.REQUEST_TYPE.MANUAL); + }); + + it('should duplicate a scan expense as a manual expense when no targetPolicy is provided', async () => { + const transactionID = 'scan-unreported-1'; + const mockScanExpenseTransaction = { + ...mockTransaction, + transactionID, + amount: mockTransaction.amount * -1, + iouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, + receipt: {source: 'https://example.com/receipt.jpg', state: CONST.IOU.RECEIPT_STATE.OPEN}, + }; + + await Onyx.clear(); + + duplicateExpenseTransaction({ + transaction: mockScanExpenseTransaction, + optimisticChatReportID: mockOptimisticChatReportID, + optimisticIOUReportID: mockOptimisticIOUReportID, + isASAPSubmitBetaEnabled: mockIsASAPSubmitBetaEnabled, + introSelected: undefined, + quickAction: undefined, + policyRecentlyUsedCurrencies: [], + isSelfTourViewed: false, + customUnitPolicyID: '', + targetPolicy: undefined, + targetPolicyCategories: undefined, + targetReport: undefined, + existingTransactionDraft: undefined, + draftTransactionIDs: [], + betas: [CONST.BETAS.ALL], + personalDetails: {}, + recentWaypoints, + targetPolicyTags, + conciergeReportID: undefined, + currentUser: {accountID: RORY_ACCOUNT_ID, email: RORY_EMAIL}, + }); + + await waitForBatchedUpdates(); + + let duplicatedTransaction: OnyxEntry; + await getOnyxData({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (allTransactions) => { + duplicatedTransaction = Object.values(allTransactions ?? {}).find((t) => !!t); + }, + }); + + expect(duplicatedTransaction?.transactionID).not.toBe(transactionID); + expect(duplicatedTransaction?.iouRequestType).toBe(CONST.IOU.REQUEST_TYPE.MANUAL); + }); + it('should create a duplicate expense successfully (previously with transaction drafts)', async () => { const {waypoints, ...restOfComment} = mockTransaction.comment ?? {}; const mockCashExpenseTransaction = { @@ -1370,6 +1471,7 @@ describe('actions/Duplicate', () => { ...mockTransaction, transactionID, amount: AMOUNT_CENTS, + iouRequestType: CONST.IOU.REQUEST_TYPE.TIME, comment: { type: 'time' as const, units: { @@ -1503,6 +1605,7 @@ describe('actions/Duplicate', () => { const mockDistanceTransaction = { ...mockTransaction, amount: mockTransaction.amount * -1, + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, comment: { type: 'customUnit' as const, customUnit: { @@ -1542,10 +1645,73 @@ describe('actions/Duplicate', () => { expect(writeSpy).toHaveBeenCalledWith(WRITE_COMMANDS.CREATE_DISTANCE_REQUEST, expect.objectContaining({}), expect.objectContaining({})); }); + it('should not corrupt modifiedCreated or leak top-level Transaction fields when duplicating a distance expense', async () => { + const transactionID = 'distance-shim-shape'; + const mockDistanceTransaction = { + ...mockTransaction, + transactionID, + amount: -1000, + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, + comment: { + type: 'customUnit' as const, + customUnit: { + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + quantity: 10, + }, + waypoints: {waypoint0: {address: 'A', lat: 1, lng: 1, keyForList: 'wp0'}}, + }, + }; + + await Onyx.clear(); + + duplicateExpenseTransaction({ + transaction: mockDistanceTransaction, + optimisticChatReportID: mockOptimisticChatReportID, + optimisticIOUReportID: mockOptimisticIOUReportID, + isASAPSubmitBetaEnabled: mockIsASAPSubmitBetaEnabled, + introSelected: undefined, + quickAction: undefined, + policyRecentlyUsedCurrencies: [], + isSelfTourViewed: false, + customUnitPolicyID: '', + targetPolicy: mockPolicy, + targetPolicyCategories: fakePolicyCategories, + targetReport: policyExpenseChat, + existingTransactionDraft: undefined, + draftTransactionIDs: [], + personalDetails: mockPersonalDetails, + betas: [CONST.BETAS.ALL], + recentWaypoints, + targetPolicyTags, + conciergeReportID: undefined, + currentUser: {accountID: RORY_ACCOUNT_ID, email: RORY_EMAIL}, + }); + + await waitForBatchedUpdates(); + + let duplicatedTransaction: OnyxEntry; + await getOnyxData({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (allTransactions) => { + duplicatedTransaction = Object.values(allTransactions ?? {}).find((t) => !!t && t.transactionID !== transactionID); + }, + }); + + expect(duplicatedTransaction).toBeDefined(); + expect(duplicatedTransaction?.modifiedCreated).not.toBe(''); + const persisted = duplicatedTransaction as Record | undefined; + expect(persisted?.distance).toBeUndefined(); + expect(persisted?.validWaypoints).toBeUndefined(); + expect(persisted?.customUnitRateID).toBeUndefined(); + }); + it('should call submitPerDiemExpense for per diem transactions', async () => { const mockPerDiemTransaction = { ...mockTransaction, amount: mockTransaction.amount * -1, + iouRequestType: CONST.IOU.REQUEST_TYPE.PER_DIEM, comment: { type: 'customUnit' as const, customUnit: { @@ -2072,6 +2238,28 @@ describe('actions/Duplicate', () => { expect(countWriteCommandCalls(WRITE_COMMANDS.CREATE_PER_DIEM_REQUEST)).toBe(1); }); + it('should duplicate a scan expense as a manual expense', async () => { + const scanExpenseTx = createCashTransaction('scan-completed-1', { + iouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, + receipt: {source: 'https://example.com/receipt.jpg', state: CONST.IOU.RECEIPT_STATE.OPEN}, + }); + + duplicateReport(getDefaultParams([scanExpenseTx])); + await waitForBatchedUpdates(); + + let duplicatedTransaction: OnyxEntry; + await getOnyxData({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (allTransactions) => { + duplicatedTransaction = Object.values(allTransactions ?? {}).find((t) => !!t && t.transactionID !== scanExpenseTx.transactionID); + }, + }); + + expect(duplicatedTransaction).toBeDefined(); + expect(duplicatedTransaction?.iouRequestType).toBe(CONST.IOU.REQUEST_TYPE.MANUAL); + }); + it('should not duplicate expenses when no target policy exists', async () => { const tx1 = createCashTransaction('tx1'); const tx2 = createCashTransaction('tx2'); diff --git a/tests/actions/IOUTest/SplitTest.ts b/tests/actions/IOUTest/SplitTest.ts index 81b00b0bb20e..8d332579c01a 100644 --- a/tests/actions/IOUTest/SplitTest.ts +++ b/tests/actions/IOUTest/SplitTest.ts @@ -4958,6 +4958,7 @@ describe('initSplitExpense', () => { amount: -20000, currency: 'USD', merchant: '', + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, comment: { comment: 'Distance expense', splitExpenses: [], @@ -5171,6 +5172,7 @@ describe('addSplitExpenseField', () => { amount: -20000, currency: 'USD', merchant: '', + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, comment: { comment: 'Distance expense', splitExpenses: [], @@ -5194,6 +5196,7 @@ describe('addSplitExpenseField', () => { amount: 20000, currency: 'USD', merchant: '', + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, comment: { comment: 'Distance expense', splitExpenses: [ @@ -5517,6 +5520,7 @@ describe('evenlyDistributeSplitExpenseAmounts', () => { transactionID: originalTransactionID, amount: -20000, currency: 'USD', + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, comment: { type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, customUnit: { @@ -5535,6 +5539,7 @@ describe('evenlyDistributeSplitExpenseAmounts', () => { amount: 20000, currency: 'USD', merchant: 'Test Merchant', + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, comment: { comment: 'Test comment', originalTransactionID, @@ -5667,6 +5672,7 @@ describe('updateSplitExpenseAmountField', () => { transactionID: originalTransactionID, amount: -20000, currency: 'USD', + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, comment: { type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, customUnit: { @@ -5685,6 +5691,7 @@ describe('updateSplitExpenseAmountField', () => { amount: 20000, currency: 'USD', merchant: 'Test Merchant', + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, comment: { comment: 'Test comment', originalTransactionID, @@ -5984,6 +5991,7 @@ describe('initDraftSplitExpenseDataForEdit', () => { amount: -20000, currency: 'USD', merchant: 'Original Merchant', + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MANUAL, comment: { comment: 'Original comment', type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, @@ -6008,6 +6016,7 @@ describe('initDraftSplitExpenseDataForEdit', () => { amount: 20000, currency: 'USD', merchant: 'Draft Merchant', + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, comment: { comment: 'Draft comment', originalTransactionID, @@ -6155,6 +6164,7 @@ describe('resetSplitExpensesByDateRange', () => { amount: -20000, currency: 'USD', merchant: '', + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, comment: { comment: 'Distance expense', splitExpenses: [], @@ -6436,6 +6446,7 @@ describe('updateSplitExpenseField', () => { amount: -20000, currency: 'USD', merchant: '', + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, comment: { comment: 'Distance expense', type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, @@ -6460,6 +6471,7 @@ describe('updateSplitExpenseField', () => { amount: 20000, currency: 'USD', merchant: '', + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, comment: { comment: 'Draft comment', originalTransactionID, diff --git a/tests/actions/IOUTest/TrackExpenseTest.ts b/tests/actions/IOUTest/TrackExpenseTest.ts index a782b96b137e..70002776a963 100644 --- a/tests/actions/IOUTest/TrackExpenseTest.ts +++ b/tests/actions/IOUTest/TrackExpenseTest.ts @@ -8,6 +8,7 @@ import { getDeleteTrackExpenseInformation, getTrackExpenseInformation, hasManualDistanceOverride, + requestMoney, trackExpense, } from '@libs/actions/IOU/TrackExpense'; import initOnyxDerivedValues from '@libs/actions/OnyxDerived'; @@ -32,6 +33,7 @@ import type {Accountant} from '@src/types/onyx/IOU'; import type ReportAction from '@src/types/onyx/ReportAction'; import type {ReportActions} from '@src/types/onyx/ReportAction'; import type Transaction from '@src/types/onyx/Transaction'; +import type {ReceiptError} from '@src/types/onyx/Transaction'; import currencyList from '../../unit/currencyList.json'; import createRandomPolicy from '../../utils/collections/policies'; import createRandomPolicyCategories from '../../utils/collections/policyCategory'; @@ -1550,6 +1552,57 @@ describe('actions/IOU/TrackExpense', () => { mockFetch?.succeed?.(); }); + it('should preserve iouRequestType in retryParams without leaking the full existingTransaction', async () => { + // Given a selfDM report and an existing SCAN transaction (carrying state that would bloat the retry payload) + const selfDMReport: Report = { + ...createRandomReport(1, CONST.REPORT.CHAT_TYPE.SELF_DM), + reportID: 'selfDM-retry-leak', + }; + const existingTransaction: Transaction = { + ...createRandomTransaction(1), + iouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, + comment: {comment: 'pre-existing comment'}, + receipt: {source: 'existing-receipt.jpg', state: CONST.IOU.RECEIPT_STATE.SCAN_READY}, + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${selfDMReport.reportID}`, selfDMReport); + mockFetch?.fail?.(); + + // When trackExpense fails with a SCAN receipt + trackExpense({ + ...getDefaultTrackExpenseParams(selfDMReport, { + receipt: {source: 'new-receipt.jpg', name: 'new-receipt.jpg', state: CONST.IOU.RECEIPT_STATE.SCAN_READY}, + }), + existingTransaction, + }); + await mockFetch?.resume?.(); + await waitForBatchedUpdates(); + + // Then the retryParams stored on the receipt error must preserve iouRequestType but not the bulky source state + let transactions: OnyxCollection; + await getOnyxData({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (val) => { + transactions = val; + }, + }); + + const failedTransaction = Object.values(transactions ?? {}).find((t) => !!t?.errors); + expect(failedTransaction).toBeDefined(); + const errors = (failedTransaction?.errors ?? {}) as Record; + const receiptError = Object.values(errors).find((err) => err?.error === CONST.IOU.RECEIPT_ERROR); + expect(receiptError).toBeDefined(); + const parsedRetryParams = JSON.parse(receiptError?.retryParams as unknown as string) as Record; + const persistedExistingTransaction = parsedRetryParams.existingTransaction as Partial | undefined; + expect(persistedExistingTransaction?.iouRequestType).toBe(CONST.IOU.REQUEST_TYPE.SCAN); + expect(persistedExistingTransaction?.comment).toBeUndefined(); + expect(persistedExistingTransaction?.receipt).toBeUndefined(); + expect(persistedExistingTransaction?.cardNumber).toBeUndefined(); + + mockFetch?.succeed?.(); + }); + it('should handle category and tag together correctly', async () => { // Given a selfDM report with category and tag const selfDMReport: Report = { @@ -1873,6 +1926,88 @@ describe('actions/IOU/TrackExpense', () => { }); }); + describe('requestMoney', () => { + it('should preserve iouRequestType in retryParams without leaking the full existingTransaction', async () => { + // Given a 1:1 chat report and an existing SCAN transaction (carrying state that would bloat the retry payload) + const chatReport: Report = { + ...createRandomReport(2, undefined), + reportID: 'chat-retry-leak', + type: CONST.REPORT.TYPE.CHAT, + participants: { + [RORY_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + [CARLOS_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + }; + const existingTransaction: Transaction = { + ...createRandomTransaction(2), + iouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, + comment: {comment: 'pre-existing comment'}, + receipt: {source: 'existing-receipt.jpg', state: CONST.IOU.RECEIPT_STATE.SCAN_READY}, + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${chatReport.reportID}`, chatReport); + mockFetch?.fail?.(); + + // When requestMoney fails with a SCAN receipt + requestMoney({ + report: chatReport, + participantParams: { + payeeEmail: RORY_EMAIL, + payeeAccountID: RORY_ACCOUNT_ID, + participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, + }, + transactionParams: { + amount: 10000, + attendees: [], + currency: CONST.CURRENCY.USD, + created: format(new Date(), CONST.DATE.FNS_FORMAT_STRING), + merchant: 'Failure Test', + comment: 'retry payload guard', + receipt: {source: 'new-receipt.jpg', name: 'new-receipt.jpg', state: CONST.IOU.RECEIPT_STATE.SCAN_READY}, + }, + shouldGenerateTransactionThreadReport: true, + isASAPSubmitBetaEnabled: false, + currentUserAccountIDParam: RORY_ACCOUNT_ID, + currentUserEmailParam: RORY_EMAIL, + transactionViolations: {}, + policyRecentlyUsedCurrencies: [], + existingTransactionDraft: undefined, + existingTransaction, + draftTransactionIDs: [], + isSelfTourViewed: false, + quickAction: undefined, + betas: [CONST.BETAS.ALL], + personalDetails: {}, + }); + await mockFetch?.resume?.(); + await waitForBatchedUpdates(); + + // Then the retryParams stored on the receipt error must preserve iouRequestType but not the bulky source state + let transactions: OnyxCollection; + await getOnyxData({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (val) => { + transactions = val; + }, + }); + + const failedTransaction = Object.values(transactions ?? {}).find((t) => !!t?.errors); + expect(failedTransaction).toBeDefined(); + const errors = (failedTransaction?.errors ?? {}) as Record; + const receiptError = Object.values(errors).find((err) => err?.error === CONST.IOU.RECEIPT_ERROR); + expect(receiptError).toBeDefined(); + const parsedRetryParams = JSON.parse(receiptError?.retryParams as unknown as string) as Record; + const persistedExistingTransaction = parsedRetryParams.existingTransaction as Partial | undefined; + expect(persistedExistingTransaction?.iouRequestType).toBe(CONST.IOU.REQUEST_TYPE.SCAN); + expect(persistedExistingTransaction?.comment).toBeUndefined(); + expect(persistedExistingTransaction?.receipt).toBeUndefined(); + expect(persistedExistingTransaction?.cardNumber).toBeUndefined(); + + mockFetch?.succeed?.(); + }); + }); + describe('getDeleteTrackExpenseInformation', () => { const amount = 10000; const comment = 'Send me money please'; diff --git a/tests/actions/IOUTest/UpdateMoneyRequestTest.ts b/tests/actions/IOUTest/UpdateMoneyRequestTest.ts index c63112d19646..1a682ceaba5f 100644 --- a/tests/actions/IOUTest/UpdateMoneyRequestTest.ts +++ b/tests/actions/IOUTest/UpdateMoneyRequestTest.ts @@ -223,6 +223,7 @@ describe('actions/IOU/UpdateMoneyRequest', () => { taxCode, taxAmount, amount: 100, + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, comment: { type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, customUnit: { diff --git a/tests/actions/MergeTransactionTest.ts b/tests/actions/MergeTransactionTest.ts index bcf77ca7558d..acd60562e109 100644 --- a/tests/actions/MergeTransactionTest.ts +++ b/tests/actions/MergeTransactionTest.ts @@ -374,6 +374,76 @@ describe('mergeTransactionRequest', () => { expect(updatedMergeTransaction).toBeNull(); }); + it('should preserve target iouRequestType when merging a distance request without an iouRequestType in the merge transaction', async () => { + // Given a distance target transaction with a sub-type and a merge transaction that omits iouRequestType + const targetReportID = 'target-report'; + const targetTransaction: Transaction = { + ...createRandomDistanceRequestTransaction(1), + transactionID: 'target-distance', + reportID: targetReportID, + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, + }; + const sourceExpenseReport = { + ...createExpenseReport(1), + reportID: 'source-report', + }; + const sourceTransaction: Transaction = { + ...createRandomTransaction(2), + transactionID: 'source-distance', + reportID: sourceExpenseReport.reportID, + }; + const mergeTransaction = { + ...createRandomMergeTransaction(1), + targetTransactionID: targetTransaction.transactionID, + sourceTransactionID: sourceTransaction.transactionID, + reportID: targetReportID, + // Intentionally no iouRequestType + }; + const mergeTransactionID = 'merge-distance'; + + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${targetTransaction.transactionID}`, targetTransaction); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${sourceTransaction.transactionID}`, sourceTransaction); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${sourceExpenseReport.reportID}`, sourceExpenseReport); + await Onyx.set(`${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${mergeTransactionID}`, mergeTransaction); + + mockFetch?.pause?.(); + + // When the merge fires + mergeTransactionRequest({ + mergeTransactionID, + mergeTransaction, + targetTransaction, + sourceTransaction, + targetTransactionThreadReport: {reportID: targetReportID}, + targetTransactionThreadParentReport: undefined, + targetTransactionThreadParentReportNextStep: undefined, + allTransactionViolations: createAllTransactionViolations(targetTransaction.transactionID, sourceTransaction.transactionID), + policy: undefined, + policyTags: undefined, + policyCategories: undefined, + currentUserAccountIDParam: TEST_ACCOUNT_ID, + currentUserEmailParam: TEST_EMAIL, + isASAPSubmitBetaEnabled: false, + delegateAccountID: undefined, + selfDMReport: undefined, + }); + + await mockFetch?.resume?.(); + await waitForBatchedUpdates(); + + // Then the target transaction's iouRequestType must fall back to its own value, not be nulled + const updatedTargetTransaction = await new Promise((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${targetTransaction.transactionID}`, + callback: (transaction) => { + Onyx.disconnect(connection); + resolve(transaction ?? null); + }, + }); + }); + expect(updatedTargetTransaction?.iouRequestType).toBe(CONST.IOU.REQUEST_TYPE.DISTANCE_MAP); + }); + it('should call MERGE_TRANSACTION with correct params and delete only the chosen source expense in Merge Expense flow', async () => { // Given: // - Two expenses on different reports diff --git a/tests/ui/MoneyRequestViewTest.tsx b/tests/ui/MoneyRequestViewTest.tsx index 1ec0e8d086c4..cdd66c5ed557 100644 --- a/tests/ui/MoneyRequestViewTest.tsx +++ b/tests/ui/MoneyRequestViewTest.tsx @@ -244,7 +244,7 @@ describe('MoneyRequestView edit fields', () => { taxCode: 'TAX_10', taxAmount: 500, taxValue: '10%', - comment: {type: CONST.TRANSACTION.TYPE.TIME}, + iouRequestType: CONST.IOU.REQUEST_TYPE.TIME, }); }); await waitForBatchedUpdatesWithAct(); diff --git a/tests/unit/MergeTransactionUtilsTest.ts b/tests/unit/MergeTransactionUtilsTest.ts index 295adccc93eb..fb827411de99 100644 --- a/tests/unit/MergeTransactionUtilsTest.ts +++ b/tests/unit/MergeTransactionUtilsTest.ts @@ -690,6 +690,33 @@ describe('MergeTransactionUtils', () => { taxName: 'Tax 10%', }); }); + + it('should propagate the merge transaction iouRequestType over the target transaction type when set', () => { + const targetTransaction = { + ...createRandomTransaction(0), + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, + }; + const mergeTransaction = { + ...createRandomMergeTransaction(0), + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MANUAL, + }; + + const result = buildMergedTransactionData(targetTransaction, mergeTransaction); + + expect(result?.iouRequestType).toBe(CONST.IOU.REQUEST_TYPE.DISTANCE_MANUAL); + }); + + it('should inherit the target transaction iouRequestType when the merge transaction has none', () => { + const targetTransaction = { + ...createRandomTransaction(0), + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, + }; + const mergeTransaction = createRandomMergeTransaction(0); + + const result = buildMergedTransactionData(targetTransaction, mergeTransaction); + + expect(result?.iouRequestType).toBe(CONST.IOU.REQUEST_TYPE.DISTANCE_MAP); + }); }); describe('selectTargetAndSourceTransactionsForMerge', () => { diff --git a/tests/unit/ReportSecondaryActionUtilsTest.ts b/tests/unit/ReportSecondaryActionUtilsTest.ts index 7f248752a620..16ef33db73eb 100644 --- a/tests/unit/ReportSecondaryActionUtilsTest.ts +++ b/tests/unit/ReportSecondaryActionUtilsTest.ts @@ -2073,6 +2073,7 @@ describe('getSecondaryAction', () => { amount: 20, merchant: 'Merchant', date: '2025-01-01', + iouRequestType: CONST.IOU.REQUEST_TYPE.PER_DIEM, comment: { type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, customUnit: { @@ -3268,6 +3269,7 @@ describe('getSecondaryTransactionThreadActions', () => { amount: 20, merchant: 'Merchant', date: '2025-01-01', + iouRequestType: CONST.IOU.REQUEST_TYPE.PER_DIEM, comment: { type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, customUnit: { diff --git a/tests/unit/TransactionPreviewUtils.test.ts b/tests/unit/TransactionPreviewUtils.test.ts index a4b92de7b36c..6eebdd216f73 100644 --- a/tests/unit/TransactionPreviewUtils.test.ts +++ b/tests/unit/TransactionPreviewUtils.test.ts @@ -901,6 +901,7 @@ describe('TransactionPreviewUtils', () => { it('should return true for a distance request with MODIFIED_AMOUNT violation', () => { const distanceTransaction = { ...basicProps.transaction, + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE, comment: { type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, customUnit: {customUnitRateID: 'rate1', name: CONST.CUSTOM_UNITS.NAME_DISTANCE}, @@ -946,6 +947,7 @@ describe('TransactionPreviewUtils', () => { it('should return false for a distance request with missing merchant (guarded by hasMissingSmartscanFields)', () => { const distanceTransaction = { ...basicProps.transaction, + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE, merchant: '', modifiedMerchant: '', comment: { diff --git a/tests/unit/TransactionTest.ts b/tests/unit/TransactionTest.ts index 29a2e0249ab9..dc2c68b94da1 100644 --- a/tests/unit/TransactionTest.ts +++ b/tests/unit/TransactionTest.ts @@ -1343,6 +1343,7 @@ describe('Transaction', () => { reportID: FAKE_OLD_REPORT_ID, amount: -500, currency: 'USD', + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE, comment: { type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, customUnit: { @@ -1419,6 +1420,7 @@ describe('Transaction', () => { reportID: FAKE_OLD_REPORT_ID, amount: -500, currency: 'USD', + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE, comment: { type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, customUnit: { @@ -1500,6 +1502,7 @@ describe('Transaction', () => { reportID: FAKE_OLD_REPORT_ID, amount: -500, currency: 'USD', + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE, comment: { type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, customUnit: { @@ -1575,6 +1578,7 @@ describe('Transaction', () => { reportID: FAKE_OLD_REPORT_ID, amount: -500, currency: 'USD', + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE, comment: { type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, customUnit: { diff --git a/tests/unit/TransactionUtilsTest.ts b/tests/unit/TransactionUtilsTest.ts index e9ada561b413..8095c9b777ed 100644 --- a/tests/unit/TransactionUtilsTest.ts +++ b/tests/unit/TransactionUtilsTest.ts @@ -557,28 +557,18 @@ describe('TransactionUtils', () => { expect(TransactionUtils.getTransactionType(null as unknown as Transaction)).toBe(CONST.SEARCH.TRANSACTION_TYPE.CASH); }); - it('returns distance when the transaction has a distance custom unit', () => { + it('returns distance when the transaction iouRequestType is a distance type', () => { const transaction = generateTransaction({ - comment: { - type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, - customUnit: { - name: CONST.CUSTOM_UNITS.NAME_DISTANCE, - }, - }, + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, merchant: '(none)', }); expect(TransactionUtils.getTransactionType(transaction)).toBe(CONST.SEARCH.TRANSACTION_TYPE.DISTANCE); }); - it('returns per diem when the transaction has an international per diem custom unit', () => { + it('returns per diem when the transaction iouRequestType is per-diem', () => { const transaction = generateTransaction({ - comment: { - type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, - customUnit: { - name: CONST.CUSTOM_UNITS.NAME_PER_DIEM_INTERNATIONAL, - }, - }, + iouRequestType: CONST.IOU.REQUEST_TYPE.PER_DIEM, }); expect(TransactionUtils.getTransactionType(transaction)).toBe(CONST.SEARCH.TRANSACTION_TYPE.PER_DIEM); @@ -603,10 +593,10 @@ describe('TransactionUtils', () => { expect(TransactionUtils.getTransactionType(transaction)).toBe(CONST.SEARCH.TRANSACTION_TYPE.CASH); }); - it('returns time when the transaction has a comment with time type', () => { + it('returns time when the transaction iouRequestType is time', () => { const transaction = generateTransaction({ + iouRequestType: CONST.IOU.REQUEST_TYPE.TIME, comment: { - type: 'time', units: { count: 2, unit: 'h', @@ -3387,4 +3377,124 @@ describe('TransactionUtils', () => { expect(TransactionUtils.shouldClearConvertedAmount(transaction, undefined, 'EUR')).toBe(false); }); }); + + describe('isDistanceRequest', () => { + describe.each([ + CONST.IOU.REQUEST_TYPE.DISTANCE, + CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, + CONST.IOU.REQUEST_TYPE.DISTANCE_MANUAL, + CONST.IOU.REQUEST_TYPE.DISTANCE_GPS, + CONST.IOU.REQUEST_TYPE.DISTANCE_ODOMETER, + ])('when iouRequestType is "%s"', (iouRequestType) => { + it('returns true', () => { + const transaction = generateTransaction({iouRequestType}); + expect(TransactionUtils.isDistanceRequest(transaction)).toBe(true); + }); + }); + + describe('when iouRequestType is a non-distance type', () => { + it('returns false', () => { + const transaction = generateTransaction({iouRequestType: CONST.IOU.REQUEST_TYPE.SCAN}); + expect(TransactionUtils.isDistanceRequest(transaction)).toBe(false); + }); + }); + + describe('when iouRequestType is null', () => { + it('returns false', () => { + const transaction = {...generateTransaction(), iouRequestType: null} as unknown as Transaction; + expect(TransactionUtils.isDistanceRequest(transaction)).toBe(false); + }); + }); + + describe('when iouRequestType is undefined', () => { + it('returns false', () => { + const transaction = generateTransaction({iouRequestType: undefined}); + expect(TransactionUtils.isDistanceRequest(transaction)).toBe(false); + }); + }); + + describe('when the transaction is undefined', () => { + it('returns false', () => { + expect(TransactionUtils.isDistanceRequest(undefined)).toBe(false); + }); + }); + }); + + describe.each([ + {fn: 'isDistanceTypeRequest', match: CONST.IOU.REQUEST_TYPE.DISTANCE, other: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP}, + {fn: 'isMapDistanceRequest', match: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, other: CONST.IOU.REQUEST_TYPE.DISTANCE_GPS}, + {fn: 'isGPSDistanceRequest', match: CONST.IOU.REQUEST_TYPE.DISTANCE_GPS, other: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP}, + {fn: 'isManualDistanceRequest', match: CONST.IOU.REQUEST_TYPE.DISTANCE_MANUAL, other: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP}, + {fn: 'isOdometerDistanceRequest', match: CONST.IOU.REQUEST_TYPE.DISTANCE_ODOMETER, other: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP}, + {fn: 'isScanRequest', match: CONST.IOU.REQUEST_TYPE.SCAN, other: CONST.IOU.REQUEST_TYPE.MANUAL}, + {fn: 'isPerDiemRequest', match: CONST.IOU.REQUEST_TYPE.PER_DIEM, other: CONST.IOU.REQUEST_TYPE.TIME}, + {fn: 'isTimeRequest', match: CONST.IOU.REQUEST_TYPE.TIME, other: CONST.IOU.REQUEST_TYPE.PER_DIEM}, + ] as const)('$fn', ({fn, match, other}) => { + describe(`when iouRequestType matches`, () => { + it('returns true', () => { + const transaction = generateTransaction({iouRequestType: match}); + expect(TransactionUtils[fn](transaction)).toBe(true); + }); + }); + + describe('when iouRequestType is a different request type', () => { + it('returns false', () => { + const transaction = generateTransaction({iouRequestType: other}); + expect(TransactionUtils[fn](transaction)).toBe(false); + }); + }); + + describe('when iouRequestType is null', () => { + it('returns false', () => { + const transaction = {...generateTransaction(), iouRequestType: null} as unknown as Transaction; + expect(TransactionUtils[fn](transaction)).toBe(false); + }); + }); + + describe('when iouRequestType is undefined', () => { + it('returns false', () => { + const transaction = generateTransaction({iouRequestType: undefined}); + expect(TransactionUtils[fn](transaction)).toBe(false); + }); + }); + }); + + describe('getRequestType', () => { + describe.each([ + CONST.IOU.REQUEST_TYPE.DISTANCE, + CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, + CONST.IOU.REQUEST_TYPE.DISTANCE_MANUAL, + CONST.IOU.REQUEST_TYPE.DISTANCE_GPS, + CONST.IOU.REQUEST_TYPE.DISTANCE_ODOMETER, + CONST.IOU.REQUEST_TYPE.SCAN, + CONST.IOU.REQUEST_TYPE.PER_DIEM, + CONST.IOU.REQUEST_TYPE.TIME, + CONST.IOU.REQUEST_TYPE.MANUAL, + ])('when iouRequestType is "%s"', (iouRequestType) => { + it('returns the iouRequestType value', () => { + const transaction = generateTransaction({iouRequestType}); + expect(TransactionUtils.getRequestType(transaction)).toBe(iouRequestType); + }); + }); + + describe('when iouRequestType is null', () => { + it('returns manual as the fallback', () => { + const transaction = {...generateTransaction(), iouRequestType: null} as unknown as Transaction; + expect(TransactionUtils.getRequestType(transaction)).toBe(CONST.IOU.REQUEST_TYPE.MANUAL); + }); + }); + + describe('when iouRequestType is undefined', () => { + it('returns manual as the fallback', () => { + const transaction = generateTransaction({iouRequestType: undefined}); + expect(TransactionUtils.getRequestType(transaction)).toBe(CONST.IOU.REQUEST_TYPE.MANUAL); + }); + }); + + describe('when the transaction is undefined', () => { + it('returns manual as the fallback', () => { + expect(TransactionUtils.getRequestType(undefined)).toBe(CONST.IOU.REQUEST_TYPE.MANUAL); + }); + }); + }); }); diff --git a/tests/unit/ViolationUtilsTest.ts b/tests/unit/ViolationUtilsTest.ts index 3e7004653e96..ea1ab73088bf 100644 --- a/tests/unit/ViolationUtilsTest.ts +++ b/tests/unit/ViolationUtilsTest.ts @@ -1220,6 +1220,7 @@ describe('getViolationsOnyxData', () => { it('should not add taxOutOfPolicy violation for time requests even with tax data and tax tracking disabled', () => { policy.tax = {trackingEnabled: false}; transaction.taxCode = 'SOME_TAX'; + transaction.iouRequestType = CONST.IOU.REQUEST_TYPE.TIME; transaction.comment = {...transaction.comment, type: CONST.TRANSACTION.TYPE.TIME}; const result = ViolationsUtils.getViolationsOnyxData(transaction, transactionViolations, policy, policyTags, policyCategories, false, false); expect(result.value).not.toContainEqual(taxOutOfPolicyViolation); @@ -1228,6 +1229,7 @@ describe('getViolationsOnyxData', () => { it('should not add taxOutOfPolicy violation for per diem requests even with tax data and tax tracking disabled', () => { policy.tax = {trackingEnabled: false}; transaction.taxCode = 'SOME_TAX'; + transaction.iouRequestType = CONST.IOU.REQUEST_TYPE.PER_DIEM; transaction.comment = {...transaction.comment, type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, customUnit: {name: CONST.CUSTOM_UNITS.NAME_PER_DIEM_INTERNATIONAL}}; const result = ViolationsUtils.getViolationsOnyxData(transaction, transactionViolations, policy, policyTags, policyCategories, false, false); expect(result.value).not.toContainEqual(taxOutOfPolicyViolation); @@ -1236,6 +1238,7 @@ describe('getViolationsOnyxData', () => { it('should not add taxOutOfPolicy violation for time requests even with invalid taxCode and tax tracking enabled', () => { policy.tax = {trackingEnabled: true}; transaction.taxCode = 'UNKNOWN_TAX'; + transaction.iouRequestType = CONST.IOU.REQUEST_TYPE.TIME; transaction.comment = {...transaction.comment, type: CONST.TRANSACTION.TYPE.TIME}; policy.taxRates = {name: 'Taxes', defaultExternalID: 'TAX_10', defaultValue: '10%', foreignTaxDefault: 'TAX_10', taxes: {TAX_10: {name: '10%', value: '10%'}}}; const result = ViolationsUtils.getViolationsOnyxData(transaction, transactionViolations, policy, policyTags, policyCategories, false, false); diff --git a/tests/unit/canEditFieldOfMoneyRequestTest.ts b/tests/unit/canEditFieldOfMoneyRequestTest.ts index bb16a9a62183..46b0e1f133f7 100644 --- a/tests/unit/canEditFieldOfMoneyRequestTest.ts +++ b/tests/unit/canEditFieldOfMoneyRequestTest.ts @@ -559,6 +559,7 @@ describe('canEditFieldOfMoneyRequest', () => { transactionID: PER_DIEM_IOU_TRANSACTION_ID, reportID: CONST.REPORT.UNREPORTED_REPORT_ID, amount: 100, + iouRequestType: CONST.IOU.REQUEST_TYPE.PER_DIEM, comment: { type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, customUnit: { diff --git a/tests/unit/inlineEditing/TransactionInlineEdit.test.ts b/tests/unit/inlineEditing/TransactionInlineEdit.test.ts index f74c358017c1..e7df742d8bd7 100644 --- a/tests/unit/inlineEditing/TransactionInlineEdit.test.ts +++ b/tests/unit/inlineEditing/TransactionInlineEdit.test.ts @@ -148,6 +148,7 @@ describe('getTransactionEditPermissions', () => { const distanceTransaction: Transaction = { ...baseTransaction, reportID: CONST.REPORT.UNREPORTED_REPORT_ID, + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE, comment: { type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, customUnit: { @@ -348,6 +349,7 @@ describe('getTransactionEditPermissions', () => { ...baseUnreportedParams, transaction: { ...unreportedTransaction, + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE, comment: { type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, customUnit: {name: CONST.CUSTOM_UNITS.NAME_DISTANCE},