From 65acdecbb3585c8e85f46e8f04d9e0d2868fbb9d Mon Sep 17 00:00:00 2001 From: "cretadn22 (via MelvinBot)" Date: Tue, 22 Sep 2026 16:25:29 +0000 Subject: [PATCH 1/7] Fix: make absent defaultReimbursable mean the same thing everywhere and honor the reimbursable lock at expense creation Co-authored-by: cretadn22 --- .../sections/selectors.ts | 3 +- .../hooks/useFooterDerivedFlags.ts | 10 ++- .../ReportActionItem/MoneyRequestView.tsx | 5 +- src/libs/IOUUtils.ts | 30 ++++++- src/libs/actions/Policy/Policy.ts | 13 ++- src/pages/Share/SubmitDetailsPage.tsx | 6 +- src/pages/iou/DynamicSplitExpenseEditPage.tsx | 3 +- .../confirmation/ExpenseDefaultsSetter.tsx | 4 +- tests/actions/PolicyTest.ts | 58 ++++++++++++ tests/unit/IOUUtilsTest.ts | 90 +++++++++++++++++++ 10 files changed, 208 insertions(+), 14 deletions(-) diff --git a/src/components/MoneyRequestConfirmationList/sections/selectors.ts b/src/components/MoneyRequestConfirmationList/sections/selectors.ts index 4ea7811a5dc3..46053806a0a2 100644 --- a/src/components/MoneyRequestConfirmationList/sections/selectors.ts +++ b/src/components/MoneyRequestConfirmationList/sections/selectors.ts @@ -228,7 +228,7 @@ const taxSliceSelector = (t: OnyxEntry): TaxSlice | undefined => { // --- ConfirmationFieldList: useFooterDerivedFlags --- -type DerivedFlagsSlice = Pick; +type DerivedFlagsSlice = Pick; const derivedFlagsSliceSelector = (t: OnyxEntry): OnyxEntry => { if (!t) { @@ -240,6 +240,7 @@ const derivedFlagsSliceSelector = (t: OnyxEntry): OnyxEntry; + isPolicyExpenseChat: boolean; + isCreatingTrackExpense: boolean; +}): boolean { + const policyDefaultReimbursable = policy?.defaultReimbursable ?? true; + + if (isGroupPolicy(policy) && policy?.disabledFields?.reimbursable) { + return policyDefaultReimbursable; + } + + return (isPolicyExpenseChat && isGroupPolicy(policy)) || isCreatingTrackExpense ? policyDefaultReimbursable : true; +} + function calculateDefaultReimbursable({ iouType, policy, @@ -468,7 +495,7 @@ function calculateDefaultReimbursable({ const isUnreported = transactionReportID === CONST.REPORT.UNREPORTED_REPORT_ID; const isPolicyExpenseChat = !!participant?.isPolicyExpenseChat; const reportPolicy = isCreatingTrackExpense || isUnreported ? policyForMovingExpenses : policy; - return (isPolicyExpenseChat && isGroupPolicy(reportPolicy)) || isCreatingTrackExpense ? (reportPolicy?.defaultReimbursable ?? true) : true; + return getDefaultReimbursableForPolicy({policy: reportPolicy, isPolicyExpenseChat, isCreatingTrackExpense}); } function getInitialPerDiemTargetReport( @@ -682,6 +709,7 @@ export { shouldShowPerDiemTabOption, navigateToConfirmationPage, calculateDefaultReimbursable, + getDefaultReimbursableForPolicy, getInitialPerDiemTargetReport, getIsWorkspacesOnlyForTransaction, getReusableP2PReportID, diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index 82e9b359d9d6..e9af2b7a0903 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -6888,15 +6888,22 @@ function getCashExpenseReimbursableMode(policy: OnyxEntry): PolicyCashEx return undefined; } - if (policy.defaultReimbursable && !policy.disabledFields?.reimbursable) { + // `defaultReimbursable` is optional on the policy blob. An absent value means "reimbursable", + // which is what the backend and the expense creation path both assume, so read it the same way + // here. Treating an absent value as `false` made this page claim "always non-reimbursable" on + // workspaces that actually create reimbursable expenses. + const defaultReimbursable = policy.defaultReimbursable ?? true; + const isReimbursableDisabled = !!policy.disabledFields?.reimbursable; + + if (defaultReimbursable && !isReimbursableDisabled) { return CONST.POLICY.CASH_EXPENSE_REIMBURSEMENT_CHOICES.REIMBURSABLE_DEFAULT; } - if (!policy.disabledFields?.reimbursable && !policy.defaultReimbursable) { + if (!defaultReimbursable && !isReimbursableDisabled) { return CONST.POLICY.CASH_EXPENSE_REIMBURSEMENT_CHOICES.NON_REIMBURSABLE_DEFAULT; } - if (policy.defaultReimbursable && policy.disabledFields?.reimbursable) { + if (defaultReimbursable && isReimbursableDisabled) { return CONST.POLICY.CASH_EXPENSE_REIMBURSEMENT_CHOICES.ALWAYS_REIMBURSABLE; } diff --git a/src/pages/Share/SubmitDetailsPage.tsx b/src/pages/Share/SubmitDetailsPage.tsx index 8ffb99a7685a..df687adeecf2 100644 --- a/src/pages/Share/SubmitDetailsPage.tsx +++ b/src/pages/Share/SubmitDetailsPage.tsx @@ -39,7 +39,7 @@ import DateUtils from '@libs/DateUtils'; import {getFileName, readFileAsync} from '@libs/fileDownload/FileUtils'; import getCurrentPosition from '@libs/getCurrentPosition'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; -import {getExistingTransactionID, isLookingAroundSearchRoutingActive, resolveReportForMoneyRequest} from '@libs/IOUUtils'; +import {getDefaultReimbursableForPolicy, getExistingTransactionID, isLookingAroundSearchRoutingActive, resolveReportForMoneyRequest} from '@libs/IOUUtils'; import Log from '@libs/Log'; import cleanupAndNavigateAfterExpenseCreate from '@libs/Navigation/helpers/cleanupAndNavigateAfterExpenseCreate'; import Navigation from '@libs/Navigation/Navigation'; @@ -47,7 +47,7 @@ import type {ShareNavigatorParamList} from '@libs/Navigation/types'; import {rand64} from '@libs/NumberUtils'; import {isTrackOnboardingChoice} from '@libs/OnboardingUtils'; import {getParticipantsOption, getReportOption} from '@libs/OptionsListUtils'; -import {hasOnlyPersonalPolicies as hasOnlyPersonalPoliciesUtil, isGroupPolicy, resolveCurrentTaxCode} from '@libs/PolicyUtils'; +import {hasOnlyPersonalPolicies as hasOnlyPersonalPoliciesUtil, resolveCurrentTaxCode} from '@libs/PolicyUtils'; import ReceiptStorage from '@libs/ReceiptStorage'; import {shouldValidateFile} from '@libs/ReceiptUtils'; import {getReportOrDraftReport, isMoneyRequestReport, isSelfDM} from '@libs/ReportUtils'; @@ -256,7 +256,7 @@ function SubmitDetailsPage({ }, [defaultBillable]); useEffect(() => { - const defaultReimbursable = (isPolicyExpenseChat && isGroupPolicy(policy)) || isCreatingTrackExpense ? (policy?.defaultReimbursable ?? true) : true; + const defaultReimbursable = getDefaultReimbursableForPolicy({policy, isPolicyExpenseChat: !!isPolicyExpenseChat, isCreatingTrackExpense}); setMoneyRequestReimbursable(CONST.IOU.OPTIMISTIC_TRANSACTION_ID, defaultReimbursable); }, [policy, isPolicyExpenseChat, isCreatingTrackExpense]); diff --git a/src/pages/iou/DynamicSplitExpenseEditPage.tsx b/src/pages/iou/DynamicSplitExpenseEditPage.tsx index 3b2754f0f21e..2212ed7060e5 100644 --- a/src/pages/iou/DynamicSplitExpenseEditPage.tsx +++ b/src/pages/iou/DynamicSplitExpenseEditPage.tsx @@ -235,7 +235,8 @@ 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; + // An absent `defaultReimbursable` means "reimbursable" - see MoneyRequestView for why we don't bail out here. + const isReimbursableDifferentFromPolicyDefault = !!currentReimbursable !== (effectivePolicy?.defaultReimbursable ?? true); const shouldShowReimbursable = (isPolicyExpenseChat || (isExpenseUnreported && !!effectivePolicy)) && (effectivePolicy?.disabledFields?.reimbursable !== true || isReimbursableDifferentFromPolicyDefault) && diff --git a/src/pages/iou/request/step/confirmation/ExpenseDefaultsSetter.tsx b/src/pages/iou/request/step/confirmation/ExpenseDefaultsSetter.tsx index ccecb0fa25ed..d2681913ad0d 100644 --- a/src/pages/iou/request/step/confirmation/ExpenseDefaultsSetter.tsx +++ b/src/pages/iou/request/step/confirmation/ExpenseDefaultsSetter.tsx @@ -1,4 +1,4 @@ -import {isGroupPolicy} from '@libs/PolicyUtils'; +import {getDefaultReimbursableForPolicy} from '@libs/IOUUtils'; import {setMoneyRequestBillable, setMoneyRequestReimbursable} from '@userActions/IOU/MoneyRequest'; @@ -36,7 +36,7 @@ function ExpenseDefaultsSetter({transactionIDs, policy, isPolicyExpenseChat, isM if (isMovingTransactionFromTrackExpense) { return; } - const defaultReimbursable = (isPolicyExpenseChat && isGroupPolicy(policy)) || isCreatingTrackExpense ? (policy?.defaultReimbursable ?? true) : true; + const defaultReimbursable = getDefaultReimbursableForPolicy({policy, isPolicyExpenseChat: !!isPolicyExpenseChat, isCreatingTrackExpense}); for (const transactionID of transactionIDs) { setMoneyRequestReimbursable(transactionID, defaultReimbursable); } diff --git a/tests/actions/PolicyTest.ts b/tests/actions/PolicyTest.ts index 7db67141cdbc..b84d9b2b1b89 100644 --- a/tests/actions/PolicyTest.ts +++ b/tests/actions/PolicyTest.ts @@ -2496,6 +2496,64 @@ describe('actions/Policy', () => { }); }); + describe('getCashExpenseReimbursableMode', () => { + // The Rules page and the expense creation path both read `defaultReimbursable`. When they disagree + // about an ABSENT value the page advertises a rule that new expenses never apply, so these cases + // pin the absent value to "reimbursable" on both sides. + it('reads an absent defaultReimbursable as reimbursable when the field is not locked', () => { + // Given a workspace whose policy blob has no defaultReimbursable and no lock + const policy = {...createRandomPolicy(0), defaultReimbursable: undefined, disabledFields: {reimbursable: false}}; + + // When resolving the cash expense mode + const mode = Policy.getCashExpenseReimbursableMode(policy); + + // Then it shows "reimbursable by default" rather than claiming the non-reimbursable rule is on + expect(mode).toBe(CONST.POLICY.CASH_EXPENSE_REIMBURSEMENT_CHOICES.REIMBURSABLE_DEFAULT); + }); + + it('reads an absent defaultReimbursable as reimbursable when the field is locked', () => { + // Given a workspace with a locked reimbursable field but no stored defaultReimbursable + const policy = {...createRandomPolicy(0), defaultReimbursable: undefined, disabledFields: {reimbursable: true}}; + + // When resolving the cash expense mode + const mode = Policy.getCashExpenseReimbursableMode(policy); + + // Then it shows "always reimbursable", matching the value new expenses are actually created with + expect(mode).toBe(CONST.POLICY.CASH_EXPENSE_REIMBURSEMENT_CHOICES.ALWAYS_REIMBURSABLE); + }); + + it('reports always non-reimbursable only when defaultReimbursable is explicitly false and locked', () => { + // Given a workspace explicitly set to "cash expenses are always non-reimbursable" + const policy = {...createRandomPolicy(0), defaultReimbursable: false, disabledFields: {reimbursable: true}}; + + // When resolving the cash expense mode + const mode = Policy.getCashExpenseReimbursableMode(policy); + + // Then it shows the always non-reimbursable rule + expect(mode).toBe(CONST.POLICY.CASH_EXPENSE_REIMBURSEMENT_CHOICES.ALWAYS_NON_REIMBURSABLE); + }); + + it('reports non-reimbursable by default when defaultReimbursable is explicitly false and unlocked', () => { + // Given a workspace defaulting to non-reimbursable without locking the field + const policy = {...createRandomPolicy(0), defaultReimbursable: false, disabledFields: {reimbursable: false}}; + + // When resolving the cash expense mode + const mode = Policy.getCashExpenseReimbursableMode(policy); + + // Then it shows the non-reimbursable default + expect(mode).toBe(CONST.POLICY.CASH_EXPENSE_REIMBURSEMENT_CHOICES.NON_REIMBURSABLE_DEFAULT); + }); + + it('returns undefined when there is no policy', () => { + // Given no policy + // When resolving the cash expense mode + const mode = Policy.getCashExpenseReimbursableMode(undefined); + + // Then there is no mode to show + expect(mode).toBeUndefined(); + }); + }); + describe('setPolicyReimbursableMode', () => { it('should update reimbursable mode to REIMBURSABLE_DEFAULT optimistically and succeed', async () => { // Given a workspace with default reimbursable as false and disabled as true diff --git a/tests/unit/IOUUtilsTest.ts b/tests/unit/IOUUtilsTest.ts index 1ce12b0e827b..a8e7066d80ec 100644 --- a/tests/unit/IOUUtilsTest.ts +++ b/tests/unit/IOUUtilsTest.ts @@ -1418,3 +1418,93 @@ describe('isLookingAroundSearchRoutingActive', () => { expect(IOUUtils.isLookingAroundSearchRoutingActive(false, true)).toBe(false); }); }); + +describe('getDefaultReimbursableForPolicy', () => { + // These cases pin down what an ABSENT `defaultReimbursable` means at expense creation time. It has to + // agree with getCashExpenseReimbursableMode on the Rules page, otherwise the page claims a rule is + // enforced while newly created expenses quietly ignore it. + it('treats an absent defaultReimbursable as reimbursable', () => { + // Given a group policy that has no stored defaultReimbursable + const policy: Policy = {...createRandomPolicy(0, CONST.POLICY.TYPE.TEAM), defaultReimbursable: undefined}; + + // When a new expense is created in its policy expense chat + const result = IOUUtils.getDefaultReimbursableForPolicy({policy, isPolicyExpenseChat: true, isCreatingTrackExpense: false}); + + // Then it defaults to reimbursable, which is what the backend assumes for an absent value + expect(result).toBe(true); + }); + + it('honors a locked non-reimbursable policy even when the participant is not flagged as a policy expense chat', () => { + // Given a workspace locked to "cash expenses are always non-reimbursable" + const policy: Policy = {...createRandomPolicy(0, CONST.POLICY.TYPE.TEAM), defaultReimbursable: false, disabledFields: {reimbursable: true}}; + + // When a new expense is created without the policy-expense-chat flag + const result = IOUUtils.getDefaultReimbursableForPolicy({policy, isPolicyExpenseChat: false, isCreatingTrackExpense: false}); + + // Then the lock wins, because members have no way to correct the value afterwards + expect(result).toBe(false); + }); + + it('still reports reimbursable for a locked policy whose defaultReimbursable is absent', () => { + // Given a workspace with a locked reimbursable field but no stored default + const policy: Policy = {...createRandomPolicy(0, CONST.POLICY.TYPE.TEAM), defaultReimbursable: undefined, disabledFields: {reimbursable: true}}; + + // When a new expense is created in its policy expense chat + const result = IOUUtils.getDefaultReimbursableForPolicy({policy, isPolicyExpenseChat: true, isCreatingTrackExpense: false}); + + // Then it is reimbursable - the same answer the Rules page now shows, instead of the two disagreeing + expect(result).toBe(true); + }); + + it('uses the stored default for an unlocked policy', () => { + // Given a workspace defaulting to non-reimbursable without locking the field + const policy: Policy = {...createRandomPolicy(0, CONST.POLICY.TYPE.TEAM), defaultReimbursable: false, disabledFields: {reimbursable: false}}; + + // When a new expense is created in its policy expense chat + const result = IOUUtils.getDefaultReimbursableForPolicy({policy, isPolicyExpenseChat: true, isCreatingTrackExpense: false}); + + // Then the stored default applies + expect(result).toBe(false); + }); + + it('stays reimbursable for a P2P expense with no policy', () => { + // Given no policy at all, which is the P2P case + // When a new expense is created + const result = IOUUtils.getDefaultReimbursableForPolicy({policy: undefined, isPolicyExpenseChat: false, isCreatingTrackExpense: false}); + + // Then it is reimbursable, the long-standing behavior for P2P + expect(result).toBe(true); + }); +}); + +describe('calculateDefaultReimbursable', () => { + it('applies a locked non-reimbursable workspace rule to a submitted expense', () => { + // Given a workspace locked to "cash expenses are always non-reimbursable" + const policy: Policy = {...createRandomPolicy(0, CONST.POLICY.TYPE.TEAM), defaultReimbursable: false, disabledFields: {reimbursable: true}}; + + // When submitting a new expense to its policy expense chat + const result = IOUUtils.calculateDefaultReimbursable({ + iouType: CONST.IOU.TYPE.SUBMIT, + policy, + participant: {isPolicyExpenseChat: true}, + }); + + // Then the expense is created non-reimbursable + expect(result).toBe(false); + }); + + it('defaults to reimbursable when the workspace has no stored defaultReimbursable', () => { + // Given a workspace whose policy blob is missing defaultReimbursable + const policy: Policy = {...createRandomPolicy(0, CONST.POLICY.TYPE.TEAM), defaultReimbursable: undefined}; + + // When submitting a new expense to its policy expense chat + const result = IOUUtils.calculateDefaultReimbursable({ + iouType: CONST.IOU.TYPE.SUBMIT, + policy, + participant: {isPolicyExpenseChat: true}, + }); + + // Then the expense is reimbursable + expect(result).toBe(true); + }); +}); From 1bd98572c5eaf25734334d9efac3714d8c235935 Mon Sep 17 00:00:00 2001 From: "cretadn22 (via MelvinBot)" Date: Mon, 12 Oct 2026 07:35:47 +0000 Subject: [PATCH 2/7] Extract the reimbursable toggle policy check into canShowReimbursableToggleForPolicy Co-authored-by: cretadn22 --- .../hooks/useFooterDerivedFlags.ts | 8 +--- .../ReportActionItem/MoneyRequestView.tsx | 6 +-- src/libs/PolicyUtils/index.ts | 15 +++++++ src/pages/iou/DynamicSplitExpenseEditPage.tsx | 14 +++++-- tests/unit/PolicyUtilsTest.ts | 42 +++++++++++++++++++ 5 files changed, 71 insertions(+), 14 deletions(-) diff --git a/src/components/MoneyRequestConfirmationListFooter/hooks/useFooterDerivedFlags.ts b/src/components/MoneyRequestConfirmationListFooter/hooks/useFooterDerivedFlags.ts index 63ec56817ad4..e7a361ceef2c 100644 --- a/src/components/MoneyRequestConfirmationListFooter/hooks/useFooterDerivedFlags.ts +++ b/src/components/MoneyRequestConfirmationListFooter/hooks/useFooterDerivedFlags.ts @@ -5,7 +5,7 @@ import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; import {isBillableEnabledOnPolicy} from '@libs/MoneyRequestReportUtils'; import {shouldShowConfirmationDate} from '@libs/MoneyRequestUtils'; -import {isSubmitPolicy} from '@libs/PolicyUtils'; +import {canShowReimbursableToggleForPolicy, isSubmitPolicy} from '@libs/PolicyUtils'; import {hasEnabledTags} from '@libs/TagsOptionsListUtils'; import {getCurrency, isManagedCardTransaction, isScanRequest, shouldShowAttendees as shouldShowAttendeesTransactionUtils} from '@libs/TransactionUtils'; @@ -94,14 +94,10 @@ function useFooterDerivedFlags({ // A flag for showing the billable field const shouldShowBillable = isBillableEnabledOnPolicy(policy); - // A locked field normally hides the toggle, but an expense that already disagrees with the policy - // default has to stay correctable - otherwise it is silently stuck on the wrong value. - // An absent `defaultReimbursable` means "reimbursable", matching the expense creation path. - const isCurrentTransactionReimbursableDifferentFromPolicyDefault = !!transaction?.reimbursable !== (policy?.defaultReimbursable ?? true); const shouldShowReimbursable = (isPolicyExpenseChat || isTrackExpense) && !!policy && - (policy?.disabledFields?.reimbursable !== true || isCurrentTransactionReimbursableDifferentFromPolicyDefault) && + canShowReimbursableToggleForPolicy(policy, transaction?.reimbursable) && !isManagedCardTransaction(transaction) && !isTypeInvoice; // Submit workspaces ship Categories/Distance enabled by default, so never route their fields to the upgrade gate. diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index b37f620cd9b3..cfed00c839bc 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -64,6 +64,7 @@ import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/crea import {hasEnabledOptions} from '@libs/OptionsListUtils'; import Parser from '@libs/Parser'; import { + canShowReimbursableToggleForPolicy, canSubmitPerDiemExpenseFromWorkspace, findVendorByID, getDistanceRateCustomUnitRate, @@ -544,12 +545,9 @@ function MoneyRequestView({ const shouldShowCategoryDisabledAlert = !policy?.areCategoriesEnabled && !!category; const shouldShowTagDisabledAlert = (!policy?.areTagsEnabled || !policyHasEnabledTags) && !!transactionTag; const shouldShowBillable = (isPolicyExpenseChat || isExpenseUnreported) && (!!transactionBillable || isBillableEnabledOnPolicy(policy) || !!updatedTransaction?.billable); - // An absent `defaultReimbursable` means "reimbursable", so compare against that instead of bailing out. - // Bailing out hid the toggle on exactly the expenses that drifted from a locked policy, leaving them stuck. - const isCurrentTransactionReimbursableDifferentFromPolicyDefault = !!(updatedTransaction?.reimbursable ?? transactionReimbursable) !== (policy?.defaultReimbursable ?? true); const shouldShowReimbursable = (isPolicyExpenseChat || (isExpenseUnreported && !!policy)) && - (policy?.disabledFields?.reimbursable !== true || isCurrentTransactionReimbursableDifferentFromPolicyDefault) && + canShowReimbursableToggleForPolicy(policy, updatedTransaction?.reimbursable ?? transactionReimbursable) && !isManagedCardTransactionTransactionUtils(transaction) && !isInvoice; const canEditReimbursable = diff --git a/src/libs/PolicyUtils/index.ts b/src/libs/PolicyUtils/index.ts index d20082a5b121..934b1ef9da41 100644 --- a/src/libs/PolicyUtils/index.ts +++ b/src/libs/PolicyUtils/index.ts @@ -1280,6 +1280,20 @@ function canDisableOrDeleteTaxRate(policy: Policy, taxID: string): boolean { return policy.taxRates?.defaultExternalID !== taxID && policy.taxRates?.foreignTaxDefault !== taxID; } +/** + * Whether the policy lets an expense with the given `reimbursable` value show the reimbursable toggle. + * + * A locked field (`disabledFields.reimbursable`) normally hides the toggle, but an expense that already + * disagrees with the policy default has to stay correctable - otherwise it is silently stuck on the wrong value. + * An absent `defaultReimbursable` means "reimbursable", matching the expense creation path. + */ +function canShowReimbursableToggleForPolicy(policy: OnyxEntry, isReimbursable: boolean | undefined): boolean { + if (!policy?.disabledFields?.reimbursable) { + return true; + } + return !!isReimbursable !== (policy.defaultReimbursable ?? true); +} + function arePolicyRulesEnabled(policy: OnyxEntry, policyCategories?: PolicyCategories | null): boolean { if (!isPaidGroupPolicy(policy)) { return false; @@ -2492,6 +2506,7 @@ export { getCorrectedAutoReportingFrequency, hasEligibleBankAccountShareRecipient, arePolicyRulesEnabled, + canShowReimbursableToggleForPolicy, isPolicyFeatureEnabled, isPolicyFieldListEmpty, canUnarchivePolicy, diff --git a/src/pages/iou/DynamicSplitExpenseEditPage.tsx b/src/pages/iou/DynamicSplitExpenseEditPage.tsx index 6236036c533a..ed58bbbfc4ea 100644 --- a/src/pages/iou/DynamicSplitExpenseEditPage.tsx +++ b/src/pages/iou/DynamicSplitExpenseEditPage.tsx @@ -45,7 +45,15 @@ import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavig import type {RightModalNavigatorParamList} from '@libs/Navigation/types'; import {hasEnabledOptions} from '@libs/OptionsListUtils'; import Parser from '@libs/Parser'; -import {arePolicyRulesEnabled, getDistanceRateCustomUnitRate, getTagLists, hasAnyPaidPolicy, isGroupPolicyByType, isTaxTrackingEnabled} from '@libs/PolicyUtils'; +import { + arePolicyRulesEnabled, + canShowReimbursableToggleForPolicy, + getDistanceRateCustomUnitRate, + getTagLists, + hasAnyPaidPolicy, + isGroupPolicyByType, + isTaxTrackingEnabled, +} from '@libs/PolicyUtils'; import {getReportName} from '@libs/ReportNameUtils'; import {isSplitAction} from '@libs/ReportSecondaryActionUtils'; import type {TransactionDetails} from '@libs/ReportUtils'; @@ -265,11 +273,9 @@ function DynamicSplitExpenseEditPage({route}: DynamicSplitExpenseEditPageProps) const shouldShowBillable = (isPolicyExpenseChat || isExpenseUnreported) && (!!splitExpenseDraftTransactionDetails?.billable || isBillableEnabledOnPolicy(effectivePolicy)); const currentReimbursable = splitExpenseDraftTransaction?.reimbursable ?? transaction?.reimbursable; - // An absent `defaultReimbursable` means "reimbursable" - see MoneyRequestView for why we don't bail out here. - const isReimbursableDifferentFromPolicyDefault = !!currentReimbursable !== (effectivePolicy?.defaultReimbursable ?? true); const shouldShowReimbursable = (isPolicyExpenseChat || (isExpenseUnreported && !!effectivePolicy)) && - (effectivePolicy?.disabledFields?.reimbursable !== true || isReimbursableDifferentFromPolicyDefault) && + canShowReimbursableToggleForPolicy(effectivePolicy, currentReimbursable) && !isManagedCardTransaction(transaction); const isDistance = isDistanceRequest(splitExpenseDraftTransaction); diff --git a/tests/unit/PolicyUtilsTest.ts b/tests/unit/PolicyUtilsTest.ts index 24655d411e4d..88005b846055 100644 --- a/tests/unit/PolicyUtilsTest.ts +++ b/tests/unit/PolicyUtilsTest.ts @@ -13,6 +13,7 @@ import { canMemberRead, canMemberWrite, canSendInvoiceFromWorkspace, + canShowReimbursableToggleForPolicy, canUnarchivePolicy, evaluateApprovalWorkflowRule, findVendorByID, @@ -6559,6 +6560,47 @@ describe('arePolicyRulesEnabled', () => { }); }); +describe('canShowReimbursableToggleForPolicy', () => { + const teamBase = createMock({id: 'policy1', type: CONST.POLICY.TYPE.TEAM}); + + it('returns true when the reimbursable field is not locked', () => { + // Given a workspace that lets members change the reimbursable field + const policy = {...teamBase, defaultReimbursable: false, disabledFields: {reimbursable: false}}; + + // When checking an expense that already matches the default + // Then the toggle is shown, because nothing stops the member from editing it + expect(canShowReimbursableToggleForPolicy(policy, false)).toBe(true); + }); + + it('returns false when the field is locked and the expense matches the policy default', () => { + // Given a workspace locked to "cash expenses are always non-reimbursable" + const policy = {...teamBase, defaultReimbursable: false, disabledFields: {reimbursable: true}}; + + // When checking an expense that is already non-reimbursable + // Then the toggle is hidden, because the lock says members cannot change it + expect(canShowReimbursableToggleForPolicy(policy, false)).toBe(false); + }); + + it('returns true when the field is locked but the expense disagrees with the policy default', () => { + // Given a workspace locked to "cash expenses are always non-reimbursable" + const policy = {...teamBase, defaultReimbursable: false, disabledFields: {reimbursable: true}}; + + // When checking an expense that drifted to reimbursable + // Then the toggle is shown so the expense can be corrected instead of staying stuck + expect(canShowReimbursableToggleForPolicy(policy, true)).toBe(true); + }); + + it('treats an absent defaultReimbursable as reimbursable when the field is locked', () => { + // Given a locked workspace with no stored defaultReimbursable + const policy = {...teamBase, defaultReimbursable: undefined, disabledFields: {reimbursable: true}}; + + // When checking a reimbursable expense and a non-reimbursable expense + // Then only the non-reimbursable one disagrees with the implied "reimbursable" default + expect(canShowReimbursableToggleForPolicy(policy, true)).toBe(false); + expect(canShowReimbursableToggleForPolicy(policy, false)).toBe(true); + }); +}); + describe('getDefaultChatEnabledPolicy', () => { const submitPolicy = {...createRandomPolicy(1, CONST.POLICY.TYPE.SUBMIT), id: 'submit1'}; const teamPolicy = {...createRandomPolicy(2, CONST.POLICY.TYPE.TEAM), id: 'team1'}; From 282b0b9a67517e53c68802c868cf1974b06098c2 Mon Sep 17 00:00:00 2001 From: "cretadn22 (via MelvinBot)" Date: Mon, 12 Oct 2026 10:03:42 +0000 Subject: [PATCH 3/7] Treat an absent expense reimbursable value as reimbursable in canShowReimbursableToggleForPolicy Co-authored-by: cretadn22 --- src/libs/PolicyUtils/index.ts | 5 +++-- tests/unit/PolicyUtilsTest.ts | 20 ++++++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/libs/PolicyUtils/index.ts b/src/libs/PolicyUtils/index.ts index 934b1ef9da41..6bab229c430f 100644 --- a/src/libs/PolicyUtils/index.ts +++ b/src/libs/PolicyUtils/index.ts @@ -1285,13 +1285,14 @@ function canDisableOrDeleteTaxRate(policy: Policy, taxID: string): boolean { * * A locked field (`disabledFields.reimbursable`) normally hides the toggle, but an expense that already * disagrees with the policy default has to stay correctable - otherwise it is silently stuck on the wrong value. - * An absent `defaultReimbursable` means "reimbursable", matching the expense creation path. + * An absent `defaultReimbursable` or `isReimbursable` means "reimbursable", matching the expense creation path + * and `getReimbursable`. */ function canShowReimbursableToggleForPolicy(policy: OnyxEntry, isReimbursable: boolean | undefined): boolean { if (!policy?.disabledFields?.reimbursable) { return true; } - return !!isReimbursable !== (policy.defaultReimbursable ?? true); + return (isReimbursable ?? true) !== (policy.defaultReimbursable ?? true); } function arePolicyRulesEnabled(policy: OnyxEntry, policyCategories?: PolicyCategories | null): boolean { diff --git a/tests/unit/PolicyUtilsTest.ts b/tests/unit/PolicyUtilsTest.ts index 88005b846055..31b6ec15434c 100644 --- a/tests/unit/PolicyUtilsTest.ts +++ b/tests/unit/PolicyUtilsTest.ts @@ -6599,6 +6599,26 @@ describe('canShowReimbursableToggleForPolicy', () => { expect(canShowReimbursableToggleForPolicy(policy, true)).toBe(false); expect(canShowReimbursableToggleForPolicy(policy, false)).toBe(true); }); + + it('treats an absent expense reimbursable value as reimbursable when the field is locked', () => { + // Given a locked workspace whose default is reimbursable, stored or implied + const policyWithDefault = {...teamBase, defaultReimbursable: true, disabledFields: {reimbursable: true}}; + const policyWithoutDefault = {...teamBase, defaultReimbursable: undefined, disabledFields: {reimbursable: true}}; + + // When checking an expense with no stored reimbursable value + // Then the toggle is hidden, because the expense defaults to reimbursable and already matches the policy + expect(canShowReimbursableToggleForPolicy(policyWithDefault, undefined)).toBe(false); + expect(canShowReimbursableToggleForPolicy(policyWithoutDefault, undefined)).toBe(false); + }); + + it('shows the toggle for an expense with no reimbursable value on a locked non-reimbursable workspace', () => { + // Given a workspace locked to "cash expenses are always non-reimbursable" + const policy = {...teamBase, defaultReimbursable: false, disabledFields: {reimbursable: true}}; + + // When checking an expense with no stored reimbursable value, which defaults to reimbursable + // Then the toggle is shown, because the expense disagrees with the policy default + expect(canShowReimbursableToggleForPolicy(policy, undefined)).toBe(true); + }); }); describe('getDefaultChatEnabledPolicy', () => { From 30eb2fb51d6613adedec9c0dbca1aa4242c9df72 Mon Sep 17 00:00:00 2001 From: "cretadn22 (via MelvinBot)" Date: Mon, 12 Oct 2026 10:41:16 +0000 Subject: [PATCH 4/7] Apply the workspace reimbursable lock only when the expense goes to that workspace Co-authored-by: cretadn22 --- src/libs/IOUUtils.ts | 16 +++++----------- tests/unit/IOUUtilsTest.ts | 35 +++++++++++++++++++++++++++++++---- 2 files changed, 36 insertions(+), 15 deletions(-) diff --git a/src/libs/IOUUtils.ts b/src/libs/IOUUtils.ts index 8989dfa65617..57819febeddc 100644 --- a/src/libs/IOUUtils.ts +++ b/src/libs/IOUUtils.ts @@ -466,10 +466,10 @@ function getExistingTransactionID(linkedTrackedExpenseReportAction: ReportAction * Resolve the `reimbursable` value a brand new expense should be created with. * * `defaultReimbursable` is optional on the policy blob and an absent value means "reimbursable", - * which is what the backend assumes too. When the workspace locks the field - * (`disabledFields.reimbursable`), members cannot change it afterwards, so the policy default is - * authoritative regardless of the chat/track context - otherwise a locked "always non-reimbursable" - * workspace would still create reimbursable expenses that nobody can correct. + * which is what the backend assumes too. The policy default, locked or not, only applies when the policy + * is where the expense goes (its policy expense chat, or a track expense). `policy` can be an unrelated + * workspace - for example the policy for moving expenses when an unreported expense is submitted to a + * P2P participant - and that workspace's rules must not leak into the P2P expense. */ function getDefaultReimbursableForPolicy({ policy, @@ -480,13 +480,7 @@ function getDefaultReimbursableForPolicy({ isPolicyExpenseChat: boolean; isCreatingTrackExpense: boolean; }): boolean { - const policyDefaultReimbursable = policy?.defaultReimbursable ?? true; - - if (isGroupPolicy(policy) && policy?.disabledFields?.reimbursable) { - return policyDefaultReimbursable; - } - - return (isPolicyExpenseChat && isGroupPolicy(policy)) || isCreatingTrackExpense ? policyDefaultReimbursable : true; + return (isPolicyExpenseChat && isGroupPolicy(policy)) || isCreatingTrackExpense ? (policy?.defaultReimbursable ?? true) : true; } function calculateDefaultReimbursable({ diff --git a/tests/unit/IOUUtilsTest.ts b/tests/unit/IOUUtilsTest.ts index eccec4dde47f..d4c5f3a06ffe 100644 --- a/tests/unit/IOUUtilsTest.ts +++ b/tests/unit/IOUUtilsTest.ts @@ -1503,17 +1503,28 @@ describe('getDefaultReimbursableForPolicy', () => { expect(result).toBe(true); }); - it('honors a locked non-reimbursable policy even when the participant is not flagged as a policy expense chat', () => { + it('honors a locked non-reimbursable policy in its policy expense chat', () => { // Given a workspace locked to "cash expenses are always non-reimbursable" const policy: Policy = {...createRandomPolicy(0, CONST.POLICY.TYPE.TEAM), defaultReimbursable: false, disabledFields: {reimbursable: true}}; - // When a new expense is created without the policy-expense-chat flag - const result = IOUUtils.getDefaultReimbursableForPolicy({policy, isPolicyExpenseChat: false, isCreatingTrackExpense: false}); + // When a new expense is created in its policy expense chat + const result = IOUUtils.getDefaultReimbursableForPolicy({policy, isPolicyExpenseChat: true, isCreatingTrackExpense: false}); - // Then the lock wins, because members have no way to correct the value afterwards + // Then the lock applies, because the expense goes to that workspace expect(result).toBe(false); }); + it('ignores a locked non-reimbursable policy when the expense does not go to that workspace', () => { + // Given a workspace locked to "cash expenses are always non-reimbursable" + const policy: Policy = {...createRandomPolicy(0, CONST.POLICY.TYPE.TEAM), defaultReimbursable: false, disabledFields: {reimbursable: true}}; + + // When a new expense is created outside its policy expense chat, such as a P2P expense + const result = IOUUtils.getDefaultReimbursableForPolicy({policy, isPolicyExpenseChat: false, isCreatingTrackExpense: false}); + + // Then it stays reimbursable, because that workspace's rules do not apply to the expense + expect(result).toBe(true); + }); + it('still reports reimbursable for a locked policy whose defaultReimbursable is absent', () => { // Given a workspace with a locked reimbursable field but no stored default const policy: Policy = {...createRandomPolicy(0, CONST.POLICY.TYPE.TEAM), defaultReimbursable: undefined, disabledFields: {reimbursable: true}}; @@ -1576,4 +1587,20 @@ describe('calculateDefaultReimbursable', () => { // Then the expense is reimbursable expect(result).toBe(true); }); + + it('does not apply a locked workspace rule when an unreported expense is submitted to a P2P participant', () => { + // Given the policy for moving expenses is locked to "cash expenses are always non-reimbursable" + const policyForMovingExpenses: Policy = {...createRandomPolicy(0, CONST.POLICY.TYPE.TEAM), defaultReimbursable: false, disabledFields: {reimbursable: true}}; + + // When an unreported expense is submitted to a P2P participant + const result = IOUUtils.calculateDefaultReimbursable({ + iouType: CONST.IOU.TYPE.SUBMIT, + policyForMovingExpenses, + participant: {isPolicyExpenseChat: false}, + transactionReportID: CONST.REPORT.UNREPORTED_REPORT_ID, + }); + + // Then the expense stays reimbursable, because the unrelated workspace's lock does not reach P2P expenses + expect(result).toBe(true); + }); }); From a6a924a64fa5c38e60440acd01ef98bfec5fa5a9 Mon Sep 17 00:00:00 2001 From: "cretadn22 (via MelvinBot)" Date: Mon, 19 Oct 2026 18:24:09 +0000 Subject: [PATCH 5/7] Show Non-reimbursable on the Amount description in multi-expense reports too Co-authored-by: cretadn22 --- .../ReportActionItem/MoneyRequestView.tsx | 9 +++- tests/ui/MoneyRequestViewTest.tsx | 49 +++++++++++++++++-- 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index 28953731da08..bbded6e5767d 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -798,8 +798,13 @@ function MoneyRequestView({ amountHintText = `${translate('common.converted')} ${convertToDisplayString(transactionConvertedAmount, moneyRequestReport?.currency)}`; } const isCurrentTransactionReimbursable = updatedTransaction?.reimbursable ?? !!transactionReimbursable; - if (isSingleTransactionReport(moneyRequestReport, visibleParentReportTransactions)) { - amountDescription += ` ${CONST.DOT_SEPARATOR} ${Str.UCFirst(translate(isCurrentTransactionReimbursable ? 'iou.reimbursable' : 'iou.nonReimbursable'))}`; + const isTransactionOnReport = !!moneyRequestReport?.reportID && transaction?.reportID === moneyRequestReport.reportID; + // Flag non-reimbursable expenses on any report so single- and multi-expense reports stay consistent. + // "Reimbursable" is only shown for single-expense reports, where it replaces the hidden report-level breakdown. + if (isTransactionOnReport && !isCurrentTransactionReimbursable) { + amountDescription += ` ${CONST.DOT_SEPARATOR} ${Str.UCFirst(translate('iou.nonReimbursable'))}`; + } else if (isSingleTransactionReport(moneyRequestReport, visibleParentReportTransactions)) { + amountDescription += ` ${CONST.DOT_SEPARATOR} ${Str.UCFirst(translate('iou.reimbursable'))}`; } // Show the converted tax here since the redundant report-level Tax total is hidden for a single-expense report. diff --git a/tests/ui/MoneyRequestViewTest.tsx b/tests/ui/MoneyRequestViewTest.tsx index 14d62db5a01c..637663eebb91 100644 --- a/tests/ui/MoneyRequestViewTest.tsx +++ b/tests/ui/MoneyRequestViewTest.tsx @@ -589,13 +589,14 @@ describe('MoneyRequestView edit fields', () => { }); }); - it('should NOT append "Non-reimbursable" to the Amount description when the parent report has multiple expenses', async () => { + it('should append "Non-reimbursable" to the Amount description when the parent report has multiple expenses', async () => { const threadReport = { ...LHNTestUtils.getFakeReport(), parentReportID: expenseReportID, parentReportActionID, }; + // Given a non-reimbursable expense on a report that has another expense await setupTestData(); await act(async () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {reimbursable: false}); @@ -612,11 +613,48 @@ describe('MoneyRequestView edit fields', () => { }); await waitForBatchedUpdatesWithAct(); + // When the expense details are rendered + renderMoneyRequestView(threadReport); + await waitForBatchedUpdatesWithAct(); + + // Then the Amount description flags it as non-reimbursable, the same as in a single-expense report + await waitFor(() => { + expect(screen.getByTestId(/^menu-item-iou\.amount.*iou\.nonReimbursable/i)).toBeOnTheScreen(); + }); + }); + + it('should NOT append "Reimbursable" to the Amount description when the parent report has multiple expenses', async () => { + const threadReport = { + ...LHNTestUtils.getFakeReport(), + parentReportID: expenseReportID, + parentReportActionID, + }; + + // Given a reimbursable expense on a report that has another expense, so the report-level breakdown is still shown + await setupTestData(); + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {reimbursable: true}); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}_sibling`, { + transactionID: `${transactionID}_sibling`, + reportID: expenseReportID, + amount: 2500, + currency: CONST.CURRENCY.USD, + created: '2025-06-02', + merchant: 'Sibling', + comment: {}, + reimbursable: false, + }); + }); + await waitForBatchedUpdatesWithAct(); + + // When the expense details are rendered renderMoneyRequestView(threadReport); await waitForBatchedUpdatesWithAct(); + // Then the Amount description has no reimbursable label await waitFor(() => { expect(screen.getByTestId(/^menu-item-iou\.amount/)).toBeOnTheScreen(); + expect(screen.queryByTestId(/^menu-item-iou\.amount.*iou\.reimbursable/i)).not.toBeOnTheScreen(); expect(screen.queryByTestId(/^menu-item-iou\.amount.*iou\.nonReimbursable/i)).not.toBeOnTheScreen(); }); }); @@ -653,16 +691,17 @@ describe('MoneyRequestView edit fields', () => { }); }); - it('should NOT append "Non-reimbursable" while offline because the pending-deleted expense is still rendered', async () => { + it('should NOT append "Reimbursable" while offline because the pending-deleted expense is still rendered', async () => { const threadReport = { ...LHNTestUtils.getFakeReport(), parentReportID: expenseReportID, parentReportActionID, }; + // Given a reimbursable expense whose only sibling is pending deletion, while offline await setupTestData(); await act(async () => { - await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {reimbursable: false}); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {reimbursable: true}); await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}_sibling`, { transactionID: `${transactionID}_sibling`, reportID: expenseReportID, @@ -678,12 +717,14 @@ describe('MoneyRequestView edit fields', () => { }); await waitForBatchedUpdatesWithAct(); + // When the expense details are rendered renderMoneyRequestView(threadReport); await waitForBatchedUpdatesWithAct(); + // Then the report still counts as multi-expense, so the single-expense "Reimbursable" label is not shown await waitFor(() => { expect(screen.getByTestId(/^menu-item-iou\.amount/)).toBeOnTheScreen(); - expect(screen.queryByTestId(/^menu-item-iou\.amount.*iou\.nonReimbursable/i)).not.toBeOnTheScreen(); + expect(screen.queryByTestId(/^menu-item-iou\.amount.*iou\.reimbursable/i)).not.toBeOnTheScreen(); }); }); From a437f1e31c4899ac5b8b8b95715a33f56478ebba Mon Sep 17 00:00:00 2001 From: "cretadn22 (via MelvinBot)" Date: Tue, 20 Oct 2026 04:43:02 +0000 Subject: [PATCH 6/7] Remove unneeded comment above the Amount reimbursable label Co-authored-by: cretadn22 --- src/components/ReportActionItem/MoneyRequestView.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index bbded6e5767d..2dbda73ff0a0 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -799,8 +799,6 @@ function MoneyRequestView({ } const isCurrentTransactionReimbursable = updatedTransaction?.reimbursable ?? !!transactionReimbursable; const isTransactionOnReport = !!moneyRequestReport?.reportID && transaction?.reportID === moneyRequestReport.reportID; - // Flag non-reimbursable expenses on any report so single- and multi-expense reports stay consistent. - // "Reimbursable" is only shown for single-expense reports, where it replaces the hidden report-level breakdown. if (isTransactionOnReport && !isCurrentTransactionReimbursable) { amountDescription += ` ${CONST.DOT_SEPARATOR} ${Str.UCFirst(translate('iou.nonReimbursable'))}`; } else if (isSingleTransactionReport(moneyRequestReport, visibleParentReportTransactions)) { From 21f77f3b0745d44656fc801f9d7c2f8696ffa59d Mon Sep 17 00:00:00 2001 From: "cretadn22 (via MelvinBot)" Date: Wed, 21 Oct 2026 04:10:02 +0000 Subject: [PATCH 7/7] Show the Reimbursable label on the Amount field in multi-expense reports too Co-authored-by: cretadn22 --- .../ReportActionItem/MoneyRequestView.tsx | 15 ++---- tests/ui/MoneyRequestViewTest.tsx | 46 ++----------------- 2 files changed, 7 insertions(+), 54 deletions(-) diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index 2dbda73ff0a0..cbc9e07c1b5f 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -38,7 +38,6 @@ import usePersonalPolicy from '@hooks/usePersonalPolicy'; import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; import {useDerivedReportNamesByReportIDs} from '@hooks/useReportAttributes'; import useReportIsArchived from '@hooks/useReportIsArchived'; -import useReportTransactions from '@hooks/useReportTransactions'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useRestrictedActionPolicyID from '@hooks/useRestrictedActionPolicyID'; import useSplitEffectivePolicy from '@hooks/useSplitEffectivePolicy'; @@ -59,7 +58,7 @@ import DistanceRequestUtils from '@libs/DistanceRequestUtils'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {insertTagIntoTransactionTagsString} from '@libs/IOUUtils'; import {getRateFromMerchant} from '@libs/MergeTransactionUtils'; -import {isBillableEnabledOnPolicy, isSingleTransactionReport} from '@libs/MoneyRequestReportUtils'; +import {isBillableEnabledOnPolicy} from '@libs/MoneyRequestReportUtils'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import {hasEnabledOptions} from '@libs/OptionsListUtils'; import Parser from '@libs/Parser'; @@ -133,7 +132,6 @@ import { isPerDiemRequest as isPerDiemRequestTransactionUtils, isScanning, isTimeRequest as isTimeRequestTransactionUtils, - isTransactionPendingDelete, shouldShowAttendees as shouldShowAttendeesTransactionUtils, } from '@libs/TransactionUtils'; import {isInvalidMerchantValue} from '@libs/ValidationUtils'; @@ -301,11 +299,6 @@ function MoneyRequestView({ const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); const isP2PDistanceRequest = isCustomUnitRateIDForP2P(transaction); const moneyRequestReport = parentReport; - const parentReportTransactions = useReportTransactions(moneyRequestReport?.reportID); - // Exclude transactions pending deletion so the report is recognized as single-expense immediately after deleting one of its expenses, - // instead of waiting for the optimistic delete to be removed from Onyx (https://github.com/Expensify/App/issues/91058). - // While offline the deleted expense is still rendered, so keep counting it to stay consistent with the visible transaction list. - const visibleParentReportTransactions = parentReportTransactions.filter((t) => isOffline || !isTransactionPendingDelete(t)); const isApproved = isReportApproved({report: moneyRequestReport}); const isInvoice = isInvoiceReport(moneyRequestReport); const isTrackExpense = !mergeTransactionID && isTrackExpenseReportNew(transactionThreadReport, moneyRequestReport, parentReportAction); @@ -799,10 +792,8 @@ function MoneyRequestView({ } const isCurrentTransactionReimbursable = updatedTransaction?.reimbursable ?? !!transactionReimbursable; const isTransactionOnReport = !!moneyRequestReport?.reportID && transaction?.reportID === moneyRequestReport.reportID; - if (isTransactionOnReport && !isCurrentTransactionReimbursable) { - amountDescription += ` ${CONST.DOT_SEPARATOR} ${Str.UCFirst(translate('iou.nonReimbursable'))}`; - } else if (isSingleTransactionReport(moneyRequestReport, visibleParentReportTransactions)) { - amountDescription += ` ${CONST.DOT_SEPARATOR} ${Str.UCFirst(translate('iou.reimbursable'))}`; + if (isTransactionOnReport) { + amountDescription += ` ${CONST.DOT_SEPARATOR} ${Str.UCFirst(translate(isCurrentTransactionReimbursable ? 'iou.reimbursable' : 'iou.nonReimbursable'))}`; } // Show the converted tax here since the redundant report-level Tax total is hidden for a single-expense report. diff --git a/tests/ui/MoneyRequestViewTest.tsx b/tests/ui/MoneyRequestViewTest.tsx index 637663eebb91..7bb0465e142a 100644 --- a/tests/ui/MoneyRequestViewTest.tsx +++ b/tests/ui/MoneyRequestViewTest.tsx @@ -623,14 +623,14 @@ describe('MoneyRequestView edit fields', () => { }); }); - it('should NOT append "Reimbursable" to the Amount description when the parent report has multiple expenses', async () => { + it('should append "Reimbursable" to the Amount description when the parent report has multiple expenses', async () => { const threadReport = { ...LHNTestUtils.getFakeReport(), parentReportID: expenseReportID, parentReportActionID, }; - // Given a reimbursable expense on a report that has another expense, so the report-level breakdown is still shown + // Given a reimbursable expense on a report that has another expense await setupTestData(); await act(async () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {reimbursable: true}); @@ -651,10 +651,9 @@ describe('MoneyRequestView edit fields', () => { renderMoneyRequestView(threadReport); await waitForBatchedUpdatesWithAct(); - // Then the Amount description has no reimbursable label + // Then the Amount description flags it as reimbursable, the same as in a single-expense report await waitFor(() => { - expect(screen.getByTestId(/^menu-item-iou\.amount/)).toBeOnTheScreen(); - expect(screen.queryByTestId(/^menu-item-iou\.amount.*iou\.reimbursable/i)).not.toBeOnTheScreen(); + expect(screen.getByTestId(/^menu-item-iou\.amount.*iou\.reimbursable/i)).toBeOnTheScreen(); expect(screen.queryByTestId(/^menu-item-iou\.amount.*iou\.nonReimbursable/i)).not.toBeOnTheScreen(); }); }); @@ -691,43 +690,6 @@ describe('MoneyRequestView edit fields', () => { }); }); - it('should NOT append "Reimbursable" while offline because the pending-deleted expense is still rendered', async () => { - const threadReport = { - ...LHNTestUtils.getFakeReport(), - parentReportID: expenseReportID, - parentReportActionID, - }; - - // Given a reimbursable expense whose only sibling is pending deletion, while offline - await setupTestData(); - await act(async () => { - await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {reimbursable: true}); - await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}_sibling`, { - transactionID: `${transactionID}_sibling`, - reportID: expenseReportID, - amount: 2500, - currency: CONST.CURRENCY.USD, - created: '2025-06-02', - merchant: 'Sibling', - comment: {}, - reimbursable: false, - pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, - }); - await Onyx.merge(ONYXKEYS.NETWORK, {shouldForceOffline: true}); - }); - await waitForBatchedUpdatesWithAct(); - - // When the expense details are rendered - renderMoneyRequestView(threadReport); - await waitForBatchedUpdatesWithAct(); - - // Then the report still counts as multi-expense, so the single-expense "Reimbursable" label is not shown - await waitFor(() => { - expect(screen.getByTestId(/^menu-item-iou\.amount/)).toBeOnTheScreen(); - expect(screen.queryByTestId(/^menu-item-iou\.amount.*iou\.reimbursable/i)).not.toBeOnTheScreen(); - }); - }); - it('appends "Converted" to the Tax amount description for a foreign-currency taxed expense', async () => { const threadReport = { ...LHNTestUtils.getFakeReport(),