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
38 changes: 27 additions & 11 deletions src/components/MoneyRequestHeaderSecondaryActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import {getOriginalMessage, getTrackExpenseActionableWhisper, isMoneyRequestActi
import {getTransactionThreadPrimaryAction} from '@libs/ReportPrimaryActionUtils';
import {getSecondaryTransactionThreadActions} from '@libs/ReportSecondaryActionUtils';
import {
canDuplicateExpenseIntoSourceReport,
generateReportID,
getPolicyExpenseChat,
isCurrentUserSubmitter,
Expand Down Expand Up @@ -200,8 +201,6 @@ function MoneyRequestHeaderSecondaryActions({reportID, onBackButtonPress}: Money

// Custom hooks
const defaultExpensePolicy = useDefaultExpensePolicy();
const [defaultPolicyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${getNonEmptyStringOnyxID(defaultExpensePolicy?.id)}`);
const [defaultPolicyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${getNonEmptyStringOnyxID(defaultExpensePolicy?.id)}`);
const {shouldNavigateToUpgradePath} = usePolicyForMovingExpenses(isPerDiemRequest(transaction));
const {deleteTransactions, shouldOpenSplitExpenseEditFlowOnDelete} = useDeleteTransactions({report: parentReport, reportActions: parentReportAction ? [parentReportAction] : [], policy});
const {iouReport, chatReport: chatIOUReport, isChatIOUReportArchived} = useGetIOUReportFromReportAction(parentReportAction);
Expand Down Expand Up @@ -238,7 +237,24 @@ function MoneyRequestHeaderSecondaryActions({reportID, onBackButtonPress}: Money
getTransactionThreadPrimaryAction(currentUserLogin ?? '', accountID, report, parentReport, parentOwnerLogin, transaction, transactionViolations, policy, false)
);
const activePolicyExpenseChat = getPolicyExpenseChat(accountID, defaultExpensePolicy?.id);
const isPerDiemRequestOnNonDefaultWorkspace = isPerDiemRequest(transaction) && defaultExpensePolicy?.id !== policy?.id;

// Duplicate expense target: the expense's report when it can accept the copy, otherwise the default workspace chat
const canDuplicateIntoSourceReport = canDuplicateExpenseIntoSourceReport({
sourceReport: iouReport,
chatReport: chatIOUReport,
policy: iouPolicy,
currentUserLogin: currentUserLogin ?? '',
isSourceReportArchived: isParentReportArchived,
isChatReportArchived: isChatIOUReportArchived,
isASAPSubmitBetaEnabled,
rules,
});
const duplicateTargetPolicy = canDuplicateIntoSourceReport ? iouPolicy : defaultExpensePolicy;
const duplicateTargetReport = canDuplicateIntoSourceReport ? iouReport : activePolicyExpenseChat;
const [duplicateTargetPolicyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${getNonEmptyStringOnyxID(duplicateTargetPolicy?.id)}`);
const [duplicateTargetPolicyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${getNonEmptyStringOnyxID(duplicateTargetPolicy?.id)}`);

const isPerDiemRequestOnNonDefaultWorkspace = isPerDiemRequest(transaction) && duplicateTargetPolicy?.id !== policy?.id;
const hasCustomUnitOutOfPolicyViolation = hasCustomUnitOutOfPolicyViolationTransactionUtils(transactionViolations);
const isParentChatReportDM = isDM(chatIOUReport);
const isDistanceExpenseUnsupportedForDuplicating = !!(
Expand All @@ -252,10 +268,10 @@ function MoneyRequestHeaderSecondaryActions({reportID, onBackButtonPress}: Money
const shouldShowSplitIndicator = isExpenseSplit && (hasMultipleSplits || isReportOpen);
const shouldShowEditSplitOnDeleteAction = !!transaction?.transactionID && shouldOpenSplitExpenseEditFlowOnDelete([transaction.transactionID]);
const isReportSubmitter = isCurrentUserSubmitter(chatIOUReport);
const targetPolicyTags = defaultPolicyTags ?? {};
const targetPolicyTags = duplicateTargetPolicyTags ?? {};

const policyTagList = useMoneyRequestPolicyTagsForReport({report: activePolicyExpenseChat, currentUserAccountID: accountID});
const participants = getMoneyRequestParticipantsFromReport(activePolicyExpenseChat, accountID);
const policyTagList = useMoneyRequestPolicyTagsForReport({report: duplicateTargetReport, currentUserAccountID: accountID});
const participants = getMoneyRequestParticipantsFromReport(duplicateTargetReport, accountID);
const participantsPolicyTags = useParticipantsPolicyTags(participants);

// Duplicate action throttle
Expand All @@ -274,7 +290,7 @@ function MoneyRequestHeaderSecondaryActions({reportID, onBackButtonPress}: Money

const optimisticChatReportID = generateReportID();
const optimisticIOUReportID = generateReportID();
const activePolicyCategoriesMap = defaultPolicyCategories ?? {};
const activePolicyCategoriesMap = duplicateTargetPolicyCategories ?? {};

for (const item of transactions) {
const existingTransactionID = getExistingTransactionID(item.linkedTrackedExpenseReportAction);
Expand All @@ -293,9 +309,9 @@ function MoneyRequestHeaderSecondaryActions({reportID, onBackButtonPress}: Money
policyRecentlyUsedCurrencies: policyRecentlyUsedCurrencies ?? [],
isSelfTourViewed,
customUnitPolicyID: policy?.id,
targetPolicy: defaultExpensePolicy ?? undefined,
targetPolicy: duplicateTargetPolicy ?? undefined,
targetPolicyCategories: activePolicyCategoriesMap,
targetReport: activePolicyExpenseChat,
targetReport: duplicateTargetReport,
existingTransactionDraft,
personalDetails,
recentWaypoints,
Expand Down Expand Up @@ -499,9 +515,9 @@ function MoneyRequestHeaderSecondaryActions({reportID, onBackButtonPress}: Money
iconFill: isDuplicateActive ? undefined : theme.icon,
value: CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.DUPLICATE,
onSelected: () => {
if (defaultExpensePolicy && shouldRestrictUserBillableActions(defaultExpensePolicy, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed, accountID)) {
if (duplicateTargetPolicy && shouldRestrictUserBillableActions(duplicateTargetPolicy, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed, accountID)) {
dropdownMenuRef.current?.setIsMenuVisible(false);
Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(defaultExpensePolicy.id));
Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(duplicateTargetPolicy.id));
return;
}

Expand Down
34 changes: 25 additions & 9 deletions src/hooks/useExpenseActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {getDistanceExpenseTypeForPolicy} from '@libs/PolicyDistanceRatesUtils';
import {isPolicyAccessible} from '@libs/PolicyUtils';
import {getIOUActionForTransactionID} from '@libs/ReportActionsUtils';
import {
canDuplicateExpenseIntoSourceReport,
canEditFieldOfMoneyRequest,
canUserPerformWriteAction as canUserPerformWriteActionReportUtils,
generateReportID,
Expand Down Expand Up @@ -177,6 +178,20 @@ function useExpenseActions({reportID, isReportInSearch = false, backTo, onDuplic
const defaultExpensePolicy = useDefaultExpensePolicy();
const activePolicyExpenseChat = getPolicyExpenseChat(accountID, defaultExpensePolicy?.id);

// Duplicate expense target: the viewed report when it can accept the copy, otherwise the default workspace chat
const canDuplicateIntoSourceReport = canDuplicateExpenseIntoSourceReport({
sourceReport: moneyRequestReport,
chatReport,
policy,
currentUserLogin: currentUserLogin ?? '',
isSourceReportArchived: isArchivedReport,
isChatReportArchived,
isASAPSubmitBetaEnabled,
rules,
});
const duplicateTargetPolicy = canDuplicateIntoSourceReport ? policy : defaultExpensePolicy;
const duplicateTargetReport = canDuplicateIntoSourceReport ? moneyRequestReport : activePolicyExpenseChat;

// Duplicate detection
const {duplicateTransactions, duplicateTransactionViolations} = useDuplicateTransactionsAndViolations(transactions.map((t) => t.transactionID));

Expand Down Expand Up @@ -227,7 +242,7 @@ function useExpenseActions({reportID, isReportInSearch = false, backTo, onDuplic
// Duplicate expense: unsupported / shouldClose flags
const transactionViolations = useTransactionViolations(transaction?.transactionID);
const hasCustomUnitOutOfPolicyViolation = hasCustomUnitOutOfPolicyViolationTransactionUtils(transactionViolations);
const isPerDiemRequestOnNonDefaultWorkspace = isPerDiemRequest(transaction) && defaultExpensePolicy?.id !== policy?.id;
const isPerDiemRequestOnNonDefaultWorkspace = isPerDiemRequest(transaction) && duplicateTargetPolicy?.id !== policy?.id;
const isDistanceExpenseUnsupportedForDuplicating = !!(
isDistanceRequest(transaction) &&
(isArchivedReport || isChatReportArchived || (activePolicyExpenseChat && (isDM(chatReport) || isSelfDM(chatReport))))
Expand All @@ -236,6 +251,7 @@ function useExpenseActions({reportID, isReportInSearch = false, backTo, onDuplic
isDistanceExpenseUnsupportedForDuplicating ||
isPerDiemRequestOnNonDefaultWorkspace ||
hasCustomUnitOutOfPolicyViolation ||
canDuplicateIntoSourceReport ||
activePolicyExpenseChat?.iouReportID === moneyRequestReport?.reportID;

const handleDuplicateReset = () => {
Expand All @@ -246,10 +262,10 @@ function useExpenseActions({reportID, isReportInSearch = false, backTo, onDuplic
};
const [isDuplicateActive, temporarilyDisableDuplicateAction] = useThrottledButtonState(handleDuplicateReset);

const targetPolicyTags = defaultExpensePolicy ? (allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${defaultExpensePolicy.id}`] ?? {}) : {};
const targetPolicyTags = duplicateTargetPolicy ? (allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${duplicateTargetPolicy.id}`] ?? {}) : {};

const policyTagList = useMoneyRequestPolicyTagsForReport({report: activePolicyExpenseChat, currentUserAccountID: accountID});
const participants = getMoneyRequestParticipantsFromReport(activePolicyExpenseChat, accountID);
const policyTagList = useMoneyRequestPolicyTagsForReport({report: duplicateTargetReport, currentUserAccountID: accountID});
const participants = getMoneyRequestParticipantsFromReport(duplicateTargetReport, accountID);
const participantsPolicyTags = useParticipantsPolicyTags(participants);

const duplicateExpenseTransaction = (transactionList: OnyxTypes.Transaction[]) => {
Expand All @@ -258,7 +274,7 @@ function useExpenseActions({reportID, isReportInSearch = false, backTo, onDuplic
}
const optimisticChatReportID = generateReportID();
const optimisticIOUReportID = generateReportID();
const activePolicyCategories = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${defaultExpensePolicy?.id}`] ?? {};
const activePolicyCategories = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${duplicateTargetPolicy?.id}`] ?? {};

for (const item of transactionList) {
const existingTransactionID = getExistingTransactionID(item.linkedTrackedExpenseReportAction);
Expand All @@ -277,9 +293,9 @@ function useExpenseActions({reportID, isReportInSearch = false, backTo, onDuplic
policyRecentlyUsedCurrencies: policyRecentlyUsedCurrencies ?? [],
isSelfTourViewed,
customUnitPolicyID: policy?.id,
targetPolicy: defaultExpensePolicy ?? undefined,
targetPolicy: duplicateTargetPolicy ?? undefined,
targetPolicyCategories: activePolicyCategories,
targetReport: activePolicyExpenseChat,
targetReport: duplicateTargetReport,
existingTransactionDraft,
personalDetails,
recentWaypoints,
Expand Down Expand Up @@ -373,9 +389,9 @@ function useExpenseActions({reportID, isReportInSearch = false, backTo, onDuplic
iconFill: isDuplicateActive ? undefined : theme.icon,
value: CONST.REPORT.SECONDARY_ACTIONS.DUPLICATE_EXPENSE,
onSelected: () => {
if (defaultExpensePolicy && shouldRestrictUserBillableActions(defaultExpensePolicy, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed, accountID)) {
if (duplicateTargetPolicy && shouldRestrictUserBillableActions(duplicateTargetPolicy, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed, accountID)) {
onDuplicateReset?.();
Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(defaultExpensePolicy.id));
Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(duplicateTargetPolicy.id));
return;
}

Expand Down
35 changes: 35 additions & 0 deletions src/libs/ReportUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ import {
isPaidGroupPolicyByType,
isPendingDeletePolicy,
isPerDiemEnabled,
isPolicyAccessible,
isPolicyAdmin as isPolicyAdminPolicyUtils,
isPolicyAuditor,
isPolicyGuest,
Expand Down Expand Up @@ -12696,6 +12697,39 @@ function shouldCreateNewMoneyRequestReport(
);
}

type CanDuplicateExpenseIntoSourceReportParams = {
sourceReport: OnyxEntry<Report>;
chatReport: OnyxEntry<Report>;
policy: OnyxEntry<Policy>;
currentUserLogin: string;
isSourceReportArchived: boolean;
isChatReportArchived: boolean;
isASAPSubmitBetaEnabled: boolean;
rules: OnyxCollection<Rule>;
};

/**
* Whether a duplicated expense can be added to the expense report its source lives on, instead of the default workspace chat.
* Reuses `shouldCreateNewMoneyRequestReport` so this always agrees with the report the money request builder will pick.
* Duplicates are never scan requests because the receipt is stripped, so `isScanRequest` is always false here.
*/
function canDuplicateExpenseIntoSourceReport({
sourceReport,
chatReport,
policy,
currentUserLogin,
isSourceReportArchived,
isChatReportArchived,
isASAPSubmitBetaEnabled,
rules,
}: CanDuplicateExpenseIntoSourceReportParams): boolean {
if (!isExpenseReport(sourceReport) || isSourceReportArchived || !chatReport || isChatReportArchived || !isPolicyAccessible(policy, currentUserLogin)) {
return false;
}

return !shouldCreateNewMoneyRequestReport(sourceReport, chatReport, false, isASAPSubmitBetaEnabled, rules, CONST.IOU.ACTION.CREATE, true);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Won't this allow adding the duplicated expenses to a submitted report before it gets any approval? The PR description says that submitted reports fall back to default routing and are not reopened. NAB though, and we should keep it that way to be consistent.

}

/** Precomputed report-action error state used to make per-transaction RBR checks O(1). */
type ActionErrorsByTransaction = {
/** A non-money-request action (or money-request action without an IOUTransactionID) has errors, flagging every transaction. */
Expand Down Expand Up @@ -15053,6 +15087,7 @@ export {
canAccessReport,
isReportNotFound,
canAddTransaction,
canDuplicateExpenseIntoSourceReport,
canDeleteTransaction,
canBeAutoReimbursed,
canApproveIOU,
Expand Down
67 changes: 67 additions & 0 deletions tests/actions/IOUTest/DuplicateTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1886,6 +1886,73 @@ describe('actions/Duplicate', () => {
expect(duplicatedTransaction?.transactionID).not.toBe(mockCashExpenseTransaction.transactionID);
});

it('should add the duplicate to the open expense report passed as targetReport', async () => {
// Given an open expense report on a workspace, while the workspace chat points at a different report
const sourcePolicy: Policy = {...createRandomPolicy(42), type: CONST.POLICY.TYPE.TEAM, role: CONST.POLICY.ROLE.USER, pendingAction: null, autoReporting: false};
const sourceChatReport: Report = {
...createRandomReport(42, CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT),
policyID: sourcePolicy.id,
ownerAccountID: RORY_ACCOUNT_ID,
iouReportID: 'another-report',
hasOutstandingChildRequest: false,
};
const sourceExpenseReport: Report = {
...createRandomReport(43, undefined),
type: CONST.REPORT.TYPE.EXPENSE,
policyID: sourcePolicy.id,
chatReportID: sourceChatReport.reportID,
ownerAccountID: RORY_ACCOUNT_ID,
stateNum: CONST.REPORT.STATE_NUM.OPEN,
statusNum: CONST.REPORT.STATUS_NUM.OPEN,
errorFields: undefined,
pendingFields: undefined,
};
const {waypoints, ...restOfComment} = mockTransaction.comment ?? {};
const sourceTransaction: Transaction = {...mockTransaction, reportID: sourceExpenseReport.reportID, amount: mockTransaction.amount * -1, comment: restOfComment};

await Onyx.set(ONYXKEYS.SESSION, {accountID: RORY_ACCOUNT_ID, email: RORY_EMAIL});
await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${sourcePolicy.id}`, sourcePolicy);
await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${sourceChatReport.reportID}`, sourceChatReport);
await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${sourceExpenseReport.reportID}`, sourceExpenseReport);
await waitForBatchedUpdates();

// When the expense is duplicated with that expense report as the target
duplicateExpenseTransaction({
isVendorMatchingBetaEnabled: false,
dateFnsLocale: undefined,
conciergeChat: undefined,
transaction: sourceTransaction,
optimisticChatReportID: mockOptimisticChatReportID,
optimisticIOUReportID: mockOptimisticIOUReportID,
isASAPSubmitBetaEnabled: mockIsASAPSubmitBetaEnabled,
introSelected: undefined,
quickAction: undefined,
policyRecentlyUsedCurrencies: [],
isSelfTourViewed: false,
customUnitPolicyID: sourcePolicy.id,
targetPolicy: sourcePolicy,
targetPolicyCategories: fakePolicyCategories,
targetReport: sourceExpenseReport,
existingTransactionDraft: undefined,
personalDetails: mockPersonalDetails,
recentWaypoints,
targetPolicyTags: {},
policyTagList: {},
currentUser: {accountID: RORY_ACCOUNT_ID, email: RORY_EMAIL},
currentUserLocalCurrency: undefined,
delegateAccountID: undefined,
isTrackIntentUser: false,
formatPhoneNumber,
getCurrencyDecimals: getCurrencyDecimalsLocal,
participantsPolicyTags: {},
rules: undefined,
});
await waitForBatchedUpdates();

// Then the duplicate is requested on the source expense report, not on the report the workspace chat points at
expect(writeSpy).toHaveBeenCalledWith(WRITE_COMMANDS.REQUEST_MONEY, expect.objectContaining({iouReportID: sourceExpenseReport.reportID}), expect.objectContaining({}));
});

it('should create a duplicate time expense successfully', async () => {
const transactionID = 'time-1';
const HOURLY_RATE = 9.99;
Expand Down
Loading
Loading