Skip to content

Commit c33afdd

Browse files
authored
Merge pull request Expensify#90553 from Expensify/claude-lhnBadgeOldestReport
Sort report actions by created date to link LHN badge to oldest report
2 parents dfb5bb3 + 6adc8f8 commit c33afdd

5 files changed

Lines changed: 203 additions & 17 deletions

File tree

src/libs/ReportActionsUtils.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1631,6 +1631,14 @@ function isNewerReportAction(a: ReportAction, b: ReportAction): boolean {
16311631
return a.reportActionID > b.reportActionID;
16321632
}
16331633

1634+
/**
1635+
* Returns true if action `a` is older than action `b`.
1636+
* This is the inverse of isNewerReportAction.
1637+
*/
1638+
function isOlderReportAction(a: ReportAction, b: ReportAction): boolean {
1639+
return isNewerReportAction(b, a);
1640+
}
1641+
16341642
/**
16351643
* The first visible action is the second last action in sortedReportActions which satisfy following conditions:
16361644
* 1. That is not pending deletion as pending deletion actions are kept in sortedReportActions in memory.
@@ -4614,6 +4622,7 @@ export {
46144622
isApprovedOrSubmittedReportAction,
46154623
isIOURequestReportAction,
46164624
isNewerReportAction,
4625+
isOlderReportAction,
46174626
isClosedAction,
46184627
isConsecutiveActionMadeByPreviousActor,
46194628
isExportedToIntegrationAction,

src/libs/ReportUtils.ts

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,7 @@ import {
212212
isModifiedExpenseAction,
213213
isMoneyRequestAction,
214214
isMovedAction,
215+
isOlderReportAction,
215216
isPendingRemove,
216217
isPolicyChangeLogAction,
217218
isReimbursementQueuedAction,
@@ -4287,18 +4288,25 @@ function getReasonAndReportActionThatRequiresAttention(
42874288
}
42884289

42894290
if (isInvoiceRoom(optionOrReport)) {
4290-
const reportAction = Object.values(reportActions).find(
4291-
(action) =>
4292-
action.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW &&
4293-
action.childReportID &&
4294-
hasMissingInvoiceBankAccount(action.childReportID) &&
4295-
!isSettled(action.childReportID),
4296-
);
4291+
let earliestAction: ReportAction | undefined;
4292+
for (const action of Object.values(reportActions)) {
4293+
if (
4294+
action.actionName !== CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW ||
4295+
!action.childReportID ||
4296+
!hasMissingInvoiceBankAccount(action.childReportID) ||
4297+
isSettled(action.childReportID)
4298+
) {
4299+
continue;
4300+
}
4301+
if (!earliestAction || isOlderReportAction(action, earliestAction)) {
4302+
earliestAction = action;
4303+
}
4304+
}
42974305

4298-
return reportAction
4306+
return earliestAction
42994307
? {
43004308
reason: CONST.REQUIRES_ATTENTION_REASONS.HAS_MISSING_INVOICE_BANK_ACCOUNT,
4301-
reportAction,
4309+
reportAction: earliestAction,
43024310
}
43034311
: null;
43044312
}

src/libs/actions/IOU/ReportWorkflow.ts

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import Navigation from '@libs/Navigation/Navigation';
1717
import {getIsOffline} from '@libs/NetworkState';
1818
import {buildNextStepNew, buildOptimisticNextStep} from '@libs/NextStepUtils';
1919
import {arePaymentsEnabled, getSubmitReportManagerAccountID, hasDynamicExternalWorkflow, isPaidGroupPolicy, isPolicyAdmin, isSubmitAndClose} from '@libs/PolicyUtils';
20-
import {getAllReportActions, getReportActionHtml, getReportActionText, hasPendingDEWApprove, isCreatedAction, isDeletedAction} from '@libs/ReportActionsUtils';
20+
import {getAllReportActions, getReportActionHtml, getReportActionText, hasPendingDEWApprove, isCreatedAction, isDeletedAction, isOlderReportAction} from '@libs/ReportActionsUtils';
2121
import {
2222
buildOptimisticApprovedReportAction,
2323
buildOptimisticChangeApproverReportAction,
@@ -308,20 +308,24 @@ function getIOUReportActionWithBadge(
308308
const chatReportActions = getAllReportActionsFromIOU()?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.reportID}`] ?? {};
309309

310310
let actionBadge: ValueOf<typeof CONST.REPORT.ACTION_BADGE> | undefined;
311-
const reportAction = Object.values(chatReportActions).find((action) => {
311+
let earliestAction: ReportAction | undefined;
312+
313+
for (const action of Object.values(chatReportActions)) {
312314
if (action?.actionName !== CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW || isDeletedAction(action)) {
313-
return false;
315+
continue;
314316
}
315317
const iouReport = getReportOrDraftReport(action.childReportID);
316318
const badge = getBadgeFromIOUReport(iouReport, chatReport, policy, reportMetadata, invoiceReceiverPolicy, currentUserLogin, currentUserAccountID);
317-
if (badge) {
319+
if (!badge) {
320+
continue;
321+
}
322+
if (!earliestAction || isOlderReportAction(action, earliestAction)) {
323+
earliestAction = action;
318324
actionBadge = badge;
319-
return true;
320325
}
321-
return false;
322-
});
326+
}
323327

324-
return {reportAction, actionBadge};
328+
return {reportAction: earliestAction, actionBadge};
325329
}
326330

327331
/**

tests/actions/IOUTest/ReportWorkflowTest.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3506,6 +3506,96 @@ describe('actions/IOU/ReportWorkflow', () => {
35063506
expect(result.actionBadge).toBeUndefined();
35073507
});
35083508

3509+
it('should return the oldest matching report action when multiple actions have badges', async () => {
3510+
const chatReportID = '500';
3511+
const olderIouReportID = '501';
3512+
const newerIouReportID = '502';
3513+
const policyID = '503';
3514+
3515+
const fakePolicy: Policy = {
3516+
...createRandomPolicy(Number(policyID)),
3517+
id: policyID,
3518+
type: CONST.POLICY.TYPE.TEAM,
3519+
approvalMode: CONST.POLICY.APPROVAL_MODE.BASIC,
3520+
};
3521+
3522+
const fakeChatReport: Report = {
3523+
...createRandomReport(Number(chatReportID), CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT),
3524+
reportID: chatReportID,
3525+
policyID,
3526+
};
3527+
3528+
// Two submitted expense reports — both will produce APPROVE badges
3529+
const olderIouReport: Report = {
3530+
...createRandomReport(Number(olderIouReportID), CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT),
3531+
reportID: olderIouReportID,
3532+
type: CONST.REPORT.TYPE.EXPENSE,
3533+
policyID,
3534+
stateNum: CONST.REPORT.STATE_NUM.SUBMITTED,
3535+
statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED,
3536+
managerID: RORY_ACCOUNT_ID,
3537+
};
3538+
3539+
const newerIouReport: Report = {
3540+
...createRandomReport(Number(newerIouReportID), CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT),
3541+
reportID: newerIouReportID,
3542+
type: CONST.REPORT.TYPE.EXPENSE,
3543+
policyID,
3544+
stateNum: CONST.REPORT.STATE_NUM.SUBMITTED,
3545+
statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED,
3546+
managerID: RORY_ACCOUNT_ID,
3547+
};
3548+
3549+
const olderTransaction: Transaction = {
3550+
...createRandomTransaction(0),
3551+
reportID: olderIouReportID,
3552+
amount: 100,
3553+
status: CONST.TRANSACTION.STATUS.POSTED,
3554+
bank: '',
3555+
};
3556+
3557+
const newerTransaction: Transaction = {
3558+
...createRandomTransaction(1),
3559+
reportID: newerIouReportID,
3560+
amount: 200,
3561+
status: CONST.TRANSACTION.STATUS.POSTED,
3562+
bank: '',
3563+
};
3564+
3565+
await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, fakePolicy);
3566+
await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`, fakeChatReport);
3567+
await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${olderIouReportID}`, olderIouReport);
3568+
await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${newerIouReportID}`, newerIouReport);
3569+
await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${olderTransaction.transactionID}`, olderTransaction);
3570+
await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${newerTransaction.transactionID}`, newerTransaction);
3571+
3572+
const olderReportPreview = {
3573+
reportActionID: 'older-preview',
3574+
actionName: CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW,
3575+
created: '2024-08-08 18:00:00.000',
3576+
childReportID: olderIouReportID,
3577+
message: [{type: 'TEXT', text: 'Older report preview'}],
3578+
};
3579+
3580+
const newerReportPreview = {
3581+
reportActionID: 'newer-preview',
3582+
actionName: CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW,
3583+
created: '2024-08-08 20:00:00.000',
3584+
childReportID: newerIouReportID,
3585+
message: [{type: 'TEXT', text: 'Newer report preview'}],
3586+
};
3587+
3588+
await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReportID}`, {
3589+
[newerReportPreview.reportActionID]: newerReportPreview,
3590+
[olderReportPreview.reportActionID]: olderReportPreview,
3591+
});
3592+
await waitForBatchedUpdates();
3593+
3594+
const result = getIOUReportActionWithBadge(fakeChatReport, fakePolicy, {}, undefined, RORY_EMAIL, RORY_ACCOUNT_ID);
3595+
expect(result.reportAction).toMatchObject(olderReportPreview);
3596+
expect(result.actionBadge).toBe(CONST.REPORT.ACTION_BADGE.APPROVE);
3597+
});
3598+
35093599
it('should return undefined actionBadge when report is settled', async () => {
35103600
const chatReportID = '400';
35113601
const iouReportID = '401';

tests/unit/ReportUtilsTest.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9309,6 +9309,81 @@ describe('ReportUtils', () => {
93099309
// Should only return the task where childManagerAccountID matches the current user
93109310
expect(result?.reportAction?.reportActionID).toBe('current-user-task');
93119311
});
9312+
9313+
it('should return the earliest matching report action for invoice rooms with missing bank account', async () => {
9314+
const invoiceRoomID = '50000';
9315+
const olderChildReportID = '50001';
9316+
const newerChildReportID = '50002';
9317+
const policyID = '50003';
9318+
9319+
const invoiceRoom: Report = {
9320+
...createInvoiceRoom(Number(invoiceRoomID)),
9321+
reportID: invoiceRoomID,
9322+
policyID,
9323+
};
9324+
9325+
// Child invoice reports: owned by current user, no bank account on policy, and settled
9326+
// Note: hasMissingInvoiceBankAccount requires isSettled=true, but the outer condition
9327+
// requires !isSettled, making this path currently unreachable. This test documents the
9328+
// current behavior and will catch regressions if the conditions are corrected.
9329+
const olderChildReport: Report = {
9330+
...createRandomReport(Number(olderChildReportID), undefined),
9331+
reportID: olderChildReportID,
9332+
type: CONST.REPORT.TYPE.INVOICE,
9333+
policyID,
9334+
ownerAccountID: currentUserAccountID,
9335+
statusNum: CONST.REPORT.STATUS_NUM.REIMBURSED,
9336+
};
9337+
9338+
const newerChildReport: Report = {
9339+
...createRandomReport(Number(newerChildReportID), undefined),
9340+
reportID: newerChildReportID,
9341+
type: CONST.REPORT.TYPE.INVOICE,
9342+
policyID,
9343+
ownerAccountID: currentUserAccountID,
9344+
statusNum: CONST.REPORT.STATUS_NUM.REIMBURSED,
9345+
};
9346+
9347+
const fakePolicy: Policy = {
9348+
...createRandomPolicy(Number(policyID)),
9349+
id: policyID,
9350+
};
9351+
9352+
await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, fakePolicy);
9353+
await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${invoiceRoomID}`, invoiceRoom);
9354+
await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${olderChildReportID}`, olderChildReport);
9355+
await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${newerChildReportID}`, newerChildReport);
9356+
9357+
const olderReportPreview: ReportAction = {
9358+
...createRandomReportAction(Number(olderChildReportID)),
9359+
reportActionID: 'older-invoice-preview',
9360+
actionName: CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW,
9361+
created: '2024-01-01 10:00:00.000',
9362+
childReportID: olderChildReportID,
9363+
};
9364+
9365+
const newerReportPreview: ReportAction = {
9366+
...createRandomReportAction(Number(newerChildReportID)),
9367+
reportActionID: 'newer-invoice-preview',
9368+
actionName: CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW,
9369+
created: '2024-01-02 10:00:00.000',
9370+
childReportID: newerChildReportID,
9371+
};
9372+
9373+
await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${invoiceRoomID}`, {
9374+
[newerReportPreview.reportActionID]: newerReportPreview,
9375+
[olderReportPreview.reportActionID]: olderReportPreview,
9376+
});
9377+
await waitForBatchedUpdates();
9378+
9379+
const {result: isReportArchived} = renderHook(() => useReportIsArchived(invoiceRoomID));
9380+
const result = getReasonAndReportActionThatRequiresAttention(invoiceRoom, currentUserEmail, currentUserAccountID, undefined, isReportArchived.current);
9381+
9382+
// Currently returns null because hasMissingInvoiceBankAccount requires isSettled=true
9383+
// but the outer condition filters out settled reports with isSettled check.
9384+
// When this contradiction is resolved, the result should return the older report action.
9385+
expect(result).toBe(null);
9386+
});
93129387
});
93139388

93149389
describe('canEditReportDescription', () => {

0 commit comments

Comments
 (0)