From d944b769672f9236d3923dbd197023da1e6e3948 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Sun, 19 Apr 2026 17:59:08 +0300 Subject: [PATCH 01/26] fix: [GPS]Transactions - Always use iouRequestType to check for request type --- .../ReportActionItem/MoneyRequestView.tsx | 2 +- src/libs/GPSDraftDetailsUtils.ts | 4 +- src/libs/TransactionUtils/index.ts | 155 ++---------------- src/libs/actions/IOU/index.ts | 8 +- src/libs/actions/MergeTransaction.ts | 2 +- src/types/onyx/Transaction.ts | 2 +- tests/unit/TransactionUtilsTest.ts | 143 ++++++++++++++-- 7 files changed, 146 insertions(+), 170 deletions(-) diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index 084143492835..00d547a21926 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -278,7 +278,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/GPSDraftDetailsUtils.ts b/src/libs/GPSDraftDetailsUtils.ts index a354abea1b2d..bbcd481d450a 100644 --- a/src/libs/GPSDraftDetailsUtils.ts +++ b/src/libs/GPSDraftDetailsUtils.ts @@ -21,7 +21,7 @@ function getGPSWaypoints(gpsDraftDetails: GpsDraftDetails | undefined): Waypoint ...(firstPoint ? { waypoint0: { - keyForList: 'gps_start', // temporary for hasGPSWaypoints() + keyForList: 'gps_start', lat: firstPoint.lat, lng: firstPoint.long, address: startAddress, @@ -32,7 +32,7 @@ function getGPSWaypoints(gpsDraftDetails: GpsDraftDetails | undefined): Waypoint ...(lastPoint ? { waypoint1: { - keyForList: 'gps_stop', // temporary for hasGPSWaypoints() + keyForList: 'gps_stop', lat: lastPoint.lat, lng: lastPoint.long, address: endAddress, diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index 575d7db9cbee..942d875ecef3 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -142,140 +142,41 @@ 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; } 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; + 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 { @@ -292,32 +193,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; } /** @@ -378,12 +254,7 @@ function getExpenseTypeTranslationKey(expenseType: ValueOf): boolean { diff --git a/src/libs/actions/IOU/index.ts b/src/libs/actions/IOU/index.ts index ad9788b13903..f87c85d24509 100644 --- a/src/libs/actions/IOU/index.ts +++ b/src/libs/actions/IOU/index.ts @@ -2307,13 +2307,7 @@ function getMoneyRequestInformation(moneyRequestInformation: MoneyRequestInforma iouReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${chatReport.iouReportID}`] ?? null; } - const isScanRequest = isScanRequestTransactionUtils({ - amount, - receipt, - ...(existingTransaction && { - iouRequestType: existingTransaction.iouRequestType, - }), - }); + const isScanRequest = existingTransaction ? isScanRequestTransactionUtils(existingTransaction) : !!receipt?.source && amount === 0; const shouldCreateNewMoneyRequestReport = isSplitExpense ? false : shouldCreateNewMoneyRequestReportReportUtils(iouReport, chatReport, isScanRequest, betas, action); diff --git a/src/libs/actions/MergeTransaction.ts b/src/libs/actions/MergeTransaction.ts index 76449ceb69bc..f5ce28896a74 100644 --- a/src/libs/actions/MergeTransaction.ts +++ b/src/libs/actions/MergeTransaction.ts @@ -306,7 +306,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/types/onyx/Transaction.ts b/src/types/onyx/Transaction.ts index 11e420acf93b..33d7d3e3a7e4 100644 --- a/src/types/onyx/Transaction.ts +++ b/src/types/onyx/Transaction.ts @@ -508,7 +508,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 request type of the transaction (e.g. manual, scan, distance). Set during creation and returned by the server. */ iouRequestType?: IOURequestType; /** The original merchant name */ diff --git a/tests/unit/TransactionUtilsTest.ts b/tests/unit/TransactionUtilsTest.ts index b2e480163dd9..934a2e25809b 100644 --- a/tests/unit/TransactionUtilsTest.ts +++ b/tests/unit/TransactionUtilsTest.ts @@ -489,28 +489,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); @@ -535,10 +525,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', @@ -2903,4 +2893,125 @@ describe('TransactionUtils', () => { expect(TransactionUtils.getExchangeRate(transaction)).toBe('1.25 USD/EUR'); }); }); + + 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}, + {fn: 'isManualRequest', match: CONST.IOU.REQUEST_TYPE.MANUAL, other: CONST.IOU.REQUEST_TYPE.SCAN}, + ] 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); + }); + }); + }); }); From 12c3d5bd29678497f5b70ee370118d359b2be0b6 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Sun, 19 Apr 2026 18:17:42 +0300 Subject: [PATCH 02/26] fix: remove remaining Partial caller in TrackExpense and tighten isScanRequest type --- src/libs/TransactionUtils/index.ts | 3 +-- src/libs/actions/IOU/TrackExpense.ts | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index 942d875ecef3..02e908dd0ee2 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -167,7 +167,7 @@ function isOdometerDistanceRequest(transaction: OnyxEntry): boolean return transaction?.iouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE_ODOMETER; } -function isScanRequest(transaction: OnyxEntry | Partial): boolean { +function isScanRequest(transaction: OnyxEntry): boolean { return transaction?.iouRequestType === CONST.IOU.REQUEST_TYPE.SCAN; } @@ -426,7 +426,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/TrackExpense.ts b/src/libs/actions/IOU/TrackExpense.ts index 91e0a456c87b..ba3f7074bbef 100644 --- a/src/libs/actions/IOU/TrackExpense.ts +++ b/src/libs/actions/IOU/TrackExpense.ts @@ -996,7 +996,7 @@ function getTrackExpenseInformation(params: GetTrackExpenseInformationParams): T } else { iouReport = getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${chatReport.iouReportID}`] ?? null; } - const isScanRequest = isScanRequestTransactionUtils({amount, receipt}); + const isScanRequest = existingTransaction ? isScanRequestTransactionUtils(existingTransaction) : !!receipt?.source && amount === 0; shouldCreateNewMoneyRequestReport = shouldCreateNewMoneyRequestReportReportUtils(iouReport, chatReport, isScanRequest, betas); if (!iouReport || shouldCreateNewMoneyRequestReport) { const reportTransactions = buildMinimalTransactionForFormula(optimisticTransactionID, optimisticExpenseReportID, created, amount, currency, merchant); From 8fb5b4d26b784c15cc59578893dfc0884269a270 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Sun, 19 Apr 2026 18:49:52 +0300 Subject: [PATCH 03/26] test: update tests to set iouRequestType after removing structural fallback --- tests/actions/IOUTest/DuplicateTest.ts | 8 +++++--- tests/actions/IOUTest/SplitTest.ts | 12 ++++++++++++ tests/actions/IOUTest/UpdateMoneyRequestTest.ts | 1 + tests/ui/MoneyRequestViewTest.tsx | 2 +- tests/unit/canEditFieldOfMoneyRequestTest.ts | 1 + 5 files changed, 20 insertions(+), 4 deletions(-) diff --git a/tests/actions/IOUTest/DuplicateTest.ts b/tests/actions/IOUTest/DuplicateTest.ts index bf884052aabd..68547fee65ea 100644 --- a/tests/actions/IOUTest/DuplicateTest.ts +++ b/tests/actions/IOUTest/DuplicateTest.ts @@ -12,7 +12,7 @@ import Navigation from '@libs/Navigation/Navigation'; import {getLoginsByAccountIDs} from '@libs/PersonalDetailsUtils'; import {getOriginalMessage, getReportAction} from '@libs/ReportActionsUtils'; import {buildOptimisticIOUReport, buildOptimisticIOUReportAction, buildTransactionThread} from '@libs/ReportUtils'; -import {buildOptimisticTransaction, isTimeRequest} from '@libs/TransactionUtils'; +import {buildOptimisticTransaction} from '@libs/TransactionUtils'; import CONST from '@src/CONST'; import IntlStore from '@src/languages/IntlStore'; import OnyxUpdateManager from '@src/libs/actions/OnyxUpdateManager'; @@ -1186,6 +1186,7 @@ describe('actions/Duplicate', () => { ...mockTransaction, transactionID, amount: AMOUNT_CENTS, + iouRequestType: CONST.IOU.REQUEST_TYPE.TIME, comment: { type: 'time' as const, units: { @@ -1239,7 +1240,6 @@ describe('actions/Duplicate', () => { expect(duplicatedTransaction?.comment?.units?.rate).toEqual(HOURLY_RATE); expect(duplicatedTransaction?.comment?.units?.unit).toBe('h'); expect(duplicatedTransaction?.comment?.type).toBe('time'); - expect(isTimeRequest(duplicatedTransaction)).toBeTruthy(); }); it('should create a duplicate expense successfully (previously with transaction drafts)', async () => { @@ -1356,6 +1356,7 @@ describe('actions/Duplicate', () => { ...mockTransaction, transactionID, amount: AMOUNT_CENTS, + iouRequestType: CONST.IOU.REQUEST_TYPE.TIME, comment: { type: 'time' as const, units: { @@ -1408,7 +1409,6 @@ describe('actions/Duplicate', () => { expect(duplicatedTransaction?.comment?.units?.rate).toEqual(HOURLY_RATE); expect(duplicatedTransaction?.comment?.units?.unit).toBe('h'); expect(duplicatedTransaction?.comment?.type).toBe('time'); - expect(isTimeRequest(duplicatedTransaction)).toBeTruthy(); }); it('should return early when transaction is undefined', async () => { @@ -1486,6 +1486,7 @@ describe('actions/Duplicate', () => { const mockDistanceTransaction = { ...mockTransaction, amount: mockTransaction.amount * -1, + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, comment: { type: 'customUnit' as const, customUnit: { @@ -1528,6 +1529,7 @@ describe('actions/Duplicate', () => { const mockPerDiemTransaction = { ...mockTransaction, amount: mockTransaction.amount * -1, + iouRequestType: CONST.IOU.REQUEST_TYPE.PER_DIEM, comment: { type: 'customUnit' as const, customUnit: { diff --git a/tests/actions/IOUTest/SplitTest.ts b/tests/actions/IOUTest/SplitTest.ts index 107c8dc19188..c21f259dc5b1 100644 --- a/tests/actions/IOUTest/SplitTest.ts +++ b/tests/actions/IOUTest/SplitTest.ts @@ -4512,6 +4512,7 @@ describe('initSplitExpense', () => { amount: -20000, currency: 'USD', merchant: '', + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, comment: { comment: 'Distance expense', splitExpenses: [], @@ -4725,6 +4726,7 @@ describe('addSplitExpenseField', () => { amount: -20000, currency: 'USD', merchant: '', + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, comment: { comment: 'Distance expense', splitExpenses: [], @@ -4748,6 +4750,7 @@ describe('addSplitExpenseField', () => { amount: 20000, currency: 'USD', merchant: '', + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, comment: { comment: 'Distance expense', splitExpenses: [ @@ -5071,6 +5074,7 @@ describe('evenlyDistributeSplitExpenseAmounts', () => { transactionID: originalTransactionID, amount: -20000, currency: 'USD', + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, comment: { type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, customUnit: { @@ -5089,6 +5093,7 @@ describe('evenlyDistributeSplitExpenseAmounts', () => { amount: 20000, currency: 'USD', merchant: 'Test Merchant', + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, comment: { comment: 'Test comment', originalTransactionID, @@ -5221,6 +5226,7 @@ describe('updateSplitExpenseAmountField', () => { transactionID: originalTransactionID, amount: -20000, currency: 'USD', + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, comment: { type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, customUnit: { @@ -5239,6 +5245,7 @@ describe('updateSplitExpenseAmountField', () => { amount: 20000, currency: 'USD', merchant: 'Test Merchant', + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, comment: { comment: 'Test comment', originalTransactionID, @@ -5538,6 +5545,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, @@ -5562,6 +5570,7 @@ describe('initDraftSplitExpenseDataForEdit', () => { amount: 20000, currency: 'USD', merchant: 'Draft Merchant', + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, comment: { comment: 'Draft comment', originalTransactionID, @@ -5709,6 +5718,7 @@ describe('resetSplitExpensesByDateRange', () => { amount: -20000, currency: 'USD', merchant: '', + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, comment: { comment: 'Distance expense', splitExpenses: [], @@ -5990,6 +6000,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, @@ -6014,6 +6025,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/UpdateMoneyRequestTest.ts b/tests/actions/IOUTest/UpdateMoneyRequestTest.ts index 223d516151ad..9350091663fa 100644 --- a/tests/actions/IOUTest/UpdateMoneyRequestTest.ts +++ b/tests/actions/IOUTest/UpdateMoneyRequestTest.ts @@ -219,6 +219,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/ui/MoneyRequestViewTest.tsx b/tests/ui/MoneyRequestViewTest.tsx index 74c06d8cb8e7..2edbd7f10c84 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/canEditFieldOfMoneyRequestTest.ts b/tests/unit/canEditFieldOfMoneyRequestTest.ts index f16dacfea154..540bdac6e381 100644 --- a/tests/unit/canEditFieldOfMoneyRequestTest.ts +++ b/tests/unit/canEditFieldOfMoneyRequestTest.ts @@ -546,6 +546,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: { From 3688075a476138bfc3963eae6d0ee262125ef099 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Sun, 19 Apr 2026 19:01:39 +0300 Subject: [PATCH 04/26] test: set iouRequestType on ViolationUtilsTest tax-violation fixtures --- tests/unit/ViolationUtilsTest.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/unit/ViolationUtilsTest.ts b/tests/unit/ViolationUtilsTest.ts index 12cdc011c1cc..7e6b67bb28d5 100644 --- a/tests/unit/ViolationUtilsTest.ts +++ b/tests/unit/ViolationUtilsTest.ts @@ -1195,6 +1195,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); @@ -1203,6 +1204,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); @@ -1211,6 +1213,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); From bd93a70ffe7ea786de41d2cc37abe27fee86c8a9 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Sun, 19 Apr 2026 20:16:00 +0300 Subject: [PATCH 05/26] fix: set iouRequestType on optimistic split bill scan transactions --- src/libs/actions/IOU/Split.ts | 2 ++ src/types/onyx/Transaction.ts | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/libs/actions/IOU/Split.ts b/src/libs/actions/IOU/Split.ts index 7a768016f79a..449a987df733 100644 --- a/src/libs/actions/IOU/Split.ts +++ b/src/libs/actions/IOU/Split.ts @@ -417,6 +417,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/types/onyx/Transaction.ts b/src/types/onyx/Transaction.ts index 33d7d3e3a7e4..e66e06c73160 100644 --- a/src/types/onyx/Transaction.ts +++ b/src/types/onyx/Transaction.ts @@ -508,7 +508,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; - /** The request type of the transaction (e.g. manual, scan, distance). Set during creation and returned by the server. */ + /** The transaction's request type (e.g. manual, scan, distance). */ iouRequestType?: IOURequestType; /** The original merchant name */ From f3a766396c753f908fb0d650b2e44046d872bca3 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Sun, 19 Apr 2026 20:20:57 +0300 Subject: [PATCH 06/26] test: verify startSplitBill sets iouRequestType on optimistic split transaction --- tests/actions/IOUTest.ts | 68 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/tests/actions/IOUTest.ts b/tests/actions/IOUTest.ts index 841660f39803..c5ce9dc90b2e 100644 --- a/tests/actions/IOUTest.ts +++ b/tests/actions/IOUTest.ts @@ -4928,6 +4928,74 @@ describe('actions/IOU', () => { expect(newPolicyRecentlyUsedTags[tagName].length).toBe(2); expect(newPolicyRecentlyUsedTags[tagName].at(0)).toBe(transactionTag); }); + + it('should set iouRequestType to SCAN on the optimistic transaction when a receipt is provided', async () => { + const participants: IOUParticipant[] = [{accountID: RORY_ACCOUNT_ID}]; + const participantsPolicyTags = await getParticipantsPolicyTags(participants); + + const {splitTransactionID} = startSplitBill({ + participants, + currentUserLogin: currentUserPersonalDetails.login ?? '', + currentUserAccountID: currentUserPersonalDetails.accountID, + comment: '', + receipt: {source: 'receipt.jpg'}, + category: undefined, + tag: '', + currency: CONST.CURRENCY.USD, + taxCode: '', + taxAmount: 0, + quickAction: {}, + policyRecentlyUsedCurrencies: [], + participantsPolicyTags, + }); + await waitForBatchedUpdates(); + + const splitTransaction = await new Promise>((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${splitTransactionID}`, + callback: (transaction) => { + Onyx.disconnect(connection); + resolve(transaction); + }, + }); + }); + + expect(splitTransaction?.iouRequestType).toBe(CONST.IOU.REQUEST_TYPE.SCAN); + }); + + it('should set iouRequestType to MANUAL on the optimistic transaction when no receipt is provided', async () => { + const participants: IOUParticipant[] = [{accountID: RORY_ACCOUNT_ID}]; + const participantsPolicyTags = await getParticipantsPolicyTags(participants); + + const {splitTransactionID} = startSplitBill({ + participants, + currentUserLogin: currentUserPersonalDetails.login ?? '', + currentUserAccountID: currentUserPersonalDetails.accountID, + comment: '', + receipt: {}, + category: undefined, + tag: '', + currency: CONST.CURRENCY.USD, + taxCode: '', + taxAmount: 0, + quickAction: {}, + policyRecentlyUsedCurrencies: [], + participantsPolicyTags, + }); + await waitForBatchedUpdates(); + + const splitTransaction = await new Promise>((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${splitTransactionID}`, + callback: (transaction) => { + Onyx.disconnect(connection); + resolve(transaction); + }, + }); + }); + + expect(splitTransaction?.iouRequestType).toBe(CONST.IOU.REQUEST_TYPE.MANUAL); + }); }); describe('updateSplitTransactionsFromSplitExpensesFlow', () => { From 6f813639145580f0d6bec33f1494093cbf03bca1 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Sun, 19 Apr 2026 20:25:40 +0300 Subject: [PATCH 07/26] test: remove redundant startSplitBill iouRequestType tests --- tests/actions/IOUTest.ts | 68 ---------------------------------------- 1 file changed, 68 deletions(-) diff --git a/tests/actions/IOUTest.ts b/tests/actions/IOUTest.ts index c5ce9dc90b2e..841660f39803 100644 --- a/tests/actions/IOUTest.ts +++ b/tests/actions/IOUTest.ts @@ -4928,74 +4928,6 @@ describe('actions/IOU', () => { expect(newPolicyRecentlyUsedTags[tagName].length).toBe(2); expect(newPolicyRecentlyUsedTags[tagName].at(0)).toBe(transactionTag); }); - - it('should set iouRequestType to SCAN on the optimistic transaction when a receipt is provided', async () => { - const participants: IOUParticipant[] = [{accountID: RORY_ACCOUNT_ID}]; - const participantsPolicyTags = await getParticipantsPolicyTags(participants); - - const {splitTransactionID} = startSplitBill({ - participants, - currentUserLogin: currentUserPersonalDetails.login ?? '', - currentUserAccountID: currentUserPersonalDetails.accountID, - comment: '', - receipt: {source: 'receipt.jpg'}, - category: undefined, - tag: '', - currency: CONST.CURRENCY.USD, - taxCode: '', - taxAmount: 0, - quickAction: {}, - policyRecentlyUsedCurrencies: [], - participantsPolicyTags, - }); - await waitForBatchedUpdates(); - - const splitTransaction = await new Promise>((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.TRANSACTION}${splitTransactionID}`, - callback: (transaction) => { - Onyx.disconnect(connection); - resolve(transaction); - }, - }); - }); - - expect(splitTransaction?.iouRequestType).toBe(CONST.IOU.REQUEST_TYPE.SCAN); - }); - - it('should set iouRequestType to MANUAL on the optimistic transaction when no receipt is provided', async () => { - const participants: IOUParticipant[] = [{accountID: RORY_ACCOUNT_ID}]; - const participantsPolicyTags = await getParticipantsPolicyTags(participants); - - const {splitTransactionID} = startSplitBill({ - participants, - currentUserLogin: currentUserPersonalDetails.login ?? '', - currentUserAccountID: currentUserPersonalDetails.accountID, - comment: '', - receipt: {}, - category: undefined, - tag: '', - currency: CONST.CURRENCY.USD, - taxCode: '', - taxAmount: 0, - quickAction: {}, - policyRecentlyUsedCurrencies: [], - participantsPolicyTags, - }); - await waitForBatchedUpdates(); - - const splitTransaction = await new Promise>((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.TRANSACTION}${splitTransactionID}`, - callback: (transaction) => { - Onyx.disconnect(connection); - resolve(transaction); - }, - }); - }); - - expect(splitTransaction?.iouRequestType).toBe(CONST.IOU.REQUEST_TYPE.MANUAL); - }); }); describe('updateSplitTransactionsFromSplitExpensesFlow', () => { From 18931b35fe198593fb403f2cfc5907eab36c790b Mon Sep 17 00:00:00 2001 From: TaduJR Date: Sun, 19 Apr 2026 20:44:58 +0300 Subject: [PATCH 08/26] fix: propagate iouRequestType in merge transaction preview --- src/libs/MergeTransactionUtils.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/libs/MergeTransactionUtils.ts b/src/libs/MergeTransactionUtils.ts index 487146dba69d..cfd876ba4f36 100644 --- a/src/libs/MergeTransactionUtils.ts +++ b/src/libs/MergeTransactionUtils.ts @@ -390,6 +390,7 @@ function buildMergedTransactionData(targetTransaction: OnyxEntry, m taxAmount: mergeTransaction.taxAmount, taxCode: mergeTransaction.taxCode, taxName: mergeTransaction.taxName, + ...(mergeTransaction.iouRequestType && {iouRequestType: mergeTransaction.iouRequestType}), }; } From 44ce9d1f32b9b7b34d717453e5aabb2ffb3cc121 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Mon, 20 Apr 2026 11:13:15 +0300 Subject: [PATCH 09/26] test: cover iouRequestType propagation in buildMergedTransactionData --- tests/unit/MergeTransactionUtilsTest.ts | 27 +++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/unit/MergeTransactionUtilsTest.ts b/tests/unit/MergeTransactionUtilsTest.ts index 119b011d0da1..360779dc24a1 100644 --- a/tests/unit/MergeTransactionUtilsTest.ts +++ b/tests/unit/MergeTransactionUtilsTest.ts @@ -668,6 +668,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', () => { From d67f364ca7bd2419971b33c145678bb1720959cf Mon Sep 17 00:00:00 2001 From: TaduJR Date: Tue, 28 Apr 2026 21:21:16 +0300 Subject: [PATCH 10/26] fix: resolve leftover merge conflict markers and drop unused isManualRequest --- src/components/ReportActionItem/MoneyRequestView.tsx | 4 ---- src/libs/TransactionUtils/index.ts | 7 ------- tests/unit/TransactionUtilsTest.ts | 1 - 3 files changed, 12 deletions(-) diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index 17dad3921153..d5b5e623258f 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -177,11 +177,7 @@ function MoneyRequestView({ const StyleUtils = useStyleUtils(); const {isOffline} = useNetwork(); const {environmentURL} = useEnvironment(); -<<<<<<< HEAD - const {translate, toLocaleDigit} = useLocalize(); -======= const {translate, toLocaleDigit, localeCompare} = useLocalize(); ->>>>>>> abbaa5ed74bb4425712fb098dd8374bf18e9da57 const {convertToDisplayString, getCurrencySymbol} = useCurrencyListActions(); const {getReportRHPActiveRoute} = useActiveRoute(); const {showConfirmModal} = useConfirmModal(); diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index 73cd0f610906..daa8084f2098 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -252,13 +252,6 @@ function getExpenseTypeTranslationKey(expenseType: ValueOf>>>>>> abbaa5ed74bb4425712fb098dd8374bf18e9da57 function isPartialTransaction(transaction: OnyxEntry): boolean { const merchant = getMerchant(transaction); diff --git a/tests/unit/TransactionUtilsTest.ts b/tests/unit/TransactionUtilsTest.ts index b1b7b9bf58fe..07a7a3a7fcc9 100644 --- a/tests/unit/TransactionUtilsTest.ts +++ b/tests/unit/TransactionUtilsTest.ts @@ -2972,7 +2972,6 @@ describe('TransactionUtils', () => { {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}, - {fn: 'isManualRequest', match: CONST.IOU.REQUEST_TYPE.MANUAL, other: CONST.IOU.REQUEST_TYPE.SCAN}, ] as const)('$fn', ({fn, match, other}) => { describe(`when iouRequestType matches`, () => { it('returns true', () => { From 40d4fc6a2c675c9ff24c35a5de74145124258c33 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Sat, 9 May 2026 15:40:57 +0300 Subject: [PATCH 11/26] fix: propagate iouRequestType to optimistic transactions for all request types --- src/libs/actions/IOU/Duplicate.ts | 2 ++ src/libs/actions/IOU/MoneyRequestBuilder.ts | 3 ++- src/libs/actions/IOU/TrackExpense.ts | 8 +++++--- tests/actions/IOUTest/DuplicateTest.ts | 4 +++- 4 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/libs/actions/IOU/Duplicate.ts b/src/libs/actions/IOU/Duplicate.ts index 1c1fc822a78f..2614da3850b5 100644 --- a/src/libs/actions/IOU/Duplicate.ts +++ b/src/libs/actions/IOU/Duplicate.ts @@ -786,6 +786,7 @@ function duplicateExpenseTransaction({ policyRecentlyUsedCurrencies, quickAction, existingTransactionDraft, + existingTransaction: transaction, draftTransactionIDs, isSelfTourViewed, betas, @@ -979,6 +980,7 @@ function duplicateReport({ quickAction, policyRecentlyUsedCurrencies, existingTransactionDraft: undefined, + existingTransaction: transaction, draftTransactionIDs, isSelfTourViewed, betas, diff --git a/src/libs/actions/IOU/MoneyRequestBuilder.ts b/src/libs/actions/IOU/MoneyRequestBuilder.ts index 85cd5ff74478..df75d5999a86 100644 --- a/src/libs/actions/IOU/MoneyRequestBuilder.ts +++ b/src/libs/actions/IOU/MoneyRequestBuilder.ts @@ -166,6 +166,7 @@ type RequestMoneyInformation = { quickAction: OnyxEntry; policyRecentlyUsedCurrencies: string[]; existingTransactionDraft: OnyxEntry; + existingTransaction?: OnyxEntry; draftTransactionIDs: string[] | undefined; isSelfTourViewed: boolean; betas: OnyxEntry; @@ -1221,7 +1222,7 @@ function getMoneyRequestInformation(moneyRequestInformation: MoneyRequestInforma iouReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${chatReport.iouReportID}`] ?? null; } - const isScanRequest = existingTransaction ? isScanRequestTransactionUtils(existingTransaction) : !!receipt?.source && amount === 0; + const isScanRequest = isScanRequestTransactionUtils(existingTransaction); const shouldCreateNewMoneyRequestReport = isSplitExpense ? false diff --git a/src/libs/actions/IOU/TrackExpense.ts b/src/libs/actions/IOU/TrackExpense.ts index 40b53e1a7661..df875096e39b 100644 --- a/src/libs/actions/IOU/TrackExpense.ts +++ b/src/libs/actions/IOU/TrackExpense.ts @@ -992,7 +992,7 @@ function getTrackExpenseInformation(params: GetTrackExpenseInformationParams): T } else { iouReport = getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${chatReport.iouReportID}`] ?? null; } - const isScanRequest = existingTransaction ? isScanRequestTransactionUtils(existingTransaction) : !!receipt?.source && amount === 0; + const isScanRequest = isScanRequestTransactionUtils(existingTransaction); shouldCreateNewMoneyRequestReport = shouldCreateNewMoneyRequestReportReportUtils(iouReport, chatReport, isScanRequest, betas); if (!iouReport || shouldCreateNewMoneyRequestReport) { const reportTransactions = buildMinimalTransactionForFormula(optimisticTransactionID, optimisticExpenseReportID, created, amount, currency, merchant); @@ -1568,6 +1568,7 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouRep quickAction, policyRecentlyUsedCurrencies, existingTransactionDraft, + existingTransaction: explicitExistingTransaction, draftTransactionIDs = [], isSelfTourViewed, betas, @@ -1616,7 +1617,8 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouRep const moneyRequestReportID = isMoneyRequestReport ? report?.reportID : ''; const isMovingTransactionFromTrackExpense = isMovingTransactionFromTrackExpenseIOUUtils(action); const existingTransactionID = existingTransactionDraft?.transactionID; - const existingTransaction = action === CONST.IOU.ACTION.SUBMIT ? existingTransactionDraft : getAllTransactions()[`${ONYXKEYS.COLLECTION.TRANSACTION}${existingTransactionID}`]; + const existingTransaction = + explicitExistingTransaction ?? (action === CONST.IOU.ACTION.SUBMIT ? existingTransactionDraft : getAllTransactions()[`${ONYXKEYS.COLLECTION.TRANSACTION}${existingTransactionID}`]); const retryParams = { ...requestMoneyInformation, @@ -1659,7 +1661,7 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouRep transactionParams, moneyRequestReportID, existingTransactionID, - existingTransaction: isDistanceRequestTransactionUtils(existingTransaction) ? existingTransaction : undefined, + existingTransaction, retryParams, testDriveCommentReportActionID, optimisticChatReportID, diff --git a/tests/actions/IOUTest/DuplicateTest.ts b/tests/actions/IOUTest/DuplicateTest.ts index 5de8e6371e0d..270f3355e666 100644 --- a/tests/actions/IOUTest/DuplicateTest.ts +++ b/tests/actions/IOUTest/DuplicateTest.ts @@ -13,7 +13,7 @@ import Navigation from '@libs/Navigation/Navigation'; import {getLoginsByAccountIDs} from '@libs/PersonalDetailsUtils'; import {getOriginalMessage, getReportAction} from '@libs/ReportActionsUtils'; import {buildOptimisticIOUReport, buildOptimisticIOUReportAction, buildTransactionThread} from '@libs/ReportUtils'; -import {buildOptimisticTransaction} from '@libs/TransactionUtils'; +import {buildOptimisticTransaction, isTimeRequest} from '@libs/TransactionUtils'; import CONST from '@src/CONST'; import IntlStore from '@src/languages/IntlStore'; import OnyxUpdateManager from '@src/libs/actions/OnyxUpdateManager'; @@ -1254,6 +1254,7 @@ describe('actions/Duplicate', () => { expect(duplicatedTransaction?.comment?.units?.rate).toEqual(HOURLY_RATE); expect(duplicatedTransaction?.comment?.units?.unit).toBe('h'); expect(duplicatedTransaction?.comment?.type).toBe('time'); + expect(isTimeRequest(duplicatedTransaction)).toBeTruthy(); }); it('should create a duplicate expense successfully (previously with transaction drafts)', async () => { @@ -1429,6 +1430,7 @@ describe('actions/Duplicate', () => { expect(duplicatedTransaction?.comment?.units?.rate).toEqual(HOURLY_RATE); expect(duplicatedTransaction?.comment?.units?.unit).toBe('h'); expect(duplicatedTransaction?.comment?.type).toBe('time'); + expect(isTimeRequest(duplicatedTransaction)).toBeTruthy(); }); it('should return early when transaction is undefined', async () => { From 6af434722c1d178fe50b6c5bd2cfffdfbd0198ab Mon Sep 17 00:00:00 2001 From: TaduJR Date: Sat, 9 May 2026 16:04:51 +0300 Subject: [PATCH 12/26] fix: pass existingTransaction through createTransaction; use minimal shim for duplicate --- src/libs/actions/IOU/Duplicate.ts | 16 ++++++++++++++-- src/libs/actions/IOU/MoneyRequest.ts | 2 ++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/libs/actions/IOU/Duplicate.ts b/src/libs/actions/IOU/Duplicate.ts index 2614da3850b5..818094fc7eb4 100644 --- a/src/libs/actions/IOU/Duplicate.ts +++ b/src/libs/actions/IOU/Duplicate.ts @@ -786,7 +786,13 @@ function duplicateExpenseTransaction({ policyRecentlyUsedCurrencies, quickAction, existingTransactionDraft, - existingTransaction: transaction, + existingTransaction: { + ...(transactionParams ?? {}), + iouRequestType: getRequestType(transaction), + modifiedCreated: '', + reportID: '1', + transactionID: '1', + }, draftTransactionIDs, isSelfTourViewed, betas, @@ -980,7 +986,13 @@ function duplicateReport({ quickAction, policyRecentlyUsedCurrencies, existingTransactionDraft: undefined, - existingTransaction: transaction, + existingTransaction: { + ...(transactionParams ?? {}), + iouRequestType: getRequestType(transaction), + modifiedCreated: '', + reportID: '1', + transactionID: '1', + }, draftTransactionIDs, isSelfTourViewed, betas, diff --git a/src/libs/actions/IOU/MoneyRequest.ts b/src/libs/actions/IOU/MoneyRequest.ts index dec086747979..1e4a656c054c 100644 --- a/src/libs/actions/IOU/MoneyRequest.ts +++ b/src/libs/actions/IOU/MoneyRequest.ts @@ -233,6 +233,7 @@ function createTransaction({ taxCode, taxAmount, }, + existingTransaction: transaction, ...(policyParams ?? {}), shouldHandleNavigation: index === files.length - 1, isASAPSubmitBetaEnabled, @@ -281,6 +282,7 @@ function createTransaction({ quickAction, policyRecentlyUsedCurrencies: policyRecentlyUsedCurrencies ?? [], existingTransactionDraft, + existingTransaction: transaction, draftTransactionIDs, isSelfTourViewed, personalDetails, From af222bee6556e88482664cbb3c46a5e6a4599ef3 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Sat, 9 May 2026 16:24:17 +0300 Subject: [PATCH 13/26] fix: pass existingTransaction in remaining scan submission paths --- src/pages/Share/SubmitDetailsPage.tsx | 2 ++ .../step/confirmation/useExpenseSubmission.ts | 2 ++ tests/actions/IOU/MoneyRequestTest.ts | 28 +++++++++++++++++++ 3 files changed, 32 insertions(+) diff --git a/src/pages/Share/SubmitDetailsPage.tsx b/src/pages/Share/SubmitDetailsPage.tsx index 3f78edd79db4..c53f271b88dc 100644 --- a/src/pages/Share/SubmitDetailsPage.tsx +++ b/src/pages/Share/SubmitDetailsPage.tsx @@ -243,6 +243,7 @@ function SubmitDetailsPage({ isLinkedTrackedExpenseReportArchived, gpsPoint, }, + existingTransaction: transaction, isASAPSubmitBetaEnabled, currentUserAccountIDParam: currentUserPersonalDetails.accountID, currentUserEmailParam: currentUserPersonalDetails.login ?? '', @@ -291,6 +292,7 @@ function SubmitDetailsPage({ policyRecentlyUsedCurrencies: policyRecentlyUsedCurrencies ?? [], quickAction, existingTransactionDraft, + existingTransaction: transaction, draftTransactionIDs, isSelfTourViewed, betas, diff --git a/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts b/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts index 370c2df32e02..c40bdf27b8cc 100644 --- a/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts +++ b/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts @@ -359,6 +359,7 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { policyRecentlyUsedCurrencies, quickAction, existingTransactionDraft, + existingTransaction: item, draftTransactionIDs, isSelfTourViewed, betas, @@ -523,6 +524,7 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { accountantParams: { accountant: item.accountant, }, + existingTransaction: item, shouldHandleNavigation: shouldHandleNav && index === transactions.length - 1, isASAPSubmitBetaEnabled, currentUserAccountIDParam: currentUserPersonalDetails.accountID, diff --git a/tests/actions/IOU/MoneyRequestTest.ts b/tests/actions/IOU/MoneyRequestTest.ts index 2af279762b92..b9f5a25d2015 100644 --- a/tests/actions/IOU/MoneyRequestTest.ts +++ b/tests/actions/IOU/MoneyRequestTest.ts @@ -350,6 +350,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); From b477c85c42d513092439e667b1e3490de5875053 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Sat, 9 May 2026 16:39:37 +0300 Subject: [PATCH 14/26] fix: avoid Transaction.comment type mismatch in duplicate shims --- src/libs/actions/IOU/Duplicate.ts | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/src/libs/actions/IOU/Duplicate.ts b/src/libs/actions/IOU/Duplicate.ts index 818094fc7eb4..da613ecd096b 100644 --- a/src/libs/actions/IOU/Duplicate.ts +++ b/src/libs/actions/IOU/Duplicate.ts @@ -45,6 +45,7 @@ import type * as OnyxTypes from '@src/types/onyx'; import type {Attendee, Participant} from '@src/types/onyx/IOU'; import type {CurrentUserPersonalDetails} from '@src/types/onyx/PersonalDetails'; import type {WaypointCollection} from '@src/types/onyx/Transaction'; +import type {IOURequestType} from '.'; import {getAllReportActionsFromIOU, getAllReports, getAllTransactions, getAllTransactionViolations, getMoneyRequestParticipantsFromReport} from '.'; import {getCleanUpTransactionThreadReportOnyxData} from './DeleteMoneyRequest'; import type {RequestMoneyInformation} from './MoneyRequestBuilder'; @@ -613,6 +614,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): IOURequestType { + 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. @@ -787,8 +797,11 @@ function duplicateExpenseTransaction({ quickAction, existingTransactionDraft, existingTransaction: { - ...(transactionParams ?? {}), - iouRequestType: getRequestType(transaction), + iouRequestType: getDuplicateRequestType(transaction), + amount: 0, + currency: '', + created: '', + merchant: '', modifiedCreated: '', reportID: '1', transactionID: '1', @@ -987,8 +1000,11 @@ function duplicateReport({ policyRecentlyUsedCurrencies, existingTransactionDraft: undefined, existingTransaction: { - ...(transactionParams ?? {}), - iouRequestType: getRequestType(transaction), + iouRequestType: getDuplicateRequestType(transaction), + amount: 0, + currency: '', + created: '', + merchant: '', modifiedCreated: '', reportID: '1', transactionID: '1', From 6f41c46ce52ad256509482c3da7db2fbb02b1d1b Mon Sep 17 00:00:00 2001 From: TaduJR Date: Sat, 9 May 2026 17:48:33 +0300 Subject: [PATCH 15/26] fix: scrub existingTransaction from retry payloads to avoid bloating receipt errors --- src/libs/actions/IOU/TrackExpense.ts | 2 + tests/actions/IOUTest/TrackExpenseTest.ts | 130 +++++++++++++++++++++- tests/actions/MergeTransactionTest.ts | 68 +++++++++++ 3 files changed, 199 insertions(+), 1 deletion(-) diff --git a/src/libs/actions/IOU/TrackExpense.ts b/src/libs/actions/IOU/TrackExpense.ts index df875096e39b..6e1fe6be3cc8 100644 --- a/src/libs/actions/IOU/TrackExpense.ts +++ b/src/libs/actions/IOU/TrackExpense.ts @@ -1630,6 +1630,7 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouRep ...requestMoneyInformation.transactionParams, receipt: undefined, }, + existingTransaction: undefined, }; const { @@ -2359,6 +2360,7 @@ function trackExpense(params: CreateTrackExpenseParams) { }, quickAction, isSelfTourViewed, + existingTransaction: undefined, }; const { diff --git a/tests/actions/IOUTest/TrackExpenseTest.ts b/tests/actions/IOUTest/TrackExpenseTest.ts index 9bdd270f4224..de7f1b8ae9c3 100644 --- a/tests/actions/IOUTest/TrackExpenseTest.ts +++ b/tests/actions/IOUTest/TrackExpenseTest.ts @@ -2,7 +2,7 @@ import {format} from 'date-fns'; import Onyx from 'react-native-onyx'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; -import {convertBulkTrackedExpensesToIOU, deleteTrackExpense, getDeleteTrackExpenseInformation, getTrackExpenseInformation, trackExpense} from '@libs/actions/IOU/TrackExpense'; +import {convertBulkTrackedExpensesToIOU, deleteTrackExpense, getDeleteTrackExpenseInformation, getTrackExpenseInformation, requestMoney, trackExpense} from '@libs/actions/IOU/TrackExpense'; import initOnyxDerivedValues from '@libs/actions/OnyxDerived'; import {addComment, openReport} from '@libs/actions/Report'; import {subscribeToUserEvents} from '@libs/actions/User'; @@ -25,6 +25,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'; @@ -1391,6 +1392,54 @@ describe('actions/IOU/TrackExpense', () => { mockFetch?.succeed?.(); }); + it('should not include existingTransaction in retryParams persisted on receipt errors', 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 not carry the full existingTransaction + 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 receiptError = Object.values(failedTransaction?.errors ?? {}).find( + (err): err is ReceiptError => typeof err === 'object' && err !== null && 'error' in err && err.error === CONST.IOU.RECEIPT_ERROR, + ); + expect(receiptError).toBeDefined(); + const parsedRetryParams = JSON.parse(receiptError?.retryParams as unknown as string) as Record; + expect(parsedRetryParams.existingTransaction).toBeUndefined(); + + mockFetch?.succeed?.(); + }); + it('should handle category and tag together correctly', async () => { // Given a selfDM report with category and tag const selfDMReport: Report = { @@ -1714,6 +1763,85 @@ describe('actions/IOU/TrackExpense', () => { }); }); + describe('requestMoney', () => { + it('should not include existingTransaction in retryParams persisted on receipt errors', 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 not carry the full existingTransaction + 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 receiptError = Object.values(failedTransaction?.errors ?? {}).find( + (err): err is ReceiptError => typeof err === 'object' && err !== null && 'error' in err && err.error === CONST.IOU.RECEIPT_ERROR, + ); + expect(receiptError).toBeDefined(); + const parsedRetryParams = JSON.parse(receiptError?.retryParams as unknown as string) as Record; + expect(parsedRetryParams.existingTransaction).toBeUndefined(); + + mockFetch?.succeed?.(); + }); + }); + describe('getDeleteTrackExpenseInformation', () => { const amount = 10000; const comment = 'Send me money please'; diff --git a/tests/actions/MergeTransactionTest.ts b/tests/actions/MergeTransactionTest.ts index be35b025ea5f..6bfed3674a86 100644 --- a/tests/actions/MergeTransactionTest.ts +++ b/tests/actions/MergeTransactionTest.ts @@ -234,6 +234,74 @@ 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 targetTransaction: Transaction = { + ...createRandomDistanceRequestTransaction(1), + transactionID: 'target-distance', + reportID: 'target-report', + 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: targetTransaction.reportID, + // 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: targetTransaction.reportID}, + 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, + 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 From 4e80f6b402bf92fbd320db3d9e7bae779940ad2d Mon Sep 17 00:00:00 2001 From: TaduJR Date: Sat, 9 May 2026 18:12:14 +0300 Subject: [PATCH 16/26] fix: preserve iouRequestType in retry payloads via minimal existingTransaction shim --- src/libs/actions/IOU/TrackExpense.ts | 26 +++++++++++++++++++++-- tests/actions/IOUTest/TrackExpenseTest.ts | 21 ++++++++++++------ 2 files changed, 39 insertions(+), 8 deletions(-) diff --git a/src/libs/actions/IOU/TrackExpense.ts b/src/libs/actions/IOU/TrackExpense.ts index 6e1fe6be3cc8..d88a9f659613 100644 --- a/src/libs/actions/IOU/TrackExpense.ts +++ b/src/libs/actions/IOU/TrackExpense.ts @@ -1630,7 +1630,18 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouRep ...requestMoneyInformation.transactionParams, receipt: undefined, }, - existingTransaction: undefined, + existingTransaction: explicitExistingTransaction + ? { + iouRequestType: explicitExistingTransaction.iouRequestType, + amount: 0, + currency: '', + created: '', + merchant: '', + modifiedCreated: '', + reportID: '1', + transactionID: '1', + } + : undefined, }; const { @@ -2360,7 +2371,18 @@ function trackExpense(params: CreateTrackExpenseParams) { }, quickAction, isSelfTourViewed, - existingTransaction: undefined, + existingTransaction: existingTransaction + ? { + iouRequestType: existingTransaction.iouRequestType, + amount: 0, + currency: '', + created: '', + merchant: '', + modifiedCreated: '', + reportID: '1', + transactionID: '1', + } + : undefined, }; const { diff --git a/tests/actions/IOUTest/TrackExpenseTest.ts b/tests/actions/IOUTest/TrackExpenseTest.ts index de7f1b8ae9c3..c99191e2c72e 100644 --- a/tests/actions/IOUTest/TrackExpenseTest.ts +++ b/tests/actions/IOUTest/TrackExpenseTest.ts @@ -1392,7 +1392,7 @@ describe('actions/IOU/TrackExpense', () => { mockFetch?.succeed?.(); }); - it('should not include existingTransaction in retryParams persisted on receipt errors', async () => { + 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), @@ -1418,7 +1418,7 @@ describe('actions/IOU/TrackExpense', () => { await mockFetch?.resume?.(); await waitForBatchedUpdates(); - // Then the retryParams stored on the receipt error must not carry the full existingTransaction + // 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, @@ -1435,7 +1435,12 @@ describe('actions/IOU/TrackExpense', () => { ); expect(receiptError).toBeDefined(); const parsedRetryParams = JSON.parse(receiptError?.retryParams as unknown as string) as Record; - expect(parsedRetryParams.existingTransaction).toBeUndefined(); + const persistedExistingTransaction = parsedRetryParams.existingTransaction as Partial | undefined; + expect(persistedExistingTransaction?.iouRequestType).toBe(CONST.IOU.REQUEST_TYPE.SCAN); + // Source-state fields like comment/receipt/cardNumber must not survive into the persisted retry payload + expect(persistedExistingTransaction?.comment).toBeUndefined(); + expect(persistedExistingTransaction?.receipt).toBeUndefined(); + expect(persistedExistingTransaction?.cardNumber).toBeUndefined(); mockFetch?.succeed?.(); }); @@ -1764,7 +1769,7 @@ describe('actions/IOU/TrackExpense', () => { }); describe('requestMoney', () => { - it('should not include existingTransaction in retryParams persisted on receipt errors', async () => { + 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), @@ -1819,7 +1824,7 @@ describe('actions/IOU/TrackExpense', () => { await mockFetch?.resume?.(); await waitForBatchedUpdates(); - // Then the retryParams stored on the receipt error must not carry the full existingTransaction + // 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, @@ -1836,7 +1841,11 @@ describe('actions/IOU/TrackExpense', () => { ); expect(receiptError).toBeDefined(); const parsedRetryParams = JSON.parse(receiptError?.retryParams as unknown as string) as Record; - expect(parsedRetryParams.existingTransaction).toBeUndefined(); + 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?.(); }); From 270b2f9a121956f0b0ffa1f8d40b7f3bb0a1d638 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Sat, 9 May 2026 18:41:37 +0300 Subject: [PATCH 17/26] test: fix lint and typecheck issues and set iouRequestType on distance fixtures --- tests/actions/IOUTest/TrackExpenseTest.ts | 10 ++++------ tests/actions/MergeTransactionTest.ts | 7 ++++--- tests/unit/TransactionPreviewUtils.test.ts | 2 ++ 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/tests/actions/IOUTest/TrackExpenseTest.ts b/tests/actions/IOUTest/TrackExpenseTest.ts index c99191e2c72e..36d58ed48711 100644 --- a/tests/actions/IOUTest/TrackExpenseTest.ts +++ b/tests/actions/IOUTest/TrackExpenseTest.ts @@ -1430,9 +1430,8 @@ describe('actions/IOU/TrackExpense', () => { const failedTransaction = Object.values(transactions ?? {}).find((t) => !!t?.errors); expect(failedTransaction).toBeDefined(); - const receiptError = Object.values(failedTransaction?.errors ?? {}).find( - (err): err is ReceiptError => typeof err === 'object' && err !== null && 'error' in err && err.error === CONST.IOU.RECEIPT_ERROR, - ); + 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; @@ -1836,9 +1835,8 @@ describe('actions/IOU/TrackExpense', () => { const failedTransaction = Object.values(transactions ?? {}).find((t) => !!t?.errors); expect(failedTransaction).toBeDefined(); - const receiptError = Object.values(failedTransaction?.errors ?? {}).find( - (err): err is ReceiptError => typeof err === 'object' && err !== null && 'error' in err && err.error === CONST.IOU.RECEIPT_ERROR, - ); + 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; diff --git a/tests/actions/MergeTransactionTest.ts b/tests/actions/MergeTransactionTest.ts index 6bfed3674a86..2b031c08f52e 100644 --- a/tests/actions/MergeTransactionTest.ts +++ b/tests/actions/MergeTransactionTest.ts @@ -236,10 +236,11 @@ describe('mergeTransactionRequest', () => { 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: 'target-report', + reportID: targetReportID, iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, }; const sourceExpenseReport = { @@ -255,7 +256,7 @@ describe('mergeTransactionRequest', () => { ...createRandomMergeTransaction(1), targetTransactionID: targetTransaction.transactionID, sourceTransactionID: sourceTransaction.transactionID, - reportID: targetTransaction.reportID, + reportID: targetReportID, // Intentionally no iouRequestType }; const mergeTransactionID = 'merge-distance'; @@ -273,7 +274,7 @@ describe('mergeTransactionRequest', () => { mergeTransaction, targetTransaction, sourceTransaction, - targetTransactionThreadReport: {reportID: targetTransaction.reportID}, + targetTransactionThreadReport: {reportID: targetReportID}, targetTransactionThreadParentReport: undefined, targetTransactionThreadParentReportNextStep: undefined, allTransactionViolations: createAllTransactionViolations(targetTransaction.transactionID, sourceTransaction.transactionID), diff --git a/tests/unit/TransactionPreviewUtils.test.ts b/tests/unit/TransactionPreviewUtils.test.ts index 2172d32f97c9..477f43946cb1 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: { From c1d6235d74f29f9e3b838830c8ce2ab9c64ba393 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Sat, 9 May 2026 19:34:49 +0300 Subject: [PATCH 18/26] refactor: drop redundant IOURequestType import in Duplicate.ts --- src/libs/actions/IOU/Duplicate.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/libs/actions/IOU/Duplicate.ts b/src/libs/actions/IOU/Duplicate.ts index da613ecd096b..83d0e6c2e4d5 100644 --- a/src/libs/actions/IOU/Duplicate.ts +++ b/src/libs/actions/IOU/Duplicate.ts @@ -45,7 +45,6 @@ import type * as OnyxTypes from '@src/types/onyx'; import type {Attendee, Participant} from '@src/types/onyx/IOU'; import type {CurrentUserPersonalDetails} from '@src/types/onyx/PersonalDetails'; import type {WaypointCollection} from '@src/types/onyx/Transaction'; -import type {IOURequestType} from '.'; import {getAllReportActionsFromIOU, getAllReports, getAllTransactions, getAllTransactionViolations, getMoneyRequestParticipantsFromReport} from '.'; import {getCleanUpTransactionThreadReportOnyxData} from './DeleteMoneyRequest'; import type {RequestMoneyInformation} from './MoneyRequestBuilder'; @@ -618,7 +617,7 @@ function buildDuplicateTransactionParams(transaction: OnyxTypes.Transaction, tra * 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): IOURequestType { +function getDuplicateRequestType(transaction: OnyxTypes.Transaction) { const sourceRequestType = getRequestType(transaction); return sourceRequestType === CONST.IOU.REQUEST_TYPE.SCAN ? CONST.IOU.REQUEST_TYPE.MANUAL : sourceRequestType; } From 27fc4279572a9ffef7b2c479c480edc695261d2b Mon Sep 17 00:00:00 2001 From: TaduJR Date: Fri, 22 May 2026 11:17:04 +0300 Subject: [PATCH 19/26] fix: revert stray prettier reformatting in MoneyRequestView from merge --- .../ReportActionItem/MoneyRequestView.tsx | 27 +++---------------- 1 file changed, 3 insertions(+), 24 deletions(-) diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index eab8e70810a1..c4c28281c550 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -367,25 +367,11 @@ function MoneyRequestView({ (shouldShowSplitIndicator && isSplitAvailable)); const canEditMerchant = isEditable && - canEditFieldOfMoneyRequest({ - reportAction: parentReportAction, - fieldToEdit: CONST.EDIT_REQUEST_FIELD.MERCHANT, - isChatReportArchived, - transaction, - report: moneyRequestReport, - policy, - }); + canEditFieldOfMoneyRequest({reportAction: parentReportAction, fieldToEdit: CONST.EDIT_REQUEST_FIELD.MERCHANT, isChatReportArchived, transaction, report: moneyRequestReport, policy}); const canEditDate = isEditable && - canEditFieldOfMoneyRequest({ - reportAction: parentReportAction, - fieldToEdit: CONST.EDIT_REQUEST_FIELD.DATE, - isChatReportArchived, - transaction, - report: moneyRequestReport, - policy, - }); + canEditFieldOfMoneyRequest({reportAction: parentReportAction, fieldToEdit: CONST.EDIT_REQUEST_FIELD.DATE, isChatReportArchived, transaction, report: moneyRequestReport, policy}); const canEditDistanceOrRate = isPolicyAccessible(policy, currentUserEmailParam) || isTrackExpense || isP2PDistanceRequest; @@ -481,14 +467,7 @@ function MoneyRequestView({ let amountDescription = `${translate('iou.amount')}`; let dateDescription = `${translate('common.date')}`; - const { - unit, - rate, - name: rateName, - } = DistanceRequestUtils.getRate({ - transaction: updatedTransaction ?? transaction, - policy: distanceOriginalPolicy ?? policy, - }); + const {unit, rate, name: rateName} = DistanceRequestUtils.getRate({transaction: updatedTransaction ?? transaction, policy: distanceOriginalPolicy ?? policy}); const distance = getDistanceInMeters(transactionBackup ?? updatedTransaction ?? transaction, unit); const currency = transactionCurrency ?? CONST.CURRENCY.USD; const hasRequiredCompanyCardViolation = transactionViolations.some((violation) => violation.name === CONST.VIOLATIONS.COMPANY_CARD_REQUIRED); From 8258c14b9fee810dee137b4070f4bacb71238903 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Fri, 22 May 2026 11:28:51 +0300 Subject: [PATCH 20/26] chore: CI restart From a82c0cd7fb307b8a0c9d0243366002bda684ed6f Mon Sep 17 00:00:00 2001 From: TaduJR Date: Fri, 22 May 2026 12:05:19 +0300 Subject: [PATCH 21/26] test: set iouRequestType on distance fixtures in TransactionTest and TransactionInlineEdit --- tests/unit/TransactionTest.ts | 4 ++++ tests/unit/inlineEditing/TransactionInlineEdit.test.ts | 2 ++ 2 files changed, 6 insertions(+) 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/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}, From e2ee7a37cfbb685bf2adb4f5497791d095b2a305 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Fri, 22 May 2026 12:16:58 +0300 Subject: [PATCH 22/26] fix: use duplicate-safe request type and propagate existingTransaction to distance/amount track flows --- src/libs/actions/IOU/Duplicate.ts | 4 ++-- src/libs/actions/IOU/MoneyRequest.ts | 1 + src/pages/iou/request/step/IOURequestStepAmount.tsx | 1 + tests/actions/IOU/MoneyRequestTest.ts | 1 + 4 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/libs/actions/IOU/Duplicate.ts b/src/libs/actions/IOU/Duplicate.ts index 9bcc394bc6bd..24d4b3818c13 100644 --- a/src/libs/actions/IOU/Duplicate.ts +++ b/src/libs/actions/IOU/Duplicate.ts @@ -671,7 +671,7 @@ function createExpenseByType({ source: undefined, waypoints, }, - iouRequestType: getRequestType(transaction), + iouRequestType: getDuplicateRequestType(transaction), modifiedCreated: '', reportID: '1', transactionID: '1', @@ -830,7 +830,7 @@ function duplicateExpenseTransaction({ source: undefined, waypoints, }, - iouRequestType: getRequestType(transaction), + iouRequestType: getDuplicateRequestType(transaction), modifiedCreated: '', reportID: '1', transactionID: '1', diff --git a/src/libs/actions/IOU/MoneyRequest.ts b/src/libs/actions/IOU/MoneyRequest.ts index 4e32aa27bb23..27ed6ad83305 100644 --- a/src/libs/actions/IOU/MoneyRequest.ts +++ b/src/libs/actions/IOU/MoneyRequest.ts @@ -736,6 +736,7 @@ function handleMoneyRequestStepDistanceNavigation({ taxCode: distanceTaxCode, taxAmount: distanceTaxAmount, }, + existingTransaction: transaction, shouldHandleNavigation: overrides.shouldHandleNavigation, shouldDeferForSearch: overrides.shouldDeferForSearch, isASAPSubmitBetaEnabled, 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/tests/actions/IOU/MoneyRequestTest.ts b/tests/actions/IOU/MoneyRequestTest.ts index bc284f71c024..60e0d591f8e8 100644 --- a/tests/actions/IOU/MoneyRequestTest.ts +++ b/tests/actions/IOU/MoneyRequestTest.ts @@ -1246,6 +1246,7 @@ describe('MoneyRequest', () => { taxCode: '', taxAmount: 0, }, + existingTransaction: fakeTransaction, shouldHandleNavigation: true, shouldDeferForSearch: false, isASAPSubmitBetaEnabled: baseParams.isASAPSubmitBetaEnabled, From a5e69fd11368df97ea8e194d4e388ca211afec87 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Fri, 22 May 2026 12:20:02 +0300 Subject: [PATCH 23/26] test: set iouRequestType on per-diem fixtures in ReportSecondaryActionUtilsTest --- tests/unit/ReportSecondaryActionUtilsTest.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/unit/ReportSecondaryActionUtilsTest.ts b/tests/unit/ReportSecondaryActionUtilsTest.ts index 88000c23ec17..6ba37df9a903 100644 --- a/tests/unit/ReportSecondaryActionUtilsTest.ts +++ b/tests/unit/ReportSecondaryActionUtilsTest.ts @@ -1850,6 +1850,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: { @@ -3045,6 +3046,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: { From 402ae74ee11a24878cb0c0fa409bcd1d57f9b85c Mon Sep 17 00:00:00 2001 From: TaduJR Date: Fri, 22 May 2026 12:26:59 +0300 Subject: [PATCH 24/26] =?UTF-8?q?test:=20cover=20SCAN=E2=86=92MANUAL=20coe?= =?UTF-8?q?rcion=20across=20all=20duplicate=20paths?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/actions/IOUTest/DuplicateTest.ts | 126 +++++++++++++++++++++++++ 1 file changed, 126 insertions(+) diff --git a/tests/actions/IOUTest/DuplicateTest.ts b/tests/actions/IOUTest/DuplicateTest.ts index 5ba2b148eb36..8aa363b127e5 100644 --- a/tests/actions/IOUTest/DuplicateTest.ts +++ b/tests/actions/IOUTest/DuplicateTest.ts @@ -1255,6 +1255,108 @@ 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); + }, + }); + + // buildDuplicateTransactionParams strips the receipt, so a SCAN source must become a MANUAL duplicate + 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); + }, + }); + + // The unreported (no-workspace) duplicate also strips the receipt — the SCAN source must become MANUAL + 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 = { @@ -2076,6 +2178,30 @@ describe('actions/Duplicate', () => { expect(countWriteCommandCalls(WRITE_COMMANDS.CREATE_PER_DIEM_REQUEST)).toBe(1); }); + it('should duplicate a scan expense as a manual expense', async () => { + // A completed scan (receipt state OPEN) is not "scanning", so it is duplicated rather than filtered out + const scanTx = 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([scanTx])); + await waitForBatchedUpdates(); + + let duplicatedTransaction: OnyxEntry; + await getOnyxData({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (allTransactions) => { + duplicatedTransaction = Object.values(allTransactions ?? {}).find((t) => !!t && t.transactionID !== scanTx.transactionID); + }, + }); + + // buildDuplicateTransactionParams strips the receipt, so a SCAN source must become a MANUAL duplicate + 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'); From e75a5249fae876a1adce663d16f2be04490207eb Mon Sep 17 00:00:00 2001 From: TaduJR Date: Fri, 22 May 2026 12:45:09 +0300 Subject: [PATCH 25/26] test: cover SCAN to MANUAL coercion across all duplicate paths --- tests/actions/IOUTest/DuplicateTest.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/actions/IOUTest/DuplicateTest.ts b/tests/actions/IOUTest/DuplicateTest.ts index 8aa363b127e5..504abb49a2fc 100644 --- a/tests/actions/IOUTest/DuplicateTest.ts +++ b/tests/actions/IOUTest/DuplicateTest.ts @@ -2180,12 +2180,12 @@ describe('actions/Duplicate', () => { it('should duplicate a scan expense as a manual expense', async () => { // A completed scan (receipt state OPEN) is not "scanning", so it is duplicated rather than filtered out - const scanTx = createCashTransaction('scan-completed-1', { + 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([scanTx])); + duplicateReport(getDefaultParams([scanExpenseTx])); await waitForBatchedUpdates(); let duplicatedTransaction: OnyxEntry; @@ -2193,7 +2193,7 @@ describe('actions/Duplicate', () => { key: ONYXKEYS.COLLECTION.TRANSACTION, waitForCollectionCallback: true, callback: (allTransactions) => { - duplicatedTransaction = Object.values(allTransactions ?? {}).find((t) => !!t && t.transactionID !== scanTx.transactionID); + duplicatedTransaction = Object.values(allTransactions ?? {}).find((t) => !!t && t.transactionID !== scanExpenseTx.transactionID); }, }); From fb05eca08c3f33ce37964738f53c25e3b7e2ab86 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Mon, 25 May 2026 14:03:33 +0300 Subject: [PATCH 26/26] fix: tighten duplicate/retry existingTransaction shim --- src/libs/actions/IOU/Duplicate.ts | 26 ++++----- src/libs/actions/IOU/TrackExpense.ts | 2 - tests/actions/IOUTest/DuplicateTest.ts | 66 +++++++++++++++++++++-- tests/actions/IOUTest/TrackExpenseTest.ts | 1 - 4 files changed, 76 insertions(+), 19 deletions(-) diff --git a/src/libs/actions/IOU/Duplicate.ts b/src/libs/actions/IOU/Duplicate.ts index 24d4b3818c13..66900621c178 100644 --- a/src/libs/actions/IOU/Duplicate.ts +++ b/src/libs/actions/IOU/Duplicate.ts @@ -663,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, @@ -671,10 +677,6 @@ function createExpenseByType({ source: undefined, waypoints, }, - iouRequestType: getDuplicateRequestType(transaction), - modifiedCreated: '', - reportID: '1', - transactionID: '1', }, transactionParams: { ...(params.transactionParams ?? {}), @@ -802,7 +804,6 @@ function duplicateExpenseTransaction({ currency: '', created: '', merchant: '', - modifiedCreated: '', reportID: '1', transactionID: '1', }, @@ -822,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, @@ -830,10 +837,6 @@ function duplicateExpenseTransaction({ source: undefined, waypoints, }, - iouRequestType: getDuplicateRequestType(transaction), - modifiedCreated: '', - reportID: '1', - transactionID: '1', }, transactionParams: { ...(params.transactionParams ?? {}), @@ -1006,7 +1009,6 @@ function duplicateReport({ currency: '', created: '', merchant: '', - modifiedCreated: '', reportID: '1', transactionID: '1', }, diff --git a/src/libs/actions/IOU/TrackExpense.ts b/src/libs/actions/IOU/TrackExpense.ts index 4f202ff87e27..eba325095365 100644 --- a/src/libs/actions/IOU/TrackExpense.ts +++ b/src/libs/actions/IOU/TrackExpense.ts @@ -1671,7 +1671,6 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouRep currency: '', created: '', merchant: '', - modifiedCreated: '', reportID: '1', transactionID: '1', } @@ -2430,7 +2429,6 @@ function trackExpense(params: CreateTrackExpenseParams) { currency: '', created: '', merchant: '', - modifiedCreated: '', reportID: '1', transactionID: '1', } diff --git a/tests/actions/IOUTest/DuplicateTest.ts b/tests/actions/IOUTest/DuplicateTest.ts index 504abb49a2fc..5e1ed1da8caa 100644 --- a/tests/actions/IOUTest/DuplicateTest.ts +++ b/tests/actions/IOUTest/DuplicateTest.ts @@ -1301,7 +1301,6 @@ describe('actions/Duplicate', () => { }, }); - // buildDuplicateTransactionParams strips the receipt, so a SCAN source must become a MANUAL duplicate expect(duplicatedTransaction?.transactionID).not.toBe(transactionID); expect(duplicatedTransaction?.iouRequestType).toBe(CONST.IOU.REQUEST_TYPE.MANUAL); }); @@ -1352,7 +1351,6 @@ describe('actions/Duplicate', () => { }, }); - // The unreported (no-workspace) duplicate also strips the receipt — the SCAN source must become MANUAL expect(duplicatedTransaction?.transactionID).not.toBe(transactionID); expect(duplicatedTransaction?.iouRequestType).toBe(CONST.IOU.REQUEST_TYPE.MANUAL); }); @@ -1647,6 +1645,68 @@ 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, @@ -2179,7 +2239,6 @@ describe('actions/Duplicate', () => { }); it('should duplicate a scan expense as a manual expense', async () => { - // A completed scan (receipt state OPEN) is not "scanning", so it is duplicated rather than filtered out 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}, @@ -2197,7 +2256,6 @@ describe('actions/Duplicate', () => { }, }); - // buildDuplicateTransactionParams strips the receipt, so a SCAN source must become a MANUAL duplicate expect(duplicatedTransaction).toBeDefined(); expect(duplicatedTransaction?.iouRequestType).toBe(CONST.IOU.REQUEST_TYPE.MANUAL); }); diff --git a/tests/actions/IOUTest/TrackExpenseTest.ts b/tests/actions/IOUTest/TrackExpenseTest.ts index 93c95ffac4cd..70002776a963 100644 --- a/tests/actions/IOUTest/TrackExpenseTest.ts +++ b/tests/actions/IOUTest/TrackExpenseTest.ts @@ -1596,7 +1596,6 @@ describe('actions/IOU/TrackExpense', () => { 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); - // Source-state fields like comment/receipt/cardNumber must not survive into the persisted retry payload expect(persistedExistingTransaction?.comment).toBeUndefined(); expect(persistedExistingTransaction?.receipt).toBeUndefined(); expect(persistedExistingTransaction?.cardNumber).toBeUndefined();