From 79a633e57516f8218be5332088eb647bad01e0f4 Mon Sep 17 00:00:00 2001 From: Ryan Teguh Date: Thu, 12 Jun 2025 20:57:26 +0800 Subject: [PATCH 1/4] Fix: Prevent deleted unreported expenses from being added to report in offline mode --- src/pages/AddUnreportedExpense.tsx | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/src/pages/AddUnreportedExpense.tsx b/src/pages/AddUnreportedExpense.tsx index 2cd369c2a14c..7fabcd86d94d 100644 --- a/src/pages/AddUnreportedExpense.tsx +++ b/src/pages/AddUnreportedExpense.tsx @@ -1,4 +1,4 @@ -import React, {useEffect, useRef, useState} from 'react'; +import React, {useEffect, useMemo, useRef, useState} from 'react'; import {useOnyx} from 'react-native-onyx'; import type {OnyxCollection} from 'react-native-onyx'; import EmptyStateComponent from '@components/EmptyStateComponent'; @@ -16,6 +16,7 @@ import {fetchUnreportedExpenses} from '@libs/actions/UnreportedExpenses'; import interceptAnonymousUser from '@libs/interceptAnonymousUser'; import type {AddUnreportedExpensesParamList} from '@libs/Navigation/types'; import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils'; +import {isTransactionPendingDelete} from '@libs/TransactionUtils'; import Navigation from '@navigation/Navigation'; import type {PlatformStackScreenProps} from '@navigation/PlatformStackNavigation/types'; import {startMoneyRequest} from '@userActions/IOU'; @@ -68,12 +69,20 @@ function AddUnreportedExpense({route}: AddUnreportedExpensePageType) { const styles = useThemeStyles(); const selectionListRef = useRef(null); - const sections: Array> = [ - { - shouldShow: true, - data: transactions.filter((t): t is Transaction & ListItem => t !== undefined), - }, - ]; + const sections: Array> = useMemo( + () => [ + { + shouldShow: true, + data: transactions + .filter((t): t is Transaction & ListItem => t !== undefined) + .map((transaction) => ({ + ...transaction, + isDisabled: isTransactionPendingDelete(transaction), + })), + }, + ], + [transactions], + ); const thereIsNoUnreportedTransaction = !((sections.at(0)?.data.length ?? 0) > 0); From aa8c0ce52c9c2f44c651662d1fe503bf3c6ec31a Mon Sep 17 00:00:00 2001 From: Ryan Teguh Date: Fri, 13 Jun 2025 00:25:00 +0800 Subject: [PATCH 2/4] Fix: Prevent deleted unreported expenses from being added to report in offline mode --- src/libs/TransactionUtils/index.ts | 18 ++++ src/pages/AddUnreportedExpense.tsx | 14 +-- tests/unit/AddUnreportedExpenseTest.ts | 115 +++++++++++++++++++++++++ 3 files changed, 135 insertions(+), 12 deletions(-) create mode 100644 tests/unit/AddUnreportedExpenseTest.ts diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index 46aec3b3508d..4e4b5bc7f485 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -1601,6 +1601,23 @@ function isTransactionPendingDelete(transaction: OnyxEntry): boolea return getTransactionPendingAction(transaction) === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; } +/** + * Creates sections data for unreported expenses, marking transactions with DELETE pending action as disabled + */ +function createUnreportedExpenseSections(transactions: Array): Array<{shouldShow: boolean; data: Array}> { + return [ + { + shouldShow: true, + data: transactions + .filter((t): t is T => t !== undefined) + .map((transaction) => ({ + ...transaction, + isDisabled: isTransactionPendingDelete(transaction), + })), + }, + ]; +} + export { buildOptimisticTransaction, calculateTaxAmount, @@ -1705,6 +1722,7 @@ export { getOriginalTransactionWithSplitInfo, getTransactionPendingAction, isTransactionPendingDelete, + createUnreportedExpenseSections, }; export type {TransactionChanges}; diff --git a/src/pages/AddUnreportedExpense.tsx b/src/pages/AddUnreportedExpense.tsx index 7fabcd86d94d..be195e2a9895 100644 --- a/src/pages/AddUnreportedExpense.tsx +++ b/src/pages/AddUnreportedExpense.tsx @@ -16,7 +16,7 @@ import {fetchUnreportedExpenses} from '@libs/actions/UnreportedExpenses'; import interceptAnonymousUser from '@libs/interceptAnonymousUser'; import type {AddUnreportedExpensesParamList} from '@libs/Navigation/types'; import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils'; -import {isTransactionPendingDelete} from '@libs/TransactionUtils'; +import {createUnreportedExpenseSections} from '@libs/TransactionUtils'; import Navigation from '@navigation/Navigation'; import type {PlatformStackScreenProps} from '@navigation/PlatformStackNavigation/types'; import {startMoneyRequest} from '@userActions/IOU'; @@ -70,17 +70,7 @@ function AddUnreportedExpense({route}: AddUnreportedExpensePageType) { const styles = useThemeStyles(); const selectionListRef = useRef(null); const sections: Array> = useMemo( - () => [ - { - shouldShow: true, - data: transactions - .filter((t): t is Transaction & ListItem => t !== undefined) - .map((transaction) => ({ - ...transaction, - isDisabled: isTransactionPendingDelete(transaction), - })), - }, - ], + () => createUnreportedExpenseSections(transactions as Array<(Transaction & ListItem) | undefined>), [transactions], ); diff --git a/tests/unit/AddUnreportedExpenseTest.ts b/tests/unit/AddUnreportedExpenseTest.ts new file mode 100644 index 000000000000..df52ecf14570 --- /dev/null +++ b/tests/unit/AddUnreportedExpenseTest.ts @@ -0,0 +1,115 @@ +import {createUnreportedExpenseSections} from '@libs/TransactionUtils'; +import CONST from '@src/CONST'; +import type Transaction from '@src/types/onyx/Transaction'; + +function generateTransaction(values: Partial = {}): Transaction { + const baseTransaction: Transaction = { + transactionID: `transaction_${Math.random()}`, + reportID: CONST.REPORT.UNREPORTED_REPORT_ID, + amount: 1000, + currency: 'USD', + merchant: 'Test Merchant', + category: '', + comment: {comment: ''}, + created: '2025-06-12', + tag: '', + billable: false, + receipt: {}, + filename: '', + taxCode: '', + taxAmount: 0, + pendingAction: undefined, + ...values, + }; + return baseTransaction; +} + +describe('AddUnreportedExpense', () => { + describe('createUnreportedExpenseSections', () => { + it('should mark transactions with DELETE pendingAction as disabled', () => { + const normalTransaction = generateTransaction({ + transactionID: '123', + pendingAction: undefined, + amount: 1000, + merchant: 'Normal Merchant', + }); + + const deletedTransaction = generateTransaction({ + transactionID: '456', + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + amount: 2000, + merchant: 'Deleted Merchant', + }); + + const transactions = [normalTransaction, deletedTransaction]; + const sections = createUnreportedExpenseSections(transactions); + + // Should create one section + expect(sections).toHaveLength(1); + expect(sections.at(0)?.shouldShow).toBe(true); + expect(sections.at(0)?.data).toHaveLength(2); + + const processedNormalTransaction = sections.at(0)?.data.find((t) => t.transactionID === '123'); + expect(processedNormalTransaction?.isDisabled).toBe(false); + + const processedDeletedTransaction = sections.at(0)?.data.find((t) => t.transactionID === '456'); + expect(processedDeletedTransaction?.isDisabled).toBe(true); + }); + + it('should not mark transactions without DELETE pendingAction as disabled', () => { + const normalTransaction = generateTransaction({ + transactionID: '123', + pendingAction: undefined, + amount: 1000, + merchant: 'Normal Merchant', + }); + + const updateTransaction = generateTransaction({ + transactionID: '456', + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + amount: 2000, + merchant: 'Update Merchant', + }); + + const addTransaction = generateTransaction({ + transactionID: '789', + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + amount: 3000, + merchant: 'Add Merchant', + }); + + const transactions = [normalTransaction, updateTransaction, addTransaction]; + const sections = createUnreportedExpenseSections(transactions); + + expect(sections.at(0)?.data).toHaveLength(3); + sections.at(0)?.data.forEach((transaction) => { + expect(transaction.isDisabled).toBe(false); + }); + }); + + it('should handle transaction list with only deleted transactions', () => { + const deletedTransaction1 = generateTransaction({ + transactionID: '123', + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + amount: 1000, + merchant: 'Deleted Merchant 1', + }); + + const deletedTransaction2 = generateTransaction({ + transactionID: '456', + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + amount: 2000, + merchant: 'Deleted Merchant 2', + }); + + const transactions = [deletedTransaction1, deletedTransaction2]; + const sections = createUnreportedExpenseSections(transactions); + + expect(sections.at(0)?.data).toHaveLength(2); + sections.at(0)?.data.forEach((transaction) => { + expect(transaction.isDisabled).toBe(true); + expect(transaction.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); + }); + }); + }); +}); From f3cc61b289eddb8364c1df5fa825f94ab3cd0e81 Mon Sep 17 00:00:00 2001 From: Ryan Teguh Date: Fri, 13 Jun 2025 21:14:22 +0800 Subject: [PATCH 3/4] Fix: Prevent deleted unreported expenses from being added to report in offline mode --- src/libs/TransactionUtils/index.ts | 24 +++++++++++++++++------- src/pages/AddUnreportedExpense.tsx | 5 +---- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index 4e4b5bc7f485..243c7bd8099e 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -45,7 +45,7 @@ import type {IOUType} from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {OnyxInputOrEntry, Policy, RecentWaypoint, Report, ReviewDuplicates, TaxRate, TaxRates, Transaction, TransactionViolation, TransactionViolations} from '@src/types/onyx'; import type {Attendee, Participant, SplitExpense} from '@src/types/onyx/IOU'; -import type {PendingAction} from '@src/types/onyx/OnyxCommon'; +import type {Errors, PendingAction} from '@src/types/onyx/OnyxCommon'; import type {SearchPolicy, SearchReport, SearchTransaction} from '@src/types/onyx/SearchResults'; import type {Comment, Receipt, TransactionChanges, TransactionCustomUnit, TransactionPendingFieldsKey, Waypoint, WaypointCollection} from '@src/types/onyx/Transaction'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; @@ -1601,19 +1601,29 @@ function isTransactionPendingDelete(transaction: OnyxEntry): boolea return getTransactionPendingAction(transaction) === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; } +type UnreportedExpenseListItem = Transaction & { + isDisabled: boolean; + keyForList: string; + errors?: Errors; +}; + /** * Creates sections data for unreported expenses, marking transactions with DELETE pending action as disabled */ -function createUnreportedExpenseSections(transactions: Array): Array<{shouldShow: boolean; data: Array}> { +function createUnreportedExpenseSections(transactions: Array): Array<{shouldShow: boolean; data: UnreportedExpenseListItem[]}> { return [ { shouldShow: true, data: transactions - .filter((t): t is T => t !== undefined) - .map((transaction) => ({ - ...transaction, - isDisabled: isTransactionPendingDelete(transaction), - })), + .filter((t): t is Transaction => t !== undefined) + .map( + (transaction): UnreportedExpenseListItem => ({ + ...transaction, + isDisabled: isTransactionPendingDelete(transaction), + keyForList: transaction.transactionID, + errors: transaction.errors as Errors | undefined, + }), + ), }, ]; } diff --git a/src/pages/AddUnreportedExpense.tsx b/src/pages/AddUnreportedExpense.tsx index be195e2a9895..cdfd56500142 100644 --- a/src/pages/AddUnreportedExpense.tsx +++ b/src/pages/AddUnreportedExpense.tsx @@ -69,10 +69,7 @@ function AddUnreportedExpense({route}: AddUnreportedExpensePageType) { const styles = useThemeStyles(); const selectionListRef = useRef(null); - const sections: Array> = useMemo( - () => createUnreportedExpenseSections(transactions as Array<(Transaction & ListItem) | undefined>), - [transactions], - ); + const sections: Array> = useMemo(() => createUnreportedExpenseSections(transactions), [transactions]); const thereIsNoUnreportedTransaction = !((sections.at(0)?.data.length ?? 0) > 0); From 3653dbdc0e5b004e38f68375b3cecc03acbc9d16 Mon Sep 17 00:00:00 2001 From: Ryan Teguh Date: Sat, 14 Jun 2025 08:11:08 +0800 Subject: [PATCH 4/4] Move UnreportedExpense type --- src/components/SelectionList/types.ts | 7 +++++++ src/libs/TransactionUtils/index.ts | 11 +++-------- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/components/SelectionList/types.ts b/src/components/SelectionList/types.ts index f895ef3a02b3..ce748ba3318d 100644 --- a/src/components/SelectionList/types.ts +++ b/src/components/SelectionList/types.ts @@ -822,6 +822,12 @@ type FlattenedSectionsReturn = { someSelected: boolean; }; +type UnreportedExpenseListItemType = Transaction & { + isDisabled: boolean; + keyForList: string; + errors?: Errors; +}; + type ButtonOrCheckBoxRoles = 'button' | 'checkbox'; type ExtendedSectionListData> = SectionListData & { @@ -867,4 +873,5 @@ export type { SplitListItemProps, SplitListItemType, SearchListItem, + UnreportedExpenseListItemType, }; diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index 243c7bd8099e..8526fa6f98ce 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -6,6 +6,7 @@ import lodashSet from 'lodash/set'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; +import type {UnreportedExpenseListItemType} from '@components/SelectionList/types'; import {getPolicyCategoriesData} from '@libs/actions/Policy/Category'; import {getPolicyTagsData} from '@libs/actions/Policy/Tag'; import type {MergeDuplicatesParams} from '@libs/API/parameters'; @@ -1601,23 +1602,17 @@ function isTransactionPendingDelete(transaction: OnyxEntry): boolea return getTransactionPendingAction(transaction) === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; } -type UnreportedExpenseListItem = Transaction & { - isDisabled: boolean; - keyForList: string; - errors?: Errors; -}; - /** * Creates sections data for unreported expenses, marking transactions with DELETE pending action as disabled */ -function createUnreportedExpenseSections(transactions: Array): Array<{shouldShow: boolean; data: UnreportedExpenseListItem[]}> { +function createUnreportedExpenseSections(transactions: Array): Array<{shouldShow: boolean; data: UnreportedExpenseListItemType[]}> { return [ { shouldShow: true, data: transactions .filter((t): t is Transaction => t !== undefined) .map( - (transaction): UnreportedExpenseListItem => ({ + (transaction): UnreportedExpenseListItemType => ({ ...transaction, isDisabled: isTransactionPendingDelete(transaction), keyForList: transaction.transactionID,