Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
8e81809
feat: enable duplicating distance expenses
jjcoffee Feb 10, 2026
2744457
fix: offline shows merchant instead of distance & rate
jjcoffee Feb 10, 2026
9874cf8
fix: remove unnecessary import
jjcoffee Feb 10, 2026
93a893a
revert: fix for offline shows merchant instead of distance & rate
jjcoffee Feb 10, 2026
60c6ffd
Merge branch 'main' into fix/duplicate-distance-expense-blockers
jjcoffee Feb 11, 2026
4ea0809
fix: distance track expense offline shows merchant instead of distanc…
jjcoffee Feb 12, 2026
ba11b26
fix: duplicating split distance expense creates a split expense with …
jjcoffee Feb 12, 2026
25ffb3c
fix: remove unnecessary params
jjcoffee Feb 12, 2026
876c51f
Merge branch 'main' into fix/duplicate-distance-expense-blockers
jjcoffee Mar 2, 2026
4a1ae7a
fix: prevent duplicating unsupported distance expenses
jjcoffee Mar 2, 2026
6f69824
fix: spelling
jjcoffee Mar 2, 2026
df96e21
feat: add Spanish translation
jjcoffee Mar 2, 2026
a6b912f
fix: prevent navigation when duplicating per diem expense
jjcoffee Mar 2, 2026
b46e5bc
fix: allow duplicating DMs if there is no workspace
jjcoffee Mar 2, 2026
55e34a9
fix: close modal if we're going to show an error
jjcoffee Mar 3, 2026
983c6f2
fix: prettier
jjcoffee Mar 3, 2026
00e81a8
fix: lint
jjcoffee Mar 3, 2026
4b600ca
fix: lint
jjcoffee Mar 3, 2026
de58659
fix: strip waypoints when duplicating split distance expenses
jjcoffee Mar 3, 2026
554685b
Merge branch 'main' into fix/duplicate-distance-expense-blockers
jjcoffee Mar 4, 2026
56f90ea
chore: add translations
jjcoffee Mar 4, 2026
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
27 changes: 26 additions & 1 deletion src/components/MoneyReportHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@
isOpenExpenseReport,
isProcessingReport,
isReportOwner,
isSelfDM,
navigateOnDeleteExpense,
navigateToDetailsPage,
rejectMoneyRequestReason,
Expand All @@ -101,6 +102,7 @@
getOriginalTransactionWithSplitInfo,
hasCustomUnitOutOfPolicyViolation as hasCustomUnitOutOfPolicyViolationTransactionUtils,
hasDuplicateTransactions,
isDistanceRequest,
isDuplicate,
isExpensifyCardTransaction,
isPayAtEndExpense as isPayAtEndExpenseTransactionUtils,
Expand Down Expand Up @@ -438,7 +440,12 @@
);

const isInvoiceReport = isInvoiceReportUtil(moneyRequestReport);
const isDistanceExpenseUnsupportedForDuplicating = !!(
isDistanceRequest(transaction) &&
(isArchivedReport || isChatReportArchived || (activePolicyExpenseChat && (isDM(chatReport) || isSelfDM(chatReport))))
);

const [duplicateDistanceErrorModalVisible, setDuplicateDistanceErrorModalVisible] = useState(false);
const [rateErrorModalVisible, setRateErrorModalVisible] = useState(false);
const [isDownloadErrorModalVisible, setIsDownloadErrorModalVisible] = useState(false);
const [isHoldEducationalModalVisible, setIsHoldEducationalModalVisible] = useState(false);
Expand Down Expand Up @@ -1500,6 +1507,11 @@
return;
}

if (isDistanceExpenseUnsupportedForDuplicating) {
setDuplicateDistanceErrorModalVisible(true);
return;
}

