Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,7 @@ const taxSliceSelector = (t: OnyxEntry<Transaction>): TaxSlice | undefined => {

// --- ConfirmationFieldList: useFooterDerivedFlags ---

type DerivedFlagsSlice = Pick<Transaction, 'modifiedCurrency' | 'currency' | 'iouRequestType' | 'reportID' | 'managedCard'>;
type DerivedFlagsSlice = Pick<Transaction, 'modifiedCurrency' | 'currency' | 'iouRequestType' | 'reportID' | 'managedCard' | 'reimbursable'>;

const derivedFlagsSliceSelector = (t: OnyxEntry<Transaction>): OnyxEntry<DerivedFlagsSlice> => {
if (!t) {
Expand All @@ -248,6 +248,7 @@ const derivedFlagsSliceSelector = (t: OnyxEntry<Transaction>): OnyxEntry<Derived
iouRequestType: t.iouRequestType,
reportID: t.reportID,
managedCard: t.managedCard,
reimbursable: t.reimbursable,
};
return slice;
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -95,7 +95,11 @@ function useFooterDerivedFlags({
// A flag for showing the billable field
const shouldShowBillable = isBillableEnabledOnPolicy(policy);
const shouldShowReimbursable =
(isPolicyExpenseChat || isTrackExpense) && !!policy && policy?.disabledFields?.reimbursable !== true && !isManagedCardTransaction(transaction) && !isTypeInvoice;
(isPolicyExpenseChat || isTrackExpense) &&
!!policy &&
canShowReimbursableToggleForPolicy(policy, transaction?.reimbursable) &&
!isManagedCardTransaction(transaction) &&
!isTypeInvoice;
// Submit workspaces ship Categories/Distance enabled by default, so never route their fields to the upgrade gate.
const isSubmitWorkspace = isSubmitPolicy(policy);
const shouldNavigateToUpgradePath = !isSubmitWorkspace && canNavigateToUpgradePath;
Expand Down
17 changes: 5 additions & 12 deletions src/components/ReportActionItem/MoneyRequestView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -59,11 +58,12 @@ 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';
import {
canShowReimbursableToggleForPolicy,
canSubmitPerDiemExpenseFromWorkspace,
findVendorByID,
getDistanceRateCustomUnitRate,
Expand Down Expand Up @@ -132,7 +132,6 @@ import {
isPerDiemRequest as isPerDiemRequestTransactionUtils,
isScanning,
isTimeRequest as isTimeRequestTransactionUtils,
isTransactionPendingDelete,
shouldShowAttendees as shouldShowAttendeesTransactionUtils,
} from '@libs/TransactionUtils';
import {isInvalidMerchantValue} from '@libs/ValidationUtils';
Expand Down Expand Up @@ -300,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);
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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'))}`;
}

Expand Down
24 changes: 23 additions & 1 deletion src/libs/IOUUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Policy>;
isPolicyExpenseChat: boolean;
isCreatingTrackExpense: boolean;
}): boolean {
return (isPolicyExpenseChat && isGroupPolicy(policy)) || isCreatingTrackExpense ? (policy?.defaultReimbursable ?? true) : true;
}

function calculateDefaultReimbursable({
iouType,
policy,
Expand All @@ -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(
Expand Down Expand Up @@ -703,6 +724,7 @@ export {
shouldShowPerDiemTabOption,
navigateToConfirmationPage,
calculateDefaultReimbursable,
getDefaultReimbursableForPolicy,
getInitialPerDiemTargetReport,
getIsWorkspacesOnlyForTransaction,
getReusableP2PReportID,
Expand Down
16 changes: 16 additions & 0 deletions src/libs/PolicyUtils/policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,21 @@ function canCreateCategoryInSitu(policy: OnyxEntry<Policy>, 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<Policy>, 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<Policy> | null, currentUserLogin: string | undefined): Policy[] {
const activePolicies = getActivePolicies(policies, currentUserLogin);
Expand Down Expand Up @@ -410,6 +425,7 @@ export {
getPoliciesForRoomCreation,
getOwnedPaidPolicies,
canCreateCategoryInSitu,
canShowReimbursableToggleForPolicy,
getActiveAdminWorkspaces,
getActiveEmployeeWorkspaces,
hasActiveAdminWorkspaces,
Expand Down
13 changes: 10 additions & 3 deletions src/libs/actions/Policy/Policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6878,15 +6878,22 @@ function getCashExpenseReimbursableMode(policy: OnyxEntry<Policy>): 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;
}

Expand Down
6 changes: 3 additions & 3 deletions src/pages/Share/SubmitDetailsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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]);

Expand Down
13 changes: 10 additions & 3 deletions src/pages/iou/DynamicSplitExpenseEditPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {isGroupPolicy} from '@libs/PolicyUtils';
import {getDefaultReimbursableForPolicy} from '@libs/IOUUtils';

import {setMoneyRequestBillable, setMoneyRequestReimbursable} from '@userActions/IOU/MoneyRequest';

Expand Down Expand Up @@ -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);
}
Expand Down
58 changes: 58 additions & 0 deletions tests/actions/PolicyTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading