From 0b38622b33eeabd99fff4d28e4b73ce4afc543a1 Mon Sep 17 00:00:00 2001 From: Krishna Chaitanya Date: Tue, 1 Sep 2026 10:05:33 +0530 Subject: [PATCH 1/3] fix: update Paid by column from live payment actions The Paid by column derived only from snapshot report actions, so paying a report from the Search page left the cell empty until the search was re-run. Resolve the paid-by actor against live report actions the same way the first-approver column does, mirroring the backend rules: pay actions count, payment actions at or before the latest reimbursement cancellation do not, and a submitter's own received-payment self-attestation never identifies a payer. --- src/CONST/index.ts | 1 + src/libs/ReportUtils.ts | 11 ++- src/libs/SearchUIUtils.ts | 54 ++++++++++++- tests/unit/Search/SearchUIUtilsTest.ts | 100 +++++++++++++++++++++++++ 4 files changed, 164 insertions(+), 2 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index bc4876d89a96..15194873c388 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1687,6 +1687,7 @@ const CONST = { INTEGRATIONS_MESSAGE: 'INTEGRATIONSMESSAGE', // OldDot Action MANAGER_ATTACH_RECEIPT: 'MANAGERATTACHRECEIPT', // OldDot Action MANAGER_DETACH_RECEIPT: 'MANAGERDETACHRECEIPT', // OldDot Action + MARKED_REDEEMED: 'MARKEDREDEEMED', // OldDot Action MARKED_REIMBURSED: 'MARKEDREIMBURSED', // OldDot Action MARK_REIMBURSED_FROM_INTEGRATION: 'ACTIONMARKEDREIMBURSEDFROMINTEGRATION', // OldDot Action MERGED_WITH_CASH_TRANSACTION: 'MERGEDWITHCASHTRANSACTION', diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 82f1491bdcbd..25ea6991d809 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -218,6 +218,7 @@ import { isMoneyRequestAction, isMovedAction, isOlderReportAction, + isPayAction, isPendingRemove, isReopenedAction, isReportActionVisible, @@ -13823,7 +13824,15 @@ function isSearchRelevantReportAction(action: OnyxInputOrEntry): a isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.APPROVED) || isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.UNAPPROVED) || isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.RETRACTED) || - isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.REOPENED) + isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.REOPENED) || + isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.REIMBURSED) || + isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.MARKED_REIMBURSED) || + isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.MARK_REIMBURSED_FROM_INTEGRATION) || + isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.MARKED_REDEEMED) || + isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENT_DEQUEUED) || + isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENT_ACH_CANCELED) || + isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENT_ACH_BOUNCE) || + isPayAction(action) ); } diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index c0a34fd5130e..e7a3d021f3f9 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -3014,6 +3014,55 @@ function getFirstApprovedAction(snapshotApprovedAction: OnyxTypes.ReportAction | return findActionByCreated(candidates, [CONST.REPORT.ACTIONS.TYPE.APPROVED, CONST.REPORT.ACTIONS.TYPE.FORWARDED], 'earliest', seed); } +/** + * Returns the latest payment action between the snapshot-derived one and the given report actions, mirroring the + * backend paid-by rules: payment actions at or before the latest reimbursement cancellation don't count, and a + * payment action recorded by the report owner that is paired with a MARKEDREDEEMED action is a "received payment" + * self-attestation, which doesn't identify a payer. + */ +function getLastPaidAction( + snapshotPaidAction: OnyxTypes.ReportAction | undefined, + actions: OnyxTypes.ReportAction[], + ownerAccountID: number | undefined, +): OnyxTypes.ReportAction | undefined { + const latestCancellation = findActionByCreated( + actions, + [CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENT_DEQUEUED, CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENT_ACH_CANCELED, CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENT_ACH_BOUNCE], + 'latest', + ); + const isValidPaymentAction = (action: OnyxTypes.ReportAction | undefined): action is OnyxTypes.ReportAction => { + if (!action) { + return false; + } + if (isMoneyRequestAction(action)) { + const originalMessage = getOriginalMessage(action); + if (originalMessage?.type !== CONST.IOU.REPORT_ACTION_TYPE.PAY || originalMessage.isSubmitterMarkedPaymentReceived) { + return false; + } + } + if (latestCancellation && action.created <= latestCancellation.created) { + return false; + } + if (action.actorAccountID !== ownerAccountID) { + return true; + } + return !actions.some( + (redeemedAction) => + redeemedAction.actionName === CONST.REPORT.ACTIONS.TYPE.MARKED_REDEEMED && + redeemedAction.actorAccountID === action.actorAccountID && + redeemedAction.created >= action.created, + ); + }; + const seed = isValidPaymentAction(snapshotPaidAction) ? snapshotPaidAction : undefined; + const candidates = actions.filter(isValidPaymentAction); + return findActionByCreated( + candidates, + [CONST.REPORT.ACTIONS.TYPE.REIMBURSED, CONST.REPORT.ACTIONS.TYPE.MARKED_REIMBURSED, CONST.REPORT.ACTIONS.TYPE.MARK_REIMBURSED_FROM_INTEGRATION, CONST.REPORT.ACTIONS.TYPE.IOU], + 'latest', + seed, + ); +} + /** * Returns the report's approved date or the latest APPROVED action's created time, whichever is newer — an offline * re-approve leaves a stale `approved` on the report. A report back to Draft/Outstanding is not approved anymore @@ -3146,7 +3195,10 @@ function getReportSections({ const formattedFirstApprover = firstApproverAccountID ? temporaryGetDisplayNameOrDefault({passedPersonalDetails: firstApproverDetails, translate, formatPhoneNumber}) : ''; // The paid-by user is the actor on the latest payment action. It stays blank until the report is paid. - const lastReimbursedAction = lastReimbursedActionByReportID.get(reportItem.reportID); + const lastReimbursedAction = + reportItem.statusNum === CONST.REPORT.STATUS_NUM.REIMBURSED + ? getLastPaidAction(lastReimbursedActionByReportID.get(reportItem.reportID), actions, reportItem.ownerAccountID) + : undefined; const paidByAccountID = lastReimbursedAction?.actorAccountID; const paidByDetails = paidByAccountID ? mergedPersonalDetails?.[paidByAccountID] : undefined; const formattedPaidBy = paidByAccountID ? temporaryGetDisplayNameOrDefault({passedPersonalDetails: paidByDetails, translate, formatPhoneNumber}) : ''; diff --git a/tests/unit/Search/SearchUIUtilsTest.ts b/tests/unit/Search/SearchUIUtilsTest.ts index 0d7eee600a77..5c9ff637a555 100644 --- a/tests/unit/Search/SearchUIUtilsTest.ts +++ b/tests/unit/Search/SearchUIUtilsTest.ts @@ -6933,6 +6933,106 @@ describe('SearchUIUtils', () => { expect(item?.firstApproverAccountID).toBeUndefined(); }); + it('should populate paidBy from a snapshot payment action on a paid report', () => { + const data = makeReportFilterTestData( + {type: CONST.REPORT.TYPE.EXPENSE, stateNum: CONST.REPORT.STATE_NUM.APPROVED, statusNum: CONST.REPORT.STATUS_NUM.REIMBURSED}, + {}, + { + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${rptFilterReportID}`]: { + 'reimbursed-1': { + reportActionID: 'reimbursed-1', + actionName: CONST.REPORT.ACTIONS.TYPE.MARKED_REIMBURSED, + actorAccountID: approverAccountID, + created: '2024-12-22 09:30:00', + }, + }, + }, + ); + const [sections] = callGetReportSections(data); + const item = sections.find((s) => s.keyForList === rptFilterReportID); + expect(item?.paidByAccountID).toBe(approverAccountID); + }); + + it('should populate paidBy from a live pay action missing from the snapshot (pay from Search)', () => { + const data = makeReportFilterTestData({type: CONST.REPORT.TYPE.EXPENSE, stateNum: CONST.REPORT.STATE_NUM.APPROVED, statusNum: CONST.REPORT.STATUS_NUM.REIMBURSED}); + const [sections] = callGetReportSections(data, { + reportActions: { + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${rptFilterReportID}`]: [ + { + reportActionID: 'optimistic-pay-1', + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + originalMessage: {type: CONST.IOU.REPORT_ACTION_TYPE.PAY}, + actorAccountID: approverAccountID, + created: '2024-12-22 09:30:00', + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }, + ], + }, + }); + const item = sections.find((s) => s.keyForList === rptFilterReportID); + expect(item?.paidByAccountID).toBe(approverAccountID); + }); + + it('should leave paidBy blank when the submitter marked the payment as received', () => { + const [sections] = callGetReportSections( + makeReportFilterTestData({type: CONST.REPORT.TYPE.EXPENSE, stateNum: CONST.REPORT.STATE_NUM.APPROVED, statusNum: CONST.REPORT.STATUS_NUM.REIMBURSED}), + { + reportActions: { + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${rptFilterReportID}`]: [ + { + reportActionID: 'received-pay-1', + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + originalMessage: {type: CONST.IOU.REPORT_ACTION_TYPE.PAY, isSubmitterMarkedPaymentReceived: true}, + actorAccountID: adminAccountID, + created: '2024-12-22 09:30:00', + }, + { + reportActionID: 'received-reimbursed-1', + actionName: CONST.REPORT.ACTIONS.TYPE.MARKED_REIMBURSED, + actorAccountID: adminAccountID, + created: '2024-12-22 09:30:00', + }, + { + reportActionID: 'received-redeemed-1', + actionName: CONST.REPORT.ACTIONS.TYPE.MARKED_REDEEMED, + actorAccountID: adminAccountID, + created: '2024-12-22 09:30:01', + }, + ], + }, + }, + ); + const item = sections.find((s) => s.keyForList === rptFilterReportID); + expect(item?.paidByAccountID).toBeUndefined(); + expect(item?.formattedPaidBy).toBe(''); + }); + + it('should ignore payment actions at or before the latest reimbursement cancellation', () => { + const [sections] = callGetReportSections( + makeReportFilterTestData({type: CONST.REPORT.TYPE.EXPENSE, stateNum: CONST.REPORT.STATE_NUM.APPROVED, statusNum: CONST.REPORT.STATUS_NUM.REIMBURSED}), + { + reportActions: { + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${rptFilterReportID}`]: [ + { + reportActionID: 'reimbursed-1', + actionName: CONST.REPORT.ACTIONS.TYPE.REIMBURSED, + actorAccountID: approverAccountID, + created: '2024-12-20 08:00:00', + }, + { + reportActionID: 'dequeued-1', + actionName: CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENT_DEQUEUED, + actorAccountID: approverAccountID, + created: '2024-12-21 10:00:00', + }, + ], + }, + }, + ); + const item = sections.find((s) => s.keyForList === rptFilterReportID); + expect(item?.paidByAccountID).toBeUndefined(); + }); + it('should use the first approval after the latest UNAPPROVED action when the report was re-approved', () => { const reApprovedAt = '2024-12-22 09:30:00'; const data = makeReportFilterTestData( From 836b84a27ba4b1cea1015ee277858aeabab8810c Mon Sep 17 00:00:00 2001 From: Krishna Chaitanya Date: Tue, 1 Sep 2026 10:07:26 +0530 Subject: [PATCH 2/3] fix: use constant name in paid-by comment for cspell --- src/libs/SearchUIUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index e7a3d021f3f9..aa74f37e53fa 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -3017,7 +3017,7 @@ function getFirstApprovedAction(snapshotApprovedAction: OnyxTypes.ReportAction | /** * Returns the latest payment action between the snapshot-derived one and the given report actions, mirroring the * backend paid-by rules: payment actions at or before the latest reimbursement cancellation don't count, and a - * payment action recorded by the report owner that is paired with a MARKEDREDEEMED action is a "received payment" + * payment action recorded by the report owner that is paired with a MARKED_REDEEMED action is a "received payment" * self-attestation, which doesn't identify a payer. */ function getLastPaidAction( From 1dfd8cfde33ae216a096eda52d566913a9a820f6 Mon Sep 17 00:00:00 2001 From: Krishna Chaitanya Date: Tue, 1 Sep 2026 17:00:30 +0530 Subject: [PATCH 3/3] fix: move Paid by filter next to Paid status in filters list --- src/hooks/useAdvancedSearchFilters.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/hooks/useAdvancedSearchFilters.ts b/src/hooks/useAdvancedSearchFilters.ts index ef09002416e9..80df206ddea3 100644 --- a/src/hooks/useAdvancedSearchFilters.ts +++ b/src/hooks/useAdvancedSearchFilters.ts @@ -30,7 +30,6 @@ const typeFiltersKeys = { CONST.SEARCH.SYNTAX_FILTER_KEYS.STATUS, CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM, CONST.SEARCH.SYNTAX_FILTER_KEYS.TO, - CONST.SEARCH.SYNTAX_FILTER_KEYS.PAID_BY, CONST.SEARCH.SYNTAX_FILTER_KEYS.POLICY_ID, ], [ @@ -66,6 +65,7 @@ const typeFiltersKeys = { CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPORTED, CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPORTED_TO, CONST.SEARCH.SYNTAX_FILTER_KEYS.PAID, + CONST.SEARCH.SYNTAX_FILTER_KEYS.PAID_BY, CONST.SEARCH.SYNTAX_FILTER_KEYS.POSTED, ], [ @@ -81,7 +81,6 @@ const typeFiltersKeys = { CONST.SEARCH.SYNTAX_FILTER_KEYS.STATUS, CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM, CONST.SEARCH.SYNTAX_FILTER_KEYS.TO, - CONST.SEARCH.SYNTAX_FILTER_KEYS.PAID_BY, CONST.SEARCH.SYNTAX_FILTER_KEYS.POLICY_ID, ], [ @@ -92,6 +91,7 @@ const typeFiltersKeys = { CONST.SEARCH.SYNTAX_FILTER_KEYS.CURRENCY, CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPORTED_TO, CONST.SEARCH.SYNTAX_FILTER_KEYS.PAID_STATUS, + CONST.SEARCH.SYNTAX_FILTER_KEYS.PAID_BY, CONST.SEARCH.SYNTAX_FILTER_KEYS.TITLE, CONST.SEARCH.SYNTAX_FILTER_KEYS.REPORT_ID, CONST.SEARCH.SYNTAX_FILTER_KEYS.REPORT_FIELD, @@ -114,7 +114,6 @@ const typeFiltersKeys = { CONST.SEARCH.SYNTAX_FILTER_KEYS.STATUS, CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM, CONST.SEARCH.SYNTAX_FILTER_KEYS.TO, - CONST.SEARCH.SYNTAX_FILTER_KEYS.PAID_BY, CONST.SEARCH.SYNTAX_FILTER_KEYS.POLICY_ID, ], [ @@ -137,6 +136,7 @@ const typeFiltersKeys = { CONST.SEARCH.SYNTAX_FILTER_KEYS.APPROVED, CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPORTED, CONST.SEARCH.SYNTAX_FILTER_KEYS.PAID, + CONST.SEARCH.SYNTAX_FILTER_KEYS.PAID_BY, CONST.SEARCH.SYNTAX_FILTER_KEYS.POSTED, CONST.SEARCH.SYNTAX_FILTER_KEYS.TOTAL, CONST.SEARCH.SYNTAX_FILTER_KEYS.TITLE, @@ -155,7 +155,6 @@ const typeFiltersKeys = { CONST.SEARCH.SYNTAX_FILTER_KEYS.STATUS, CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM, CONST.SEARCH.SYNTAX_FILTER_KEYS.TO, - CONST.SEARCH.SYNTAX_FILTER_KEYS.PAID_BY, CONST.SEARCH.SYNTAX_FILTER_KEYS.POLICY_ID, ], [ @@ -179,6 +178,7 @@ const typeFiltersKeys = { CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPORTED, CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPORTED_TO, CONST.SEARCH.SYNTAX_FILTER_KEYS.PAID, + CONST.SEARCH.SYNTAX_FILTER_KEYS.PAID_BY, CONST.SEARCH.SYNTAX_FILTER_KEYS.POSTED, CONST.SEARCH.SYNTAX_FILTER_KEYS.TOTAL, CONST.SEARCH.SYNTAX_FILTER_KEYS.TITLE,