if (isPerDiemRequestOnNonDefaultWorkspace) {
setDuplicatePerDiemErrorModalVisible(true);
return;
Expand All @@ -1513,7 +1525,11 @@

duplicateExpenseTransaction([transaction]);
},
shouldCloseModalOnSelect: isPerDiemRequestOnNonDefaultWorkspace || hasCustomUnitOutOfPolicyViolation || activePolicyExpenseChat?.iouReportID === moneyRequestReport?.reportID,
shouldCloseModalOnSelect:
isDistanceExpenseUnsupportedForDuplicating ||
isPerDiemRequestOnNonDefaultWorkspace ||
hasCustomUnitOutOfPolicyViolation ||
activePolicyExpenseChat?.iouReportID === moneyRequestReport?.reportID,
},
[CONST.REPORT.SECONDARY_ACTIONS.DUPLICATE_REPORT]: {
text: translate('common.duplicateReport'),
Expand Down Expand Up @@ -1823,7 +1839,7 @@
}
return option;
});
}, [originalSelectedTransactionsOptions, showDeleteModal, dismissedRejectUseExplanation]);

Check warning on line 1842 in src/components/MoneyReportHeader.tsx

View workflow job for this annotation

GitHub Actions / ESLint check

React Hook useMemo has missing dependencies: 'isDelegateAccessRestricted' and 'showDelegateNoAccessModal'. Either include them or remove the dependency array

Check warning on line 1842 in src/components/MoneyReportHeader.tsx

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

React Hook useMemo has missing dependencies: 'isDelegateAccessRestricted' and 'showDelegateNoAccessModal'. Either include them or remove the dependency array

const shouldShowSelectedTransactionsButton = !!selectedTransactionsOptions.length && !transactionThreadReportID;

