Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
67f23f8
Fix merge expense transaction thread navigation
marufsharifi Jul 21, 2026
3bb31b3
Fix test spelling
marufsharifi Jul 21, 2026
d066c8d
Merge branch 'main' into fix/merge-expense-transaction-thread-navigation
marufsharifi Jul 28, 2026
0874813
Merge branch 'main' into fix/merge-expense-transaction-thread-navigation
marufsharifi Aug 5, 2026
1009c56
Fix merge test params
marufsharifi Aug 5, 2026
d7895ca
Fix merge thread action lookup
marufsharifi Aug 5, 2026
7abd263
Fix self DM merge action type
marufsharifi Aug 5, 2026
1972055
Merge branch 'main' into fix/merge-expense-transaction-thread-navigation
marufsharifi Aug 6, 2026
430ffe5
Fix self DM merge optimistic duplicate
marufsharifi Aug 6, 2026
297e32d
Merge branch 'main' into fix/merge-expense-transaction-thread-navigation
marufsharifi Aug 7, 2026
ba12aed
Merge branch 'main' into fix/merge-expense-transaction-thread-navigation
marufsharifi Aug 8, 2026
e322f09
Fix duplicate expense in self DM merge
marufsharifi Aug 8, 2026
fa50be1
Merge branch 'main' into fix/merge-expense-transaction-thread-navigation
marufsharifi Aug 11, 2026
50f3e08
Pass getCurrencyDecimals in self DM merge test
marufsharifi Aug 11, 2026
ee994aa
Merge branch 'main' into fix/merge-expense-transaction-thread-navigation
marufsharifi Aug 12, 2026
2c0f8e5
Merge branch 'main' into fix/merge-expense-transaction-thread-navigation
marufsharifi Aug 17, 2026
009f30d
Fix policy misalignment in search merge
marufsharifi Aug 17, 2026
9887fbc
Avoid optimistic writes to report "0"
marufsharifi Aug 17, 2026
06412bf
Replace unsafe cast with existing helper
marufsharifi Aug 17, 2026
c110fd8
Trim self-DM merge comment
marufsharifi Aug 17, 2026
e023a56
Merge branch 'main' into fix/merge-expense-transaction-thread-navigation
marufsharifi Aug 18, 2026
c88dda1
Pass getCurrencySymbol in self DM merge test
marufsharifi Aug 18, 2026
dc74a7e
Trim self-DM merge comment
marufsharifi Aug 18, 2026
371c468
Merge branch 'main' into fix/merge-expense-transaction-thread-navigation
marufsharifi Aug 21, 2026
f892c15
Fix duplicate self-DM card on offline merge
marufsharifi Aug 21, 2026
bef0af0
Merge branch 'main' into fix/merge-expense-transaction-thread-navigation
marufsharifi Aug 26, 2026
8075812
Merge branch 'main' into fix/merge-expense-transaction-thread-navigation
marufsharifi Aug 27, 2026
3d50a0b
Fix negative amount on offline merge to self DM
marufsharifi Aug 27, 2026
4102aee
Keep self-DM source action deleted on success
marufsharifi Aug 27, 2026
c6589d9
Merge branch 'main' into fix/merge-expense-transaction-thread-navigation
marufsharifi Sep 1, 2026
88109ed
Resolve target IOU action from snapshot
marufsharifi Sep 1, 2026
4e0fad1
Merge branch 'main' into fix/merge-expense-transaction-thread-navigation
marufsharifi Sep 3, 2026
37fd005
Merge branch 'main' into fix/merge-expense-transaction-thread-navigation
marufsharifi Sep 5, 2026
b18efbc
Merge branch 'main' into fix/merge-expense-transaction-thread-navigation
marufsharifi Sep 8, 2026
0d41fd3
Merge branch 'main' into fix/merge-expense-transaction-thread-navigation
marufsharifi Sep 9, 2026
b16f1bf
Merge branch 'main' into fix/merge-expense-transaction-thread-navigation
marufsharifi Sep 11, 2026
c226404
Pass rules param in merge test
marufsharifi Sep 11, 2026
f6e3527
Merge branch 'main' into fix/merge-expense-transaction-thread-navigation
marufsharifi Sep 16, 2026
a5be11d
Merge branch 'main' into fix/merge-expense-transaction-thread-navigation
marufsharifi Sep 21, 2026
86964e4
Skip loading skeleton offline when actions cached
marufsharifi Sep 21, 2026
d121c90
Show source card as pending delete offline
marufsharifi Sep 21, 2026
60b6623
Merge branch 'main' into fix/merge-expense-transaction-thread-navigation
marufsharifi Sep 24, 2026
03cf0dd
Merge branch 'main' into fix/merge-expense-transaction-thread-navigation
marufsharifi Sep 25, 2026
a41aee8
Merge branch 'main' into fix/merge-expense-transaction-thread-navigation
marufsharifi Sep 30, 2026
c3e79ec
Merge branch 'main' into fix/merge-expense-transaction-thread-navigation
marufsharifi Oct 1, 2026
a622ecd
Merge branch 'main' into fix/merge-expense-transaction-thread-navigation
marufsharifi Oct 2, 2026
0bb5722
Merge branch 'main' into fix/merge-expense-transaction-thread-navigation
marufsharifi Oct 3, 2026
76af31d
Merge branch 'main' into fix/merge-expense-transaction-thread-navigation
marufsharifi Oct 15, 2026
73cec16
Merge branch 'main' into fix/merge-expense-transaction-thread-navigation
marufsharifi Oct 16, 2026
da30206
Merge branch 'main' into fix/merge-expense-transaction-thread-navigation
marufsharifi Oct 19, 2026
98a6b29
fix: offline empty actions skeleton
marufsharifi Oct 19, 2026
2e46d9f
Merge branch 'main' into fix/merge-expense-transaction-thread-navigation
marufsharifi Oct 21, 2026
4466b09
fix: reuse existing transaction thread
marufsharifi Oct 21, 2026
c0517ae
Merge branch 'main' into fix/merge-expense-transaction-thread-navigation
marufsharifi Oct 22, 2026
12c3f7f
Merge branch 'main' into fix/merge-expense-transaction-thread-navigation
marufsharifi Oct 23, 2026
6c496c1
revert: offline empty actions skeleton
marufsharifi Oct 23, 2026
00241a5
fix: seed thread report missing from Onyx
marufsharifi Oct 23, 2026
69ed1b7
Merge branch 'main' into fix/merge-expense-transaction-thread-navigation
marufsharifi Oct 26, 2026
64f7525
fix: seed missing thread report on merge
marufsharifi Oct 26, 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
3 changes: 2 additions & 1 deletion src/hooks/useSearchBulkActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3257,7 +3257,8 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) {
text: translate('common.merge'),
icon: expensifyIcons.ArrowCollapse,
value: CONST.SEARCH.BULK_ACTION_TYPES.MERGE,
onSelected: () => setupMergeTransactionDataAndNavigate(transactionID, searchedTransactions, localeCompare, getCurrencyDecimals, reports, false, true, undefined),
onSelected: () =>
setupMergeTransactionDataAndNavigate(transactionID, searchedTransactions, localeCompare, getCurrencyDecimals, reports, false, true, transactionPolicies),
});
}
}
Expand Down
6 changes: 4 additions & 2 deletions src/libs/MergeTransactionUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ function getTransactionsAndReportsFromSearch(
): {
transactions: Transaction[];
reports: Report[];
policies: Policy[];
policies: Array<OnyxEntry<Policy>>;
} {
const transaction1 = searchResults.data[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionIDs.at(0)}`];
const transaction2 = searchResults.data[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionIDs.at(1)}`];
Expand All @@ -210,7 +210,9 @@ function getTransactionsAndReportsFromSearch(
reports: [searchResults.data[`${ONYXKEYS.COLLECTION.REPORT}${transaction1?.reportID}`], searchResults.data[`${ONYXKEYS.COLLECTION.REPORT}${transaction2?.reportID}`]].filter(
(report) => !!report,
),
policies: [searchResults.data[`${ONYXKEYS.COLLECTION.POLICY}${policyID1}`], searchResults.data[`${ONYXKEYS.COLLECTION.POLICY}${policyID2}`]].filter((policy) => !!policy),
// Not filtered, unlike the arrays above: this is read by index alongside `transactions`, and a self-DM
// transaction has no policyID, so dropping its undefined slot would pair a policy with the wrong transaction.
policies: [searchResults.data[`${ONYXKEYS.COLLECTION.POLICY}${policyID1}`], searchResults.data[`${ONYXKEYS.COLLECTION.POLICY}${policyID2}`]],
};
}

