diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 7a54e5e399d7..fe68b18a29bb 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -5669,6 +5669,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/MoneyRequestConfirmationList/sections/selectors.ts b/src/components/MoneyRequestConfirmationList/sections/selectors.ts index 4a7f2a3945b1..e48b1013a6cd 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,6 +249,9 @@ const derivedFlagsSliceSelector = (t: OnyxEntry): OnyxEntry>; 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/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index 2c8630740931..798eebf1f4ba 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -1004,7 +1004,7 @@ function getFormattedCreated(transaction: OnyxInputOrEntry, 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; } @@ -1015,6 +1015,30 @@ function isManagedCardTransaction(transaction: OnyxEntry>): boolean { + if (!isManagedCardTransaction(transaction)) { + return true; + } + + if (isExpensifyCardTransaction(transaction)) { + return false; + } + + const forceReimbursable = transaction?.comment?.forceReimbursable; + + 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. @@ -1907,6 +1931,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 ea42fafd29bb..61456bef5681 100644 --- a/src/pages/Search/SearchEditMultiple/SearchEditMultiplePage.tsx +++ b/src/pages/Search/SearchEditMultiple/SearchEditMultiplePage.tsx @@ -34,6 +34,7 @@ import { getTagArrayFromName, getTaxName, hasSplitExpenseInSelection, + isCardTransactionReimbursableEditable, isDistanceRequest, isManagedCardTransaction, isPerDiemRequest, @@ -153,11 +154,17 @@ function SearchEditMultiplePage() { }); const areSelectedTransactionsReimbursable = selectedTransactionContexts.every(({transaction, report, transactionPolicy}) => { + if (!isCardTransactionReimbursableEditable(transaction)) { + 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 ed58bbbfc4ea..68a84fd12165 100644 --- a/src/pages/iou/DynamicSplitExpenseEditPage.tsx +++ b/src/pages/iou/DynamicSplitExpenseEditPage.tsx @@ -65,6 +65,7 @@ import { getTag, getTagForDisplay, getTaxName, + isCardTransactionReimbursableEditable, isDistanceRequest, isManagedCardTransaction, isManualDistanceRequest, @@ -273,10 +274,11 @@ function DynamicSplitExpenseEditPage({route}: DynamicSplitExpenseEditPageProps) const shouldShowBillable = (isPolicyExpenseChat || isExpenseUnreported) && (!!splitExpenseDraftTransactionDetails?.billable || isBillableEnabledOnPolicy(effectivePolicy)); const currentReimbursable = splitExpenseDraftTransaction?.reimbursable ?? transaction?.reimbursable; + // 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)) && - canShowReimbursableToggleForPolicy(effectivePolicy, currentReimbursable) && - !isManagedCardTransaction(transaction); + (isManagedCardTransaction(transaction) || canShowReimbursableToggleForPolicy(effectivePolicy, currentReimbursable)) && + isCardTransactionReimbursableEditable(transaction); const isDistance = isDistanceRequest(splitExpenseDraftTransaction); const isManualDistance = isManualDistanceRequest(splitExpenseDraftTransaction); diff --git a/src/types/onyx/Transaction.ts b/src/types/onyx/Transaction.ts index 2a9efc970826..f9a92b8e44e5 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..53b439a0212f 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('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; + 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 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 () => { // 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 a0a4655bbc11..71ab8cbdf13b 100644 --- a/tests/unit/TransactionUtilsTest.ts +++ b/tests/unit/TransactionUtilsTest.ts @@ -5163,6 +5163,70 @@ 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 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})]; + + // When the duplicate fields are compared + const result = TransactionUtils.compareDuplicateTransactionFields({}, companyCardTransaction, duplicates, mockReport, undefined, mockPolicy, undefined); + + // 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(); + }); + + 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', () => { @@ -6407,6 +6471,48 @@ 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 + const transaction = generateTransaction({managedCard: true, comment: {forceReimbursable}}); + + // 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', () => { + // 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 + 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();