Expand Down Expand Up @@ -2022,6 +2038,15 @@
prompt={translate('iou.correctRateError')}
shouldShowCancelButton={false}
/>
<ConfirmModal
title={translate('common.duplicateExpense')}
isVisible={duplicateDistanceErrorModalVisible}
onConfirm={() => setDuplicateDistanceErrorModalVisible(false)}
onCancel={() => setDuplicateDistanceErrorModalVisible(false)}
confirmText={translate('common.buttonConfirm')}
prompt={translate('iou.cannotDuplicateDistanceExpense')}
shouldShowCancelButton={false}
/>
<ConfirmModal
title={translate('common.duplicateExpense')}
isVisible={duplicatePerDiemErrorModalVisible}
Expand Down
18 changes: 17 additions & 1 deletion src/components/MoneyRequestHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import {
getOriginalTransactionWithSplitInfo,
hasCustomUnitOutOfPolicyViolation as hasCustomUnitOutOfPolicyViolationTransactionUtils,
hasPendingRTERViolation as hasPendingRTERViolationTransactionUtils,
isDistanceRequest,
isDuplicate as isDuplicateTransactionUtils,
isExpensifyCardTransaction,
isOnHold as isOnHoldTransactionUtils,
Expand Down Expand Up @@ -192,6 +193,11 @@ function MoneyRequestHeader({report, parentReportAction, policy, onBackButtonPre
// If the parent report is a selfDM, it should always be opened in the Inbox tab
const shouldOpenParentReportInCurrentTab = !isSelfDM(parentReport);

const isDistanceExpenseUnsupportedForDuplicating = !!(
isDistanceRequest(transaction) &&
(isParentReportArchived || (activePolicyExpenseChat && (isSelfDM(parentReport) || isParentChatReportDM)))
);

const {wideRHPRouteKeys} = useWideRHPState();
const [shouldFailAllRequests] = useOnyx(ONYXKEYS.NETWORK, {selector: shouldFailAllRequestsSelector});
const [quickAction] = useOnyx(ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE);
Expand Down Expand Up @@ -500,6 +506,16 @@ function MoneyRequestHeader({report, parentReportAction, policy, onBackButtonPre
return;
}

if (isDistanceExpenseUnsupportedForDuplicating) {
showConfirmModal({
title: translate('common.duplicateExpense'),
prompt: translate('iou.cannotDuplicateDistanceExpense'),
confirmText: translate('common.buttonConfirm'),
shouldShowCancelButton: false,
});
return;
}

if (isPerDiemRequestOnNonDefaultWorkspace) {
showConfirmModal({
title: translate('common.duplicateExpense'),
Expand All @@ -518,7 +534,7 @@ function MoneyRequestHeader({report, parentReportAction, policy, onBackButtonPre

duplicateTransaction([transaction]);
},
shouldCloseModalOnSelect: hasCustomUnitOutOfPolicyViolation || isPerDiemRequestOnNonDefaultWorkspace,
shouldCloseModalOnSelect: isDistanceExpenseUnsupportedForDuplicating || hasCustomUnitOutOfPolicyViolation || isPerDiemRequestOnNonDefaultWorkspace,
},
[CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.VIEW_DETAILS]: {
value: CONST.REPORT.SECONDARY_ACTIONS.VIEW_DETAILS,
Expand Down
3 changes: 2 additions & 1 deletion src/languages/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1600,6 +1600,8 @@ const translations: TranslationDeepObject<typeof en> = {
failedToSubmitViaDEW: (reason: string) => `Der Bericht konnte nicht übermittelt werden. ${reason}`,
failedToAutoApproveViaDEW: (reason: string) => `Genehmigung über <a href="${CONST.CONFIGURE_EXPENSE_REPORT_RULES_HELP_URL}">Workspace-Regeln</a> fehlgeschlagen. ${reason}`,
failedToApproveViaDEW: (reason: string) => `Genehmigung fehlgeschlagen. ${reason}`,
cannotDuplicateDistanceExpense:
'Sie können Entfernungsausgaben nicht über mehrere Arbeitsbereiche hinweg duplizieren, da sich die Sätze zwischen den Arbeitsbereichen unterscheiden können.',
},
transactionMerge: {
listPage: {
Expand Down Expand Up @@ -8596,7 +8598,6 @@ Hier ist ein *Testbeleg*, um dir zu zeigen, wie es funktioniert:`,
addMember: 'Dieses Mitglied kann nicht hinzugefügt werden. Bitte versuche es erneut.',
vacationDelegate: 'Dieser Benutzer kann nicht als Urlaubsvertretung festgelegt werden. Bitte versuche es erneut.',
},

reportSuspiciousActivityPrompt: (email: string) =>
`Bist du sicher? Dadurch wird das Konto von <strong>${email}</strong> gesperrt. <br /><br /> Unser Team wird das Konto anschließend überprüfen und unbefugten Zugriff entfernen. Um den Zugriff wiederherzustellen, muss die Person mit Concierge zusammenarbeiten.`,
reportSuspiciousActivityConfirmationPrompt: 'Wir überprüfen das Konto, um sicherzustellen, dass es sicher entsperrt werden kann, und melden uns bei Fragen über Concierge.',
Expand Down
1 change: 1 addition & 0 deletions src/languages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1620,6 +1620,7 @@ const translations = {
formatPolicyRules: (fragments: string, route: string) => `${fragments} via <a href="${route}">workspace rules</a>`,
},
duplicateNonDefaultWorkspacePerDiemError: "You can't duplicate per diem expenses across workspaces because the rates may differ between workspaces.",
cannotDuplicateDistanceExpense: "You can't duplicate distance expenses across workspaces because the rates may differ between workspaces.",
},
transactionMerge: {
listPage: {
Expand Down
1 change: 1 addition & 0 deletions src/languages/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1454,6 +1454,7 @@ const translations: TranslationDeepObject<typeof en> = {
formatPolicyRules: (fragments: string, route: string) => `${fragments} vía <a href="${route}">reglas del espacio de trabajo</a>`,
},
duplicateNonDefaultWorkspacePerDiemError: 'No puedes duplicar gastos de viáticos entre espacios de trabajo porque las tarifas pueden variar entre ellos.',
cannotDuplicateDistanceExpense: 'No puedes duplicar gastos de distancia entre espacios de trabajo porque las tasas pueden diferir entre espacios de trabajo.',
},
transactionMerge: {
listPage: {
Expand Down
1 change: 1 addition & 0 deletions src/languages/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1606,6 +1606,7 @@ const translations: TranslationDeepObject<typeof en> = {
failedToAutoApproveViaDEW: (reason: string) =>
`impossible d’approuver via les <a href="${CONST.CONFIGURE_EXPENSE_REPORT_RULES_HELP_URL}">règles de l’espace de travail</a>. ${reason}`,
failedToApproveViaDEW: (reason: string) => `échec de l’approbation. ${reason}`,
cannotDuplicateDistanceExpense: 'Vous ne pouvez pas dupliquer des dépenses de distance entre espaces de travail, car les taux peuvent différer d’un espace de travail à l’autre.',
},
transactionMerge: {
listPage: {
Expand Down
1 change: 1 addition & 0 deletions src/languages/it.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1597,6 +1597,7 @@ const translations: TranslationDeepObject<typeof en> = {
failedToAutoApproveViaDEW: (reason: string) =>
`approvazione non riuscita tramite le <a href="${CONST.CONFIGURE_EXPENSE_REPORT_RULES_HELP_URL}">regole dello spazio di lavoro</a>. ${reason}`,
failedToApproveViaDEW: (reason: string) => `approvazione non riuscita. ${reason}`,
cannotDuplicateDistanceExpense: 'Non puoi duplicare le spese chilometriche tra diversi spazi di lavoro perché le tariffe potrebbero essere diverse.',
},
transactionMerge: {
listPage: {
Expand Down
1 change: 1 addition & 0 deletions src/languages/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1586,6 +1586,7 @@ const translations: TranslationDeepObject<typeof en> = {
failedToSubmitViaDEW: (reason: string) => `レポートの送信に失敗しました。${reason}`,
failedToAutoApproveViaDEW: (reason: string) => `<a href="${CONST.CONFIGURE_EXPENSE_REPORT_RULES_HELP_URL}">ワークスペースルール</a>で承認に失敗しました。${reason}`,
failedToApproveViaDEW: (reason: string) => `承認に失敗しました。${reason}`,
cannotDuplicateDistanceExpense: '距離精算はワークスペースごとにレートが異なる可能性があるため、ワークスペース間で複製することはできません。',
},
transactionMerge: {
listPage: {
Expand Down
1 change: 1 addition & 0 deletions src/languages/nl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1594,6 +1594,7 @@ const translations: TranslationDeepObject<typeof en> = {
failedToSubmitViaDEW: (reason: string) => `het is niet gelukt om het rapport in te dienen. ${reason}`,
failedToAutoApproveViaDEW: (reason: string) => `goedkeuren via <a href="${CONST.CONFIGURE_EXPENSE_REPORT_RULES_HELP_URL}">werkruimte­regels</a> is mislukt. ${reason}`,
failedToApproveViaDEW: (reason: string) => `goedkeuren mislukt. ${reason}`,
cannotDuplicateDistanceExpense: 'Je kunt afstandsvergoedingen niet dupliceren tussen werkruimtes, omdat de tarieven per werkruimte kunnen verschillen.',
},
transactionMerge: {
listPage: {
Expand Down
2 changes: 1 addition & 1 deletion src/languages/pl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1595,6 +1595,7 @@ const translations: TranslationDeepObject<typeof en> = {
failedToAutoApproveViaDEW: (reason: string) =>
`nie udało się zatwierdzić przez <a href="${CONST.CONFIGURE_EXPENSE_REPORT_RULES_HELP_URL}">zasady w przestrzeni roboczej</a>. ${reason}`,
failedToApproveViaDEW: (reason: string) => `nie udało się zaakceptować. ${reason}`,
cannotDuplicateDistanceExpense: 'Nie możesz duplikować wydatków za przejazdy między przestrzeniami roboczymi, ponieważ stawki mogą się różnić między poszczególnymi przestrzeniami.',
},
transactionMerge: {
listPage: {
Expand Down Expand Up @@ -8546,7 +8547,6 @@ Oto *paragon testowy*, żeby pokazać Ci, jak to działa:`,
vacationDelegate: 'Nie można ustawić tego użytkownika jako zastępującego na czas nieobecności. Spróbuj ponownie.',
},
cannotSetVacationDelegateForMember: (email: string) => `Nie możesz ustawić zastępstwa urlopowego dla ${email}, ponieważ jest on/ona obecnie zastępcą dla następujących członków:`,

reportSuspiciousActivityPrompt: (email: string) =>
`Czy na pewno? To zablokuje konto użytkownika <strong>${email}</strong>. <br /><br /> Nasz zespół następnie przejrzy konto i usunie wszelki nieautoryzowany dostęp. Aby odzyskać dostęp, będą musieli współpracować z Concierge.`,
reportSuspiciousActivityConfirmationPrompt: 'Przejrzymy konto, aby potwierdzić, że bezpiecznie je odblokować, i skontaktujemy się przez Concierge w razie pytań.',
Expand Down
2 changes: 1 addition & 1 deletion src/languages/pt-BR.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1591,6 +1591,7 @@ const translations: TranslationDeepObject<typeof en> = {
failedToSubmitViaDEW: (reason: string) => `falha ao enviar o relatório. ${reason}`,
failedToAutoApproveViaDEW: (reason: string) => `falha ao aprovar pelas <a href="${CONST.CONFIGURE_EXPENSE_REPORT_RULES_HELP_URL}">regras do workspace</a>. ${reason}`,
failedToApproveViaDEW: (reason: string) => `falha ao aprovar. ${reason}`,
cannotDuplicateDistanceExpense: 'Você não pode duplicar despesas de distância entre espaços de trabalho porque as tarifas podem ser diferentes entre eles.',
},
transactionMerge: {
listPage: {
Expand Down Expand Up @@ -8551,7 +8552,6 @@ Aqui está um *comprovante de teste* para mostrar como funciona:`,
vacationDelegate: 'Não foi possível definir este usuário como delegado de férias. Tente novamente.',
},
cannotSetVacationDelegateForMember: (email: string) => `Você não pode definir um procurador de férias para ${email} porque esta pessoa já é procuradora dos seguintes membros:`,

reportSuspiciousActivityPrompt: (email: string) =>
`Tem certeza? Isso irá bloquear a conta de <strong>${email}</strong>. <br /><br /> Nossa equipe irá então analisar a conta e remover qualquer acesso não autorizado. Para recuperar o acesso, será necessário que trabalhem com a Concierge.`,
reportSuspiciousActivityConfirmationPrompt: 'Vamos revisar a conta para verificar se é seguro desbloqueá-la e entraremos em contato via Concierge caso haja dúvidas.',
Expand Down
1 change: 1 addition & 0 deletions src/languages/zh-hans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1563,6 +1563,7 @@ const translations: TranslationDeepObject<typeof en> = {
failedToSubmitViaDEW: (reason: string) => `报表提交失败。${reason}`,
failedToAutoApproveViaDEW: (reason: string) => `未能通过<a href="${CONST.CONFIGURE_EXPENSE_REPORT_RULES_HELP_URL}">工作区规则</a>批准。${reason}`,
failedToApproveViaDEW: (reason: string) => `批准失败。${reason}`,
cannotDuplicateDistanceExpense: '你无法在不同工作区之间复制里程报销,因为各个工作区的费率可能不同。',
},
transactionMerge: {
listPage: {
Expand Down
5 changes: 0 additions & 5 deletions src/libs/ReportSecondaryActionUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,6 @@ import {
getOriginalTransactionWithSplitInfo,
hasReceipt as hasReceiptTransactionUtils,
hasSubmissionBlockingViolations,
isDistanceRequest as isDistanceRequestTransactionUtils,
isDuplicate,
isManagedCardTransaction as isManagedCardTransactionTransactionUtils,
isOdometerDistanceRequest,
Expand Down Expand Up @@ -802,10 +801,6 @@ function isDuplicateAction(report: Report, reportTransactions: Transaction[]): b

const reportTransaction = reportTransactions.at(0);

if (isDistanceRequestTransactionUtils(reportTransaction)) {
return false;
}

// We can't duplicate per diem expenses that don't have start & end dates.
const dates = reportTransaction?.comment?.customUnit?.attributes?.dates;
if (isPerDiemRequestTransactionUtils(reportTransaction) && (!dates?.start || !dates?.end)) {
Expand Down
34 changes: 29 additions & 5 deletions src/libs/actions/IOU/Duplicate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
buildTransactionThread,
getTransactionDetails,
} from '@libs/ReportUtils';
import {getRequestType, getTransactionType} from '@libs/TransactionUtils';
import {getRequestType, getTransactionType, isDistanceRequest, isExpenseSplit} from '@libs/TransactionUtils';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type * as OnyxTypes from '@src/types/onyx';
Expand Down Expand Up @@ -545,6 +545,9 @@ function duplicateExpenseTransaction({
// to try to create a transaction thread report with an ID that already exists.
const {linkedTrackedExpenseReportAction, ...transactionWithoutLinkedAction} = transaction;

// We remove waypoints for split distance expenses in order to preserve the split's amount and distance.
const waypoints = !isExpenseSplit(transaction) ? (transactionDetails?.waypoints as WaypointCollection) : undefined;

const params: RequestMoneyInformation = {
report: targetReport,
optimisticChatReportID,
Expand All @@ -571,7 +574,7 @@ function duplicateExpenseTransaction({
originalTransactionID: undefined,
receipt: undefined,
source: undefined,
waypoints: transactionDetails?.waypoints as WaypointCollection | undefined,
waypoints,
type: transaction?.comment?.type,
count: transaction?.comment?.units?.count,
rate: transaction?.comment?.units?.rate,
Expand All @@ -592,6 +595,11 @@ function duplicateExpenseTransaction({
personalDetails,
};

// Since we remove waypoints for split distance expenses, we need to re-add the distance param here
if (isExpenseSplit(transaction) && isDistanceRequest(transaction)) {
params.transactionParams.distance = transaction.comment?.customUnit?.quantity ?? undefined;
}

// If no workspace is provided the expense should be unreported
if (!targetPolicy) {
const trackExpenseParams: CreateTrackExpenseParams = {
Expand All @@ -600,9 +608,21 @@ function duplicateExpenseTransaction({
...(params.participantParams ?? {}),
participant: {accountID: userAccountID, selected: true},
},
existingTransaction: {
...(params.transactionParams ?? {}),
comment: {
...transaction.comment,
originalTransactionID: undefined,
source: undefined,
},
iouRequestType: getRequestType(transaction),
modifiedCreated: '',
reportID: '1',
transactionID: '1',
},
transactionParams: {
...(params.transactionParams ?? {}),
validWaypoints: transactionDetails?.waypoints as WaypointCollection | undefined,
validWaypoints: waypoints,
},
report: undefined,
isDraftPolicy: false,
Expand Down Expand Up @@ -632,7 +652,11 @@ function duplicateExpenseTransaction({
participants,
existingTransaction: {
...(params.transactionParams ?? {}),
comment: transaction.comment,
comment: {
...transaction.comment,
originalTransactionID: undefined,
source: undefined,
},
iouRequestType: getRequestType(transaction),
modifiedCreated: '',
reportID: '1',
Expand All @@ -641,7 +665,7 @@ function duplicateExpenseTransaction({
transactionParams: {
...(params.transactionParams ?? {}),
comment: Parser.htmlToMarkdown(transactionDetails?.comment ?? ''),
validWaypoints: transactionDetails?.waypoints as WaypointCollection | undefined,
validWaypoints: waypoints,
},
policyRecentlyUsedCurrencies: policyRecentlyUsedCurrencies ?? [],
quickAction,
Expand Down
Loading
Loading