Skip to content

Commit ab945f7

Browse files
Fix scan-failed expense sheet showing not found after reconnect by reconciling the moved thread and route with the backend report
1 parent 76cdf9d commit ab945f7

3 files changed

Lines changed: 143 additions & 4 deletions

File tree

src/libs/actions/IOU/Hold.ts

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -549,6 +549,11 @@ type OptimisticHoldReportExpenseActionID = {
549549
oldReportActionID: string;
550550
};
551551

552+
type MovedScanFailedTransaction = {
553+
transactionID: string;
554+
optimisticReportActionID: string;
555+
};
556+
552557
function getHoldReportActionsAndTransactions(
553558
reportID: string | undefined,
554559
iouReport?: OnyxEntry<OnyxTypes.Report>,
@@ -700,6 +705,7 @@ function getReportFromHoldRequestsOnyxData({
700705
optimisticCreatedReportForUnapprovedTransactionsActionID: string | undefined;
701706
optimisticHoldReportExpenseActionIDs: OptimisticHoldReportExpenseActionID[];
702707
optimisticReportActionCopyIDs: OptimisticReportActionCopyIDs;
708+
movedScanFailedTransactions: MovedScanFailedTransaction[];
703709
optimisticData: Array<OnyxUpdate<typeof ONYXKEYS.COLLECTION.REPORT | typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS | typeof ONYXKEYS.COLLECTION.TRANSACTION>>;
704710
successData: Array<OnyxUpdate<typeof ONYXKEYS.COLLECTION.REPORT | typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS>>;
705711
failureData: Array<OnyxUpdate<typeof ONYXKEYS.COLLECTION.REPORT | typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS | typeof ONYXKEYS.COLLECTION.TRANSACTION>>;
@@ -774,6 +780,7 @@ function getReportFromHoldRequestsOnyxData({
774780
const addHoldReportActionsSuccess: OnyxCollection<NullishDeep<OnyxTypes.ReportAction>> = {};
775781
const deleteHoldReportActions: Record<string, Pick<OnyxTypes.ReportAction, 'message'>> = {};
776782
const optimisticHoldReportExpenseActionIDs: OptimisticHoldReportExpenseActionID[] = [];
783+
const movedScanFailedTransactions: MovedScanFailedTransaction[] = [];
777784

778785
for (const holdReportAction of holdReportActions) {
779786
// eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style
@@ -803,6 +810,10 @@ function getReportFromHoldRequestsOnyxData({
803810

804811
optimisticHoldReportExpenseActionIDs.push({optimisticReportActionID: reportActionID, oldReportActionID: holdReportAction.reportActionID});
805812

813+
if (shouldMoveScanFailedTransactions && originalMessage.IOUTransactionID) {
814+
movedScanFailedTransactions.push({transactionID: originalMessage.IOUTransactionID, optimisticReportActionID: reportActionID});
815+
}
816+
806817
const heldReport = getReportOrDraftReport(holdReportAction.childReportID);
807818
if (heldReport) {
808819
updateHeldReports[`${ONYXKEYS.COLLECTION.REPORT}${heldReport.reportID}`] = {
@@ -1044,7 +1055,73 @@ function getReportFromHoldRequestsOnyxData({
10441055
optimisticHoldReportID: optimisticExpenseReport.reportID,
10451056
optimisticHoldReportExpenseActionIDs,
10461057
optimisticReportActionCopyIDs,
1058+
movedScanFailedTransactions,
10471059
};
10481060
}
10491061

1050-
export {getReportFromHoldRequestsOnyxData, putOnHold, putTransactionsOnHold, unholdRequest};
1062+
function repointMovedScanFailedThread(transactionID: string, optimisticReportID: string, optimisticReportActionID: string, realReportID: string) {
1063+
// The backend's report actions for the moved expense may arrive after the transaction update, and this runs from the
1064+
// action layer where no view exists to subscribe with useOnyx, so connectWithoutView is the only way to wait for them.
1065+
const connection = Onyx.connectWithoutView({
1066+
key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${realReportID}`,
1067+
callback: (reportActions) => {
1068+
const realAction = Object.values(reportActions ?? {}).find(
1069+
(reportAction) => isMoneyRequestAction(reportAction) && getOriginalMessage(reportAction)?.IOUTransactionID === transactionID,
1070+
);
1071+
if (!realAction) {
1072+
return;
1073+
}
1074+
Onyx.disconnect(connection);
1075+
1076+
const threadReport = Object.values(getAllReports() ?? {}).find(
1077+
(report) => report?.parentReportActionID === optimisticReportActionID && report?.parentReportID === optimisticReportID,
1078+
);
1079+
if (threadReport?.reportID) {
1080+
Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${threadReport.reportID}`, {
1081+
parentReportID: realReportID,
1082+
parentReportActionID: realAction.reportActionID,
1083+
chatReportID: realReportID,
1084+
});
1085+
if (!realAction.childReportID) {
1086+
Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${realReportID}`, {
1087+
[realAction.reportActionID]: {childReportID: threadReport.reportID},
1088+
});
1089+
}
1090+
}
1091+
1092+
if (Navigation.getActiveRoute().includes(optimisticReportID)) {
1093+
Navigation.setParams({reportID: realReportID});
1094+
}
1095+
},
1096+
});
1097+
}
1098+
1099+
function watchMovedScanFailedTransactions(movedTransactions: MovedScanFailedTransaction[], optimisticReportID: string) {
1100+
for (const {transactionID, optimisticReportActionID} of movedTransactions) {
1101+
// The backend moves the expense into its own report instead of reusing optimisticReportID, and the only signal of
1102+
// the real report ID is the transaction's reportID changing. This runs from the action layer with no view to
1103+
// subscribe through, so connectWithoutView is required to observe that change.
1104+
let hasSeenOptimisticMove = false;
1105+
const connection = Onyx.connectWithoutView({
1106+
key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`,
1107+
callback: (transaction) => {
1108+
if (!transaction) {
1109+
Onyx.disconnect(connection);
1110+
return;
1111+
}
1112+
const currentReportID = transaction.reportID;
1113+
if (currentReportID === optimisticReportID) {
1114+
hasSeenOptimisticMove = true;
1115+
return;
1116+
}
1117+
if (!hasSeenOptimisticMove || !currentReportID) {
1118+
return;
1119+
}
1120+
Onyx.disconnect(connection);
1121+
repointMovedScanFailedThread(transactionID, optimisticReportID, optimisticReportActionID, currentReportID);
1122+
},
1123+
});
1124+
}
1125+
}
1126+
1127+
export {getReportFromHoldRequestsOnyxData, putOnHold, putTransactionsOnHold, unholdRequest, watchMovedScanFailedTransactions};

src/libs/actions/IOU/PayMoneyRequest.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ import type {ValueOf} from 'type-fest';
5151
import Onyx from 'react-native-onyx';
5252

5353
import {getAllPersonalDetails, getAllTransactionViolations} from '.';
54-
import {getReportFromHoldRequestsOnyxData} from './Hold';
54+
import {getReportFromHoldRequestsOnyxData, watchMovedScanFailedTransactions} from './Hold';
5555
import {getReportPreviewReportAction} from './MoneyRequestBuilder';
5656

5757
type PayInvoiceArgs = {
@@ -90,6 +90,7 @@ type PayMoneyRequestData = {
9090
| typeof ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS
9191
| BuildPolicyDataKeys
9292
>;
93+
movedScanFailedTransactions?: Array<{transactionID: string; optimisticReportActionID: string}>;
9394
};
9495

9596
type SearchPayOnyxKey = typeof ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE | typeof ONYXKEYS.COLLECTION.SNAPSHOT | typeof ONYXKEYS.COLLECTION.REPORT;
@@ -495,6 +496,7 @@ function getPayMoneyRequestParams({
495496
let optimisticHoldReportID;
496497
let optimisticHoldActionID;
497498
let optimisticHoldReportExpenseActionIDs;
499+
let movedScanFailedTransactions;
498500
if (!full || shouldMoveScanFailedTransactions) {
499501
const holdReportOnyxData = getReportFromHoldRequestsOnyxData({
500502
chatReport,
@@ -514,6 +516,7 @@ function getPayMoneyRequestParams({
514516
optimisticHoldReportID = holdReportOnyxData.optimisticHoldReportID;
515517
optimisticHoldActionID = holdReportOnyxData.optimisticHoldActionID;
516518
optimisticHoldReportExpenseActionIDs = JSON.stringify(holdReportOnyxData.optimisticHoldReportExpenseActionIDs);
519+
movedScanFailedTransactions = holdReportOnyxData.movedScanFailedTransactions;
517520
}
518521

519522
return {
@@ -531,6 +534,7 @@ function getPayMoneyRequestParams({
531534
...policyParams,
532535
},
533536
onyxData,
537+
movedScanFailedTransactions,
534538
};
535539
}
536540

@@ -818,7 +822,11 @@ function payMoneyRequest(params: PayMoneyRequestFunctionParams) {
818822
completePaymentOnboarding(paymentSelected, introSelected, isSelfTourViewed, betas, currentUserAccountID, conciergeChat);
819823

820824
const recipient = {accountID: iouReport?.ownerAccountID ?? CONST.DEFAULT_NUMBER_ID};
821-
const {params: payMoneyRequestParams, onyxData} = getPayMoneyRequestParams({
825+
const {
826+
params: payMoneyRequestParams,
827+
onyxData,
828+
movedScanFailedTransactions,
829+
} = getPayMoneyRequestParams({
822830
initialChatReport: chatReport,
823831
iouReport,
824832
recipient,
@@ -850,6 +858,9 @@ function payMoneyRequest(params: PayMoneyRequestFunctionParams) {
850858
playSound(SOUNDS.SUCCESS);
851859
}
852860
API.write(apiCommand, payMoneyRequestParams, mergeAdditionalPayOnyxData(onyxData, additionalOnyxData));
861+
if (movedScanFailedTransactions?.length && payMoneyRequestParams.optimisticHoldReportID) {
862+
watchMovedScanFailedTransactions(movedScanFailedTransactions, payMoneyRequestParams.optimisticHoldReportID);
863+
}
853864
notifyNewAction(!full ? (Navigation.getTopmostReportId() ?? iouReport?.reportID) : iouReport?.reportID, undefined, true);
854865
return payMoneyRequestParams.optimisticHoldReportID;
855866
}

tests/actions/IOUTest/PayMoneyRequestTest.ts

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,8 @@ jest.mock('@src/libs/Navigation/Navigation', () => ({
5959
isNavigationReady: jest.fn(() => Promise.resolve()),
6060
getReportRouteByID: jest.fn(),
6161
getActiveRouteWithoutParams: jest.fn(),
62-
getActiveRoute: jest.fn(),
62+
getActiveRoute: jest.fn(() => ''),
63+
setParams: jest.fn(),
6364
navigationRef: {
6465
getRootState: jest.fn(),
6566
isReady: jest.fn(() => true),
@@ -1106,6 +1107,56 @@ describe('actions/IOU/PayMoneyRequest', () => {
11061107
expect(payAction && isMoneyRequestAction(payAction) ? getOriginalMessage(payAction)?.amount : undefined).toBe(3000);
11071108
});
11081109

1110+
it('re-points the expense thread to the backend report once the server moves the scan-failed expense', async () => {
1111+
const validTransaction = buildTransaction('valid1', -3000, false);
1112+
const scanFailedTransaction = buildTransaction('scanFailed1', 0, true);
1113+
const expenseReport = await setUpReport([validTransaction, scanFailedTransaction]);
1114+
1115+
mockFetch?.pause?.();
1116+
const optimisticReportID = pay(expenseReport);
1117+
await waitForBatchedUpdates();
1118+
1119+
const optimisticActions = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${optimisticReportID}`);
1120+
const copiedAction = Object.values(optimisticActions ?? {}).find(
1121+
(action) => isMoneyRequestAction(action) && getOriginalMessage(action)?.IOUTransactionID === scanFailedTransaction.transactionID,
1122+
);
1123+
expect(copiedAction).toBeDefined();
1124+
1125+
const threadReportID = '999';
1126+
await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${threadReportID}`, {
1127+
reportID: threadReportID,
1128+
type: CONST.REPORT.TYPE.CHAT,
1129+
parentReportID: optimisticReportID,
1130+
parentReportActionID: copiedAction?.reportActionID,
1131+
});
1132+
await waitForBatchedUpdates();
1133+
1134+
jest.mocked(Navigation.getActiveRoute).mockReturnValue(`/r/${optimisticReportID}`);
1135+
await mockFetch?.resume?.();
1136+
1137+
const backendReportID = '777';
1138+
const backendAction = buildOptimisticIOUReportAction({
1139+
type: CONST.IOU.REPORT_ACTION_TYPE.CREATE,
1140+
amount: 0,
1141+
currency: 'USD',
1142+
comment: '',
1143+
participants: [],
1144+
transactionID: scanFailedTransaction.transactionID,
1145+
getCurrencyDecimals: getCurrencyDecimalsLocal,
1146+
});
1147+
await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${backendReportID}`, {reportID: backendReportID, type: CONST.REPORT.TYPE.EXPENSE, chatReportID: chatReport.reportID});
1148+
await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${backendReportID}`, {[backendAction.reportActionID]: backendAction as ReportAction});
1149+
await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${scanFailedTransaction.transactionID}`, {reportID: backendReportID});
1150+
await waitForBatchedUpdates();
1151+
1152+
const threadReport = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${threadReportID}`);
1153+
expect(threadReport?.parentReportID).toBe(backendReportID);
1154+
expect(threadReport?.parentReportActionID).toBe(backendAction.reportActionID);
1155+
const backendActions = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${backendReportID}`);
1156+
expect(backendActions?.[backendAction.reportActionID]?.childReportID).toBe(threadReportID);
1157+
expect(Navigation.setParams).toHaveBeenCalledWith({reportID: backendReportID});
1158+
});
1159+
11091160
it('does not create a new report when every expense in the report is scan-failed', async () => {
11101161
const scanFailedTransaction = buildTransaction('scanFailed1', 0, true);
11111162
const expenseReport = await setUpReport([scanFailedTransaction]);

0 commit comments

Comments
 (0)