From c44f1b20b434a5809d9a9a0c23c3b530d683079e Mon Sep 17 00:00:00 2001 From: "dmkt9 (via MelvinBot)" Date: Thu, 15 Oct 2026 02:56:02 +0000 Subject: [PATCH 1/6] Show the Reimbursable toggle on Do Not Force company card expenses Co-authored-by: dmkt9 --- src/CONST/index.ts | 7 +++ .../ReportActionItem/MoneyRequestView.tsx | 9 ++- src/libs/CardFeedUtils.ts | 1 + src/libs/DebugUtils.ts | 2 + src/libs/TransactionUtils/index.ts | 28 +++++++++ .../SearchEditMultiplePage.tsx | 14 ++++- src/pages/iou/DynamicSplitExpenseEditPage.tsx | 11 +++- src/types/onyx/Transaction.ts | 3 + tests/ui/SearchEditMultiplePageTest.tsx | 52 +++++++++++++++++ tests/unit/TransactionUtilsTest.ts | 58 +++++++++++++++++++ 10 files changed, 179 insertions(+), 6 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index a35bbd7dfe8d..fa0706cfaa9e 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -5406,6 +5406,13 @@ const CONST = { COMPANY_CARD: 'CompanyCard', EXPENSIFY_CARD: 'ExpensifyCard', }, + FORCE_REIMBURSABLE: { + FORCE_YES: 'force_yes', + FORCE_NO: 'force_no', + DEFAULT_YES: 'default_yes', + DEFAULT_NO: 'default_no', + NONE: 'none', + }, CARD_LIST: 'cardList', FEED_KEY_SEPARATOR: '#', CARD_NUMBER_MASK_CHAR: 'X', diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index fb30e81b2aba..7a49ed684912 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -53,7 +53,7 @@ import {updateMoneyRequestBillable, updateMoneyRequestCategory, updateMoneyReque import {openExternalLink} from '@libs/actions/Link'; import initSplitExpense from '@libs/actions/SplitExpenses'; import {enrichAndSortAttendees, getIsMissingAttendeesViolation} from '@libs/AttendeeUtils'; -import {getBrokenConnectionUrlToFixPersonalCard, getCommercialFeedCardDescription, getCompanyCardDescription} from '@libs/CardUtils'; +import {getBrokenConnectionUrlToFixPersonalCard, getCommercialFeedCardDescription, getCompanyCardDescription, getCompanyCardFeed} from '@libs/CardUtils'; import {getDecodedLeafCategoryName, isCategoryMissing} from '@libs/CategoryUtils'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; @@ -117,6 +117,7 @@ import { hasMissingSmartscanFields, hasReservationList, hasRoute as hasRouteTransactionUtils, + isCardTransactionReimbursableEditable, isFromCreditCardImport as isCardTransactionTransactionUtils, isCategoryBeingAnalyzed, isCustomUnitRateIDForP2P, @@ -549,10 +550,12 @@ function MoneyRequestView({ const shouldShowBillable = (isPolicyExpenseChat || isExpenseUnreported) && (!!transactionBillable || isBillableEnabledOnPolicy(policy) || !!updatedTransaction?.billable); const isCurrentTransactionReimbursableDifferentFromPolicyDefault = policy?.defaultReimbursable !== undefined && !!(updatedTransaction?.reimbursable ?? transactionReimbursable) !== policy.defaultReimbursable; + const transactionCardFeedSettings = transactionCard ? cardFeedsForTransactionCard?.settings?.companyCards?.[getCompanyCardFeed(transactionCard.bank)] : undefined; + // The workspace cash expense rules don't apply to card expenses, which follow their card feed's reimbursable setting instead const shouldShowReimbursable = (isPolicyExpenseChat || (isExpenseUnreported && !!policy)) && - (policy?.disabledFields?.reimbursable !== true || isCurrentTransactionReimbursableDifferentFromPolicyDefault) && - !isManagedCardTransactionTransactionUtils(transaction) && + (isManagedCardTransactionTransactionUtils(transaction) || policy?.disabledFields?.reimbursable !== true || isCurrentTransactionReimbursableDifferentFromPolicyDefault) && + isCardTransactionReimbursableEditable(transaction, transactionCardFeedSettings) && !isInvoice; const canEditReimbursable = isEditable && diff --git a/src/libs/CardFeedUtils.ts b/src/libs/CardFeedUtils.ts index eba005cd50e8..9343800fdb21 100644 --- a/src/libs/CardFeedUtils.ts +++ b/src/libs/CardFeedUtils.ts @@ -700,6 +700,7 @@ export { getCardFeedsForDisplay, getExpensifyCardFeedsForDisplay, getCardFeedsForDisplayPerPolicy, + getFeedSettingsForCard, getPolicyIDsNamedByCardFeeds, getAssignedCardFeedAccess, getVisibleCompanyCardFeedsForSelector, diff --git a/src/libs/DebugUtils.ts b/src/libs/DebugUtils.ts index 8c8361be9712..33f54029f67b 100644 --- a/src/libs/DebugUtils.ts +++ b/src/libs/DebugUtils.ts @@ -1210,6 +1210,7 @@ function validateTransactionDraftProperty(key: keyof Transaction, value: string) linkedTrackedExpenseReportID: CONST.RED_BRICK_ROAD_PENDING_ACTION, bank: CONST.RED_BRICK_ROAD_PENDING_ACTION, liabilityType: CONST.RED_BRICK_ROAD_PENDING_ACTION, + forceReimbursable: CONST.RED_BRICK_ROAD_PENDING_ACTION, cardName: CONST.RED_BRICK_ROAD_PENDING_ACTION, feedCountry: CONST.RED_BRICK_ROAD_PENDING_ACTION, cardNumber: CONST.RED_BRICK_ROAD_PENDING_ACTION, @@ -1277,6 +1278,7 @@ function validateTransactionDraftProperty(key: keyof Transaction, value: string) source: 'string', originalTransactionID: 'string', liabilityType: CONST.TRANSACTION.LIABILITY_TYPE, + forceReimbursable: CONST.COMPANY_CARD.FORCE_REIMBURSABLE, vendor: 'object', splits: 'array', dismissedViolations: 'object', diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index 9165c2264c68..7779c4f88333 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -30,6 +30,7 @@ import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Card, CardList, OnyxInputOrEntry, PersonalDetails, Policy, RecentWaypoint, Report, Transaction} from '@src/types/onyx'; +import type {CustomCardFeedData} from '@src/types/onyx/CardFeeds'; import type {Attendee, DistanceExpenseType} from '@src/types/onyx/IOU'; import type {Errors, PendingAction} from '@src/types/onyx/OnyxCommon'; import type {Comment, UnreportedTransaction, Waypoint, WaypointCollection} from '@src/types/onyx/Transaction'; @@ -1010,6 +1011,32 @@ function isManagedCardTransaction(transaction: OnyxEntry, feedSettings?: Pick): boolean { + if (!isManagedCardTransaction(transaction)) { + return true; + } + + if (isExpensifyCardTransaction(transaction)) { + return false; + } + + // A feed without a stored reimbursable setting behaves as "Force No" + const feedForceReimbursable = feedSettings ? (feedSettings.forceReimbursable ?? CONST.COMPANY_CARD.FORCE_REIMBURSABLE.FORCE_NO) : undefined; + const forceReimbursable = transaction?.comment?.forceReimbursable ?? feedForceReimbursable; + + return ( + forceReimbursable === CONST.COMPANY_CARD.FORCE_REIMBURSABLE.DEFAULT_YES || + forceReimbursable === CONST.COMPANY_CARD.FORCE_REIMBURSABLE.DEFAULT_NO || + forceReimbursable === CONST.COMPANY_CARD.FORCE_REIMBURSABLE.NONE + ); +} + /** * Determine whether a transaction is imported from a credit card. * This includes managed cards (Expensify/Company cards) and personal cards imported via bank connection. @@ -1893,6 +1920,7 @@ export { hasPendingDistanceReceiptRegeneration, isExpensifyCardTransaction, isManagedCardTransaction, + isCardTransactionReimbursableEditable, isDuplicate, isPending, hasOnlyPendingCardTransactions, diff --git a/src/pages/Search/SearchEditMultiple/SearchEditMultiplePage.tsx b/src/pages/Search/SearchEditMultiple/SearchEditMultiplePage.tsx index 6174ee24dfca..9e026296f5c1 100644 --- a/src/pages/Search/SearchEditMultiple/SearchEditMultiplePage.tsx +++ b/src/pages/Search/SearchEditMultiple/SearchEditMultiplePage.tsx @@ -22,6 +22,7 @@ import usePersonalPolicy from '@hooks/usePersonalPolicy'; import useThemeStyles from '@hooks/useThemeStyles'; import {clearBulkEditDraftTransaction, updateMultipleMoneyRequests} from '@libs/actions/IOU/BulkEdit'; +import {getFeedSettingsForCard} from '@libs/CardFeedUtils'; import Navigation from '@libs/Navigation/Navigation'; import {hasEnabledOptions} from '@libs/OptionsListUtils'; import Parser from '@libs/Parser'; @@ -34,6 +35,7 @@ import { getTagArrayFromName, getTaxName, hasSplitExpenseInSelection, + isCardTransactionReimbursableEditable, isDistanceRequest, isManagedCardTransaction, isPerDiemRequest, @@ -94,6 +96,8 @@ function SearchEditMultiplePage() { const [reportNameValuePairs] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS); const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); + const [nonPersonalAndWorkspaceCards] = useOnyx(ONYXKEYS.DERIVED.NON_PERSONAL_AND_WORKSPACE_CARD_LIST); + const [allCardFeeds] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER); const snapshotData = currentSearchResults?.data; const mergedTransactions = withSnapshotTransactions(allTransactions, snapshotData); @@ -153,11 +157,19 @@ function SearchEditMultiplePage() { }); const areSelectedTransactionsReimbursable = selectedTransactionContexts.every(({transaction, report, transactionPolicy}) => { + const transactionCard = transaction.cardID ? nonPersonalAndWorkspaceCards?.[transaction.cardID] : undefined; + const transactionCardFeedSettings = transactionCard ? getFeedSettingsForCard(transactionCard, allCardFeeds) : undefined; + if (!isCardTransactionReimbursableEditable(transaction, transactionCardFeedSettings)) { + return false; + } + // Unreported expenses have no policy yet but reimbursable is always applicable if (!transaction.reportID || transaction.reportID === CONST.REPORT.UNREPORTED_REPORT_ID) { return true; } - return !isIOUReport(report) && !isInvoiceReport(report) && transactionPolicy?.disabledFields?.reimbursable === false && !isManagedCardTransaction(transaction); + + // The workspace cash expense rules don't apply to card expenses, which follow their card feed's reimbursable setting instead + return !isIOUReport(report) && !isInvoiceReport(report) && (isManagedCardTransaction(transaction) || transactionPolicy?.disabledFields?.reimbursable === false); }); const policyID = getSearchBulkEditPolicyID(selectedTransactionIDs, activePolicyID, mergedTransactions, mergedReports); diff --git a/src/pages/iou/DynamicSplitExpenseEditPage.tsx b/src/pages/iou/DynamicSplitExpenseEditPage.tsx index 189dd2f1d8fc..0131405c4752 100644 --- a/src/pages/iou/DynamicSplitExpenseEditPage.tsx +++ b/src/pages/iou/DynamicSplitExpenseEditPage.tsx @@ -35,6 +35,7 @@ import type {ViolationField} from '@hooks/useViolations'; import {initDraftSplitExpenseDataForEdit, removeSplitExpenseField, updateSplitExpenseDraftField, updateSplitExpenseField} from '@libs/actions/IOU/SplitExpenseItems'; import {openPolicyCategoriesPage} from '@libs/actions/Policy/Category'; import {openPolicyTagsPage} from '@libs/actions/Policy/Tag'; +import {getCompanyCardFeed} from '@libs/CardUtils'; import {getDecodedLeafCategoryName, isCategoryDescriptionRequired, isCategoryMissing} from '@libs/CategoryUtils'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; @@ -57,6 +58,7 @@ import { getTag, getTagForDisplay, getTaxName, + isCardTransactionReimbursableEditable, isDistanceRequest, isManagedCardTransaction, isManualDistanceRequest, @@ -132,6 +134,9 @@ function DynamicSplitExpenseEditPage({route}: DynamicSplitExpenseEditPageProps) const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const [personalDetails] = useAllPersonalDetails(); + const [nonPersonalAndWorkspaceCards] = useOnyx(ONYXKEYS.DERIVED.NON_PERSONAL_AND_WORKSPACE_CARD_LIST); + const transactionCard = transaction?.cardID ? nonPersonalAndWorkspaceCards?.[transaction.cardID] : undefined; + const [cardFeedsForTransactionCard] = useOnyx(`${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${getNonEmptyStringOnyxID(transactionCard?.fundID)}`); // Detect selfDM splits whose source workspace is gone: nothing for the Rate step to render. const hasAnyPaidWorkspace = hasAnyPaidPolicy(allPolicies ?? {}); @@ -266,10 +271,12 @@ function DynamicSplitExpenseEditPage({route}: DynamicSplitExpenseEditPageProps) const shouldShowBillable = (isPolicyExpenseChat || isExpenseUnreported) && (!!splitExpenseDraftTransactionDetails?.billable || isBillableEnabledOnPolicy(effectivePolicy)); const currentReimbursable = splitExpenseDraftTransaction?.reimbursable ?? transaction?.reimbursable; const isReimbursableDifferentFromPolicyDefault = effectivePolicy?.defaultReimbursable !== undefined && !!currentReimbursable !== effectivePolicy.defaultReimbursable; + const transactionCardFeedSettings = transactionCard ? cardFeedsForTransactionCard?.settings?.companyCards?.[getCompanyCardFeed(transactionCard.bank)] : undefined; + // The workspace cash expense rules don't apply to card expenses, which follow their card feed's reimbursable setting instead const shouldShowReimbursable = (isPolicyExpenseChat || (isExpenseUnreported && !!effectivePolicy)) && - (effectivePolicy?.disabledFields?.reimbursable !== true || isReimbursableDifferentFromPolicyDefault) && - !isManagedCardTransaction(transaction); + (isManagedCardTransaction(transaction) || effectivePolicy?.disabledFields?.reimbursable !== true || isReimbursableDifferentFromPolicyDefault) && + isCardTransactionReimbursableEditable(transaction, transactionCardFeedSettings); const isDistance = isDistanceRequest(splitExpenseDraftTransaction); const isManualDistance = isManualDistanceRequest(splitExpenseDraftTransaction); diff --git a/src/types/onyx/Transaction.ts b/src/types/onyx/Transaction.ts index 54923d5f3601..c9944c319668 100644 --- a/src/types/onyx/Transaction.ts +++ b/src/types/onyx/Transaction.ts @@ -117,6 +117,9 @@ type Comment = { liabilityType?: ValueOf; + /** The card feed's reimbursable setting, stamped on company card transactions at import */ + forceReimbursable?: ValueOf; + /** * Accounting-system vendor matched to this expense. * Stored on non-reimbursable card expenses when a vendor is set either by the diff --git a/tests/ui/SearchEditMultiplePageTest.tsx b/tests/ui/SearchEditMultiplePageTest.tsx index 58a6debf3306..05db42e8e146 100644 --- a/tests/ui/SearchEditMultiplePageTest.tsx +++ b/tests/ui/SearchEditMultiplePageTest.tsx @@ -186,6 +186,58 @@ describe('SearchEditMultiplePage', () => { expect(screen.getByLabelText(/^Reimbursable/).props.accessibilityState).toEqual(expect.objectContaining({disabled: true})); }); + it('shows Reimbursable for "Do Not Force" company card expenses on an "Always" workspace', async () => { + // Given a workspace whose cash expenses are always non-reimbursable, and a company card expense stamped "Do Not Force" + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, {defaultReimbursable: false, disabledFields: {reimbursable: true}}); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${OPEN_TRANSACTION_ID}`, {managedCard: true, comment: {forceReimbursable: CONST.COMPANY_CARD.FORCE_REIMBURSABLE.DEFAULT_YES}}); + await selectTransactions([OPEN_TRANSACTION_ID]); + + // When the Edit multiple page opens + renderPage(); + await waitForBatchedUpdatesWithAct(); + + // Then the Reimbursable row shows, because the workspace cash expense rules don't apply to card expenses + expect(screen.getByLabelText(/^Reimbursable/)).toBeOnTheScreen(); + }); + + it('hides Reimbursable when the selection contains a forced company card expense', async () => { + // Given a cash expense and a company card expense stamped "Force No" + const cardTransactionID = 'cardTransaction'; + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${cardTransactionID}`, { + ...buildTransaction(cardTransactionID, OPEN_REPORT_ID), + managedCard: true, + comment: {forceReimbursable: CONST.COMPANY_CARD.FORCE_REIMBURSABLE.FORCE_NO}, + }); + await selectTransactions([OPEN_TRANSACTION_ID, cardTransactionID]); + + // When the Edit multiple page opens + renderPage(); + await waitForBatchedUpdatesWithAct(); + + // Then the Reimbursable row is hidden, because the forced expense's reimbursable value can't change + expect(screen.queryByLabelText(/^Reimbursable/)).not.toBeOnTheScreen(); + }); + + it('uses the card feed setting for a company card expense with no stamped value', async () => { + // Given a company card expense with no stamped value, whose card belongs to a feed set to "Do Not Force" + const cardID = 1234; + const fundID = '5678'; + const feed = CONST.COMPANY_CARD.FEED_BANK_NAME.VISA; + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${OPEN_TRANSACTION_ID}`, {managedCard: true, cardID}); + await Onyx.set(ONYXKEYS.CARD_LIST, {[cardID]: {cardID, bank: feed, fundID, state: CONST.EXPENSIFY_CARD.STATE.OPEN}}); + await Onyx.set(`${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${fundID}`, { + settings: {companyCards: {[feed]: {forceReimbursable: CONST.COMPANY_CARD.FORCE_REIMBURSABLE.DEFAULT_NO}}}, + }); + await selectTransactions([OPEN_TRANSACTION_ID]); + + // When the Edit multiple page opens + renderPage(); + await waitForBatchedUpdatesWithAct(); + + // Then the Reimbursable row shows, because the feed setting is the fallback for expenses that weren't stamped + expect(screen.getByLabelText(/^Reimbursable/)).toBeOnTheScreen(); + }); + it('fetches policy categories and tags when they are enabled but not loaded yet', async () => { // Given a workspace with categories and tags enabled whose categories and tags were never loaded, as after a cache clear await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, {areCategoriesEnabled: true, areTagsEnabled: true}); diff --git a/tests/unit/TransactionUtilsTest.ts b/tests/unit/TransactionUtilsTest.ts index 63cbaa1d2546..fbc1fdd795af 100644 --- a/tests/unit/TransactionUtilsTest.ts +++ b/tests/unit/TransactionUtilsTest.ts @@ -6372,6 +6372,64 @@ describe('isTransactionSubmittable', () => { }); }); +describe('isCardTransactionReimbursableEditable', () => { + const {FORCE_YES, FORCE_NO, DEFAULT_YES, DEFAULT_NO, NONE} = CONST.COMPANY_CARD.FORCE_REIMBURSABLE; + + it('returns true for a cash expense', () => { + // Given a cash expense, which follows the workspace cash expense rules instead of a card feed + const transaction = generateTransaction(); + + // Then the reimbursable value can be changed + expect(TransactionUtils.isCardTransactionReimbursableEditable(transaction)).toBe(true); + }); + + it('returns false for an Expensify Card expense', () => { + // Given an Expensify Card expense, which can never be reimbursable, even with a "Do Not Force" value + const transaction = generateTransaction({managedCard: true, bank: CONST.EXPENSIFY_CARD.BANK, comment: {forceReimbursable: DEFAULT_YES}}); + + // Then the reimbursable value can't be changed + expect(TransactionUtils.isCardTransactionReimbursableEditable(transaction)).toBe(false); + }); + + it.each([ + [DEFAULT_YES, true], + [DEFAULT_NO, true], + [NONE, true], + [FORCE_YES, false], + [FORCE_NO, false], + ])('uses the stamped %s value of a company card expense', (forceReimbursable, expected) => { + // Given a company card expense stamped with the feed setting at import, on a feed whose setting has since changed + const transaction = generateTransaction({managedCard: true, comment: {forceReimbursable}}); + const feedSettings = {forceReimbursable: forceReimbursable === DEFAULT_YES ? FORCE_NO : DEFAULT_YES}; + + // Then only the "Do Not Force" stamped values allow changing the reimbursable value, because feed changes only affect new expenses + expect(TransactionUtils.isCardTransactionReimbursableEditable(transaction, feedSettings)).toBe(expected); + }); + + it.each([ + [DEFAULT_YES, true], + [DEFAULT_NO, true], + [NONE, true], + [FORCE_YES, false], + [FORCE_NO, false], + [undefined, false], + ])('falls back to the %s feed setting when the company card expense has no stamped value', (forceReimbursable, expected) => { + // Given a company card expense with no stamped value, on a feed with the given setting (a missing setting behaves as "Force No") + const transaction = generateTransaction({managedCard: true}); + + // Then the feed setting decides whether the reimbursable value can be changed + expect(TransactionUtils.isCardTransactionReimbursableEditable(transaction, {forceReimbursable})).toBe(expected); + }); + + it('returns false for a company card expense with no stamped value and no feed settings', () => { + // Given a company card expense with no stamped value, viewed by someone who doesn't have its feed settings + const transaction = generateTransaction({managedCard: true}); + + // Then the reimbursable value can't be changed, matching the "Force No" default + expect(TransactionUtils.isCardTransactionReimbursableEditable(transaction)).toBe(false); + }); +}); + describe('showHeldExpensesBlockModal', () => { it('shows a confirm modal explaining that a report with only held expenses cannot be submitted', () => { const showConfirmModal = jest.fn(); From ab4e09638f6b38fa61142aa2f2aabd34d0d66742 Mon Sep 17 00:00:00 2001 From: "dmkt9 (via MelvinBot)" Date: Fri, 16 Oct 2026 04:09:01 +0000 Subject: [PATCH 2/6] Keep forced company card reimbursable value when resolving duplicates Resolving duplicates forced every kept managed card expense to non-reimbursable, which silently turned "Force Yes" company card expenses into non-reimbursable ones. Only Expensify Card expenses are now forced to false; company card expenses that can't be changed keep their own value, and "Do Not Force" ones show the review step. Co-authored-by: dmkt9 --- src/libs/TransactionUtils/duplicates.ts | 19 +++++---- tests/unit/TransactionUtilsTest.ts | 51 +++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 8 deletions(-) diff --git a/src/libs/TransactionUtils/duplicates.ts b/src/libs/TransactionUtils/duplicates.ts index b3ec2c66ea5c..485024767d44 100644 --- a/src/libs/TransactionUtils/duplicates.ts +++ b/src/libs/TransactionUtils/duplicates.ts @@ -25,7 +25,7 @@ import Onyx from 'react-native-onyx'; // This cycle import is safe because this file was extracted from TransactionUtils/index.ts, which re-exports it. // The functions imported here are pure helpers that aren't called at initialization time. // eslint-disable-next-line import/no-cycle -import {getAmount, getCurrency, getDescription, getFormattedCreated, getMerchant, isManagedCardTransaction} from './index'; +import {getAmount, getCurrency, getDescription, getFormattedCreated, getMerchant, getReimbursable, isCardTransactionReimbursableEditable, isExpensifyCardTransaction} from './index'; type FieldsToCompare = Record>; type FieldsToChange = { @@ -396,16 +396,19 @@ function compareDuplicateTransactionFields( } } } else if (fieldName === 'reimbursable') { - // Managed card transactions are always non-reimbursable. The resolved reimbursable value is applied to - // the transaction that is kept, so we only force it to false — and hide the reimbursable review step — - // when that kept transaction is itself a managed card. We gate on the selected (kept) transaction rather - // than the reviewing transaction: the Review* pages recompute this from the thread's transaction (which - // may be the cash expense) while the kept transaction is the managed card, so gating on the reviewing + // The resolved reimbursable value is applied to the transaction that is kept, so when that kept transaction + // is a card expense whose reimbursable value can't be changed, we keep its own value and hide the + // reimbursable review step. Expensify Card expenses are always non-reimbursable, and company card expenses + // keep their forced value (e.g. "Force Yes" stays reimbursable). We gate on the selected (kept) transaction + // rather than the reviewing transaction: the Review* pages recompute this from the thread's transaction (which + // may be the cash expense) while the kept transaction is the card expense, so gating on the reviewing // transaction would re-add the reimbursable step on back navigation. Gating on any duplicate in the set - // would instead wrongly convert a kept cash expense to non-reimbursable. + // would instead wrongly change the reimbursable value of a kept cash expense. const selectedTransaction = transactions.find((transactionItem) => transactionItem?.transactionID === selectedTransactionID) ?? firstTransaction; - if (isManagedCardTransaction(selectedTransaction)) { + if (isExpensifyCardTransaction(selectedTransaction)) { keep[fieldName] = false; + } else if (!isCardTransactionReimbursableEditable(selectedTransaction)) { + keep[fieldName] = getReimbursable(selectedTransaction); } else if (areAllFieldsEqualForKey) { keep[fieldName] = firstTransaction?.[keys[0]] ?? firstTransaction?.[keys[1]]; } else { diff --git a/tests/unit/TransactionUtilsTest.ts b/tests/unit/TransactionUtilsTest.ts index fbc1fdd795af..b1aec1be8826 100644 --- a/tests/unit/TransactionUtilsTest.ts +++ b/tests/unit/TransactionUtilsTest.ts @@ -5163,6 +5163,57 @@ describe('TransactionUtils', () => { expect(result.keep.reimbursable).toBeUndefined(); expect(result.change.reimbursable).toEqual(expect.arrayContaining([true, false])); }); + + it('should keep a "Force Yes" company card expense reimbursable and not show the step', () => { + // Given a kept company card expense whose feed forces it to be reimbursable, and a non-reimbursable duplicate + const companyCardTransaction = generateTransaction({ + reimbursable: true, + managedCard: true, + comment: {forceReimbursable: CONST.COMPANY_CARD.FORCE_REIMBURSABLE.FORCE_YES}, + }); + const duplicates = [generateTransaction({reimbursable: false, managedCard: false})]; + + // When the duplicate fields are compared + const result = TransactionUtils.compareDuplicateTransactionFields({}, companyCardTransaction, duplicates, mockReport, undefined, mockPolicy, undefined); + + // Then the forced value is kept, so resolving duplicates can't silently make the expense non-reimbursable + expect(result.keep.reimbursable).toBe(true); + expect(result.change.reimbursable).toBeUndefined(); + }); + + it('should surface the reimbursable step when the kept company card expense uses a "Do Not Force" setting', () => { + // Given a kept company card expense whose reimbursable value the user can change + const companyCardTransaction = generateTransaction({ + reimbursable: true, + managedCard: true, + comment: {forceReimbursable: CONST.COMPANY_CARD.FORCE_REIMBURSABLE.DEFAULT_YES}, + }); + const duplicates = [generateTransaction({reimbursable: false, managedCard: false})]; + + // When the duplicate fields are compared + const result = TransactionUtils.compareDuplicateTransactionFields({}, companyCardTransaction, duplicates, mockReport, undefined, mockPolicy, undefined); + + // Then the user picks the reimbursable value in the review step + expect(result.keep.reimbursable).toBeUndefined(); + expect(result.change.reimbursable).toEqual(expect.arrayContaining([true, false])); + }); + + it('should force reimbursable to false when the kept transaction is an Expensify Card expense', () => { + // Given a kept Expensify Card expense and a reimbursable duplicate + const expensifyCardTransaction = generateTransaction({ + reimbursable: false, + managedCard: true, + bank: CONST.EXPENSIFY_CARD.BANK, + }); + const duplicates = [generateTransaction({reimbursable: true, managedCard: false})]; + + // When the duplicate fields are compared + const result = TransactionUtils.compareDuplicateTransactionFields({}, expensifyCardTransaction, duplicates, mockReport, undefined, mockPolicy, undefined); + + // Then Expensify Card expenses stay non-reimbursable + expect(result.keep.reimbursable).toBe(false); + expect(result.change.reimbursable).toBeUndefined(); + }); }); describe('selectedTransactionID parameter', () => { From 0f3b32620a7c81998550b0685d749a836b06c5ea Mon Sep 17 00:00:00 2001 From: "dmkt9 (via MelvinBot)" Date: Fri, 16 Oct 2026 08:46:23 +0000 Subject: [PATCH 3/6] Apply the card feed reimbursable rule to the confirmation footer The confirmation footer still hid the Reimbursable toggle on every managed card expense. It now uses isCardTransactionReimbursableEditable with the card's feed settings, like the expense view, so Do Not Force company card expenses can change reimbursability there too. Co-authored-by: dmkt9 --- .../sections/selectors.ts | 6 ++++- .../hooks/useFooterDerivedFlags.ts | 23 +++++++++++++++++-- src/libs/TransactionUtils/index.ts | 7 ++++-- 3 files changed, 31 insertions(+), 5 deletions(-) diff --git a/src/components/MoneyRequestConfirmationList/sections/selectors.ts b/src/components/MoneyRequestConfirmationList/sections/selectors.ts index cac073eb44ba..b47f9d4f1c01 100644 --- a/src/components/MoneyRequestConfirmationList/sections/selectors.ts +++ b/src/components/MoneyRequestConfirmationList/sections/selectors.ts @@ -236,7 +236,7 @@ const taxSliceSelector = (t: OnyxEntry): TaxSlice | undefined => { // --- ConfirmationFieldList: useFooterDerivedFlags --- -type DerivedFlagsSlice = Pick; +type DerivedFlagsSlice = Pick; const derivedFlagsSliceSelector = (t: OnyxEntry): OnyxEntry => { if (!t) { @@ -248,6 +248,10 @@ const derivedFlagsSliceSelector = (t: OnyxEntry): OnyxEntry, dateFor /** * Determine whether a transaction is made with an Expensify card. */ -function isExpensifyCardTransaction(transaction: OnyxEntry): boolean { +function isExpensifyCardTransaction(transaction: OnyxEntry>): boolean { return transaction?.bank === CONST.EXPENSIFY_CARD.BANK; } @@ -1017,7 +1017,10 @@ function isManagedCardTransaction(transaction: OnyxEntry, feedSettings?: Pick): boolean { +function isCardTransactionReimbursableEditable( + transaction: OnyxEntry>, + feedSettings?: Pick, +): boolean { if (!isManagedCardTransaction(transaction)) { return true; } From 57bf4d98121dc7e51b95dc83cf6e152d3f0c916e Mon Sep 17 00:00:00 2001 From: "dmkt9 (via MelvinBot)" Date: Fri, 16 Oct 2026 08:56:18 +0000 Subject: [PATCH 4/6] Share the card feed settings lookup across reimbursable checks MoneyRequestView, DynamicSplitExpenseEditPage and the confirmation footer each indexed settings.companyCards by the card's feed inline. They now use getFeedSettingsFromCardFeeds, which getFeedSettingsForCard also uses. Co-authored-by: dmkt9 --- .../hooks/useFooterDerivedFlags.ts | 4 ++-- .../ReportActionItem/MoneyRequestView.tsx | 5 +++-- src/libs/CardFeedUtils.ts | 15 ++++++++++++++- src/pages/iou/DynamicSplitExpenseEditPage.tsx | 4 ++-- 4 files changed, 21 insertions(+), 7 deletions(-) diff --git a/src/components/MoneyRequestConfirmationListFooter/hooks/useFooterDerivedFlags.ts b/src/components/MoneyRequestConfirmationListFooter/hooks/useFooterDerivedFlags.ts index 9ffa33586ad8..dc122a7aa219 100644 --- a/src/components/MoneyRequestConfirmationListFooter/hooks/useFooterDerivedFlags.ts +++ b/src/components/MoneyRequestConfirmationListFooter/hooks/useFooterDerivedFlags.ts @@ -4,7 +4,7 @@ import useTransactionSelector from '@components/MoneyRequestConfirmationList/sec import useOnyx from '@hooks/useOnyx'; import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; -import {getCompanyCardFeed} from '@libs/CardUtils'; +import {getFeedSettingsFromCardFeeds} from '@libs/CardFeedUtils'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {isBillableEnabledOnPolicy} from '@libs/MoneyRequestReportUtils'; import {shouldShowConfirmationDate} from '@libs/MoneyRequestUtils'; @@ -84,7 +84,7 @@ function useFooterDerivedFlags({ const [nonPersonalAndWorkspaceCards] = useOnyx(ONYXKEYS.DERIVED.NON_PERSONAL_AND_WORKSPACE_CARD_LIST); const transactionCard = transaction?.cardID ? nonPersonalAndWorkspaceCards?.[transaction.cardID] : undefined; const [cardFeedsForTransactionCard] = useOnyx(`${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${getNonEmptyStringOnyxID(transactionCard?.fundID)}`); - const transactionCardFeedSettings = transactionCard ? cardFeedsForTransactionCard?.settings?.companyCards?.[getCompanyCardFeed(transactionCard.bank)] : undefined; + const transactionCardFeedSettings = getFeedSettingsFromCardFeeds(cardFeedsForTransactionCard, transactionCard); // Self-derived iou* values from the slice const iouCurrencyCode = getCurrency(transaction); diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index 7a49ed684912..9878292241fc 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -53,7 +53,8 @@ import {updateMoneyRequestBillable, updateMoneyRequestCategory, updateMoneyReque import {openExternalLink} from '@libs/actions/Link'; import initSplitExpense from '@libs/actions/SplitExpenses'; import {enrichAndSortAttendees, getIsMissingAttendeesViolation} from '@libs/AttendeeUtils'; -import {getBrokenConnectionUrlToFixPersonalCard, getCommercialFeedCardDescription, getCompanyCardDescription, getCompanyCardFeed} from '@libs/CardUtils'; +import {getFeedSettingsFromCardFeeds} from '@libs/CardFeedUtils'; +import {getBrokenConnectionUrlToFixPersonalCard, getCommercialFeedCardDescription, getCompanyCardDescription} from '@libs/CardUtils'; import {getDecodedLeafCategoryName, isCategoryMissing} from '@libs/CategoryUtils'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; @@ -550,7 +551,7 @@ function MoneyRequestView({ const shouldShowBillable = (isPolicyExpenseChat || isExpenseUnreported) && (!!transactionBillable || isBillableEnabledOnPolicy(policy) || !!updatedTransaction?.billable); const isCurrentTransactionReimbursableDifferentFromPolicyDefault = policy?.defaultReimbursable !== undefined && !!(updatedTransaction?.reimbursable ?? transactionReimbursable) !== policy.defaultReimbursable; - const transactionCardFeedSettings = transactionCard ? cardFeedsForTransactionCard?.settings?.companyCards?.[getCompanyCardFeed(transactionCard.bank)] : undefined; + const transactionCardFeedSettings = getFeedSettingsFromCardFeeds(cardFeedsForTransactionCard, transactionCard); // The workspace cash expense rules don't apply to card expenses, which follow their card feed's reimbursable setting instead const shouldShowReimbursable = (isPolicyExpenseChat || (isExpenseUnreported && !!policy)) && diff --git a/src/libs/CardFeedUtils.ts b/src/libs/CardFeedUtils.ts index 9343800fdb21..420cc512184e 100644 --- a/src/libs/CardFeedUtils.ts +++ b/src/libs/CardFeedUtils.ts @@ -283,7 +283,19 @@ function getFeedSettingsForCard(card: AssignedCardForFeedAccess, allCardFeeds: O return undefined; } - return allCardFeeds?.[`${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${card.fundID}`]?.settings?.companyCards?.[getCompanyCardFeed(card.bank)]; + return getFeedSettingsFromCardFeeds(allCardFeeds?.[`${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${card.fundID}`], card); +} + +/** + * The settings of the company card feed an assigned card belongs to, read from the card's own feed record + * (the `SHARED_NVP_PRIVATE_DOMAIN_MEMBER` entry for its `fundID`). + */ +function getFeedSettingsFromCardFeeds(cardFeeds: OnyxEntry, card: AssignedCardForFeedAccess | undefined): CustomCardFeedData | undefined { + if (!card) { + return undefined; + } + + return cardFeeds?.settings?.companyCards?.[getCompanyCardFeed(card.bank)]; } /** @@ -701,6 +713,7 @@ export { getExpensifyCardFeedsForDisplay, getCardFeedsForDisplayPerPolicy, getFeedSettingsForCard, + getFeedSettingsFromCardFeeds, getPolicyIDsNamedByCardFeeds, getAssignedCardFeedAccess, getVisibleCompanyCardFeedsForSelector, diff --git a/src/pages/iou/DynamicSplitExpenseEditPage.tsx b/src/pages/iou/DynamicSplitExpenseEditPage.tsx index 0131405c4752..3d8a7559839c 100644 --- a/src/pages/iou/DynamicSplitExpenseEditPage.tsx +++ b/src/pages/iou/DynamicSplitExpenseEditPage.tsx @@ -35,7 +35,7 @@ import type {ViolationField} from '@hooks/useViolations'; import {initDraftSplitExpenseDataForEdit, removeSplitExpenseField, updateSplitExpenseDraftField, updateSplitExpenseField} from '@libs/actions/IOU/SplitExpenseItems'; import {openPolicyCategoriesPage} from '@libs/actions/Policy/Category'; import {openPolicyTagsPage} from '@libs/actions/Policy/Tag'; -import {getCompanyCardFeed} from '@libs/CardUtils'; +import {getFeedSettingsFromCardFeeds} from '@libs/CardFeedUtils'; import {getDecodedLeafCategoryName, isCategoryDescriptionRequired, isCategoryMissing} from '@libs/CategoryUtils'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; @@ -271,7 +271,7 @@ function DynamicSplitExpenseEditPage({route}: DynamicSplitExpenseEditPageProps) const shouldShowBillable = (isPolicyExpenseChat || isExpenseUnreported) && (!!splitExpenseDraftTransactionDetails?.billable || isBillableEnabledOnPolicy(effectivePolicy)); const currentReimbursable = splitExpenseDraftTransaction?.reimbursable ?? transaction?.reimbursable; const isReimbursableDifferentFromPolicyDefault = effectivePolicy?.defaultReimbursable !== undefined && !!currentReimbursable !== effectivePolicy.defaultReimbursable; - const transactionCardFeedSettings = transactionCard ? cardFeedsForTransactionCard?.settings?.companyCards?.[getCompanyCardFeed(transactionCard.bank)] : undefined; + const transactionCardFeedSettings = getFeedSettingsFromCardFeeds(cardFeedsForTransactionCard, transactionCard); // The workspace cash expense rules don't apply to card expenses, which follow their card feed's reimbursable setting instead const shouldShowReimbursable = (isPolicyExpenseChat || (isExpenseUnreported && !!effectivePolicy)) && From ea5f2293c463d7a088a4361c1b08fc222e62fa22 Mon Sep 17 00:00:00 2001 From: "dmkt9 (via MelvinBot)" Date: Fri, 16 Oct 2026 09:34:34 +0000 Subject: [PATCH 5/6] Fall back to the feed setting when reviewing unstamped company card duplicates Company card expenses imported before the feed's reimbursable setting was stamped on them hid the Reimbursable review step even on "Do Not Force" feeds. The duplicate review now passes the kept expense's feed settings to compareDuplicateTransactionFields, matching the expense view and bulk edit. Co-authored-by: dmkt9 --- .../MoneyRequestHeaderPrimaryAction.tsx | 3 ++ .../TransactionPreview/index.tsx | 16 ++++++++++- src/hooks/useTransactionCardFeedSettings.ts | 23 +++++++++++++++ src/libs/TransactionPreviewUtils.ts | 13 ++++++++- src/libs/TransactionUtils/duplicates.ts | 7 +++-- .../DynamicReviewBillablePage.tsx | 15 +++++++++- .../DynamicReviewCategoryPage.tsx | 15 +++++++++- .../DynamicReviewDescriptionPage.tsx | 15 +++++++++- .../DynamicReviewMerchantPage.tsx | 15 +++++++++- .../DynamicReviewPage.tsx | 3 ++ .../DynamicReviewReimbursablePage.tsx | 15 +++++++++- .../DynamicReviewTagPage.tsx | 15 +++++++++- .../DynamicReviewTaxCodePage.tsx | 15 +++++++++- tests/unit/TransactionUtilsTest.ts | 28 +++++++++++++++++++ 14 files changed, 187 insertions(+), 11 deletions(-) create mode 100644 src/hooks/useTransactionCardFeedSettings.ts diff --git a/src/components/MoneyRequestHeaderPrimaryAction.tsx b/src/components/MoneyRequestHeaderPrimaryAction.tsx index 1c8eefa6fb24..0f72c2ae06ed 100644 --- a/src/components/MoneyRequestHeaderPrimaryAction.tsx +++ b/src/components/MoneyRequestHeaderPrimaryAction.tsx @@ -8,6 +8,7 @@ import {usePersonalDetail} from '@hooks/usePersonalDetails'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useShouldDisplayButtonsInSeparateLine from '@hooks/useShouldDisplayButtonsInSeparateLine'; import useThemeStyles from '@hooks/useThemeStyles'; +import useTransactionCardFeedSettings from '@hooks/useTransactionCardFeedSettings'; import useTransactionViolations from '@hooks/useTransactionViolations'; import {changeMoneyRequestHoldStatus} from '@libs/actions/IOU/Hold'; @@ -76,6 +77,7 @@ function MoneyRequestHeaderPrimaryAction({reportID}: MoneyRequestHeaderPrimaryAc ? (getOriginalMessage(parentReportAction)?.IOUTransactionID ?? CONST.DEFAULT_NUMBER_ID) : CONST.DEFAULT_NUMBER_ID; const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionIDFromAction}`); + const transactionCardFeedSettings = useTransactionCardFeedSettings(transaction); const [transactionReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(transaction?.reportID)}`); const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${getNonEmptyStringOnyxID(transactionReport?.policyID)}`); const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${getNonEmptyStringOnyxID(transactionReport?.policyID)}`); @@ -176,6 +178,7 @@ function MoneyRequestHeaderPrimaryAction({reportID}: MoneyRequestHeaderPrimaryAc policyCategories, policyTags ?? {}, transactionReport, + transactionCardFeedSettings, ), ); }} diff --git a/src/components/ReportActionItem/TransactionPreview/index.tsx b/src/components/ReportActionItem/TransactionPreview/index.tsx index b1850100660e..f44e629274b9 100644 --- a/src/components/ReportActionItem/TransactionPreview/index.tsx +++ b/src/components/ReportActionItem/TransactionPreview/index.tsx @@ -5,6 +5,7 @@ import {showContextMenuForReport, useShowContextMenuActions, useShowContextMenuS import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import useTransactionCardFeedSettings from '@hooks/useTransactionCardFeedSettings'; import useTransactionViolations from '@hooks/useTransactionViolations'; import ControlSelection from '@libs/ControlSelection'; @@ -85,8 +86,21 @@ function TransactionPreview(props: TransactionPreviewProps) { clearIOUError(chatReportID); }, [chatReportID]); + const transactionCardFeedSettings = useTransactionCardFeedSettings(transaction); const navigateToReviewFields = () => - Navigation.navigate(getReviewNavigationRoute(Navigation.getActiveRoute(), route.params?.reportID, transaction, duplicates, policy, policyCategories, policyTags ?? {}, report)); + Navigation.navigate( + getReviewNavigationRoute( + Navigation.getActiveRoute(), + route.params?.reportID, + transaction, + duplicates, + policy, + policyCategories, + policyTags ?? {}, + report, + transactionCardFeedSettings, + ), + ); const transactionPreview = transaction; diff --git a/src/hooks/useTransactionCardFeedSettings.ts b/src/hooks/useTransactionCardFeedSettings.ts new file mode 100644 index 000000000000..8e203bdce1fd --- /dev/null +++ b/src/hooks/useTransactionCardFeedSettings.ts @@ -0,0 +1,23 @@ +import {getFeedSettingsFromCardFeeds} from '@libs/CardFeedUtils'; +import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; + +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Transaction} from '@src/types/onyx'; +import type {CustomCardFeedData} from '@src/types/onyx/CardFeeds'; + +import type {OnyxEntry} from 'react-native-onyx'; + +import useOnyx from './useOnyx'; + +/** + * The settings of the company card feed that a card expense was imported from. + * Returns undefined for cash expenses and for cards whose feed isn't loaded. + */ +function useTransactionCardFeedSettings(transaction: OnyxEntry>): CustomCardFeedData | undefined { + const [nonPersonalAndWorkspaceCards] = useOnyx(ONYXKEYS.DERIVED.NON_PERSONAL_AND_WORKSPACE_CARD_LIST); + const transactionCard = transaction?.cardID ? nonPersonalAndWorkspaceCards?.[transaction.cardID] : undefined; + const [cardFeedsForTransactionCard] = useOnyx(`${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${getNonEmptyStringOnyxID(transactionCard?.fundID)}`); + return getFeedSettingsFromCardFeeds(cardFeedsForTransactionCard, transactionCard); +} + +export default useTransactionCardFeedSettings; diff --git a/src/libs/TransactionPreviewUtils.ts b/src/libs/TransactionPreviewUtils.ts index 748e413cc96c..7a3da8c8b3b6 100644 --- a/src/libs/TransactionPreviewUtils.ts +++ b/src/libs/TransactionPreviewUtils.ts @@ -5,6 +5,7 @@ import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type * as OnyxTypes from '@src/types/onyx'; +import type {CustomCardFeedData} from '@src/types/onyx/CardFeeds'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {Locale as DateFnsLocale} from 'date-fns'; @@ -63,10 +64,20 @@ const getReviewNavigationRoute = ( policyCategories: OnyxTypes.PolicyCategories | undefined, policyTags: OnyxTypes.PolicyTagLists, transactionReport: OnyxEntry, + transactionCardFeedSettings?: CustomCardFeedData, ) => { // Use set method to prevent merging fields from the previous expense // (e.g., category, tag, tax) that may be not enabled/available in the new expense's policy. - const comparisonResult = compareDuplicateTransactionFields(policyTags, transaction, duplicates, transactionReport, transaction?.transactionID, policy, policyCategories); + const comparisonResult = compareDuplicateTransactionFields( + policyTags, + transaction, + duplicates, + transactionReport, + transaction?.transactionID, + policy, + policyCategories, + transactionCardFeedSettings, + ); setReviewDuplicatesKey( { ...comparisonResult.keep, diff --git a/src/libs/TransactionUtils/duplicates.ts b/src/libs/TransactionUtils/duplicates.ts index 485024767d44..aa3bcae8151c 100644 --- a/src/libs/TransactionUtils/duplicates.ts +++ b/src/libs/TransactionUtils/duplicates.ts @@ -13,6 +13,7 @@ import type {UpdateMoneyRequestDataKeys} from '@userActions/IOU/UpdateMoneyReque import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Policy, PolicyCategories, PolicyTagLists, Report, ReviewDuplicates, Transaction, TransactionViolation, TransactionViolations} from '@src/types/onyx'; +import type {CustomCardFeedData} from '@src/types/onyx/CardFeeds'; import type {OnyxData} from '@src/types/onyx/Request'; import type {Comment} from '@src/types/onyx/Transaction'; @@ -247,6 +248,7 @@ function compareDuplicateTransactionFields( selectedTransactionID: string | undefined, policy: OnyxEntry, policyCategories: OnyxEntry, + selectedTransactionCardFeedSettings?: Pick, ): {keep: Partial; change: FieldsToChange} { const reportID = report?.reportID; const reviewingTransactionID = reviewingTransaction?.transactionID; @@ -399,7 +401,8 @@ function compareDuplicateTransactionFields( // The resolved reimbursable value is applied to the transaction that is kept, so when that kept transaction // is a card expense whose reimbursable value can't be changed, we keep its own value and hide the // reimbursable review step. Expensify Card expenses are always non-reimbursable, and company card expenses - // keep their forced value (e.g. "Force Yes" stays reimbursable). We gate on the selected (kept) transaction + // keep their forced value (e.g. "Force Yes" stays reimbursable). Company card expenses without a stamped setting + // fall back to their feed's setting (`selectedTransactionCardFeedSettings`). We gate on the selected (kept) transaction // rather than the reviewing transaction: the Review* pages recompute this from the thread's transaction (which // may be the cash expense) while the kept transaction is the card expense, so gating on the reviewing // transaction would re-add the reimbursable step on back navigation. Gating on any duplicate in the set @@ -407,7 +410,7 @@ function compareDuplicateTransactionFields( const selectedTransaction = transactions.find((transactionItem) => transactionItem?.transactionID === selectedTransactionID) ?? firstTransaction; if (isExpensifyCardTransaction(selectedTransaction)) { keep[fieldName] = false; - } else if (!isCardTransactionReimbursableEditable(selectedTransaction)) { + } else if (!isCardTransactionReimbursableEditable(selectedTransaction, selectedTransactionCardFeedSettings)) { keep[fieldName] = getReimbursable(selectedTransaction); } else if (areAllFieldsEqualForKey) { keep[fieldName] = firstTransaction?.[keys[0]] ?? firstTransaction?.[keys[1]]; diff --git a/src/pages/TransactionDuplicate/DynamicReviewBillablePage.tsx b/src/pages/TransactionDuplicate/DynamicReviewBillablePage.tsx index ff4a84651e20..d5bc639189ad 100644 --- a/src/pages/TransactionDuplicate/DynamicReviewBillablePage.tsx +++ b/src/pages/TransactionDuplicate/DynamicReviewBillablePage.tsx @@ -5,6 +5,7 @@ import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useReviewDuplicatesNavigation from '@hooks/useReviewDuplicatesNavigation'; +import useTransactionCardFeedSettings from '@hooks/useTransactionCardFeedSettings'; import useTransactionsByID from '@hooks/useTransactionsByID'; import {setReviewDuplicatesKey} from '@libs/actions/Transaction'; @@ -46,7 +47,19 @@ function DynamicReviewBillablePage() { const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${getNonEmptyStringOnyxID(reviewDuplicatesReport?.policyID)}`); const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${getNonEmptyStringOnyxID(reviewDuplicatesReport?.policyID)}`); - const compareResult = compareDuplicateTransactionFields(policyTags ?? {}, transaction, allDuplicates, reviewDuplicatesReport, reviewDuplicates?.transactionID, policy, policyCategories); + const keptTransaction = allDuplicates?.find((duplicate) => duplicate?.transactionID === reviewDuplicates?.transactionID) ?? transaction; + const keptTransactionCardFeedSettings = useTransactionCardFeedSettings(keptTransaction); + + const compareResult = compareDuplicateTransactionFields( + policyTags ?? {}, + transaction, + allDuplicates, + reviewDuplicatesReport, + reviewDuplicates?.transactionID, + policy, + policyCategories, + keptTransactionCardFeedSettings, + ); const stepNames = Object.keys(compareResult.change ?? {}).map((key, index) => (index + 1).toString()); const {currentScreenIndex, navigateToNextScreen} = useReviewDuplicatesNavigation(Object.keys(compareResult.change ?? {}), 'billable', route.params.reportID, route.params.backTo); const options = useMemo( diff --git a/src/pages/TransactionDuplicate/DynamicReviewCategoryPage.tsx b/src/pages/TransactionDuplicate/DynamicReviewCategoryPage.tsx index 0555df16db40..6d4187bf2f61 100644 --- a/src/pages/TransactionDuplicate/DynamicReviewCategoryPage.tsx +++ b/src/pages/TransactionDuplicate/DynamicReviewCategoryPage.tsx @@ -5,6 +5,7 @@ import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useReviewDuplicatesNavigation from '@hooks/useReviewDuplicatesNavigation'; +import useTransactionCardFeedSettings from '@hooks/useTransactionCardFeedSettings'; import useTransactionsByID from '@hooks/useTransactionsByID'; import {setReviewDuplicatesKey} from '@libs/actions/Transaction'; @@ -46,7 +47,19 @@ function DynamicReviewCategoryPage() { const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${getNonEmptyStringOnyxID(reviewDuplicatesReport?.policyID)}`); const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${getNonEmptyStringOnyxID(reviewDuplicatesReport?.policyID)}`); - const compareResult = compareDuplicateTransactionFields(policyTags ?? {}, transaction, allDuplicates, reviewDuplicatesReport, reviewDuplicates?.transactionID, policy, policyCategories); + const keptTransaction = allDuplicates?.find((duplicate) => duplicate?.transactionID === reviewDuplicates?.transactionID) ?? transaction; + const keptTransactionCardFeedSettings = useTransactionCardFeedSettings(keptTransaction); + + const compareResult = compareDuplicateTransactionFields( + policyTags ?? {}, + transaction, + allDuplicates, + reviewDuplicatesReport, + reviewDuplicates?.transactionID, + policy, + policyCategories, + keptTransactionCardFeedSettings, + ); const stepNames = Object.keys(compareResult.change ?? {}).map((key, index) => (index + 1).toString()); const {currentScreenIndex, navigateToNextScreen} = useReviewDuplicatesNavigation(Object.keys(compareResult.change ?? {}), 'category', route.params.reportID, route.params.backTo); const options = useMemo( diff --git a/src/pages/TransactionDuplicate/DynamicReviewDescriptionPage.tsx b/src/pages/TransactionDuplicate/DynamicReviewDescriptionPage.tsx index 247626fbc318..ad46f1e29696 100644 --- a/src/pages/TransactionDuplicate/DynamicReviewDescriptionPage.tsx +++ b/src/pages/TransactionDuplicate/DynamicReviewDescriptionPage.tsx @@ -5,6 +5,7 @@ import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useReviewDuplicatesNavigation from '@hooks/useReviewDuplicatesNavigation'; +import useTransactionCardFeedSettings from '@hooks/useTransactionCardFeedSettings'; import useTransactionsByID from '@hooks/useTransactionsByID'; import {setReviewDuplicatesKey} from '@libs/actions/Transaction'; @@ -47,7 +48,19 @@ function DynamicReviewDescriptionPage() { const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${getNonEmptyStringOnyxID(reviewDuplicatesReport?.policyID)}`); const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${getNonEmptyStringOnyxID(reviewDuplicatesReport?.policyID)}`); - const compareResult = compareDuplicateTransactionFields(policyTags ?? {}, transaction, allDuplicates, reviewDuplicatesReport, reviewDuplicates?.transactionID, policy, policyCategories); + const keptTransaction = allDuplicates?.find((duplicate) => duplicate?.transactionID === reviewDuplicates?.transactionID) ?? transaction; + const keptTransactionCardFeedSettings = useTransactionCardFeedSettings(keptTransaction); + + const compareResult = compareDuplicateTransactionFields( + policyTags ?? {}, + transaction, + allDuplicates, + reviewDuplicatesReport, + reviewDuplicates?.transactionID, + policy, + policyCategories, + keptTransactionCardFeedSettings, + ); const stepNames = Object.keys(compareResult.change ?? {}).map((key, index) => (index + 1).toString()); const {currentScreenIndex, navigateToNextScreen} = useReviewDuplicatesNavigation(Object.keys(compareResult.change ?? {}), 'description', route.params.reportID, route.params.backTo); const options = useMemo( diff --git a/src/pages/TransactionDuplicate/DynamicReviewMerchantPage.tsx b/src/pages/TransactionDuplicate/DynamicReviewMerchantPage.tsx index 067495ea62a5..59d08ee3ec47 100644 --- a/src/pages/TransactionDuplicate/DynamicReviewMerchantPage.tsx +++ b/src/pages/TransactionDuplicate/DynamicReviewMerchantPage.tsx @@ -5,6 +5,7 @@ import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useReviewDuplicatesNavigation from '@hooks/useReviewDuplicatesNavigation'; +import useTransactionCardFeedSettings from '@hooks/useTransactionCardFeedSettings'; import useTransactionsByID from '@hooks/useTransactionsByID'; import {setReviewDuplicatesKey} from '@libs/actions/Transaction'; @@ -45,7 +46,19 @@ function DynamicReviewMerchantPage() { const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${getNonEmptyStringOnyxID(reviewDuplicatesReport?.policyID)}`); const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${getNonEmptyStringOnyxID(reviewDuplicatesReport?.policyID)}`); - const compareResult = compareDuplicateTransactionFields(policyTags ?? {}, transaction, allDuplicates, reviewDuplicatesReport, reviewDuplicates?.transactionID, policy, policyCategories); + const keptTransaction = allDuplicates?.find((duplicate) => duplicate?.transactionID === reviewDuplicates?.transactionID) ?? transaction; + const keptTransactionCardFeedSettings = useTransactionCardFeedSettings(keptTransaction); + + const compareResult = compareDuplicateTransactionFields( + policyTags ?? {}, + transaction, + allDuplicates, + reviewDuplicatesReport, + reviewDuplicates?.transactionID, + policy, + policyCategories, + keptTransactionCardFeedSettings, + ); const stepNames = Object.keys(compareResult.change ?? {}).map((key, index) => (index + 1).toString()); const {currentScreenIndex, navigateToNextScreen} = useReviewDuplicatesNavigation(Object.keys(compareResult.change ?? {}), 'merchant', route.params.reportID, route.params.backTo); const options = useMemo( diff --git a/src/pages/TransactionDuplicate/DynamicReviewPage.tsx b/src/pages/TransactionDuplicate/DynamicReviewPage.tsx index b053d14073b4..a6f81d4ccf78 100644 --- a/src/pages/TransactionDuplicate/DynamicReviewPage.tsx +++ b/src/pages/TransactionDuplicate/DynamicReviewPage.tsx @@ -17,6 +17,7 @@ import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; import useThemeStyles from '@hooks/useThemeStyles'; +import useTransactionCardFeedSettings from '@hooks/useTransactionCardFeedSettings'; import useTransactionViolations from '@hooks/useTransactionViolations'; import {clearDeleteTransactionNavigateBackUrl, openReport} from '@libs/actions/Report'; @@ -113,6 +114,7 @@ function DynamicReviewPage() { const [selectedTransactionPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${selectedTransactionReport?.policyID}`); const [selectedTransactionPolicyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${selectedTransactionReport?.policyID}`); const [selectedTransactionPolicyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${selectedTransactionReport?.policyID}`); + const selectedTransactionCardFeedSettings = useTransactionCardFeedSettings(selectedTransaction); const isTrackIntentUser = isTrackOnboardingChoice(introSelected?.choice); const hasSettledOrApprovedTransaction = transactions.some((transaction) => isSettled(transaction?.reportID) || isReportIDApproved(transaction?.reportID)); @@ -250,6 +252,7 @@ function DynamicReviewPage() { selectedTransactionPolicyCategories, selectedTransactionPolicyTags ?? {}, selectedTransactionReport, + selectedTransactionCardFeedSettings, ), ); }; diff --git a/src/pages/TransactionDuplicate/DynamicReviewReimbursablePage.tsx b/src/pages/TransactionDuplicate/DynamicReviewReimbursablePage.tsx index 98b45971e96d..efed7ae2ec7f 100644 --- a/src/pages/TransactionDuplicate/DynamicReviewReimbursablePage.tsx +++ b/src/pages/TransactionDuplicate/DynamicReviewReimbursablePage.tsx @@ -5,6 +5,7 @@ import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useReviewDuplicatesNavigation from '@hooks/useReviewDuplicatesNavigation'; +import useTransactionCardFeedSettings from '@hooks/useTransactionCardFeedSettings'; import useTransactionsByID from '@hooks/useTransactionsByID'; import {setReviewDuplicatesKey} from '@libs/actions/Transaction'; @@ -45,7 +46,19 @@ function DynamicReviewReimbursablePage() { const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${getNonEmptyStringOnyxID(reviewDuplicatesReport?.policyID)}`); const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${getNonEmptyStringOnyxID(reviewDuplicatesReport?.policyID)}`); - const compareResult = compareDuplicateTransactionFields(policyTags ?? {}, transaction, allDuplicates, reviewDuplicatesReport, reviewDuplicates?.transactionID, policy, policyCategories); + const keptTransaction = allDuplicates?.find((duplicate) => duplicate?.transactionID === reviewDuplicates?.transactionID) ?? transaction; + const keptTransactionCardFeedSettings = useTransactionCardFeedSettings(keptTransaction); + + const compareResult = compareDuplicateTransactionFields( + policyTags ?? {}, + transaction, + allDuplicates, + reviewDuplicatesReport, + reviewDuplicates?.transactionID, + policy, + policyCategories, + keptTransactionCardFeedSettings, + ); const stepNames = Object.keys(compareResult.change ?? {}).map((key, index) => (index + 1).toString()); const {currentScreenIndex, navigateToNextScreen} = useReviewDuplicatesNavigation(Object.keys(compareResult.change ?? {}), 'reimbursable', route.params.reportID, route.params.backTo); const options = useMemo( diff --git a/src/pages/TransactionDuplicate/DynamicReviewTagPage.tsx b/src/pages/TransactionDuplicate/DynamicReviewTagPage.tsx index ae44438a43b6..c235d87091bc 100644 --- a/src/pages/TransactionDuplicate/DynamicReviewTagPage.tsx +++ b/src/pages/TransactionDuplicate/DynamicReviewTagPage.tsx @@ -5,6 +5,7 @@ import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useReviewDuplicatesNavigation from '@hooks/useReviewDuplicatesNavigation'; +import useTransactionCardFeedSettings from '@hooks/useTransactionCardFeedSettings'; import useTransactionsByID from '@hooks/useTransactionsByID'; import {setReviewDuplicatesKey} from '@libs/actions/Transaction'; @@ -46,7 +47,19 @@ function DynamicReviewTagPage() { const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${getNonEmptyStringOnyxID(reviewDuplicatesReport?.policyID)}`); const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${getNonEmptyStringOnyxID(reviewDuplicatesReport?.policyID)}`); - const compareResult = compareDuplicateTransactionFields(policyTags ?? {}, transaction, allDuplicates, reviewDuplicatesReport, reviewDuplicates?.transactionID, policy, policyCategories); + const keptTransaction = allDuplicates?.find((duplicate) => duplicate?.transactionID === reviewDuplicates?.transactionID) ?? transaction; + const keptTransactionCardFeedSettings = useTransactionCardFeedSettings(keptTransaction); + + const compareResult = compareDuplicateTransactionFields( + policyTags ?? {}, + transaction, + allDuplicates, + reviewDuplicatesReport, + reviewDuplicates?.transactionID, + policy, + policyCategories, + keptTransactionCardFeedSettings, + ); const stepNames = Object.keys(compareResult.change ?? {}).map((key, index) => (index + 1).toString()); const {currentScreenIndex, navigateToNextScreen} = useReviewDuplicatesNavigation(Object.keys(compareResult.change ?? {}), 'tag', route.params.reportID, route.params.backTo); const options = useMemo( diff --git a/src/pages/TransactionDuplicate/DynamicReviewTaxCodePage.tsx b/src/pages/TransactionDuplicate/DynamicReviewTaxCodePage.tsx index 62950433a52d..911889b2e2a4 100644 --- a/src/pages/TransactionDuplicate/DynamicReviewTaxCodePage.tsx +++ b/src/pages/TransactionDuplicate/DynamicReviewTaxCodePage.tsx @@ -7,6 +7,7 @@ import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePolicy from '@hooks/usePolicy'; import useReviewDuplicatesNavigation from '@hooks/useReviewDuplicatesNavigation'; +import useTransactionCardFeedSettings from '@hooks/useTransactionCardFeedSettings'; import useTransactionsByID from '@hooks/useTransactionsByID'; import {setReviewDuplicatesKey} from '@libs/actions/Transaction'; @@ -51,7 +52,19 @@ function DynamicReviewTaxCodePage() { const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${getNonEmptyStringOnyxID(reviewDuplicatesReport?.policyID)}`); const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${getNonEmptyStringOnyxID(reviewDuplicatesReport?.policyID)}`); - const compareResult = compareDuplicateTransactionFields(policyTags ?? {}, transaction, allDuplicates, reviewDuplicatesReport, reviewDuplicates?.transactionID, policy, policyCategories); + const keptTransaction = allDuplicates?.find((duplicate) => duplicate?.transactionID === reviewDuplicates?.transactionID) ?? transaction; + const keptTransactionCardFeedSettings = useTransactionCardFeedSettings(keptTransaction); + + const compareResult = compareDuplicateTransactionFields( + policyTags ?? {}, + transaction, + allDuplicates, + reviewDuplicatesReport, + reviewDuplicates?.transactionID, + policy, + policyCategories, + keptTransactionCardFeedSettings, + ); const stepNames = Object.keys(compareResult.change ?? {}).map((key, index) => (index + 1).toString()); const selectedTaxCode = reviewDuplicates?.taxCode ? resolveCurrentTaxCode(policy, reviewDuplicates.taxCode) : reviewDuplicates?.taxCode; diff --git a/tests/unit/TransactionUtilsTest.ts b/tests/unit/TransactionUtilsTest.ts index b1aec1be8826..e222524aa487 100644 --- a/tests/unit/TransactionUtilsTest.ts +++ b/tests/unit/TransactionUtilsTest.ts @@ -5198,6 +5198,34 @@ describe('TransactionUtils', () => { expect(result.change.reimbursable).toEqual(expect.arrayContaining([true, false])); }); + it('should surface the reimbursable step when an unstamped company card expense belongs to a "Do Not Force" feed', () => { + // Given a kept company card expense imported before the feed setting was stamped on it, from a "Do Not Force" feed + const companyCardTransaction = generateTransaction({reimbursable: true, managedCard: true}); + const duplicates = [generateTransaction({reimbursable: false, managedCard: false})]; + const feedSettings = {forceReimbursable: CONST.COMPANY_CARD.FORCE_REIMBURSABLE.DEFAULT_NO}; + + // When the duplicate fields are compared with the kept expense's feed settings + const result = TransactionUtils.compareDuplicateTransactionFields({}, companyCardTransaction, duplicates, mockReport, undefined, mockPolicy, undefined, feedSettings); + + // Then the feed setting allows changes, so the user picks the reimbursable value in the review step + expect(result.keep.reimbursable).toBeUndefined(); + expect(result.change.reimbursable).toEqual(expect.arrayContaining([true, false])); + }); + + it('should keep the value of an unstamped company card expense whose feed forces it', () => { + // Given a kept unstamped company card expense from a "Force Yes" feed, and a non-reimbursable duplicate + const companyCardTransaction = generateTransaction({reimbursable: true, managedCard: true}); + const duplicates = [generateTransaction({reimbursable: false, managedCard: false})]; + const feedSettings = {forceReimbursable: CONST.COMPANY_CARD.FORCE_REIMBURSABLE.FORCE_YES}; + + // When the duplicate fields are compared with the kept expense's feed settings + const result = TransactionUtils.compareDuplicateTransactionFields({}, companyCardTransaction, duplicates, mockReport, undefined, mockPolicy, undefined, feedSettings); + + // Then the expense stays reimbursable and the step is hidden + expect(result.keep.reimbursable).toBe(true); + expect(result.change.reimbursable).toBeUndefined(); + }); + it('should force reimbursable to false when the kept transaction is an Expensify Card expense', () => { // Given a kept Expensify Card expense and a reimbursable duplicate const expensifyCardTransaction = generateTransaction({ From be30f9348d4d45cedd99700ee948f19344ee6eeb Mon Sep 17 00:00:00 2001 From: "dmkt9 (via MelvinBot)" Date: Tue, 20 Oct 2026 07:07:36 +0000 Subject: [PATCH 6/6] Use only the stamped value to decide whether a company card expense's reimbursable value can change Changing the feed's reimbursable setting should only affect expenses imported afterward, so the current feed setting is no longer used as a fallback for expenses without a stamped value. Co-authored-by: dmkt9 --- .../sections/selectors.ts | 3 +- .../hooks/useFooterDerivedFlags.ts | 10 +--- .../MoneyRequestHeaderPrimaryAction.tsx | 3 -- .../ReportActionItem/MoneyRequestView.tsx | 4 +- .../TransactionPreview/index.tsx | 16 +----- src/hooks/useTransactionCardFeedSettings.ts | 23 --------- src/libs/CardFeedUtils.ts | 16 +----- src/libs/TransactionPreviewUtils.ts | 13 +---- src/libs/TransactionUtils/duplicates.ts | 7 +-- src/libs/TransactionUtils/index.ts | 14 ++--- .../SearchEditMultiplePage.tsx | 7 +-- .../DynamicReviewBillablePage.tsx | 15 +----- .../DynamicReviewCategoryPage.tsx | 15 +----- .../DynamicReviewDescriptionPage.tsx | 15 +----- .../DynamicReviewMerchantPage.tsx | 15 +----- .../DynamicReviewPage.tsx | 3 -- .../DynamicReviewReimbursablePage.tsx | 15 +----- .../DynamicReviewTagPage.tsx | 15 +----- .../DynamicReviewTaxCodePage.tsx | 15 +----- src/pages/iou/DynamicSplitExpenseEditPage.tsx | 7 +-- tests/ui/SearchEditMultiplePageTest.tsx | 8 +-- tests/unit/TransactionUtilsTest.ts | 51 ++++--------------- 22 files changed, 35 insertions(+), 255 deletions(-) delete mode 100644 src/hooks/useTransactionCardFeedSettings.ts diff --git a/src/components/MoneyRequestConfirmationList/sections/selectors.ts b/src/components/MoneyRequestConfirmationList/sections/selectors.ts index b47f9d4f1c01..db2a39a7280f 100644 --- a/src/components/MoneyRequestConfirmationList/sections/selectors.ts +++ b/src/components/MoneyRequestConfirmationList/sections/selectors.ts @@ -236,7 +236,7 @@ const taxSliceSelector = (t: OnyxEntry): TaxSlice | undefined => { // --- ConfirmationFieldList: useFooterDerivedFlags --- -type DerivedFlagsSlice = Pick; +type DerivedFlagsSlice = Pick; const derivedFlagsSliceSelector = (t: OnyxEntry): OnyxEntry => { if (!t) { @@ -249,7 +249,6 @@ const derivedFlagsSliceSelector = (t: OnyxEntry): OnyxEntry - Navigation.navigate( - getReviewNavigationRoute( - Navigation.getActiveRoute(), - route.params?.reportID, - transaction, - duplicates, - policy, - policyCategories, - policyTags ?? {}, - report, - transactionCardFeedSettings, - ), - ); + Navigation.navigate(getReviewNavigationRoute(Navigation.getActiveRoute(), route.params?.reportID, transaction, duplicates, policy, policyCategories, policyTags ?? {}, report)); const transactionPreview = transaction; diff --git a/src/hooks/useTransactionCardFeedSettings.ts b/src/hooks/useTransactionCardFeedSettings.ts deleted file mode 100644 index 8e203bdce1fd..000000000000 --- a/src/hooks/useTransactionCardFeedSettings.ts +++ /dev/null @@ -1,23 +0,0 @@ -import {getFeedSettingsFromCardFeeds} from '@libs/CardFeedUtils'; -import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; - -import ONYXKEYS from '@src/ONYXKEYS'; -import type {Transaction} from '@src/types/onyx'; -import type {CustomCardFeedData} from '@src/types/onyx/CardFeeds'; - -import type {OnyxEntry} from 'react-native-onyx'; - -import useOnyx from './useOnyx'; - -/** - * The settings of the company card feed that a card expense was imported from. - * Returns undefined for cash expenses and for cards whose feed isn't loaded. - */ -function useTransactionCardFeedSettings(transaction: OnyxEntry>): CustomCardFeedData | undefined { - const [nonPersonalAndWorkspaceCards] = useOnyx(ONYXKEYS.DERIVED.NON_PERSONAL_AND_WORKSPACE_CARD_LIST); - const transactionCard = transaction?.cardID ? nonPersonalAndWorkspaceCards?.[transaction.cardID] : undefined; - const [cardFeedsForTransactionCard] = useOnyx(`${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${getNonEmptyStringOnyxID(transactionCard?.fundID)}`); - return getFeedSettingsFromCardFeeds(cardFeedsForTransactionCard, transactionCard); -} - -export default useTransactionCardFeedSettings; diff --git a/src/libs/CardFeedUtils.ts b/src/libs/CardFeedUtils.ts index 420cc512184e..eba005cd50e8 100644 --- a/src/libs/CardFeedUtils.ts +++ b/src/libs/CardFeedUtils.ts @@ -283,19 +283,7 @@ function getFeedSettingsForCard(card: AssignedCardForFeedAccess, allCardFeeds: O return undefined; } - return getFeedSettingsFromCardFeeds(allCardFeeds?.[`${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${card.fundID}`], card); -} - -/** - * The settings of the company card feed an assigned card belongs to, read from the card's own feed record - * (the `SHARED_NVP_PRIVATE_DOMAIN_MEMBER` entry for its `fundID`). - */ -function getFeedSettingsFromCardFeeds(cardFeeds: OnyxEntry, card: AssignedCardForFeedAccess | undefined): CustomCardFeedData | undefined { - if (!card) { - return undefined; - } - - return cardFeeds?.settings?.companyCards?.[getCompanyCardFeed(card.bank)]; + return allCardFeeds?.[`${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${card.fundID}`]?.settings?.companyCards?.[getCompanyCardFeed(card.bank)]; } /** @@ -712,8 +700,6 @@ export { getCardFeedsForDisplay, getExpensifyCardFeedsForDisplay, getCardFeedsForDisplayPerPolicy, - getFeedSettingsForCard, - getFeedSettingsFromCardFeeds, getPolicyIDsNamedByCardFeeds, getAssignedCardFeedAccess, getVisibleCompanyCardFeedsForSelector, diff --git a/src/libs/TransactionPreviewUtils.ts b/src/libs/TransactionPreviewUtils.ts index 7a3da8c8b3b6..748e413cc96c 100644 --- a/src/libs/TransactionPreviewUtils.ts +++ b/src/libs/TransactionPreviewUtils.ts @@ -5,7 +5,6 @@ import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type * as OnyxTypes from '@src/types/onyx'; -import type {CustomCardFeedData} from '@src/types/onyx/CardFeeds'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {Locale as DateFnsLocale} from 'date-fns'; @@ -64,20 +63,10 @@ const getReviewNavigationRoute = ( policyCategories: OnyxTypes.PolicyCategories | undefined, policyTags: OnyxTypes.PolicyTagLists, transactionReport: OnyxEntry, - transactionCardFeedSettings?: CustomCardFeedData, ) => { // Use set method to prevent merging fields from the previous expense // (e.g., category, tag, tax) that may be not enabled/available in the new expense's policy. - const comparisonResult = compareDuplicateTransactionFields( - policyTags, - transaction, - duplicates, - transactionReport, - transaction?.transactionID, - policy, - policyCategories, - transactionCardFeedSettings, - ); + const comparisonResult = compareDuplicateTransactionFields(policyTags, transaction, duplicates, transactionReport, transaction?.transactionID, policy, policyCategories); setReviewDuplicatesKey( { ...comparisonResult.keep, diff --git a/src/libs/TransactionUtils/duplicates.ts b/src/libs/TransactionUtils/duplicates.ts index aa3bcae8151c..485024767d44 100644 --- a/src/libs/TransactionUtils/duplicates.ts +++ b/src/libs/TransactionUtils/duplicates.ts @@ -13,7 +13,6 @@ import type {UpdateMoneyRequestDataKeys} from '@userActions/IOU/UpdateMoneyReque import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Policy, PolicyCategories, PolicyTagLists, Report, ReviewDuplicates, Transaction, TransactionViolation, TransactionViolations} from '@src/types/onyx'; -import type {CustomCardFeedData} from '@src/types/onyx/CardFeeds'; import type {OnyxData} from '@src/types/onyx/Request'; import type {Comment} from '@src/types/onyx/Transaction'; @@ -248,7 +247,6 @@ function compareDuplicateTransactionFields( selectedTransactionID: string | undefined, policy: OnyxEntry, policyCategories: OnyxEntry, - selectedTransactionCardFeedSettings?: Pick, ): {keep: Partial; change: FieldsToChange} { const reportID = report?.reportID; const reviewingTransactionID = reviewingTransaction?.transactionID; @@ -401,8 +399,7 @@ function compareDuplicateTransactionFields( // The resolved reimbursable value is applied to the transaction that is kept, so when that kept transaction // is a card expense whose reimbursable value can't be changed, we keep its own value and hide the // reimbursable review step. Expensify Card expenses are always non-reimbursable, and company card expenses - // keep their forced value (e.g. "Force Yes" stays reimbursable). Company card expenses without a stamped setting - // fall back to their feed's setting (`selectedTransactionCardFeedSettings`). We gate on the selected (kept) transaction + // keep their forced value (e.g. "Force Yes" stays reimbursable). We gate on the selected (kept) transaction // rather than the reviewing transaction: the Review* pages recompute this from the thread's transaction (which // may be the cash expense) while the kept transaction is the card expense, so gating on the reviewing // transaction would re-add the reimbursable step on back navigation. Gating on any duplicate in the set @@ -410,7 +407,7 @@ function compareDuplicateTransactionFields( const selectedTransaction = transactions.find((transactionItem) => transactionItem?.transactionID === selectedTransactionID) ?? firstTransaction; if (isExpensifyCardTransaction(selectedTransaction)) { keep[fieldName] = false; - } else if (!isCardTransactionReimbursableEditable(selectedTransaction, selectedTransactionCardFeedSettings)) { + } else if (!isCardTransactionReimbursableEditable(selectedTransaction)) { keep[fieldName] = getReimbursable(selectedTransaction); } else if (areAllFieldsEqualForKey) { keep[fieldName] = firstTransaction?.[keys[0]] ?? firstTransaction?.[keys[1]]; diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index d4df9a4e27e9..6b7cc7d98d53 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -30,7 +30,6 @@ import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Card, CardList, OnyxInputOrEntry, PersonalDetails, Policy, RecentWaypoint, Report, Transaction} from '@src/types/onyx'; -import type {CustomCardFeedData} from '@src/types/onyx/CardFeeds'; import type {Attendee, DistanceExpenseType} from '@src/types/onyx/IOU'; import type {Errors, PendingAction} from '@src/types/onyx/OnyxCommon'; import type {Comment, UnreportedTransaction, Waypoint, WaypointCollection} from '@src/types/onyx/Transaction'; @@ -1014,13 +1013,10 @@ function isManagedCardTransaction(transaction: OnyxEntry>, - feedSettings?: Pick, -): boolean { +function isCardTransactionReimbursableEditable(transaction: OnyxEntry>): boolean { if (!isManagedCardTransaction(transaction)) { return true; } @@ -1029,9 +1025,7 @@ function isCardTransactionReimbursableEditable( return false; } - // A feed without a stored reimbursable setting behaves as "Force No" - const feedForceReimbursable = feedSettings ? (feedSettings.forceReimbursable ?? CONST.COMPANY_CARD.FORCE_REIMBURSABLE.FORCE_NO) : undefined; - const forceReimbursable = transaction?.comment?.forceReimbursable ?? feedForceReimbursable; + const forceReimbursable = transaction?.comment?.forceReimbursable; return ( forceReimbursable === CONST.COMPANY_CARD.FORCE_REIMBURSABLE.DEFAULT_YES || diff --git a/src/pages/Search/SearchEditMultiple/SearchEditMultiplePage.tsx b/src/pages/Search/SearchEditMultiple/SearchEditMultiplePage.tsx index 9e026296f5c1..479c3d01f1cd 100644 --- a/src/pages/Search/SearchEditMultiple/SearchEditMultiplePage.tsx +++ b/src/pages/Search/SearchEditMultiple/SearchEditMultiplePage.tsx @@ -22,7 +22,6 @@ import usePersonalPolicy from '@hooks/usePersonalPolicy'; import useThemeStyles from '@hooks/useThemeStyles'; import {clearBulkEditDraftTransaction, updateMultipleMoneyRequests} from '@libs/actions/IOU/BulkEdit'; -import {getFeedSettingsForCard} from '@libs/CardFeedUtils'; import Navigation from '@libs/Navigation/Navigation'; import {hasEnabledOptions} from '@libs/OptionsListUtils'; import Parser from '@libs/Parser'; @@ -96,8 +95,6 @@ function SearchEditMultiplePage() { const [reportNameValuePairs] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS); const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); - const [nonPersonalAndWorkspaceCards] = useOnyx(ONYXKEYS.DERIVED.NON_PERSONAL_AND_WORKSPACE_CARD_LIST); - const [allCardFeeds] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER); const snapshotData = currentSearchResults?.data; const mergedTransactions = withSnapshotTransactions(allTransactions, snapshotData); @@ -157,9 +154,7 @@ function SearchEditMultiplePage() { }); const areSelectedTransactionsReimbursable = selectedTransactionContexts.every(({transaction, report, transactionPolicy}) => { - const transactionCard = transaction.cardID ? nonPersonalAndWorkspaceCards?.[transaction.cardID] : undefined; - const transactionCardFeedSettings = transactionCard ? getFeedSettingsForCard(transactionCard, allCardFeeds) : undefined; - if (!isCardTransactionReimbursableEditable(transaction, transactionCardFeedSettings)) { + if (!isCardTransactionReimbursableEditable(transaction)) { return false; } diff --git a/src/pages/TransactionDuplicate/DynamicReviewBillablePage.tsx b/src/pages/TransactionDuplicate/DynamicReviewBillablePage.tsx index d5bc639189ad..ff4a84651e20 100644 --- a/src/pages/TransactionDuplicate/DynamicReviewBillablePage.tsx +++ b/src/pages/TransactionDuplicate/DynamicReviewBillablePage.tsx @@ -5,7 +5,6 @@ import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useReviewDuplicatesNavigation from '@hooks/useReviewDuplicatesNavigation'; -import useTransactionCardFeedSettings from '@hooks/useTransactionCardFeedSettings'; import useTransactionsByID from '@hooks/useTransactionsByID'; import {setReviewDuplicatesKey} from '@libs/actions/Transaction'; @@ -47,19 +46,7 @@ function DynamicReviewBillablePage() { const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${getNonEmptyStringOnyxID(reviewDuplicatesReport?.policyID)}`); const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${getNonEmptyStringOnyxID(reviewDuplicatesReport?.policyID)}`); - const keptTransaction = allDuplicates?.find((duplicate) => duplicate?.transactionID === reviewDuplicates?.transactionID) ?? transaction; - const keptTransactionCardFeedSettings = useTransactionCardFeedSettings(keptTransaction); - - const compareResult = compareDuplicateTransactionFields( - policyTags ?? {}, - transaction, - allDuplicates, - reviewDuplicatesReport, - reviewDuplicates?.transactionID, - policy, - policyCategories, - keptTransactionCardFeedSettings, - ); + const compareResult = compareDuplicateTransactionFields(policyTags ?? {}, transaction, allDuplicates, reviewDuplicatesReport, reviewDuplicates?.transactionID, policy, policyCategories); const stepNames = Object.keys(compareResult.change ?? {}).map((key, index) => (index + 1).toString()); const {currentScreenIndex, navigateToNextScreen} = useReviewDuplicatesNavigation(Object.keys(compareResult.change ?? {}), 'billable', route.params.reportID, route.params.backTo); const options = useMemo( diff --git a/src/pages/TransactionDuplicate/DynamicReviewCategoryPage.tsx b/src/pages/TransactionDuplicate/DynamicReviewCategoryPage.tsx index 6d4187bf2f61..0555df16db40 100644 --- a/src/pages/TransactionDuplicate/DynamicReviewCategoryPage.tsx +++ b/src/pages/TransactionDuplicate/DynamicReviewCategoryPage.tsx @@ -5,7 +5,6 @@ import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useReviewDuplicatesNavigation from '@hooks/useReviewDuplicatesNavigation'; -import useTransactionCardFeedSettings from '@hooks/useTransactionCardFeedSettings'; import useTransactionsByID from '@hooks/useTransactionsByID'; import {setReviewDuplicatesKey} from '@libs/actions/Transaction'; @@ -47,19 +46,7 @@ function DynamicReviewCategoryPage() { const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${getNonEmptyStringOnyxID(reviewDuplicatesReport?.policyID)}`); const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${getNonEmptyStringOnyxID(reviewDuplicatesReport?.policyID)}`); - const keptTransaction = allDuplicates?.find((duplicate) => duplicate?.transactionID === reviewDuplicates?.transactionID) ?? transaction; - const keptTransactionCardFeedSettings = useTransactionCardFeedSettings(keptTransaction); - - const compareResult = compareDuplicateTransactionFields( - policyTags ?? {}, - transaction, - allDuplicates, - reviewDuplicatesReport, - reviewDuplicates?.transactionID, - policy, - policyCategories, - keptTransactionCardFeedSettings, - ); + const compareResult = compareDuplicateTransactionFields(policyTags ?? {}, transaction, allDuplicates, reviewDuplicatesReport, reviewDuplicates?.transactionID, policy, policyCategories); const stepNames = Object.keys(compareResult.change ?? {}).map((key, index) => (index + 1).toString()); const {currentScreenIndex, navigateToNextScreen} = useReviewDuplicatesNavigation(Object.keys(compareResult.change ?? {}), 'category', route.params.reportID, route.params.backTo); const options = useMemo( diff --git a/src/pages/TransactionDuplicate/DynamicReviewDescriptionPage.tsx b/src/pages/TransactionDuplicate/DynamicReviewDescriptionPage.tsx index ad46f1e29696..247626fbc318 100644 --- a/src/pages/TransactionDuplicate/DynamicReviewDescriptionPage.tsx +++ b/src/pages/TransactionDuplicate/DynamicReviewDescriptionPage.tsx @@ -5,7 +5,6 @@ import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useReviewDuplicatesNavigation from '@hooks/useReviewDuplicatesNavigation'; -import useTransactionCardFeedSettings from '@hooks/useTransactionCardFeedSettings'; import useTransactionsByID from '@hooks/useTransactionsByID'; import {setReviewDuplicatesKey} from '@libs/actions/Transaction'; @@ -48,19 +47,7 @@ function DynamicReviewDescriptionPage() { const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${getNonEmptyStringOnyxID(reviewDuplicatesReport?.policyID)}`); const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${getNonEmptyStringOnyxID(reviewDuplicatesReport?.policyID)}`); - const keptTransaction = allDuplicates?.find((duplicate) => duplicate?.transactionID === reviewDuplicates?.transactionID) ?? transaction; - const keptTransactionCardFeedSettings = useTransactionCardFeedSettings(keptTransaction); - - const compareResult = compareDuplicateTransactionFields( - policyTags ?? {}, - transaction, - allDuplicates, - reviewDuplicatesReport, - reviewDuplicates?.transactionID, - policy, - policyCategories, - keptTransactionCardFeedSettings, - ); + const compareResult = compareDuplicateTransactionFields(policyTags ?? {}, transaction, allDuplicates, reviewDuplicatesReport, reviewDuplicates?.transactionID, policy, policyCategories); const stepNames = Object.keys(compareResult.change ?? {}).map((key, index) => (index + 1).toString()); const {currentScreenIndex, navigateToNextScreen} = useReviewDuplicatesNavigation(Object.keys(compareResult.change ?? {}), 'description', route.params.reportID, route.params.backTo); const options = useMemo( diff --git a/src/pages/TransactionDuplicate/DynamicReviewMerchantPage.tsx b/src/pages/TransactionDuplicate/DynamicReviewMerchantPage.tsx index 59d08ee3ec47..067495ea62a5 100644 --- a/src/pages/TransactionDuplicate/DynamicReviewMerchantPage.tsx +++ b/src/pages/TransactionDuplicate/DynamicReviewMerchantPage.tsx @@ -5,7 +5,6 @@ import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useReviewDuplicatesNavigation from '@hooks/useReviewDuplicatesNavigation'; -import useTransactionCardFeedSettings from '@hooks/useTransactionCardFeedSettings'; import useTransactionsByID from '@hooks/useTransactionsByID'; import {setReviewDuplicatesKey} from '@libs/actions/Transaction'; @@ -46,19 +45,7 @@ function DynamicReviewMerchantPage() { const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${getNonEmptyStringOnyxID(reviewDuplicatesReport?.policyID)}`); const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${getNonEmptyStringOnyxID(reviewDuplicatesReport?.policyID)}`); - const keptTransaction = allDuplicates?.find((duplicate) => duplicate?.transactionID === reviewDuplicates?.transactionID) ?? transaction; - const keptTransactionCardFeedSettings = useTransactionCardFeedSettings(keptTransaction); - - const compareResult = compareDuplicateTransactionFields( - policyTags ?? {}, - transaction, - allDuplicates, - reviewDuplicatesReport, - reviewDuplicates?.transactionID, - policy, - policyCategories, - keptTransactionCardFeedSettings, - ); + const compareResult = compareDuplicateTransactionFields(policyTags ?? {}, transaction, allDuplicates, reviewDuplicatesReport, reviewDuplicates?.transactionID, policy, policyCategories); const stepNames = Object.keys(compareResult.change ?? {}).map((key, index) => (index + 1).toString()); const {currentScreenIndex, navigateToNextScreen} = useReviewDuplicatesNavigation(Object.keys(compareResult.change ?? {}), 'merchant', route.params.reportID, route.params.backTo); const options = useMemo( diff --git a/src/pages/TransactionDuplicate/DynamicReviewPage.tsx b/src/pages/TransactionDuplicate/DynamicReviewPage.tsx index a6f81d4ccf78..b053d14073b4 100644 --- a/src/pages/TransactionDuplicate/DynamicReviewPage.tsx +++ b/src/pages/TransactionDuplicate/DynamicReviewPage.tsx @@ -17,7 +17,6 @@ import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; import useThemeStyles from '@hooks/useThemeStyles'; -import useTransactionCardFeedSettings from '@hooks/useTransactionCardFeedSettings'; import useTransactionViolations from '@hooks/useTransactionViolations'; import {clearDeleteTransactionNavigateBackUrl, openReport} from '@libs/actions/Report'; @@ -114,7 +113,6 @@ function DynamicReviewPage() { const [selectedTransactionPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${selectedTransactionReport?.policyID}`); const [selectedTransactionPolicyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${selectedTransactionReport?.policyID}`); const [selectedTransactionPolicyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${selectedTransactionReport?.policyID}`); - const selectedTransactionCardFeedSettings = useTransactionCardFeedSettings(selectedTransaction); const isTrackIntentUser = isTrackOnboardingChoice(introSelected?.choice); const hasSettledOrApprovedTransaction = transactions.some((transaction) => isSettled(transaction?.reportID) || isReportIDApproved(transaction?.reportID)); @@ -252,7 +250,6 @@ function DynamicReviewPage() { selectedTransactionPolicyCategories, selectedTransactionPolicyTags ?? {}, selectedTransactionReport, - selectedTransactionCardFeedSettings, ), ); }; diff --git a/src/pages/TransactionDuplicate/DynamicReviewReimbursablePage.tsx b/src/pages/TransactionDuplicate/DynamicReviewReimbursablePage.tsx index efed7ae2ec7f..98b45971e96d 100644 --- a/src/pages/TransactionDuplicate/DynamicReviewReimbursablePage.tsx +++ b/src/pages/TransactionDuplicate/DynamicReviewReimbursablePage.tsx @@ -5,7 +5,6 @@ import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useReviewDuplicatesNavigation from '@hooks/useReviewDuplicatesNavigation'; -import useTransactionCardFeedSettings from '@hooks/useTransactionCardFeedSettings'; import useTransactionsByID from '@hooks/useTransactionsByID'; import {setReviewDuplicatesKey} from '@libs/actions/Transaction'; @@ -46,19 +45,7 @@ function DynamicReviewReimbursablePage() { const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${getNonEmptyStringOnyxID(reviewDuplicatesReport?.policyID)}`); const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${getNonEmptyStringOnyxID(reviewDuplicatesReport?.policyID)}`); - const keptTransaction = allDuplicates?.find((duplicate) => duplicate?.transactionID === reviewDuplicates?.transactionID) ?? transaction; - const keptTransactionCardFeedSettings = useTransactionCardFeedSettings(keptTransaction); - - const compareResult = compareDuplicateTransactionFields( - policyTags ?? {}, - transaction, - allDuplicates, - reviewDuplicatesReport, - reviewDuplicates?.transactionID, - policy, - policyCategories, - keptTransactionCardFeedSettings, - ); + const compareResult = compareDuplicateTransactionFields(policyTags ?? {}, transaction, allDuplicates, reviewDuplicatesReport, reviewDuplicates?.transactionID, policy, policyCategories); const stepNames = Object.keys(compareResult.change ?? {}).map((key, index) => (index + 1).toString()); const {currentScreenIndex, navigateToNextScreen} = useReviewDuplicatesNavigation(Object.keys(compareResult.change ?? {}), 'reimbursable', route.params.reportID, route.params.backTo); const options = useMemo( diff --git a/src/pages/TransactionDuplicate/DynamicReviewTagPage.tsx b/src/pages/TransactionDuplicate/DynamicReviewTagPage.tsx index c235d87091bc..ae44438a43b6 100644 --- a/src/pages/TransactionDuplicate/DynamicReviewTagPage.tsx +++ b/src/pages/TransactionDuplicate/DynamicReviewTagPage.tsx @@ -5,7 +5,6 @@ import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useReviewDuplicatesNavigation from '@hooks/useReviewDuplicatesNavigation'; -import useTransactionCardFeedSettings from '@hooks/useTransactionCardFeedSettings'; import useTransactionsByID from '@hooks/useTransactionsByID'; import {setReviewDuplicatesKey} from '@libs/actions/Transaction'; @@ -47,19 +46,7 @@ function DynamicReviewTagPage() { const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${getNonEmptyStringOnyxID(reviewDuplicatesReport?.policyID)}`); const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${getNonEmptyStringOnyxID(reviewDuplicatesReport?.policyID)}`); - const keptTransaction = allDuplicates?.find((duplicate) => duplicate?.transactionID === reviewDuplicates?.transactionID) ?? transaction; - const keptTransactionCardFeedSettings = useTransactionCardFeedSettings(keptTransaction); - - const compareResult = compareDuplicateTransactionFields( - policyTags ?? {}, - transaction, - allDuplicates, - reviewDuplicatesReport, - reviewDuplicates?.transactionID, - policy, - policyCategories, - keptTransactionCardFeedSettings, - ); + const compareResult = compareDuplicateTransactionFields(policyTags ?? {}, transaction, allDuplicates, reviewDuplicatesReport, reviewDuplicates?.transactionID, policy, policyCategories); const stepNames = Object.keys(compareResult.change ?? {}).map((key, index) => (index + 1).toString()); const {currentScreenIndex, navigateToNextScreen} = useReviewDuplicatesNavigation(Object.keys(compareResult.change ?? {}), 'tag', route.params.reportID, route.params.backTo); const options = useMemo( diff --git a/src/pages/TransactionDuplicate/DynamicReviewTaxCodePage.tsx b/src/pages/TransactionDuplicate/DynamicReviewTaxCodePage.tsx index 911889b2e2a4..62950433a52d 100644 --- a/src/pages/TransactionDuplicate/DynamicReviewTaxCodePage.tsx +++ b/src/pages/TransactionDuplicate/DynamicReviewTaxCodePage.tsx @@ -7,7 +7,6 @@ import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePolicy from '@hooks/usePolicy'; import useReviewDuplicatesNavigation from '@hooks/useReviewDuplicatesNavigation'; -import useTransactionCardFeedSettings from '@hooks/useTransactionCardFeedSettings'; import useTransactionsByID from '@hooks/useTransactionsByID'; import {setReviewDuplicatesKey} from '@libs/actions/Transaction'; @@ -52,19 +51,7 @@ function DynamicReviewTaxCodePage() { const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${getNonEmptyStringOnyxID(reviewDuplicatesReport?.policyID)}`); const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${getNonEmptyStringOnyxID(reviewDuplicatesReport?.policyID)}`); - const keptTransaction = allDuplicates?.find((duplicate) => duplicate?.transactionID === reviewDuplicates?.transactionID) ?? transaction; - const keptTransactionCardFeedSettings = useTransactionCardFeedSettings(keptTransaction); - - const compareResult = compareDuplicateTransactionFields( - policyTags ?? {}, - transaction, - allDuplicates, - reviewDuplicatesReport, - reviewDuplicates?.transactionID, - policy, - policyCategories, - keptTransactionCardFeedSettings, - ); + const compareResult = compareDuplicateTransactionFields(policyTags ?? {}, transaction, allDuplicates, reviewDuplicatesReport, reviewDuplicates?.transactionID, policy, policyCategories); const stepNames = Object.keys(compareResult.change ?? {}).map((key, index) => (index + 1).toString()); const selectedTaxCode = reviewDuplicates?.taxCode ? resolveCurrentTaxCode(policy, reviewDuplicates.taxCode) : reviewDuplicates?.taxCode; diff --git a/src/pages/iou/DynamicSplitExpenseEditPage.tsx b/src/pages/iou/DynamicSplitExpenseEditPage.tsx index 3d8a7559839c..d68df161eba1 100644 --- a/src/pages/iou/DynamicSplitExpenseEditPage.tsx +++ b/src/pages/iou/DynamicSplitExpenseEditPage.tsx @@ -35,7 +35,6 @@ import type {ViolationField} from '@hooks/useViolations'; import {initDraftSplitExpenseDataForEdit, removeSplitExpenseField, updateSplitExpenseDraftField, updateSplitExpenseField} from '@libs/actions/IOU/SplitExpenseItems'; import {openPolicyCategoriesPage} from '@libs/actions/Policy/Category'; import {openPolicyTagsPage} from '@libs/actions/Policy/Tag'; -import {getFeedSettingsFromCardFeeds} from '@libs/CardFeedUtils'; import {getDecodedLeafCategoryName, isCategoryDescriptionRequired, isCategoryMissing} from '@libs/CategoryUtils'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; @@ -134,9 +133,6 @@ function DynamicSplitExpenseEditPage({route}: DynamicSplitExpenseEditPageProps) const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const [personalDetails] = useAllPersonalDetails(); - const [nonPersonalAndWorkspaceCards] = useOnyx(ONYXKEYS.DERIVED.NON_PERSONAL_AND_WORKSPACE_CARD_LIST); - const transactionCard = transaction?.cardID ? nonPersonalAndWorkspaceCards?.[transaction.cardID] : undefined; - const [cardFeedsForTransactionCard] = useOnyx(`${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${getNonEmptyStringOnyxID(transactionCard?.fundID)}`); // Detect selfDM splits whose source workspace is gone: nothing for the Rate step to render. const hasAnyPaidWorkspace = hasAnyPaidPolicy(allPolicies ?? {}); @@ -271,12 +267,11 @@ function DynamicSplitExpenseEditPage({route}: DynamicSplitExpenseEditPageProps) const shouldShowBillable = (isPolicyExpenseChat || isExpenseUnreported) && (!!splitExpenseDraftTransactionDetails?.billable || isBillableEnabledOnPolicy(effectivePolicy)); const currentReimbursable = splitExpenseDraftTransaction?.reimbursable ?? transaction?.reimbursable; const isReimbursableDifferentFromPolicyDefault = effectivePolicy?.defaultReimbursable !== undefined && !!currentReimbursable !== effectivePolicy.defaultReimbursable; - const transactionCardFeedSettings = getFeedSettingsFromCardFeeds(cardFeedsForTransactionCard, transactionCard); // The workspace cash expense rules don't apply to card expenses, which follow their card feed's reimbursable setting instead const shouldShowReimbursable = (isPolicyExpenseChat || (isExpenseUnreported && !!effectivePolicy)) && (isManagedCardTransaction(transaction) || effectivePolicy?.disabledFields?.reimbursable !== true || isReimbursableDifferentFromPolicyDefault) && - isCardTransactionReimbursableEditable(transaction, transactionCardFeedSettings); + isCardTransactionReimbursableEditable(transaction); const isDistance = isDistanceRequest(splitExpenseDraftTransaction); const isManualDistance = isManualDistanceRequest(splitExpenseDraftTransaction); diff --git a/tests/ui/SearchEditMultiplePageTest.tsx b/tests/ui/SearchEditMultiplePageTest.tsx index 05db42e8e146..53b439a0212f 100644 --- a/tests/ui/SearchEditMultiplePageTest.tsx +++ b/tests/ui/SearchEditMultiplePageTest.tsx @@ -218,8 +218,8 @@ describe('SearchEditMultiplePage', () => { expect(screen.queryByLabelText(/^Reimbursable/)).not.toBeOnTheScreen(); }); - it('uses the card feed setting for a company card expense with no stamped value', async () => { - // Given a company card expense with no stamped value, whose card belongs to a feed set to "Do Not Force" + it('ignores the current card feed setting for a company card expense with no stamped value', async () => { + // Given a company card expense with no stamped value, whose card belongs to a feed that is now set to "Do Not Force" const cardID = 1234; const fundID = '5678'; const feed = CONST.COMPANY_CARD.FEED_BANK_NAME.VISA; @@ -234,8 +234,8 @@ describe('SearchEditMultiplePage', () => { renderPage(); await waitForBatchedUpdatesWithAct(); - // Then the Reimbursable row shows, because the feed setting is the fallback for expenses that weren't stamped - expect(screen.getByLabelText(/^Reimbursable/)).toBeOnTheScreen(); + // Then the Reimbursable row is hidden, because changing the feed setting only affects expenses imported afterward + expect(screen.queryByLabelText(/^Reimbursable/)).not.toBeOnTheScreen(); }); it('fetches policy categories and tags when they are enabled but not loaded yet', async () => { diff --git a/tests/unit/TransactionUtilsTest.ts b/tests/unit/TransactionUtilsTest.ts index e222524aa487..d91890da3f75 100644 --- a/tests/unit/TransactionUtilsTest.ts +++ b/tests/unit/TransactionUtilsTest.ts @@ -5198,30 +5198,15 @@ describe('TransactionUtils', () => { expect(result.change.reimbursable).toEqual(expect.arrayContaining([true, false])); }); - it('should surface the reimbursable step when an unstamped company card expense belongs to a "Do Not Force" feed', () => { - // Given a kept company card expense imported before the feed setting was stamped on it, from a "Do Not Force" feed + it('should keep the value of a company card expense with no stamped value and not show the step', () => { + // Given a kept company card expense with no stamped value, and a non-reimbursable duplicate const companyCardTransaction = generateTransaction({reimbursable: true, managedCard: true}); const duplicates = [generateTransaction({reimbursable: false, managedCard: false})]; - const feedSettings = {forceReimbursable: CONST.COMPANY_CARD.FORCE_REIMBURSABLE.DEFAULT_NO}; - // When the duplicate fields are compared with the kept expense's feed settings - const result = TransactionUtils.compareDuplicateTransactionFields({}, companyCardTransaction, duplicates, mockReport, undefined, mockPolicy, undefined, feedSettings); - - // Then the feed setting allows changes, so the user picks the reimbursable value in the review step - expect(result.keep.reimbursable).toBeUndefined(); - expect(result.change.reimbursable).toEqual(expect.arrayContaining([true, false])); - }); - - it('should keep the value of an unstamped company card expense whose feed forces it', () => { - // Given a kept unstamped company card expense from a "Force Yes" feed, and a non-reimbursable duplicate - const companyCardTransaction = generateTransaction({reimbursable: true, managedCard: true}); - const duplicates = [generateTransaction({reimbursable: false, managedCard: false})]; - const feedSettings = {forceReimbursable: CONST.COMPANY_CARD.FORCE_REIMBURSABLE.FORCE_YES}; - - // When the duplicate fields are compared with the kept expense's feed settings - const result = TransactionUtils.compareDuplicateTransactionFields({}, companyCardTransaction, duplicates, mockReport, undefined, mockPolicy, undefined, feedSettings); + // When the duplicate fields are compared + const result = TransactionUtils.compareDuplicateTransactionFields({}, companyCardTransaction, duplicates, mockReport, undefined, mockPolicy, undefined); - // Then the expense stays reimbursable and the step is hidden + // Then the expense keeps its value and the step is hidden, because an expense with no stamped value behaves as "Force No" expect(result.keep.reimbursable).toBe(true); expect(result.change.reimbursable).toBeUndefined(); }); @@ -6477,31 +6462,15 @@ describe('isCardTransactionReimbursableEditable', () => { [FORCE_YES, false], [FORCE_NO, false], ])('uses the stamped %s value of a company card expense', (forceReimbursable, expected) => { - // Given a company card expense stamped with the feed setting at import, on a feed whose setting has since changed + // Given a company card expense stamped with the feed setting at import const transaction = generateTransaction({managedCard: true, comment: {forceReimbursable}}); - const feedSettings = {forceReimbursable: forceReimbursable === DEFAULT_YES ? FORCE_NO : DEFAULT_YES}; - - // Then only the "Do Not Force" stamped values allow changing the reimbursable value, because feed changes only affect new expenses - expect(TransactionUtils.isCardTransactionReimbursableEditable(transaction, feedSettings)).toBe(expected); - }); - - it.each([ - [DEFAULT_YES, true], - [DEFAULT_NO, true], - [NONE, true], - [FORCE_YES, false], - [FORCE_NO, false], - [undefined, false], - ])('falls back to the %s feed setting when the company card expense has no stamped value', (forceReimbursable, expected) => { - // Given a company card expense with no stamped value, on a feed with the given setting (a missing setting behaves as "Force No") - const transaction = generateTransaction({managedCard: true}); - // Then the feed setting decides whether the reimbursable value can be changed - expect(TransactionUtils.isCardTransactionReimbursableEditable(transaction, {forceReimbursable})).toBe(expected); + // Then only the "Do Not Force" stamped values allow changing the reimbursable value + expect(TransactionUtils.isCardTransactionReimbursableEditable(transaction)).toBe(expected); }); - it('returns false for a company card expense with no stamped value and no feed settings', () => { - // Given a company card expense with no stamped value, viewed by someone who doesn't have its feed settings + it('returns false for a company card expense with no stamped value', () => { + // Given a company card expense with no stamped value. The current feed setting is ignored, because feed changes only affect new expenses const transaction = generateTransaction({managedCard: true}); // Then the reimbursable value can't be changed, matching the "Force No" default