Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
8 changes: 4 additions & 4 deletions src/hooks/useAdvancedSearchFilters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
],
[
Expand Down Expand Up @@ -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,
],
[
Expand All @@ -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,
],
[
Expand All @@ -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,
Expand All @@ -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,
],
[
Expand All @@ -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,
Expand All @@ -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,
],
[
Expand All @@ -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,
Expand Down
11 changes: 10 additions & 1 deletion src/libs/ReportUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,7 @@ import {
isMoneyRequestAction,
isMovedAction,
isOlderReportAction,
isPayAction,
isPendingRemove,
isReopenedAction,
isReportActionVisible,
Expand Down Expand Up @@ -13823,7 +13824,15 @@ function isSearchRelevantReportAction(action: OnyxInputOrEntry<ReportAction>): 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)
);
}

Expand Down
54 changes: 53 additions & 1 deletion src/libs/SearchUIUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 MARKED_REDEEMED 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 => {
Comment thread
JS00001 marked this conversation as resolved.
if (!action) {
return false;
}
if (isMoneyRequestAction(action)) {
const originalMessage = getOriginalMessage<typeof CONST.REPORT.ACTIONS.TYPE.IOU>(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(
Comment thread
JS00001 marked this conversation as resolved.
(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
Expand Down Expand Up @@ -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}) : '';
Expand Down
100 changes: 100 additions & 0 deletions tests/unit/Search/SearchUIUtilsTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading