diff --git a/src/libs/actions/IOU/Duplicate.ts b/src/libs/actions/IOU/Duplicate.ts index 3159a5ff65f9..e5a216504a8a 100644 --- a/src/libs/actions/IOU/Duplicate.ts +++ b/src/libs/actions/IOU/Duplicate.ts @@ -34,6 +34,7 @@ import { getCurrentUserEmail, getMoneyRequestParticipantsFromReport, getPolicyTags, + getRecentWaypoints, getUserAccountID, requestMoney, trackExpense, @@ -499,6 +500,7 @@ function duplicateExpenseTransaction({ const userAccountID = getUserAccountID(); const currentUserEmail = getCurrentUserEmail(); + const recentWaypoints = getRecentWaypoints(); const participants = getMoneyRequestParticipantsFromReport(targetReport, userAccountID); const transactionDetails = getTransactionDetails(transaction); @@ -563,6 +565,7 @@ function duplicateExpenseTransaction({ introSelected, activePolicyID, quickAction, + recentWaypoints, }; return trackExpense(trackExpenseParams); } diff --git a/src/libs/actions/IOU/MoneyRequest.ts b/src/libs/actions/IOU/MoneyRequest.ts index 289a76e6468d..4896e21dedf5 100644 --- a/src/libs/actions/IOU/MoneyRequest.ts +++ b/src/libs/actions/IOU/MoneyRequest.ts @@ -24,6 +24,7 @@ import type {GpsPoint} from './index'; import { createDistanceRequest, getMoneyRequestParticipantsFromReport, + getRecentWaypoints, requestMoney, resetSplitShares, setCustomUnitRateID, @@ -156,6 +157,8 @@ function createTransaction({ reimbursable = true, isSelfTourViewed, }: CreateTransactionParams) { + const recentWaypoints = getRecentWaypoints(); + for (const [index, receiptFile] of files.entries()) { const transaction = transactions.find((item) => item.transactionID === receiptFile.transactionID); const receipt: Receipt = receiptFile.file ?? {}; @@ -187,6 +190,7 @@ function createTransaction({ introSelected, activePolicyID, quickAction, + recentWaypoints, }); } else { requestMoney({ @@ -494,6 +498,7 @@ function handleMoneyRequestStepDistanceNavigation({ }: MoneyRequestStepDistanceNavigationParams) { const isManualDistance = manualDistance !== undefined; const isGPSDistance = gpsDistance !== undefined && gpsCoordinates !== undefined; + const recentWaypoints = getRecentWaypoints(); if (transaction?.splitShares && !isManualDistance) { resetSplitShares(transaction); @@ -558,6 +563,7 @@ function handleMoneyRequestStepDistanceNavigation({ introSelected, activePolicyID, quickAction, + recentWaypoints, }); return; } diff --git a/src/libs/actions/IOU/index.ts b/src/libs/actions/IOU/index.ts index 67af9e80cdfd..22e8c7987696 100644 --- a/src/libs/actions/IOU/index.ts +++ b/src/libs/actions/IOU/index.ts @@ -714,6 +714,7 @@ type CreateTrackExpenseParams = { introSelected: OnyxEntry; activePolicyID: string | undefined; quickAction: OnyxEntry; + recentWaypoints: OnyxEntry; }; type GetTrackExpenseInformationTransactionParams = { @@ -948,13 +949,10 @@ Onyx.connectWithoutView({ callback: (value) => (recentAttendees = value), }); -// TODO: remove `recentWaypoints` from this file (https://github.com/Expensify/App/issues/73024) -// `recentWaypoints` was moved here temporarily from `src/libs/actions/Policy/Tag.ts` during the `Deprecate Onyx.connect` refactor. -// All uses of this variable should be replaced with `useOnyx`. -let recentWaypoints: OnyxTypes.RecentWaypoint[] = []; +let deprecatedRecentWaypoints: OnyxTypes.RecentWaypoint[] = []; Onyx.connect({ key: ONYXKEYS.NVP_RECENT_WAYPOINTS, - callback: (val) => (recentWaypoints = val ?? []), + callback: (val) => (deprecatedRecentWaypoints = val ?? []), }); function getAllPersonalDetails(): OnyxTypes.PersonalDetailsList { @@ -985,6 +983,15 @@ function getUserAccountID(): number { return userAccountID; } +function getRecentWaypoints(): OnyxTypes.RecentWaypoint[] { + return deprecatedRecentWaypoints; +} + +/** + * This function uses Onyx.connect and should be replaced with useOnyx for reactive data access. + * TODO: remove `getPolicyTagsData` from this file (https://github.com/Expensify/App/issues/72721) + * All usages of this function should be replaced with params passed to the functions or useOnyx hook in React components. + */ function getPolicyTags(): OnyxCollection { return allPolicyTags; } @@ -5340,7 +5347,7 @@ function updateMoneyRequestDistance({ } if (!distance) { - const recentServerValidatedWaypoints = recentWaypoints.filter((item) => !item.pendingAction); + const recentServerValidatedWaypoints = deprecatedRecentWaypoints.filter((item) => !item.pendingAction); onyxData?.failureData?.push({ onyxMethod: Onyx.METHOD.SET, key: `${ONYXKEYS.NVP_RECENT_WAYPOINTS}`, @@ -6543,6 +6550,7 @@ function trackExpense(params: CreateTrackExpenseParams) { introSelected, activePolicyID, quickAction, + recentWaypoints = [], } = params; const {participant, payeeAccountID, payeeEmail} = participantParams; const {policy, policyCategories, policyTagList} = policyData; @@ -7683,7 +7691,7 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest }; } - const recentServerValidatedWaypoints = recentWaypoints.filter((item) => !item.pendingAction); + const recentServerValidatedWaypoints = deprecatedRecentWaypoints.filter((item) => !item.pendingAction); onyxData?.failureData?.push({ onyxMethod: Onyx.METHOD.SET, key: `${ONYXKEYS.NVP_RECENT_WAYPOINTS}`, @@ -13178,6 +13186,7 @@ export { getAllReportActionsFromIOU, getCurrentUserEmail, getUserAccountID, + getRecentWaypoints, getReceiptError, getSearchOnyxUpdate, getPolicyTags, diff --git a/src/pages/Share/SubmitDetailsPage.tsx b/src/pages/Share/SubmitDetailsPage.tsx index 37198a5b38dc..b97488cf3c24 100644 --- a/src/pages/Share/SubmitDetailsPage.tsx +++ b/src/pages/Share/SubmitDetailsPage.tsx @@ -123,6 +123,7 @@ function SubmitDetailsPage({ const defaultTaxCode = getDefaultTaxCode(policy, transaction); const transactionTaxCode = (transaction?.taxCode ? transaction?.taxCode : defaultTaxCode) ?? ''; const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); + const [recentWaypoints] = useOnyx(ONYXKEYS.NVP_RECENT_WAYPOINTS, {canBeMissing: true}); const finishRequestAndNavigate = (participant: Participant, receipt: Receipt, gpsPoint?: GpsPoint) => { if (!transaction) { @@ -160,6 +161,7 @@ function SubmitDetailsPage({ activePolicyID, introSelected, quickAction, + recentWaypoints, }); } else { requestMoney({ diff --git a/src/pages/iou/request/step/IOURequestStepAmount.tsx b/src/pages/iou/request/step/IOURequestStepAmount.tsx index 2bfb58537af2..9e1baac1fcb0 100644 --- a/src/pages/iou/request/step/IOURequestStepAmount.tsx +++ b/src/pages/iou/request/step/IOURequestStepAmount.tsx @@ -182,6 +182,8 @@ function IOURequestStepAmount({ Navigation.goBack(backTo); }; + const [recentWaypoints] = useOnyx(ONYXKEYS.NVP_RECENT_WAYPOINTS, {canBeMissing: true}); + const navigateToNextPage = ({amount, paymentMethod}: AmountParams) => { isSaveButtonPressed.current = true; const amountInSmallestCurrencyUnits = convertToBackendAmount(Number.parseFloat(amount)); @@ -278,6 +280,7 @@ function IOURequestStepAmount({ introSelected, activePolicyID, quickAction, + recentWaypoints, }); return; } diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx index f42db3aafb00..14da5a90851b 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx @@ -731,6 +731,8 @@ function IOURequestStepConfirmation({ ], ); + const [recentWaypoints] = useOnyx(ONYXKEYS.NVP_RECENT_WAYPOINTS, {canBeMissing: true}); + const trackExpense = useCallback( (selectedParticipants: Participant[], gpsPoint?: GpsPoint) => { if (!transactions.length) { @@ -795,6 +797,7 @@ function IOURequestStepConfirmation({ introSelected, activePolicyID, quickAction, + recentWaypoints, }); } }, @@ -821,6 +824,7 @@ function IOURequestStepConfirmation({ introSelected, activePolicyID, quickAction, + recentWaypoints, ], ); diff --git a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx index 0978d060ecd5..a87e54cb446a 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -301,6 +301,8 @@ function IOURequestStepDistanceOdometer({ Navigation.goBack(); }; + const [recentWaypoints] = useOnyx(ONYXKEYS.NVP_RECENT_WAYPOINTS, {canBeMissing: true}); + // Navigate to next page following Manual tab pattern const navigateToNextPage = () => { const start = parseFloat(startReading); @@ -408,6 +410,7 @@ function IOURequestStepDistanceOdometer({ introSelected, activePolicyID, quickAction, + recentWaypoints, }); return; } diff --git a/tests/actions/IOU/MoneyRequestTest.ts b/tests/actions/IOU/MoneyRequestTest.ts index f44a8207d13a..16fc81247749 100644 --- a/tests/actions/IOU/MoneyRequestTest.ts +++ b/tests/actions/IOU/MoneyRequestTest.ts @@ -95,7 +95,7 @@ describe('MoneyRequest', () => { jest.clearAllMocks(); }); - it('should call trackExpense for TRACK iouType', () => { + it('should call trackExpense for TRACK iouType', async () => { createTransaction({ ...baseParams, iouType: CONST.IOU.TYPE.TRACK, @@ -496,6 +496,8 @@ describe('MoneyRequest', () => { await waitForBatchedUpdates(); + const recentWaypoints = (await getOnyxValue(ONYXKEYS.NVP_RECENT_WAYPOINTS)) ?? []; + expect(IOU.trackExpense).toHaveBeenCalledWith({ report: baseParams.report, isDraftPolicy: false, @@ -523,6 +525,7 @@ describe('MoneyRequest', () => { currentUserEmailParam: baseParams.currentUserLogin, quickAction: baseParams.quickAction, shouldHandleNavigation: true, + recentWaypoints, }); // Should not call request money inside createTransaction function expect(IOU.requestMoney).not.toHaveBeenCalled(); @@ -779,6 +782,8 @@ describe('MoneyRequest', () => { expect(IOU.resetSplitShares).not.toHaveBeenCalled(); + const recentWaypoints = (await getOnyxValue(ONYXKEYS.NVP_RECENT_WAYPOINTS)) ?? []; + expect(IOU.trackExpense).toHaveBeenCalledWith({ report: baseParams.report, isDraftPolicy: false, @@ -814,6 +819,7 @@ describe('MoneyRequest', () => { currentUserAccountIDParam: baseParams.currentUserAccountID, currentUserEmailParam: baseParams.currentUserLogin, quickAction: baseParams.quickAction, + recentWaypoints, }); // The function must return after trackExpense and not call createDistanceRequest @@ -840,6 +846,8 @@ describe('MoneyRequest', () => { waypoints: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, }); + const recentWaypoints = (await getOnyxValue(ONYXKEYS.NVP_RECENT_WAYPOINTS)) ?? []; + expect(IOU.trackExpense).toHaveBeenCalledWith({ report: baseParams.report, isDraftPolicy: false, @@ -875,6 +883,7 @@ describe('MoneyRequest', () => { currentUserAccountIDParam: baseParams.currentUserAccountID, currentUserEmailParam: baseParams.currentUserLogin, quickAction: baseParams.quickAction, + recentWaypoints, }); }); diff --git a/tests/actions/IOUTest.ts b/tests/actions/IOUTest.ts index 37db6c9566c2..52f3c54cc5b9 100644 --- a/tests/actions/IOUTest.ts +++ b/tests/actions/IOUTest.ts @@ -489,6 +489,8 @@ describe('actions/IOU', () => { await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${fakeTransaction.transactionID}`, fakeTransaction); mockFetch?.pause?.(); + const recentWaypoints = (await getOnyxValue(ONYXKEYS.NVP_RECENT_WAYPOINTS)) ?? []; + // When the user submits the transaction to the selfDM report trackExpense({ report: selfDMReport, @@ -517,6 +519,7 @@ describe('actions/IOU', () => { introSelected: undefined, activePolicyID: undefined, quickAction: undefined, + recentWaypoints, }); await waitForBatchedUpdates(); await mockFetch?.resume?.(); @@ -573,15 +576,13 @@ describe('actions/IOU', () => { await waitForBatchedUpdates(); // Then the transaction draft should be saved successfully - const allTransactionsDraft = await new Promise>((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, - waitForCollectionCallback: true, - callback: (transactionDrafts) => { - Onyx.disconnect(connection); - resolve(transactionDrafts); - }, - }); + let allTransactionsDraft: OnyxCollection; + await getOnyxData({ + key: ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, + waitForCollectionCallback: true, + callback: (val) => { + allTransactionsDraft = val; + }, }); const transactionDraft = allTransactionsDraft?.[`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transaction?.transactionID}`]; @@ -617,6 +618,7 @@ describe('actions/IOU', () => { introSelected: undefined, activePolicyID: undefined, quickAction: undefined, + recentWaypoints, }); await waitForBatchedUpdates(); await mockFetch?.resume?.(); @@ -677,6 +679,8 @@ describe('actions/IOU', () => { await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${policyExpenseChat.reportID}`, policyExpenseChat); await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transaction.transactionID}`, transaction); + const recentWaypoints = (await getOnyxValue(ONYXKEYS.NVP_RECENT_WAYPOINTS)) ?? []; + // Create a tracked expense trackExpense({ report: selfDMReport, @@ -700,6 +704,7 @@ describe('actions/IOU', () => { introSelected: undefined, activePolicyID: undefined, quickAction: undefined, + recentWaypoints, }); await waitForBatchedUpdates(); @@ -752,6 +757,7 @@ describe('actions/IOU', () => { introSelected: undefined, activePolicyID: undefined, quickAction: undefined, + recentWaypoints, }); await waitForBatchedUpdates(); @@ -807,6 +813,8 @@ describe('actions/IOU', () => { await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transaction.transactionID}`, transaction); await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, {[accountant.accountID]: accountant}); + const recentWaypoints = (await getOnyxValue(ONYXKEYS.NVP_RECENT_WAYPOINTS)) ?? []; + // Create a tracked expense trackExpense({ report: selfDMReport, @@ -830,6 +838,7 @@ describe('actions/IOU', () => { introSelected: undefined, activePolicyID: undefined, quickAction: undefined, + recentWaypoints, }); await waitForBatchedUpdates(); @@ -882,6 +891,7 @@ describe('actions/IOU', () => { introSelected: undefined, activePolicyID: undefined, quickAction: undefined, + recentWaypoints, }); await waitForBatchedUpdates(); @@ -914,6 +924,571 @@ describe('actions/IOU', () => { // Accountant role should change to admin expect(policyOnyx?.employeeList?.[accountant.login].role).toBe(CONST.POLICY.ROLE.ADMIN); }); + + /** + * Creates default trackExpense parameters - only override what's needed for each test + */ + function getDefaultTrackExpenseParams( + report: Report | undefined, + transactionOverrides: Partial[0]['transactionParams']> = {}, + ): Parameters[0] { + return { + report, + isDraftPolicy: false, + action: CONST.IOU.ACTION.CREATE, + participantParams: { + payeeEmail: RORY_EMAIL, + payeeAccountID: RORY_ACCOUNT_ID, + participant: {accountID: RORY_ACCOUNT_ID}, + }, + transactionParams: { + amount: 10000, + currency: 'USD', + created: format(new Date(), CONST.DATE.FNS_FORMAT_STRING), + merchant: 'Test Merchant', + billable: false, + ...transactionOverrides, + }, + isASAPSubmitBetaEnabled: false, + currentUserAccountIDParam: RORY_ACCOUNT_ID, + currentUserEmailParam: RORY_EMAIL, + introSelected: undefined, + activePolicyID: undefined, + quickAction: undefined, + recentWaypoints: [], + }; + } + + it('should create optimistic transaction with correct amount and currency', async () => { + // Given a selfDM report and transaction data + const selfDMReport: Report = { + ...createRandomReport(1, CONST.REPORT.CHAT_TYPE.SELF_DM), + reportID: 'selfDM-unit-1', + }; + const testAmount = 15000; // $150.00 + const testCurrency = 'USD'; + const testMerchant = 'Unit Test Merchant'; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${selfDMReport.reportID}`, selfDMReport); + + // When trackExpense is called with specific amount and currency + trackExpense(getDefaultTrackExpenseParams(selfDMReport, {amount: testAmount, currency: testCurrency, merchant: testMerchant})); + await waitForBatchedUpdates(); + + // Then transaction should be created with correct values + let transactions: OnyxCollection; + await getOnyxData({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (val) => { + transactions = val; + }, + }); + + const createdTransaction = Object.values(transactions ?? {}).at(0); + expect(createdTransaction).toBeTruthy(); + // Amount is stored as negative for track expenses + expect(Math.abs(createdTransaction?.amount ?? 0)).toBe(testAmount); + expect(createdTransaction?.currency).toBe(testCurrency); + expect(createdTransaction?.merchant).toBe(testMerchant); + }); + + it('should create actionable track expense whisper for selfDM reports', async () => { + // Given a selfDM report + const selfDMReport: Report = { + ...createRandomReport(1, CONST.REPORT.CHAT_TYPE.SELF_DM), + reportID: 'selfDM-unit-2', + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${selfDMReport.reportID}`, selfDMReport); + + // When trackExpense is called on selfDM + trackExpense(getDefaultTrackExpenseParams(selfDMReport, {amount: 5000})); + await waitForBatchedUpdates(); + + // Then an actionable track expense whisper should be created + const reportActions = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${selfDMReport.reportID}`); + + const actionableWhisper = Object.values(reportActions ?? {}).find((action) => isActionableTrackExpense(action)); + expect(actionableWhisper).toBeTruthy(); + }); + + it('should set correct tax fields when tax parameters are provided', async () => { + // Given a selfDM report and transaction with tax + const selfDMReport: Report = { + ...createRandomReport(1, CONST.REPORT.CHAT_TYPE.SELF_DM), + reportID: 'selfDM-unit-3', + }; + const testTaxCode = 'TAX_CODE_1'; + const testTaxAmount = 500; // $5.00 tax + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${selfDMReport.reportID}`, selfDMReport); + + // When trackExpense is called with tax parameters + trackExpense(getDefaultTrackExpenseParams(selfDMReport, {merchant: 'Tax Test Merchant', taxCode: testTaxCode, taxAmount: testTaxAmount})); + await waitForBatchedUpdates(); + + // Then transaction should have correct tax fields + let transactions: OnyxCollection; + await getOnyxData({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (val) => { + transactions = val; + }, + }); + + const createdTransaction = Object.values(transactions ?? {}).at(0); + expect(createdTransaction?.taxCode).toBe(testTaxCode); + expect(createdTransaction?.taxAmount).toBe(testTaxAmount); + }); + + it('should set billable and reimbursable flags correctly', async () => { + // Given a selfDM report + const selfDMReport: Report = { + ...createRandomReport(1, CONST.REPORT.CHAT_TYPE.SELF_DM), + reportID: 'selfDM-unit-4', + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${selfDMReport.reportID}`, selfDMReport); + + // When trackExpense is called with billable=true and reimbursable=true + trackExpense(getDefaultTrackExpenseParams(selfDMReport, {amount: 7500, merchant: 'Billable Test', billable: true, reimbursable: true})); + await waitForBatchedUpdates(); + + // Then transaction should have correct billable and reimbursable flags + let transactions: OnyxCollection; + await getOnyxData({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (val) => { + transactions = val; + }, + }); + + const createdTransaction = Object.values(transactions ?? {}).at(0); + expect(createdTransaction?.billable).toBe(true); + expect(createdTransaction?.reimbursable).toBe(true); + }); + + it('should complete full track expense flow: create -> categorize -> submit to workspace', async () => { + // Given a selfDM report, policy, and expense chat + const selfDMReport: Report = { + ...createRandomReport(1, CONST.REPORT.CHAT_TYPE.SELF_DM), + reportID: 'selfDM-func-1', + }; + const policy = createRandomPolicy(1); + const policyExpenseChat: Report = { + ...createRandomReport(2, CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT), + reportID: 'expense-chat-func-1', + policyID: policy.id, + type: CONST.REPORT.TYPE.CHAT, + isOwnPolicyExpenseChat: true, + }; + const policyCategories = createRandomPolicyCategories(3); + const selectedCategory = Object.keys(policyCategories).at(0) ?? ''; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${selfDMReport.reportID}`, selfDMReport); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${policyExpenseChat.reportID}`, policyExpenseChat); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); + + // When trackExpense is called to create a tracked expense in selfDM + trackExpense(getDefaultTrackExpenseParams(selfDMReport, {amount: 25000, merchant: 'Functional Test Restaurant'})); + await waitForBatchedUpdates(); + + // Then the initial expense should be created with report actions + const selfDMReportActions = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${selfDMReport.reportID}`); + + expect(Object.values(selfDMReportActions ?? {}).length).toBe(2); + const moneyRequestAction = Object.values(selfDMReportActions ?? {}).find((action) => isMoneyRequestAction(action)); + const actionableWhisper = Object.values(selfDMReportActions ?? {}).find((action) => isActionableTrackExpense(action)); + expect(moneyRequestAction).toBeTruthy(); + expect(actionableWhisper).toBeTruthy(); + + let transactions: OnyxCollection; + await getOnyxData({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (val) => { + transactions = val; + }, + }); + const createdTransaction = Object.values(transactions ?? {}).at(0); + expect(createdTransaction).toBeTruthy(); + + // When a draft is created for categorization + createDraftTransactionAndNavigateToParticipantSelector( + createdTransaction?.transactionID, + selfDMReport.reportID, + CONST.IOU.ACTION.CATEGORIZE, + actionableWhisper?.reportActionID, + {choice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM}, + {}, + undefined, + ); + await waitForBatchedUpdates(); + + // Then the draft should be created + let transactionDrafts: OnyxCollection; + await getOnyxData({ + key: ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, + waitForCollectionCallback: true, + callback: (val) => { + transactionDrafts = val; + }, + }); + const draftTransaction = transactionDrafts?.[`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${createdTransaction?.transactionID}`]; + expect(draftTransaction).toBeTruthy(); + + // When the expense is categorized and submitted to workspace + trackExpense({ + report: policyExpenseChat, + isDraftPolicy: false, + action: CONST.IOU.ACTION.CATEGORIZE, + participantParams: { + payeeEmail: RORY_EMAIL, + payeeAccountID: RORY_ACCOUNT_ID, + participant: {reportID: policyExpenseChat.reportID, isPolicyExpenseChat: true}, + }, + policyParams: { + policy, + policyCategories, + }, + transactionParams: { + amount: draftTransaction?.amount ?? 25000, + currency: draftTransaction?.currency ?? 'USD', + created: format(new Date(), CONST.DATE.FNS_FORMAT_STRING), + merchant: draftTransaction?.merchant ?? 'Functional Test Restaurant', + category: selectedCategory, + actionableWhisperReportActionID: draftTransaction?.actionableWhisperReportActionID, + linkedTrackedExpenseReportAction: moneyRequestAction, + linkedTrackedExpenseReportID: selfDMReport.reportID, + }, + isASAPSubmitBetaEnabled: false, + currentUserAccountIDParam: RORY_ACCOUNT_ID, + currentUserEmailParam: RORY_EMAIL, + introSelected: undefined, + activePolicyID: undefined, + quickAction: undefined, + recentWaypoints: [], + }); + await waitForBatchedUpdates(); + + // Then the transaction should be categorized + let finalTransactions: OnyxCollection; + await getOnyxData({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (val) => { + finalTransactions = val; + }, + }); + const categorizedTransaction = finalTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${createdTransaction?.transactionID}`]; + expect(categorizedTransaction?.category).toBe(selectedCategory); + }); + + it('should handle expense with attendees correctly', async () => { + // Given a selfDM report with attendees data + const selfDMReport: Report = { + ...createRandomReport(1, CONST.REPORT.CHAT_TYPE.SELF_DM), + reportID: 'selfDM-func-2', + }; + const testAttendees = [ + {email: 'attendee1@test.com', displayName: 'Attendee One', avatarUrl: ''}, + {email: 'attendee2@test.com', displayName: 'Attendee Two', avatarUrl: ''}, + ]; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${selfDMReport.reportID}`, selfDMReport); + + // When trackExpense is called with attendees + trackExpense(getDefaultTrackExpenseParams(selfDMReport, {amount: 30000, merchant: 'Team Lunch', attendees: testAttendees})); + await waitForBatchedUpdates(); + + // Then transaction should have attendees + let transactions: OnyxCollection; + await getOnyxData({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (val) => { + transactions = val; + }, + }); + + const createdTransaction = Object.values(transactions ?? {}).at(0); + expect(createdTransaction?.comment?.attendees).toHaveLength(2); + expect(createdTransaction?.comment?.attendees?.at(0)?.email).toBe('attendee1@test.com'); + }); + + it('should update quick action when tracking expense to policy expense chat', async () => { + // Given a policy expense chat + const policy = createRandomPolicy(1); + const policyExpenseChat: Report = { + ...createRandomReport(1, CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT), + reportID: 'expense-chat-func-2', + policyID: policy.id, + type: CONST.REPORT.TYPE.CHAT, + isOwnPolicyExpenseChat: true, + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${policyExpenseChat.reportID}`, policyExpenseChat); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); + + // When trackExpense is called on policy expense chat + trackExpense({ + report: policyExpenseChat, + isDraftPolicy: false, + action: CONST.IOU.ACTION.CREATE, + participantParams: { + payeeEmail: RORY_EMAIL, + payeeAccountID: RORY_ACCOUNT_ID, + participant: {reportID: policyExpenseChat.reportID, isPolicyExpenseChat: true}, + }, + policyParams: { + policy, + }, + transactionParams: { + amount: 12000, + currency: 'USD', + created: format(new Date(), CONST.DATE.FNS_FORMAT_STRING), + merchant: 'Quick Action Test', + billable: false, + }, + isASAPSubmitBetaEnabled: false, + currentUserAccountIDParam: RORY_ACCOUNT_ID, + currentUserEmailParam: RORY_EMAIL, + introSelected: undefined, + activePolicyID: undefined, + quickAction: undefined, + recentWaypoints: [], + }); + await waitForBatchedUpdates(); + + // Then quick action should be updated + const quickAction = await getOnyxValue(ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE); + expect(quickAction).toBeTruthy(); + expect(quickAction?.chatReportID).toBe(policyExpenseChat.reportID); + }); + + it('should handle tracking expense without merchant gracefully', async () => { + // Given a selfDM report + const selfDMReport: Report = { + ...createRandomReport(1, CONST.REPORT.CHAT_TYPE.SELF_DM), + reportID: 'selfDM-qa-1', + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${selfDMReport.reportID}`, selfDMReport); + + // When trackExpense is called without merchant + trackExpense(getDefaultTrackExpenseParams(selfDMReport, {amount: 5000, merchant: ''})); + await waitForBatchedUpdates(); + + // Then transaction should still be created + let transactions: OnyxCollection; + await getOnyxData({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (val) => { + transactions = val; + }, + }); + + expect(Object.values(transactions ?? {}).length).toBeGreaterThan(0); + }); + + it('should handle zero amount expense', async () => { + // Given a selfDM report + const selfDMReport: Report = { + ...createRandomReport(1, CONST.REPORT.CHAT_TYPE.SELF_DM), + reportID: 'selfDM-qa-2', + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${selfDMReport.reportID}`, selfDMReport); + + // When trackExpense is called with zero amount + trackExpense(getDefaultTrackExpenseParams(selfDMReport, {amount: 0, merchant: 'Zero Amount Test'})); + await waitForBatchedUpdates(); + + // Then transaction should be created with zero amount + let transactions: OnyxCollection; + await getOnyxData({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (val) => { + transactions = val; + }, + }); + + const createdTransaction = Object.values(transactions ?? {}).at(0); + // trackExpense negates the amount, so 0 becomes -0, defaults to 1 to be able to use Math.abs + expect(createdTransaction).toBeTruthy(); + expect(Object.is(Math.abs(createdTransaction?.amount ?? 1), 0)).toBe(true); + }); + + it('should handle different currency codes correctly', async () => { + // Given a selfDM report + const selfDMReport: Report = { + ...createRandomReport(1, CONST.REPORT.CHAT_TYPE.SELF_DM), + reportID: 'selfDM-qa-3', + }; + const testCurrency = 'EUR'; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${selfDMReport.reportID}`, selfDMReport); + + // When trackExpense is called with EUR currency + trackExpense(getDefaultTrackExpenseParams(selfDMReport, {amount: 8500, currency: testCurrency, merchant: 'European Merchant'})); + await waitForBatchedUpdates(); + + // Then transaction should have correct currency + let transactions: OnyxCollection; + await getOnyxData({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (val) => { + transactions = val; + }, + }); + + const createdTransaction = Object.values(transactions ?? {}).at(0); + expect(createdTransaction?.currency).toBe(testCurrency); + }); + + it('should create optimistic selfDM report when none exists', async () => { + // Given no selfDM report exists + + // When trackExpense is called with undefined report + trackExpense(getDefaultTrackExpenseParams(undefined, {amount: 3000, merchant: 'Optimistic SelfDM Test'})); + await waitForBatchedUpdates(); + + // Then a selfDM report should be created optimistically + const reports = await new Promise>((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (val) => { + Onyx.disconnect(connection); + resolve(val); + }, + }); + }); + + const selfDMReports = Object.values(reports ?? {}).filter((r) => r?.chatType === CONST.REPORT.CHAT_TYPE.SELF_DM); + expect(selfDMReports.length).toBeGreaterThan(0); + }); + + it('should handle API failure gracefully with failure data', async () => { + // Given a selfDM report + const selfDMReport: Report = { + ...createRandomReport(1, CONST.REPORT.CHAT_TYPE.SELF_DM), + reportID: 'selfDM-qa-5', + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${selfDMReport.reportID}`, selfDMReport); + mockFetch?.fail?.(); + + // When trackExpense is called and the API fails + trackExpense(getDefaultTrackExpenseParams(selfDMReport, {amount: 5000, merchant: 'Failure Test'})); + await waitForBatchedUpdates(); + + // Then optimistic data should still be created initially + let transactions: OnyxCollection; + await getOnyxData({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (val) => { + transactions = val; + }, + }); + + expect(Object.values(transactions ?? {}).length).toBeGreaterThan(0); + + mockFetch?.succeed?.(); + }); + + it('should handle category and tag together correctly', async () => { + // Given a selfDM report with category and tag + const selfDMReport: Report = { + ...createRandomReport(1, CONST.REPORT.CHAT_TYPE.SELF_DM), + reportID: 'selfDM-qa-6', + }; + const testCategory = 'Travel'; + const testTag = 'Business Trip'; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${selfDMReport.reportID}`, selfDMReport); + + // When trackExpense is called with category and tag + trackExpense(getDefaultTrackExpenseParams(selfDMReport, {amount: 50000, merchant: 'Airline', category: testCategory, tag: testTag})); + await waitForBatchedUpdates(); + + // Then transaction should have correct category and tag + let transactions: OnyxCollection; + await getOnyxData({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (val) => { + transactions = val; + }, + }); + + const createdTransaction = Object.values(transactions ?? {}).at(0); + expect(createdTransaction?.category).toBe(testCategory); + expect(createdTransaction?.tag).toBe(testTag); + }); + + it('should handle very large expense amounts', async () => { + // Given a selfDM report + const selfDMReport: Report = { + ...createRandomReport(1, CONST.REPORT.CHAT_TYPE.SELF_DM), + reportID: 'selfDM-qa-7', + }; + const largeAmount = 99999999; // Large amount in cents + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${selfDMReport.reportID}`, selfDMReport); + + // When trackExpense is called with very large amount + trackExpense(getDefaultTrackExpenseParams(selfDMReport, {amount: largeAmount, merchant: 'Large Purchase'})); + await waitForBatchedUpdates(); + + // Then transaction should handle large amount correctly + let transactions: OnyxCollection; + await getOnyxData({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (val) => { + transactions = val; + }, + }); + + const createdTransaction = Object.values(transactions ?? {}).at(0); + expect(Math.abs(createdTransaction?.amount ?? 0)).toBe(largeAmount); + }); + + it('should handle expense with special characters in merchant name', async () => { + // Given a selfDM report + const selfDMReport: Report = { + ...createRandomReport(1, CONST.REPORT.CHAT_TYPE.SELF_DM), + reportID: 'selfDM-qa-8', + }; + const specialMerchant = "McDonald's & Café ñ 日本語"; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${selfDMReport.reportID}`, selfDMReport); + + // When trackExpense is called with special characters in merchant + trackExpense(getDefaultTrackExpenseParams(selfDMReport, {amount: 1500, merchant: specialMerchant})); + await waitForBatchedUpdates(); + + // Then transaction should preserve special characters + let transactions: OnyxCollection; + await getOnyxData({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (val) => { + transactions = val; + }, + }); + + const createdTransaction = Object.values(transactions ?? {}).at(0); + expect(createdTransaction?.merchant).toBe(specialMerchant); + }); }); describe('createDraftTransactionAndNavigateToParticipantSelector', () => { @@ -936,15 +1511,13 @@ describe('actions/IOU', () => { await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${selfDMReport.reportID}`, selfDMReport); // Get the existing drafts to pass to the function - const allTransactionDrafts = await new Promise>((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, - waitForCollectionCallback: true, - callback: (transactionDrafts) => { - Onyx.disconnect(connection); - resolve(transactionDrafts); - }, - }); + let allTransactionDrafts: OnyxCollection; + await getOnyxData({ + key: ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, + waitForCollectionCallback: true, + callback: (val) => { + allTransactionDrafts = val; + }, }); // Verify existing drafts exist before calling the function @@ -964,15 +1537,13 @@ describe('actions/IOU', () => { await waitForBatchedUpdates(); // Then the existing draft transactions should be cleared - const updatedTransactionDrafts = await new Promise>((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, - waitForCollectionCallback: true, - callback: (transactionDrafts) => { - Onyx.disconnect(connection); - resolve(transactionDrafts); - }, - }); + let updatedTransactionDrafts: OnyxCollection; + await getOnyxData({ + key: ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, + waitForCollectionCallback: true, + callback: (val) => { + updatedTransactionDrafts = val; + }, }); // Old drafts should be cleared @@ -1012,15 +1583,13 @@ describe('actions/IOU', () => { await waitForBatchedUpdates(); // Then a draft transaction should be created with the correct data - const transactionDrafts = await new Promise>((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, - waitForCollectionCallback: true, - callback: (drafts) => { - Onyx.disconnect(connection); - resolve(drafts); - }, - }); + let transactionDrafts: OnyxCollection; + await getOnyxData({ + key: ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, + waitForCollectionCallback: true, + callback: (val) => { + transactionDrafts = val; + }, }); const draftTransaction = transactionDrafts?.[`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${originalTransaction.transactionID}`]; @@ -1049,15 +1618,13 @@ describe('actions/IOU', () => { await waitForBatchedUpdates(); // Then no draft transaction should be created - const transactionDrafts = await new Promise>((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, - waitForCollectionCallback: true, - callback: (drafts) => { - Onyx.disconnect(connection); - resolve(drafts); - }, - }); + let transactionDrafts: OnyxCollection; + await getOnyxData({ + key: ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, + waitForCollectionCallback: true, + callback: (val) => { + transactionDrafts = val; + }, }); expect(Object.keys(transactionDrafts ?? {}).length).toBe(0); @@ -1081,15 +1648,13 @@ describe('actions/IOU', () => { await waitForBatchedUpdates(); // Then no draft transaction should be created - const transactionDrafts = await new Promise>((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, - waitForCollectionCallback: true, - callback: (drafts) => { - Onyx.disconnect(connection); - resolve(drafts); - }, - }); + let transactionDrafts: OnyxCollection; + await getOnyxData({ + key: ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, + waitForCollectionCallback: true, + callback: (val) => { + transactionDrafts = val; + }, }); expect(Object.keys(transactionDrafts ?? {}).length).toBe(0); @@ -2158,6 +2723,8 @@ describe('actions/IOU', () => { ]); await waitForBatchedUpdates(); + const recentWaypoints = (await getOnyxValue(ONYXKEYS.NVP_RECENT_WAYPOINTS)) ?? []; + // First create a tracked expense in self DM trackExpense({ report: selfDMReport, @@ -2182,6 +2749,7 @@ describe('actions/IOU', () => { introSelected: undefined, activePolicyID: undefined, quickAction: undefined, + recentWaypoints, }); mockFetch?.resume?.(); @@ -2247,6 +2815,7 @@ describe('actions/IOU', () => { introSelected: undefined, activePolicyID: undefined, quickAction: undefined, + recentWaypoints, }); await waitForBatchedUpdates(); @@ -5118,15 +5687,13 @@ describe('actions/IOU', () => { expect(createIOUAction && getOriginalMessage(createIOUAction)?.IOUReportID).toBe(iouReport?.reportID); // When fetching all transactions from Onyx - const allTransactions = await new Promise>((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.TRANSACTION, - waitForCollectionCallback: true, - callback: (transactions) => { - Onyx.disconnect(connection); - resolve(transactions); - }, - }); + let allTransactions: OnyxCollection; + await getOnyxData({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (val) => { + allTransactions = val; + }, }); // Then we should find a specific transaction with relevant properties @@ -7597,6 +8164,8 @@ describe('actions/IOU', () => { [WRITE_COMMANDS.CATEGORIZE_TRACKED_EXPENSE, CONST.IOU.ACTION.CATEGORIZE], [WRITE_COMMANDS.SHARE_TRACKED_EXPENSE, CONST.IOU.ACTION.SHARE], ])('%s', async (expectedCommand: ApiCommand, action: IOUAction) => { + const recentWaypoints = (await getOnyxValue(ONYXKEYS.NVP_RECENT_WAYPOINTS)) ?? []; + // When a track expense is created trackExpense({ report: {reportID: '123', policyID: 'A'}, @@ -7628,6 +8197,7 @@ describe('actions/IOU', () => { introSelected: undefined, activePolicyID: undefined, quickAction: undefined, + recentWaypoints, }); await waitForBatchedUpdates(); @@ -9260,6 +9830,8 @@ describe('actions/IOU', () => { const amount = 100; + const recentWaypoints = (await getOnyxValue(ONYXKEYS.NVP_RECENT_WAYPOINTS)) ?? []; + trackExpense({ report: selfDMReport, isDraftPolicy: true, @@ -9283,6 +9855,7 @@ describe('actions/IOU', () => { introSelected: undefined, activePolicyID: undefined, quickAction: undefined, + recentWaypoints, }); await getOnyxData({ key: ONYXKEYS.COLLECTION.TRANSACTION, diff --git a/tests/ui/UnreadIndicatorsTest.tsx b/tests/ui/UnreadIndicatorsTest.tsx index c9b27b702b0b..0c00ba592e2b 100644 --- a/tests/ui/UnreadIndicatorsTest.tsx +++ b/tests/ui/UnreadIndicatorsTest.tsx @@ -22,7 +22,7 @@ import FontUtils from '@styles/utils/FontUtils'; import App from '@src/App'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {ReportAction, ReportActions} from '@src/types/onyx'; +import type {RecentWaypoint, ReportAction, ReportActions} from '@src/types/onyx'; import type {NativeNavigationMock} from '../../__mocks__/@react-navigation/native'; import {createRandomReport} from '../utils/collections/reports'; import createRandomTransaction from '../utils/collections/transaction'; @@ -698,6 +698,12 @@ describe('Unread Indicators', () => { comment: 'description', }; + let recentWaypoints: RecentWaypoint[] = []; + Onyx.connect({ + key: ONYXKEYS.NVP_RECENT_WAYPOINTS, + callback: (val) => (recentWaypoints = val ?? []), + }); + // When the user track an expense on the self DM const participant = {login: USER_A_EMAIL, accountID: USER_A_ACCOUNT_ID}; trackExpense({ @@ -720,6 +726,7 @@ describe('Unread Indicators', () => { introSelected: undefined, activePolicyID: undefined, quickAction: undefined, + recentWaypoints, }); await waitForBatchedUpdates(); diff --git a/tests/unit/GoogleTagManagerTest.tsx b/tests/unit/GoogleTagManagerTest.tsx index 2d93af6bfb83..bb7e490708d3 100644 --- a/tests/unit/GoogleTagManagerTest.tsx +++ b/tests/unit/GoogleTagManagerTest.tsx @@ -12,6 +12,7 @@ import {getCardForSubscriptionBilling} from '@libs/SubscriptionUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {FundList} from '@src/types/onyx'; +import getOnyxValue from '../utils/getOnyxValue'; import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; jest.mock('@libs/GoogleTagManager'); @@ -169,6 +170,8 @@ describe('GoogleTagManagerTest', () => { }); test('workspace_created - categorizeTrackedExpense', async () => { + const recentWaypoints = (await getOnyxValue(ONYXKEYS.NVP_RECENT_WAYPOINTS)) ?? []; + trackExpense({ report: {reportID: '123'}, isDraftPolicy: true, @@ -197,6 +200,7 @@ describe('GoogleTagManagerTest', () => { introSelected: undefined, activePolicyID: undefined, quickAction: undefined, + recentWaypoints, }); await waitForBatchedUpdatesWithAct();