diff --git a/src/components/MoneyRequestConfirmationList/sections/selectors.ts b/src/components/MoneyRequestConfirmationList/sections/selectors.ts index cac073eb44ba..4a7f2a3945b1 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,7 @@ const derivedFlagsSliceSelector = (t: OnyxEntry): OnyxEntry isOffline || !isTransactionPendingDelete(t)); const isApproved = isReportApproved({report: moneyRequestReport}); const isInvoice = isInvoiceReport(moneyRequestReport); const isTrackExpense = !mergeTransactionID && isTrackExpenseReportNew(transactionThreadReport, moneyRequestReport, parentReportAction); @@ -547,11 +541,9 @@ function MoneyRequestView({ const shouldShowCategoryDisabledAlert = !policy?.areCategoriesEnabled && !!category; const shouldShowTagDisabledAlert = (!policy?.areTagsEnabled || !policyHasEnabledTags) && !!transactionTag; const shouldShowBillable = (isPolicyExpenseChat || isExpenseUnreported) && (!!transactionBillable || isBillableEnabledOnPolicy(policy) || !!updatedTransaction?.billable); - const isCurrentTransactionReimbursableDifferentFromPolicyDefault = - policy?.defaultReimbursable !== undefined && !!(updatedTransaction?.reimbursable ?? transactionReimbursable) !== policy.defaultReimbursable; const shouldShowReimbursable = (isPolicyExpenseChat || (isExpenseUnreported && !!policy)) && - (policy?.disabledFields?.reimbursable !== true || isCurrentTransactionReimbursableDifferentFromPolicyDefault) && + canShowReimbursableToggleForPolicy(policy, updatedTransaction?.reimbursable ?? transactionReimbursable) && !isManagedCardTransactionTransactionUtils(transaction) && !isInvoice; const canEditReimbursable = @@ -799,7 +791,8 @@ function MoneyRequestView({ amountHintText = `${translate('common.converted')} ${convertToDisplayString(transactionConvertedAmount, moneyRequestReport?.currency)}`; } const isCurrentTransactionReimbursable = updatedTransaction?.reimbursable ?? !!transactionReimbursable; - if (isSingleTransactionReport(moneyRequestReport, visibleParentReportTransactions)) { + const isTransactionOnReport = !!moneyRequestReport?.reportID && transaction?.reportID === moneyRequestReport.reportID; + if (isTransactionOnReport) { amountDescription += ` ${CONST.DOT_SEPARATOR} ${Str.UCFirst(translate(isCurrentTransactionReimbursable ? 'iou.reimbursable' : 'iou.nonReimbursable'))}`; } diff --git a/src/libs/IOUUtils.ts b/src/libs/IOUUtils.ts index 37e55333ae8c..47ed5584025e 100644 --- a/src/libs/IOUUtils.ts +++ b/src/libs/IOUUtils.ts @@ -462,6 +462,27 @@ function getExistingTransactionID(linkedTrackedExpenseReportAction: ReportAction return getOriginalMessage(linkedTrackedExpenseReportAction)?.IOUTransactionID; } +/** + * 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. 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, + isPolicyExpenseChat, + isCreatingTrackExpense, +}: { + policy: OnyxEntry; + isPolicyExpenseChat: boolean; + isCreatingTrackExpense: boolean; +}): boolean { + return (isPolicyExpenseChat && isGroupPolicy(policy)) || isCreatingTrackExpense ? (policy?.defaultReimbursable ?? true) : true; +} + function calculateDefaultReimbursable({ iouType, policy, @@ -479,7 +500,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( @@ -703,6 +724,7 @@ export { shouldShowPerDiemTabOption, navigateToConfirmationPage, calculateDefaultReimbursable, + getDefaultReimbursableForPolicy, getInitialPerDiemTargetReport, getIsWorkspacesOnlyForTransaction, getReusableP2PReportID, diff --git a/src/libs/PolicyUtils/policy.ts b/src/libs/PolicyUtils/policy.ts index 1326dccada03..23faa9cc4acb 100644 --- a/src/libs/PolicyUtils/policy.ts +++ b/src/libs/PolicyUtils/policy.ts @@ -160,6 +160,21 @@ function canCreateCategoryInSitu(policy: OnyxEntry, login?: string) { return canEditWorkspaceSettings(policy, login) && !hasAccountingConnections(policy) && !!policy?.areCategoriesEnabled; } +/** + * 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` 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 ?? true) !== (policy.defaultReimbursable ?? true); +} + /** Return active policies where current user is an admin */ function getActiveAdminWorkspaces(policies: OnyxCollection | null, currentUserLogin: string | undefined): Policy[] { const activePolicies = getActivePolicies(policies, currentUserLogin); @@ -410,6 +425,7 @@ export { getPoliciesForRoomCreation, getOwnedPaidPolicies, canCreateCategoryInSitu, + canShowReimbursableToggleForPolicy, getActiveAdminWorkspaces, getActiveEmployeeWorkspaces, hasActiveAdminWorkspaces, diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index 88e9ea3ecc5b..6c333c607743 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -6878,15 +6878,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 5690db0d0822..1cfe11e00561 100644 --- a/src/pages/Share/SubmitDetailsPage.tsx +++ b/src/pages/Share/SubmitDetailsPage.tsx @@ -41,14 +41,14 @@ import DateUtils from '@libs/DateUtils'; import {getFileName, readFileAsync} from '@libs/fileDownload/FileUtils'; import getCurrentPositionWithinCap from '@libs/getCurrentPosition/getCurrentPositionWithinCap'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; -import {getExistingTransactionID, isLookingAroundSearchRoutingActive, resolveReportForMoneyRequest} from '@libs/IOUUtils'; +import {getDefaultReimbursableForPolicy, getExistingTransactionID, isLookingAroundSearchRoutingActive, resolveReportForMoneyRequest} from '@libs/IOUUtils'; import cleanupAndNavigateAfterExpenseCreate from '@libs/Navigation/helpers/cleanupAndNavigateAfterExpenseCreate'; import Navigation from '@libs/Navigation/Navigation'; 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'; @@ -286,7 +286,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 189dd2f1d8fc..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,10 +273,9 @@ 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 shouldShowReimbursable = (isPolicyExpenseChat || (isExpenseUnreported && !!effectivePolicy)) && - (effectivePolicy?.disabledFields?.reimbursable !== true || isReimbursableDifferentFromPolicyDefault) && + canShowReimbursableToggleForPolicy(effectivePolicy, currentReimbursable) && !isManagedCardTransaction(transaction); const isDistance = isDistanceRequest(splitExpenseDraftTransaction); 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 85482a4d1a45..b20b89b45c69 100644 --- a/tests/actions/PolicyTest.ts +++ b/tests/actions/PolicyTest.ts @@ -2706,6 +2706,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/ui/MoneyRequestViewTest.tsx b/tests/ui/MoneyRequestViewTest.tsx index 14d62db5a01c..7bb0465e142a 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,25 +613,27 @@ 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/)).toBeOnTheScreen(); - expect(screen.queryByTestId(/^menu-item-iou\.amount.*iou\.nonReimbursable/i)).not.toBeOnTheScreen(); + expect(screen.getByTestId(/^menu-item-iou\.amount.*iou\.nonReimbursable/i)).toBeOnTheScreen(); }); }); - it('should append "Non-reimbursable" immediately when the only other expense is pending deletion', 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 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, @@ -640,20 +643,22 @@ describe('MoneyRequestView edit fields', () => { merchant: 'Sibling', comment: {}, reimbursable: false, - pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, }); }); await waitForBatchedUpdatesWithAct(); + // When the expense details are rendered renderMoneyRequestView(threadReport); await waitForBatchedUpdatesWithAct(); + // 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.*iou\.nonReimbursable/i)).toBeOnTheScreen(); + expect(screen.getByTestId(/^menu-item-iou\.amount.*iou\.reimbursable/i)).toBeOnTheScreen(); + expect(screen.queryByTestId(/^menu-item-iou\.amount.*iou\.nonReimbursable/i)).not.toBeOnTheScreen(); }); }); - it('should NOT append "Non-reimbursable" while offline because the pending-deleted expense is still rendered', async () => { + it('should append "Non-reimbursable" immediately when the only other expense is pending deletion', async () => { const threadReport = { ...LHNTestUtils.getFakeReport(), parentReportID: expenseReportID, @@ -674,7 +679,6 @@ describe('MoneyRequestView edit fields', () => { reimbursable: false, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, }); - await Onyx.merge(ONYXKEYS.NETWORK, {shouldForceOffline: true}); }); await waitForBatchedUpdatesWithAct(); @@ -682,8 +686,7 @@ describe('MoneyRequestView edit fields', () => { await waitForBatchedUpdatesWithAct(); await waitFor(() => { - expect(screen.getByTestId(/^menu-item-iou\.amount/)).toBeOnTheScreen(); - expect(screen.queryByTestId(/^menu-item-iou\.amount.*iou\.nonReimbursable/i)).not.toBeOnTheScreen(); + expect(screen.getByTestId(/^menu-item-iou\.amount.*iou\.nonReimbursable/i)).toBeOnTheScreen(); }); }); diff --git a/tests/unit/IOUUtilsTest.ts b/tests/unit/IOUUtilsTest.ts index 3f0d21c3b209..86e0cb0ef781 100644 --- a/tests/unit/IOUUtilsTest.ts +++ b/tests/unit/IOUUtilsTest.ts @@ -1500,3 +1500,120 @@ 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 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 in its policy expense chat + const result = IOUUtils.getDefaultReimbursableForPolicy({policy, isPolicyExpenseChat: true, isCreatingTrackExpense: false}); + + // 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}}; + + // 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); + }); + + 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); + }); +}); diff --git a/tests/unit/PolicyUtilsTest.ts b/tests/unit/PolicyUtilsTest.ts index 82714f593591..cb2a5034745d 100644 --- a/tests/unit/PolicyUtilsTest.ts +++ b/tests/unit/PolicyUtilsTest.ts @@ -18,6 +18,7 @@ import { canMemberWrite, canSendInvoice, canSendInvoiceFromWorkspace, + canShowReimbursableToggleForPolicy, canSubmitPerDiemExpenseFromWorkspace, canUnarchivePolicy, evaluateApprovalWorkflowRule, @@ -736,6 +737,67 @@ describe('PolicyUtils', () => { }); }); + 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); + }); + + 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('getActiveAdminWorkspaces', () => { const currentUserLogin = 'admin@test.com'; // createRandomPolicy randomizes these, so set the fields that decide whether a workspace is active