Skip to content

Commit 712cdef

Browse files
authored
Merge pull request #98760 from callstack-internal/VickyStash/bugfix/98444-bulk-pay-fallback-chat-report
Do not block Search page payments on a missing chat report
2 parents 29a825e + 13a551c commit 712cdef

7 files changed

Lines changed: 364 additions & 39 deletions

File tree

src/components/Search/SearchList/ListItem/ActionCell/PayActionCell.tsx

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import useThemeStyles from '@hooks/useThemeStyles';
1414

1515
import {payInvoice, payMoneyRequest} from '@libs/actions/IOU/PayMoneyRequest';
1616
import {canIOUBePaid} from '@libs/actions/IOU/ReportWorkflow';
17-
import {getSearchPayOnyxData} from '@libs/actions/Search';
17+
import {getChatReportWithFallback, getSearchPayOnyxData} from '@libs/actions/Search';
1818
import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID';
1919
import Log from '@libs/Log';
2020
import {getReimbursableTotal, isIndividualInvoiceRoom, isInvoiceReport} from '@libs/ReportUtils';
@@ -83,13 +83,12 @@ function PayActionCell({isLoading, policyID, reportID, hash, amount, shouldDisab
8383
const {currency} = iouReport ?? {};
8484

8585
const confirmPayment = ({paymentType: type, payAsBusiness, methodID, paymentMethod}: PaymentActionParams) => {
86-
if (!type || !reportID || !hash || !amount || !chatReport) {
86+
if (!type || !reportID || !hash || !amount) {
8787
Log.info('[SearchPay] Dropping row pay: missing required data', false, {
8888
hasPaymentType: !!type,
8989
reportID,
9090
hasHash: !!hash,
9191
hasAmount: !!amount,
92-
hasChatReport: !!chatReport,
9392
});
9493
return;
9594
}
@@ -102,6 +101,11 @@ function PayActionCell({isLoading, policyID, reportID, hash, amount, shouldDisab
102101
const additionalOnyxData = getSearchPayOnyxData(hash, reportID);
103102

104103
if (isInvoiceReport(iouReport)) {
104+
// Invoice payments rely on the invoice room data, so they can't proceed without the chat report.
105+
if (!chatReport) {
106+
Log.info('[SearchPay] Dropping invoice row pay: chat report is not loaded', false, {reportID});
107+
return;
108+
}
105109
const existingB2BInvoiceReport = getParticipantsInvoiceReport(
106110
allReports,
107111
reportNameValuePairs,
@@ -143,10 +147,21 @@ function PayActionCell({isLoading, policyID, reportID, hash, amount, shouldDisab
143147
return;
144148
}
145149

150+
// The chat report is only needed for optimistic chat updates, so when it isn't loaded, pay with a fallback
151+
// built from the known IDs and let the server fill in the chat data.
152+
const fallbackChatReportID = iouReport?.chatReportID ?? iouReport?.parentReportID;
153+
const fallbackPolicyID = iouReport?.policyID ?? policyID;
154+
const {chatReport: chatReportForPayment, isFallbackChatReport} = getChatReportWithFallback(chatReport, fallbackChatReportID, fallbackPolicyID);
155+
if (!chatReportForPayment) {
156+
Log.info('[SearchPay] Dropping row pay: chat report is not loaded and no chatReportID is available', false, {reportID});
157+
return;
158+
}
159+
146160
payMoneyRequest({
147161
getCurrencyDecimals,
148162
paymentType: type,
149-
chatReport,
163+
chatReport: chatReportForPayment,
164+
isFallbackChatReport,
150165
iouReport,
151166
introSelected,
152167
currentUserAccountID,

src/hooks/useSearchBulkActions.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
getPolicyFromSearchSnapshot,
2626
getReportFromSearchSnapshot,
2727
getReportType,
28+
getChatReportWithFallback,
2829
getSearchApproveOnyxData,
2930
getSearchPayOnyxData,
3031
getTotalFormattedAmount,
@@ -1401,11 +1402,21 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) {
14011402
continue;
14021403
}
14031404

1404-
const chatReport = getChatReportForBulkPay(iouReport, item.chatReportID, searchData, allReports);
1405-
if (!chatReport) {
1405+
const isItemInvoice = isInvoiceReport(iouReport);
1406+
const fallbackChatReportID = item.chatReportID ?? iouReport.chatReportID ?? iouReport.parentReportID;
1407+
const fallbackPolicyID = iouReport.policyID ?? item.policyID;
1408+
const {chatReport, isFallbackChatReport} = getChatReportWithFallback(
1409+
getChatReportForBulkPay(iouReport, item.chatReportID, searchData, allReports),
1410+
fallbackChatReportID,
1411+
fallbackPolicyID,
1412+
);
1413+
// The fallback covers money requests only. Invoices genuinely need the invoice room data such as
1414+
// receiver type and pay-as-business, so skip them when the chat isn't loaded.
1415+
if (!chatReport || (isItemInvoice && isFallbackChatReport)) {
14061416
Log.info('[BulkPay] Skipping report: chat report not found in the search snapshot or Onyx', false, {
14071417
reportID: item.reportID,
1408-
chatReportID: item.chatReportID ?? iouReport.chatReportID ?? iouReport.parentReportID,
1418+
chatReportID: fallbackChatReportID,
1419+
isItemInvoice,
14091420
});
14101421
continue;
14111422
}
@@ -1422,7 +1433,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) {
14221433
reportID: item.reportID,
14231434
amount: item.amount,
14241435
paymentType: resolvedPaymentType,
1425-
...(isInvoiceReport(item.reportID)
1436+
...(isItemInvoice
14261437
? getPayMoneyOnSearchInvoiceParams(
14271438
item.policyID,
14281439
additionalData?.payAsBusiness ?? isBusinessInvoiceRoom(item.chatReportID),
@@ -1438,7 +1449,6 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) {
14381449
const chatReportPolicy = getPolicyFromSearchSnapshot(chatReport.policyID, searchData, policies);
14391450
const reportPolicy = workspacePayPolicy ?? getPolicyFromSearchSnapshot(item.policyID, searchData, policies);
14401451
const additionalOnyxData = getSearchPayOnyxData(hash, item.reportID, currentSearchKey);
1441-
const isItemInvoice = isInvoiceReport(item.reportID);
14421452

14431453
if (isItemInvoice) {
14441454
const invoiceReceiverPolicyID = chatReport?.invoiceReceiver && 'policyID' in chatReport.invoiceReceiver ? chatReport.invoiceReceiver.policyID : undefined;
@@ -1506,6 +1516,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) {
15061516
delegateAccountID,
15071517
isTrackIntentUser,
15081518
conciergeChat,
1519+
isFallbackChatReport,
15091520
});
15101521
paidReportCount += 1;
15111522
}

src/libs/actions/IOU/PayMoneyRequest.ts

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ type PayMoneyRequestFunctionParams = {
126126
chatReportActions: OnyxEntry<OnyxTypes.ReportActions>;
127127
isTrackIntentUser: boolean | undefined;
128128
getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals'];
129+
isFallbackChatReport?: boolean;
129130
};
130131

131132
function mergeAdditionalPayOnyxData<
@@ -172,6 +173,7 @@ function getPayMoneyRequestParams({
172173
chatReportActions,
173174
isTrackIntentUser,
174175
getCurrencyDecimals,
176+
isFallbackChatReport,
175177
}: {
176178
initialChatReport: OnyxTypes.Report;
177179
iouReport: OnyxEntry<OnyxTypes.Report>;
@@ -197,6 +199,7 @@ function getPayMoneyRequestParams({
197199
chatReportActions: OnyxEntry<OnyxTypes.ReportActions>;
198200
isTrackIntentUser: boolean | undefined;
199201
getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals'];
202+
isFallbackChatReport?: boolean;
200203
}): PayMoneyRequestData {
201204
// TODO: https://github.com/Expensify/App/issues/66512
202205
// eslint-disable-next-line @typescript-eslint/no-deprecated
@@ -318,12 +321,20 @@ function getPayMoneyRequestParams({
318321
};
319322
}
320323

321-
onyxData.optimisticData?.push(
322-
{
324+
if (!isFallbackChatReport) {
325+
onyxData.optimisticData?.push({
323326
onyxMethod: Onyx.METHOD.MERGE,
324327
key: `${ONYXKEYS.COLLECTION.REPORT}${chatReport.reportID}`,
325328
value: optimisticChatReport,
326-
},
329+
});
330+
onyxData.failureData?.push({
331+
onyxMethod: Onyx.METHOD.MERGE,
332+
key: `${ONYXKEYS.COLLECTION.REPORT}${chatReport.reportID}`,
333+
value: chatReport,
334+
});
335+
}
336+
337+
onyxData.optimisticData?.push(
327338
{
328339
onyxMethod: Onyx.METHOD.MERGE,
329340
key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport?.reportID}`,
@@ -419,11 +430,6 @@ function getPayMoneyRequestParams({
419430
...iouReport,
420431
},
421432
},
422-
{
423-
onyxMethod: Onyx.METHOD.MERGE,
424-
key: `${ONYXKEYS.COLLECTION.REPORT}${chatReport.reportID}`,
425-
value: chatReport,
426-
},
427433
);
428434

429435
// In case the report preview action is loaded locally, let's update it.
@@ -881,6 +887,7 @@ function payMoneyRequest(params: PayMoneyRequestFunctionParams) {
881887
chatReportActions,
882888
isTrackIntentUser,
883889
getCurrencyDecimals,
890+
isFallbackChatReport,
884891
} = params;
885892
const policyForBillingRestriction = chatReportPolicy ?? (policy?.id === chatReport.policyID ? policy : undefined);
886893
if (
@@ -917,6 +924,7 @@ function payMoneyRequest(params: PayMoneyRequestFunctionParams) {
917924
chatReportActions,
918925
isTrackIntentUser,
919926
getCurrencyDecimals,
927+
isFallbackChatReport,
920928
});
921929

922930
// For now, we need to call the PayMoneyRequestWithWallet API since PayMoneyRequest was not updated to work with

src/libs/actions/Search.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,24 @@ function getChatReportForSearchPay(chatReport: OnyxEntry<Report>, snapshotReport
139139
return chatReport ?? snapshotChatReport ?? (chatReportID ? getReportOrDraftReport(chatReportID) : undefined);
140140
}
141141

142+
/**
143+
* Returns the chat report to pay with. When the chat isn't loaded, builds a fallback from the known IDs so the
144+
* payment isn't blocked; isFallbackChatReport tells payMoneyRequest to skip the optimistic chat updates.
145+
*/
146+
function getChatReportWithFallback(
147+
loadedChatReport: OnyxEntry<Report>,
148+
fallbackChatReportID: string | undefined,
149+
fallbackPolicyID: string | undefined,
150+
): {chatReport: OnyxEntry<Report>; isFallbackChatReport: boolean} {
151+
if (loadedChatReport) {
152+
return {chatReport: loadedChatReport, isFallbackChatReport: false};
153+
}
154+
if (!fallbackChatReportID) {
155+
return {chatReport: undefined, isFallbackChatReport: false};
156+
}
157+
return {chatReport: {reportID: fallbackChatReportID, policyID: fallbackPolicyID}, isFallbackChatReport: true};
158+
}
159+
142160
function getReportFromSearchSnapshot(reportID: string | undefined, searchData: SearchResultDataType | undefined, allReports: OnyxCollection<Report> | undefined): OnyxEntry<Report> {
143161
if (!reportID) {
144162
return undefined;
@@ -2302,6 +2320,7 @@ export {
23022320
setSearchContext,
23032321
deleteSavedSearch,
23042322
getSearchPayOnyxData,
2323+
getChatReportWithFallback,
23052324
getSearchApproveOnyxData,
23062325
handleActionButtonPress,
23072326
submitMoneyRequestOnSearch,

tests/ui/components/PayActionCellTest.tsx

Lines changed: 116 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@ import type {PaymentActionParams} from '@components/SettlementButton/types';
66
import useOnyx from '@hooks/useOnyx';
77
import useReportWithTransactionsAndViolations from '@hooks/useReportWithTransactionsAndViolations';
88

9-
import {payInvoice} from '@userActions/IOU/PayMoneyRequest';
9+
import type * as SearchActions from '@libs/actions/Search';
10+
import {isInvoiceReport} from '@libs/ReportUtils';
11+
12+
import {payInvoice, payMoneyRequest} from '@userActions/IOU/PayMoneyRequest';
1013

1114
import CONST from '@src/CONST';
1215
import type {Report} from '@src/types/onyx';
@@ -59,9 +62,21 @@ jest.mock('@userActions/IOU/ReportWorkflow', () => ({
5962
canIOUBePaid: jest.fn(() => true),
6063
}));
6164

65+
const mockLogInfo = jest.fn();
66+
jest.mock('@libs/Log', () => ({
67+
__esModule: true,
68+
default: {
69+
info: (...args: unknown[]) => {
70+
mockLogInfo(...args);
71+
},
72+
warn: jest.fn(),
73+
},
74+
}));
75+
6276
jest.mock('@libs/actions/Search', () => ({
6377
__esModule: true,
6478
getSearchPayOnyxData: jest.fn(() => ({optimisticData: [], successData: [], failureData: []})),
79+
getChatReportWithFallback: jest.requireActual<typeof SearchActions>('@libs/actions/Search').getChatReportWithFallback,
6580
}));
6681

6782
jest.mock('@libs/ReportUtils', () => {
@@ -113,6 +128,19 @@ jest.mock('@components/DelegateNoAccessModalProvider', () => ({
113128
const mockedUseOnyx = jest.mocked(useOnyx);
114129
const mockedUseReportWithTransactionsAndViolations = jest.mocked(useReportWithTransactionsAndViolations);
115130
const mockedPayInvoice = jest.mocked(payInvoice);
131+
const mockedPayMoneyRequest = jest.mocked(payMoneyRequest);
132+
const mockedIsInvoiceReport = jest.mocked(isInvoiceReport);
133+
134+
const TEST_EXPENSE_REPORT_ID = '3003';
135+
136+
const expenseReport = {
137+
reportID: TEST_EXPENSE_REPORT_ID,
138+
chatReportID: TEST_CHAT_REPORT_ID,
139+
type: CONST.REPORT.TYPE.EXPENSE,
140+
currency: CONST.CURRENCY.USD,
141+
policyID: 'policy1',
142+
total: -5000,
143+
} as Report;
116144

117145
describe('PayActionCell', () => {
118146
beforeEach(() => {
@@ -170,5 +198,92 @@ describe('PayActionCell', () => {
170198
});
171199

172200
expect(mockedPayInvoice).not.toHaveBeenCalled();
201+
expect(mockLogInfo).toHaveBeenCalledWith('[SearchPay] Dropping invoice row pay: chat report is not loaded', false, {reportID: TEST_INVOICE_REPORT_ID});
202+
});
203+
204+
it('pays a money request with a fallback chat report when no chatReport prop is supplied', () => {
205+
mockedIsInvoiceReport.mockReturnValue(false);
206+
mockedUseReportWithTransactionsAndViolations.mockReturnValue([expenseReport, [], undefined]);
207+
208+
render(
209+
<PayActionCell
210+
isLoading={false}
211+
policyID="policy1"
212+
reportID={TEST_EXPENSE_REPORT_ID}
213+
hash={TEST_HASH}
214+
amount={5000}
215+
chatReport={undefined}
216+
/>,
217+
);
218+
219+
act(() => {
220+
mockOnPressHolder.current?.({
221+
paymentType: CONST.IOU.PAYMENT_TYPE.ELSEWHERE,
222+
payAsBusiness: false,
223+
});
224+
});
225+
226+
expect(mockedPayMoneyRequest).toHaveBeenCalledWith(
227+
expect.objectContaining({
228+
chatReport: {reportID: TEST_CHAT_REPORT_ID, policyID: 'policy1'},
229+
isFallbackChatReport: true,
230+
}),
231+
);
232+
});
233+
234+
it('pays a money request with the loaded chat report when it is supplied', () => {
235+
mockedIsInvoiceReport.mockReturnValue(false);
236+
mockedUseReportWithTransactionsAndViolations.mockReturnValue([expenseReport, [], undefined]);
237+
238+
render(
239+
<PayActionCell
240+
isLoading={false}
241+
policyID="policy1"
242+
reportID={TEST_EXPENSE_REPORT_ID}
243+
hash={TEST_HASH}
244+
amount={5000}
245+
chatReport={chatReport}
246+
/>,
247+
);
248+
249+
act(() => {
250+
mockOnPressHolder.current?.({
251+
paymentType: CONST.IOU.PAYMENT_TYPE.ELSEWHERE,
252+
payAsBusiness: false,
253+
});
254+
});
255+
256+
expect(mockedPayMoneyRequest).toHaveBeenCalledWith(
257+
expect.objectContaining({
258+
chatReport,
259+
isFallbackChatReport: false,
260+
}),
261+
);
262+
});
263+
264+
it('does not pay a money request and logs the reason when the chat is not loaded and no chatReportID is available', () => {
265+
mockedIsInvoiceReport.mockReturnValue(false);
266+
mockedUseReportWithTransactionsAndViolations.mockReturnValue([{...expenseReport, chatReportID: undefined, parentReportID: undefined}, [], undefined]);
267+
268+
render(
269+
<PayActionCell
270+
isLoading={false}
271+
policyID="policy1"
272+
reportID={TEST_EXPENSE_REPORT_ID}
273+
hash={TEST_HASH}
274+
amount={5000}
275+
chatReport={undefined}
276+
/>,
277+
);
278+
279+
act(() => {
280+
mockOnPressHolder.current?.({
281+
paymentType: CONST.IOU.PAYMENT_TYPE.ELSEWHERE,
282+
payAsBusiness: false,
283+
});
284+
});
285+
286+
expect(mockedPayMoneyRequest).not.toHaveBeenCalled();
287+
expect(mockLogInfo).toHaveBeenCalledWith('[SearchPay] Dropping row pay: chat report is not loaded and no chatReportID is available', false, {reportID: TEST_EXPENSE_REPORT_ID});
173288
});
174289
});

0 commit comments

Comments
 (0)