Expand Down
2 changes: 1 addition & 1 deletion src/libs/ReportSecondaryActionUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -864,7 +864,7 @@ function isMergeAction(parentReport: Report, reportTransactions: Transaction[],
function isMergeActionForSelectedTransactions(
transactions: Transaction[],
reports: Report[],
policies: Policy[],
policies: Array<OnyxEntry<Policy>>,
rules: OnyxCollection<Rule>,
// The full list is needed because the loop below resolves a different report owner on each pass.
personalDetails: OnyxEntry<PersonalDetailsList>,
Expand Down
4 changes: 2 additions & 2 deletions src/libs/TransactionUtils/getUpdatedTransaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ function getRecalculatedDistanceMerchant(
}

/**
* Given the edit made to the expense, return an updated transaction object.
* Given the edit made to the expense, return an updated transaction object. Tracked expenses store the opposite sign, so the amount follows the report the expense ends up on: a merge moves it to Self-DM in the same change set.
*/
function getUpdatedTransaction({
transaction,
Expand All @@ -150,7 +150,7 @@ function getUpdatedTransaction({
getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals'];
getCurrencySymbol: CurrencyListActionsContextType['getCurrencySymbol'];
}): Transaction {
const isUnReportedExpense = transaction?.reportID === CONST.REPORT.UNREPORTED_REPORT_ID;
const isUnReportedExpense = transaction?.reportID === CONST.REPORT.UNREPORTED_REPORT_ID || transactionChanges.reportID === CONST.REPORT.UNREPORTED_REPORT_ID;

// Only changing the first level fields so no need for deep clone now
const updatedTransaction = lodashDeepClone(transaction);
Expand Down
187 changes: 150 additions & 37 deletions src/libs/actions/MergeTransaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ import {getLoginByAccountID} from '@libs/PersonalDetailsUtils';
import {isPaidGroupPolicy, isPolicyAdmin, resolveCurrentTaxCode} from '@libs/PolicyUtils';
import {getIOUActionForReportID, getReportAction, getTrackExpenseActionableWhisper} from '@libs/ReportActionsUtils';
import {
buildOptimisticCreatedReportAction,
buildOptimisticIOUReportAction,
buildTransactionThread,
getReportOrDraftReport,
getTransactionDetails,
isCurrentUserSubmitter,
Expand Down Expand Up @@ -559,6 +561,8 @@ function mergeTransactionRequest({
// Optimistic delete the source transaction and also delete its report if it was a single expense report
const isUnreportedSourceTransaction = sourceTransaction.reportID === CONST.REPORT.UNREPORTED_REPORT_ID;
const selfDMReportID = selfDMReport?.reportID;
const isSelfDMDestinationWithoutReport = mergeTransaction.reportID === CONST.REPORT.UNREPORTED_REPORT_ID && !selfDMReportID;
const optimisticDestinationReportID = mergeTransaction.reportID === CONST.REPORT.UNREPORTED_REPORT_ID ? (selfDMReportID ?? mergeTransaction.reportID) : mergeTransaction.reportID;

const sourceTransactionOptimisticData: Array<
OnyxUpdate<typeof ONYXKEYS.COLLECTION.REPORT | typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS | typeof ONYXKEYS.COLLECTION.TRANSACTION | typeof ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS>
Expand Down Expand Up @@ -713,6 +717,19 @@ function mergeTransactionRequest({
sourceTransactionOptimisticData.push(...optimisticData);
sourceTransactionSuccessData.push(...successData);
sourceTransactionFailureData.push(...failureData);

// With a Self-DM destination the surviving expense's optimistic action is written to this same report, so
// the source keeps its pending-delete styling while offline and is removed once the merge succeeds. The
// successData above only clears pendingAction and errors, which would leave the card behind, so drop it.
if (mergeTransaction.reportID === CONST.REPORT.UNREPORTED_REPORT_ID) {
sourceTransactionSuccessData.push({
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${selfDMReportID}`,
value: {
[sourceIouAction.reportActionID]: null,
},
});
}
}
}
// Optimistic delete the merge transaction
Expand Down Expand Up @@ -746,84 +763,180 @@ function mergeTransactionRequest({
},
);

const optimisticData: Array<OnyxUpdate<UpdateMoneyRequestDataKeys | typeof ONYXKEYS.COLLECTION.MERGE_TRANSACTION>> = [
const optimisticData: Array<OnyxUpdate<UpdateMoneyRequestDataKeys | typeof ONYXKEYS.COLLECTION.MERGE_TRANSACTION | typeof ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE>> = [
...(onyxTargetTransactionData.optimisticData ?? []),
optimisticMergeTransactionData,
...optimisticTransactionViolations,
...sourceTransactionOptimisticData,
];

const failureData: Array<OnyxUpdate<UpdateMoneyRequestDataKeys>> = [...(onyxTargetTransactionData.failureData ?? []), ...failureTransactionViolations, ...sourceTransactionFailureData];
const failureData: Array<OnyxUpdate<UpdateMoneyRequestDataKeys | typeof ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE>> = [
...(onyxTargetTransactionData.failureData ?? []),
...failureTransactionViolations,
...sourceTransactionFailureData,
];

const successData: Array<OnyxUpdate<UpdateMoneyRequestDataKeys>> = [];
successData.push(...sourceTransactionSuccessData);
successData.push(...(onyxTargetTransactionData.successData ?? []));

// User picked a destination report other than the target expense's current report (e.g. the source report).
// Move the surviving expense (targetTransaction) onto mergeTransaction.reportID.
// Move the surviving expense (targetTransaction) onto the destination report. For unreported
// expenses, the API uses reportID "0", but optimistic UI must attach to the real self-DM report.
if (mergeTransaction.reportID !== targetTransaction.reportID) {
const newIOUActionType = mergeTransaction.reportID === CONST.REPORT.UNREPORTED_REPORT_ID ? CONST.IOU.REPORT_ACTION_TYPE.TRACK : CONST.IOU.REPORT_ACTION_TYPE.CREATE;
const newIOUAction = buildOptimisticIOUReportAction({
type: CONST.IOU.REPORT_ACTION_TYPE.CREATE,
type: newIOUActionType,
amount: mergeTransaction.amount,
currency: mergeTransaction.currency,
comment: mergeTransaction.description,
participants: [],
transactionID: mergeTransaction.targetTransactionID,
iouReportID: mergeTransaction.reportID,
iouReportID: optimisticDestinationReportID,
Comment thread
marufsharifi marked this conversation as resolved.
delegateAccountIDParam: delegateAccountID,
getCurrencyDecimals,
});

// IOU action for the surviving expense on its original report (not on mergeTransaction.reportID yet).
const targetIOUActionOnOriginalReport = getIOUActionForReportID(targetTransaction.reportID, targetTransaction.transactionID);
const targetTransactionThreadReportID = targetIOUActionOnOriginalReport?.childReportID;
const targetTransactionThreadReportID = targetIOUActionOnOriginalReport?.childReportID ?? targetTransactionThreadReport?.reportID;

if (targetTransactionThreadReportID) {
if (targetTransactionThreadReportID && !isSelfDMDestinationWithoutReport) {
// Reattach the transaction thread to the new IOU action on the destination report.
newIOUAction.childReportID = targetTransactionThreadReportID;

// The thread can be one that was never opened, so Onyx has no report for it even though the confirmation
// page holds a copy from the Search snapshot. A report saved by an earlier merge counts as missing too:
// it holds the two parent fields and nothing else, so it has no reportID and would otherwise never heal.
// Merging the parent fields onto either leaves a report the screen reads as unloaded, and we navigate
// straight to this thread after the merge, so it is what the user lands on.
const threadReportInOnyx = getReportOrDraftReport(targetTransactionThreadReportID);
const threadReportKey: `${typeof ONYXKEYS.COLLECTION.REPORT}${string}` = `${ONYXKEYS.COLLECTION.REPORT}${targetTransactionThreadReportID}`;
const threadReportActionsKey: `${typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS}${string}` = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${targetTransactionThreadReportID}`;
const threadLoadingStateKey: `${typeof ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${string}` = `${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${targetTransactionThreadReportID}`;

if (threadReportInOnyx?.reportID) {
optimisticData.push({
onyxMethod: Onyx.METHOD.MERGE,
key: threadReportKey,
value: {
parentReportID: optimisticDestinationReportID,
parentReportActionID: newIOUAction.reportActionID,
},
});

failureData.push({
onyxMethod: Onyx.METHOD.MERGE,
key: threadReportKey,
value: {
parentReportID: threadReportInOnyx.parentReportID ?? targetTransaction.reportID,
parentReportActionID: threadReportInOnyx.parentReportActionID ?? targetIOUActionOnOriginalReport?.reportActionID,
},
});
} else {
// The snapshot copy, when there is one, carries the thread's real fields, so it is the better base.
const seededThreadReport = {
...buildTransactionThread(
newIOUAction,
getReportOrDraftReport(optimisticDestinationReportID) ?? selfDMReport,
currentUserAccountIDParam,
undefined,
targetTransactionThreadReportID,
),
...targetTransactionThreadReport,
reportID: targetTransactionThreadReportID,
parentReportID: optimisticDestinationReportID,
parentReportActionID: newIOUAction.reportActionID,
};
const seededCreatedAction = buildOptimisticCreatedReportAction({
emailCreatingAction: currentUserEmailParam,
currentUserAccountID: currentUserAccountIDParam,
currentUserEmail: currentUserEmailParam,
});

optimisticData.push(
{
onyxMethod: Onyx.METHOD.SET,
key: threadReportKey,
value: seededThreadReport,
},
{
// An empty thread still reads as loading, so the placeholder keeps the screen off the skeleton.
onyxMethod: Onyx.METHOD.SET,
key: threadReportActionsKey,
value: {[seededCreatedAction.reportActionID]: seededCreatedAction},
},
{
// Only an OpenReport response clears these, and offline that response never arrives.
onyxMethod: Onyx.METHOD.MERGE,
key: threadLoadingStateKey,
value: {hasOnceLoadedReportActions: true, isLoadingInitialReportActions: false},
},
);

// The response carries the thread's real actions, so the placeholder is dropped rather than left
// alongside them.
successData.push({
onyxMethod: Onyx.METHOD.MERGE,
key: threadReportActionsKey,
value: {[seededCreatedAction.reportActionID]: null},
});

// Nothing usable was stored before the merge, so a failure drops the thread instead of restoring
// fields onto a report that should not exist.
failureData.push(
{
onyxMethod: Onyx.METHOD.SET,
key: threadReportKey,
value: null,
},
{
onyxMethod: Onyx.METHOD.SET,
key: threadReportActionsKey,
value: null,
},
{
onyxMethod: Onyx.METHOD.SET,
key: threadLoadingStateKey,
value: null,
},
);
}
}

// For a Self-DM destination the server creates its own TRACK action instead of reusing
// createdIOUReportActionID, so successData deletes our placeholder rather than clearing its
// pendingAction, which would leave a duplicate expense card. The server's onyxData is applied
// first and also updates the thread's parentReportID and parentReportActionID, so we do not set them here.
const isSelfDMDestination = mergeTransaction.reportID === CONST.REPORT.UNREPORTED_REPORT_ID;
const destinationReportActionsKey: `${typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS}${string}` = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${optimisticDestinationReportID}`;

if (!isSelfDMDestinationWithoutReport) {
optimisticData.push({
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.REPORT}${targetTransactionThreadReportID}`,
key: destinationReportActionsKey,
value: {
parentReportID: mergeTransaction.reportID,
parentReportActionID: newIOUAction.reportActionID,
[newIOUAction.reportActionID]: newIOUAction,
},
});

successData.push({
onyxMethod: Onyx.METHOD.MERGE,
key: destinationReportActionsKey,
value: {
[newIOUAction.reportActionID]: isSelfDMDestination ? null : {pendingAction: null},
},
});

failureData.push({
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.REPORT}${targetTransactionThreadReportID}`,
key: destinationReportActionsKey,
value: {
parentReportID: targetTransaction.reportID,
parentReportActionID: targetIOUActionOnOriginalReport.reportActionID,
[newIOUAction.reportActionID]: null,
},
});
}

optimisticData.push({
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${mergeTransaction.reportID}`,
value: {
[newIOUAction.reportActionID]: newIOUAction,
},
});

successData.push({
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${mergeTransaction.reportID}`,
value: {
[newIOUAction.reportActionID]: {pendingAction: null},
},
});

failureData.push({
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${mergeTransaction.reportID}`,
value: {
[newIOUAction.reportActionID]: null,
},
});

// Remove the surviving expense's IOU action from its original report so it does not
// appear in both reports during offline/optimistic state.
if (targetIOUActionOnOriginalReport) {
Expand Down
Loading
Loading