From ab766b70bfb5c05d77ea514574a204b7c4649a3e Mon Sep 17 00:00:00 2001 From: Samiya Hamid Date: Wed, 14 Oct 2026 03:05:34 +0100 Subject: [PATCH 1/7] Add Paid column option to the Spend reports table Add an optional Paid date column for expense reports. The date comes from the latest payment action that was not cancelled, since the Search response has no paid date on the report. Share that rule with the Paid by column. --- src/CONST/index.ts | 2 + .../ExpenseReportListItemRowWide.tsx | 11 ++ .../Search/SearchList/ListItem/types.ts | 3 + src/components/Search/SearchTableHeader.tsx | 4 + src/libs/SearchUIUtils.ts | 69 +++++-- src/styles/utils/index.ts | 1 + tests/unit/Search/ColumnAvailabilityTest.ts | 1 + tests/unit/Search/SearchUIUtilsTest.ts | 176 ++++++++++++++++++ 8 files changed, 247 insertions(+), 20 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 8782c225bc3f..2e207772a825 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -7940,6 +7940,7 @@ const CONST = { APPROVED: this.TABLE_COLUMNS.APPROVED, FIRST_APPROVER: this.TABLE_COLUMNS.FIRST_APPROVER, FIRST_APPROVED: this.TABLE_COLUMNS.FIRST_APPROVED, + PAID: this.TABLE_COLUMNS.PAID, PAID_BY: this.TABLE_COLUMNS.PAID_BY, EXPORTED: this.TABLE_COLUMNS.EXPORTED, STATUS: this.TABLE_COLUMNS.STATUS, @@ -8143,6 +8144,7 @@ const CONST = { APPROVED: 'approved', FIRST_APPROVER: 'firstapprover', FIRST_APPROVED: 'firstapproved', + PAID: 'paid', PAID_BY: 'paidBy', POSTED: 'posted', EXPORTED: 'exported', diff --git a/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowWide.tsx b/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowWide.tsx index b31183b44ef8..9298c8e0280d 100644 --- a/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowWide.tsx +++ b/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowWide.tsx @@ -60,6 +60,7 @@ function ExpenseReportListItemRowWide({ const submitterPayrollID = item.submitterPayrollID; const orderDealNumbers = item.orderDealNumbers; const {debitedAmount, debitedCurrency, creditedAmount, creditedCurrency} = item; + const {paid: paidDate = ''} = item; const columnComponents = { [CONST.SEARCH.TABLE_COLUMNS.AVATAR]: , @@ -113,6 +114,16 @@ function ExpenseReportListItemRowWide({ /> ), + [CONST.SEARCH.TABLE_COLUMNS.PAID]: ( + + + + ), [CONST.SEARCH.TABLE_COLUMNS.PAID_BY]: ( {!!item.paidByAccountID && ( diff --git a/src/components/Search/SearchList/ListItem/types.ts b/src/components/Search/SearchList/ListItem/types.ts index 8debb6e1bfcf..8e6c48a5283c 100644 --- a/src/components/Search/SearchList/ListItem/types.ts +++ b/src/components/Search/SearchList/ListItem/types.ts @@ -259,6 +259,9 @@ type TransactionReportGroupListItemType = TransactionGroupListItemType & {groupe /** The date of the report's first approval (created date of the earliest APPROVED/FORWARDED report action) */ firstApproved?: string; + /** The date the report was paid (created date of the latest payment report action that was not cancelled) */ + paid?: string; + firstApproverAvatar?: AvatarSource; /** Account ID of the first approver (actor on the earliest APPROVED/FORWARDED report action) */ diff --git a/src/components/Search/SearchTableHeader.tsx b/src/components/Search/SearchTableHeader.tsx index b1477a3c0545..815bd50e8881 100644 --- a/src/components/Search/SearchTableHeader.tsx +++ b/src/components/Search/SearchTableHeader.tsx @@ -276,6 +276,10 @@ const getExpenseReportHeaders = (profileIcon?: IconAsset): SearchColumnConfig[] columnName: CONST.SEARCH.TABLE_COLUMNS.FIRST_APPROVED, translationKey: 'search.filters.firstApproved', }, + { + columnName: CONST.SEARCH.TABLE_COLUMNS.PAID, + translationKey: 'search.filters.paid', + }, { columnName: CONST.SEARCH.TABLE_COLUMNS.PAID_BY, translationKey: 'search.filters.paidBy', diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index a2bec325a21a..e251e94f4ea9 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -355,6 +355,7 @@ const expenseReportColumnNamesToSortingProperty: ExpenseReportSorting = { [CONST.SEARCH.TABLE_COLUMNS.APPROVED]: 'approved' as const, [CONST.SEARCH.TABLE_COLUMNS.FIRST_APPROVER]: 'formattedFirstApprover' as const, [CONST.SEARCH.TABLE_COLUMNS.FIRST_APPROVED]: 'firstApproved' as const, + [CONST.SEARCH.TABLE_COLUMNS.PAID]: 'paid' as const, [CONST.SEARCH.TABLE_COLUMNS.PAID_BY]: 'formattedPaidBy' as const, [CONST.SEARCH.TABLE_COLUMNS.EXPORTED]: 'exported' as const, [CONST.SEARCH.TABLE_COLUMNS.STATUS]: 'formattedStatus' as const, @@ -2652,15 +2653,14 @@ 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 MARKED_REDEEMED action is a "received payment" - * self-attestation, which doesn't identify a payer. + * Returns the latest payment action between the snapshot-derived one and the given report actions. Payment actions + * at or before the latest reimbursement cancellation don't count, because that payment was reversed. + * `shouldCountPayment` lets a caller leave out payments that don't answer its question. */ -function getLastPaidAction( +function getLastPaymentAction( snapshotPaidAction: OnyxTypes.ReportAction | undefined, actions: OnyxTypes.ReportAction[], - ownerAccountID: number | undefined, + shouldCountPayment: (action: OnyxTypes.ReportAction) => boolean = () => true, ): OnyxTypes.ReportAction | undefined { const latestCancellation = findActionByCreated( actions, @@ -2671,24 +2671,13 @@ function getLastPaidAction( 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 (isMoneyRequestAction(action) && getOriginalMessage(action)?.type !== CONST.IOU.REPORT_ACTION_TYPE.PAY) { + 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, - ); + return shouldCountPayment(action); }; const seed = isValidPaymentAction(snapshotPaidAction) ? snapshotPaidAction : undefined; const candidates = actions.filter(isValidPaymentAction); @@ -2700,6 +2689,43 @@ function getLastPaidAction( ); } +/** + * Returns the latest payment action that identifies a payer, mirroring the backend paid-by rules: 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 { + return getLastPaymentAction(snapshotPaidAction, actions, (action) => { + if (isMoneyRequestAction(action) && getOriginalMessage(action)?.isSubmitterMarkedPaymentReceived) { + 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, + ); + }); +} + +/** + * Returns the created time of the report's latest payment action, or blank when the report is not Paid. Unlike + * the paid-by lookup, a payment the submitter marked as received counts: the report was still paid on that date. + */ +function getPaidDate(reportItem: OnyxTypes.Report, snapshotPaidAction: OnyxTypes.ReportAction | undefined, actions: OnyxTypes.ReportAction[]): string { + if (reportItem.statusNum !== CONST.REPORT.STATUS_NUM.REIMBURSED) { + return ''; + } + return getLastPaymentAction(snapshotPaidAction, actions)?.created ?? ''; +} + /** * 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 @@ -2895,6 +2921,7 @@ function getReportSections({ approved: getApprovedDate(reportItem, actions), exportedTo: getExportedToSortValue(exportedToNamesByReportID, reportItem.reportID), firstApproved, + paid: getPaidDate(reportItem, lastReimbursedActionByReportID.get(reportItem.reportID), actions), firstApproverAvatar: firstApproverDetails?.avatar, firstApproverAccountID, formattedFirstApprover, @@ -4475,6 +4502,8 @@ function getSearchColumnTranslationKey(column: SearchSortBy, type?: SearchDataTy return 'search.filters.firstApprover'; case CONST.SEARCH.TABLE_COLUMNS.FIRST_APPROVED: return 'search.filters.firstApproved'; + case CONST.SEARCH.TABLE_COLUMNS.PAID: + return 'search.filters.paid'; case CONST.SEARCH.TABLE_COLUMNS.PAID_BY: return 'search.filters.paidBy'; case CONST.SEARCH.TABLE_COLUMNS.POSTED: diff --git a/src/styles/utils/index.ts b/src/styles/utils/index.ts index de4cdb4259a5..1ed1326d722e 100644 --- a/src/styles/utils/index.ts +++ b/src/styles/utils/index.ts @@ -2044,6 +2044,7 @@ const createStyleUtils = (theme: ThemeColors, styles: ThemeStyles) => ({ columnWidth = {...getWidthStyle(isApprovedColumnWide ? variables.w102 : variables.w62)}; break; case CONST.SEARCH.TABLE_COLUMNS.FIRST_APPROVED: + case CONST.SEARCH.TABLE_COLUMNS.PAID: // Fixed width: wide enough for both the long "First approved" header and a past-year date, so no year-based widening is needed. columnWidth = {...getWidthStyle(variables.w102)}; break; diff --git a/tests/unit/Search/ColumnAvailabilityTest.ts b/tests/unit/Search/ColumnAvailabilityTest.ts index fa2b76501de2..3a5d62fbe925 100644 --- a/tests/unit/Search/ColumnAvailabilityTest.ts +++ b/tests/unit/Search/ColumnAvailabilityTest.ts @@ -32,6 +32,7 @@ describe('Column availability single source of truth', () => { TABLE_COLUMNS.NON_REIMBURSABLE_TOTAL, TABLE_COLUMNS.FIRST_APPROVER, TABLE_COLUMNS.FIRST_APPROVED, + TABLE_COLUMNS.PAID, TABLE_COLUMNS.PAID_BY, TABLE_COLUMNS.PAID_STATUS, TABLE_COLUMNS.AMOUNT_DEBITED, diff --git a/tests/unit/Search/SearchUIUtilsTest.ts b/tests/unit/Search/SearchUIUtilsTest.ts index 605892308cc2..661a87e57e90 100644 --- a/tests/unit/Search/SearchUIUtilsTest.ts +++ b/tests/unit/Search/SearchUIUtilsTest.ts @@ -1196,6 +1196,7 @@ const transactionReportGroupListItems = createMock { expect(item?.paidByAccountID).toBeUndefined(); }); + it('should populate paid from a snapshot payment action on a paid report', () => { + // Given a paid report whose only payment action comes from the Search snapshot, which is the case when the report was never opened + const paidAt = '2024-12-22 09:30:00'; + 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: paidAt, + }, + }, + }, + ); + + // When the report sections are built + const [sections] = callGetReportSections(data); + const item = sections.find((s) => s.keyForList === rptFilterReportID); + + // Then the paid date is the payment action's created time, because the report itself carries no paid date + expect(item?.paid).toBe(paidAt); + }); + + it('should populate paid from a live pay action missing from the snapshot (pay from Search)', () => { + // Given a paid report whose payment action only exists in the live report actions, as it does right after paying from Search + const paidAt = '2024-12-22 09:30:00'; + const data = makeReportFilterTestData({type: CONST.REPORT.TYPE.EXPENSE, stateNum: CONST.REPORT.STATE_NUM.APPROVED, statusNum: CONST.REPORT.STATUS_NUM.REIMBURSED}); + + // When the report sections are built with the live actions + 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: paidAt, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }, + ], + }, + }); + const item = sections.find((s) => s.keyForList === rptFilterReportID); + + // Then the paid date shows without waiting for the snapshot to catch up + expect(item?.paid).toBe(paidAt); + }); + + it('should populate paid when the submitter marked the payment as received', () => { + // Given a paid report that the submitter marked as received, which identifies no payer + const receivedAt = '2024-12-22 09:30:00'; + 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: receivedAt, + }, + { + reportActionID: 'received-reimbursed-1', + actionName: CONST.REPORT.ACTIONS.TYPE.MARKED_REIMBURSED, + actorAccountID: adminAccountID, + created: receivedAt, + }, + { + reportActionID: 'received-redeemed-1', + actionName: CONST.REPORT.ACTIONS.TYPE.MARKED_REDEEMED, + actorAccountID: adminAccountID, + created: '2024-12-22 09:30:01', + }, + ], + }, + }, + ); + + // When the row is read + const item = sections.find((s) => s.keyForList === rptFilterReportID); + + // Then Paid by stays blank but the paid date is set, because the report was still paid on that date + expect(item?.paidByAccountID).toBeUndefined(); + expect(item?.paid).toBe(receivedAt); + }); + + it('should leave paid blank when the payment was cancelled', () => { + // Given a report still marked Paid whose only payment action precedes a 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', + }, + ], + }, + }, + ); + + // When the row is read + const item = sections.find((s) => s.keyForList === rptFilterReportID); + + // Then the paid date is blank, because the reversed payment no longer counts + expect(item?.paid).toBe(''); + }); + + it('should leave paid blank when the report is not paid', () => { + // Given an approved report that still holds a payment action from an earlier payment + const [sections] = callGetReportSections( + makeReportFilterTestData({type: CONST.REPORT.TYPE.EXPENSE, stateNum: CONST.REPORT.STATE_NUM.APPROVED, statusNum: CONST.REPORT.STATUS_NUM.APPROVED}), + { + reportActions: { + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${rptFilterReportID}`]: [ + { + reportActionID: 'reimbursed-1', + actionName: CONST.REPORT.ACTIONS.TYPE.MARKED_REIMBURSED, + actorAccountID: approverAccountID, + created: '2024-12-20 08:00:00', + }, + ], + }, + }, + ); + + // When the row is read + const item = sections.find((s) => s.keyForList === rptFilterReportID); + + // Then the paid date is blank, because only a report in the Paid status has a paid date + expect(item?.paid).toBe(''); + }); + 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( @@ -8632,6 +8784,30 @@ describe('SearchUIUtils', () => { ]); }); + it('should sort expense reports by paid date, leaving the unpaid reports at the empty end', () => { + // Given two paid reports and one that has no paid date + const withPaid = (keyForList: string, paid: string) => + createMock({ + groupedBy: 'expense-report', + keyForList, + reportID: keyForList, + created: '2025-08-12 17:11:22', + transactions: [], + paid, + }); + const reports = [withPaid('report_paid_later', '2025-09-02 10:00:00'), withPaid('report_unpaid', ''), withPaid('report_paid_earlier', '2025-08-20 10:00:00')]; + + // When the reports are sorted by the Paid column in each direction + const sortReports = (sortOrder: SortOrder) => + SearchUIUtils.getSortedSections(CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT, [...reports], localeCompare, translateLocal, CONST.SEARCH.TABLE_COLUMNS.PAID, sortOrder).map( + (report) => report.keyForList, + ); + + // Then the paid dates are ordered and the unpaid report groups at one end, the same as a blank Approved date + expect(sortReports(CONST.SEARCH.SORT_ORDER.ASC)).toStrictEqual(['report_unpaid', 'report_paid_earlier', 'report_paid_later']); + expect(sortReports(CONST.SEARCH.SORT_ORDER.DESC)).toStrictEqual(['report_paid_later', 'report_paid_earlier', 'report_unpaid']); + }); + it('should sort category group data when type is EXPENSE and groupBy is category', () => { expect( SearchUIUtils.getSortedSections( From 471532ecf0250c8367c825722588a72c78b127e2 Mon Sep 17 00:00:00 2001 From: Samiya Hamid Date: Wed, 14 Oct 2026 12:45:12 +0100 Subject: [PATCH 2/7] Match the Paid column width to the other date columns Paid had a fixed wide width, so it didn't line up with Approved. It now uses the narrow width like the other date columns and widens only when a paid date is from a past year and needs to show the year. --- .../ExpenseReportListItemRowWide.tsx | 2 +- .../Search/SearchList/ListItem/types.ts | 6 ++ src/components/Search/SearchTableHeader.tsx | 3 + src/components/Search/SortableTableHeader.tsx | 3 + src/components/Search/index.tsx | 6 +- src/libs/SearchUIUtils.ts | 24 ++++++++ src/styles/utils/index.ts | 6 +- tests/unit/Search/SearchUIUtilsTest.ts | 58 +++++++++++++++++++ 8 files changed, 105 insertions(+), 3 deletions(-) diff --git a/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowWide.tsx b/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowWide.tsx index 9298c8e0280d..9544e35ce4fb 100644 --- a/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowWide.tsx +++ b/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowWide.tsx @@ -115,7 +115,7 @@ function ExpenseReportListItemRowWide({ ), [CONST.SEARCH.TABLE_COLUMNS.PAID]: ( - + | undefined): boolean { + const paidDate = getPaidDate(report, undefined, Object.values(actions ?? {})); + return !!paidDate && DateUtils.doesDateBelongToAPastYear(paidDate); +} + /** * 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 @@ -2811,6 +2833,7 @@ function getReportSections({ shouldShowYearSubmittedReport, shouldShowYearApprovedReport, shouldShowYearExportedReport, + shouldShowYearPaidReport, shouldShowAmountInWideColumn, shouldShowTaxAmountInWideColumn, hasDeletedTransaction, @@ -2939,6 +2962,7 @@ function getReportSections({ shouldShowYearSubmitted: shouldShowYearSubmittedReport, shouldShowYearApproved: shouldShowYearApprovedReport, shouldShowYearExported: shouldShowYearExportedReport, + shouldShowYearPaid: shouldShowYearPaidReport, hasVisibleViolations: hasVisibleViolationsForReport, isRejectedReport, totalDisplaySpend, diff --git a/src/styles/utils/index.ts b/src/styles/utils/index.ts index 1ed1326d722e..63d1f10c7256 100644 --- a/src/styles/utils/index.ts +++ b/src/styles/utils/index.ts @@ -68,6 +68,7 @@ type GetReportTableColumnStylesParams = { isApprovedColumnWide?: boolean; isPostedColumnWide?: boolean; isExportedColumnWide?: boolean; + isPaidColumnWide?: boolean; shouldRemoveTotalColumnFlex?: boolean; isWithdrawnColumnWide?: boolean; isActionColumnWide?: boolean; @@ -1993,6 +1994,7 @@ const createStyleUtils = (theme: ThemeColors, styles: ThemeStyles) => ({ isApprovedColumnWide, isPostedColumnWide, isExportedColumnWide, + isPaidColumnWide, isDateColumnWide, isDateColumnCreated, isTaxAmountColumnWide, @@ -2043,8 +2045,10 @@ const createStyleUtils = (theme: ThemeColors, styles: ThemeStyles) => ({ case CONST.SEARCH.TABLE_COLUMNS.APPROVED: columnWidth = {...getWidthStyle(isApprovedColumnWide ? variables.w102 : variables.w62)}; break; - case CONST.SEARCH.TABLE_COLUMNS.FIRST_APPROVED: case CONST.SEARCH.TABLE_COLUMNS.PAID: + columnWidth = {...getWidthStyle(isPaidColumnWide ? variables.w102 : variables.w62)}; + break; + case CONST.SEARCH.TABLE_COLUMNS.FIRST_APPROVED: // Fixed width: wide enough for both the long "First approved" header and a past-year date, so no year-based widening is needed. columnWidth = {...getWidthStyle(variables.w102)}; break; diff --git a/tests/unit/Search/SearchUIUtilsTest.ts b/tests/unit/Search/SearchUIUtilsTest.ts index 661a87e57e90..8be03342539d 100644 --- a/tests/unit/Search/SearchUIUtilsTest.ts +++ b/tests/unit/Search/SearchUIUtilsTest.ts @@ -1195,6 +1195,7 @@ const transactionReportGroupListItems = createMock { expect(item?.paid).toBe(''); }); + it('should widen the Paid column when a report was paid in a past year', () => { + // Given a report paid in a past year + 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', + }, + }, + }, + ); + + // When the report sections and the header's year flags are built + const [sections] = callGetReportSections(data); + const item = sections.find((s) => s.keyForList === rptFilterReportID); + const yearFlags = SearchUIUtils.shouldShowYear(data, true); + + // Then both the row and the header use the wide Paid column, so the date has room for its year + expect(item?.shouldShowYearPaid).toBe(true); + expect(yearFlags.shouldShowYearPaid).toBe(true); + }); + + it('should keep the Paid column narrow when every report was paid this year', () => { + // Given a report paid this year + 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: `${new Date().getFullYear()}-01-02 09:30:00`, + }, + }, + }, + ); + + // When the report sections and the header's year flags are built + const [sections] = callGetReportSections(data); + const item = sections.find((s) => s.keyForList === rptFilterReportID); + const yearFlags = SearchUIUtils.shouldShowYear(data, true); + + // Then the row and the header keep the narrow Paid column, matching the other date columns + expect(item?.shouldShowYearPaid).toBe(false); + expect(yearFlags.shouldShowYearPaid).toBe(false); + }); + 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 a0ffd9c9c03f2b11a38ac28cb19d3b13eade97a8 Mon Sep 17 00:00:00 2001 From: Samiya Hamid Date: Mon, 19 Oct 2026 07:23:56 +0100 Subject: [PATCH 3/7] Read the paid date from the report sent by the backend --- src/libs/DebugUtils.ts | 2 + src/libs/SearchUIUtils.ts | 11 ++-- src/selectors/Report.ts | 1 + src/types/onyx/Report.ts | 3 ++ src/types/utils/whitelistedReportKeys.ts | 1 + tests/unit/Search/SearchUIUtilsTest.ts | 66 ++++++++++++++++++++++++ 6 files changed, 81 insertions(+), 3 deletions(-) diff --git a/src/libs/DebugUtils.ts b/src/libs/DebugUtils.ts index 8c8361be9712..2eaa5fb9ecc7 100644 --- a/src/libs/DebugUtils.ts +++ b/src/libs/DebugUtils.ts @@ -453,6 +453,7 @@ function validateReportDraftProperty(key: keyof Report | keyof ReportNameValuePa case 'created': case 'submitted': case 'approved': + case 'paid': case 'lastMessageText': case 'lastVisibleActionCreated': case 'lastReadTime': @@ -683,6 +684,7 @@ function validateReportDraftProperty(key: keyof Report | keyof ReportNameValuePa hasReportBeenReopened: CONST.RED_BRICK_ROAD_PENDING_ACTION, submitted: CONST.RED_BRICK_ROAD_PENDING_ACTION, approved: CONST.RED_BRICK_ROAD_PENDING_ACTION, + paid: CONST.RED_BRICK_ROAD_PENDING_ACTION, isExportedToIntegration: CONST.RED_BRICK_ROAD_PENDING_ACTION, hasExportError: CONST.RED_BRICK_ROAD_PENDING_ACTION, export: CONST.RED_BRICK_ROAD_PENDING_ACTION, diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 44f6374de628..702547df5db6 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -2735,14 +2735,19 @@ function getLastPaidAction( } /** - * Returns the created time of the report's latest payment action, or blank when the report is not Paid. Unlike - * the paid-by lookup, a payment the submitter marked as received counts: the report was still paid on that date. + * Returns the report's paid date or the latest payment action's created time, whichever is newer, or blank when the + * report is not Paid. The Search response only includes payment actions when the Paid by column is requested, so the + * report's own date is what fills the column on its own; an offline re-payment leaves that date stale, so a newer + * action wins. Unlike the paid-by lookup, a payment the submitter marked as received counts: the report was still + * paid on that date. */ function getPaidDate(reportItem: OnyxTypes.Report, snapshotPaidAction: OnyxTypes.ReportAction | undefined, actions: OnyxTypes.ReportAction[]): string { if (reportItem.statusNum !== CONST.REPORT.STATUS_NUM.REIMBURSED) { return ''; } - return getLastPaymentAction(snapshotPaidAction, actions)?.created ?? ''; + const {paid: reportPaid = ''} = reportItem; + const actionPaid = getLastPaymentAction(snapshotPaidAction, actions)?.created ?? ''; + return reportPaid >= actionPaid ? reportPaid : actionPaid; } /** diff --git a/src/selectors/Report.ts b/src/selectors/Report.ts index 29cf6f463698..ced534c8f5af 100644 --- a/src/selectors/Report.ts +++ b/src/selectors/Report.ts @@ -319,6 +319,7 @@ function getStableReportSelector(report: OnyxEntry) { created: report.created, submitted: report.submitted, approved: report.approved, + paid: report.paid, submitterUserID: report.submitterUserID, submitterPayrollID: report.submitterPayrollID, orderDealNumbers: report.orderDealNumbers, diff --git a/src/types/onyx/Report.ts b/src/types/onyx/Report.ts index 18055b381c89..ce892104b29c 100644 --- a/src/types/onyx/Report.ts +++ b/src/types/onyx/Report.ts @@ -89,6 +89,9 @@ type Report = OnyxCommon.OnyxValueWithOfflineFeedback< /** The date the report was approved */ approved?: string; + /** The date the report was paid */ + paid?: string; + /** Custom field 1 value for the report submitter */ submitterUserID?: string; diff --git a/src/types/utils/whitelistedReportKeys.ts b/src/types/utils/whitelistedReportKeys.ts index 1a4c786da824..3ab8f0e59f2f 100644 --- a/src/types/utils/whitelistedReportKeys.ts +++ b/src/types/utils/whitelistedReportKeys.ts @@ -35,6 +35,7 @@ type WhitelistedReport = OnyxCommon.OnyxValueWithOfflineFeedback< created: unknown; submitted: unknown; approved: unknown; + paid: unknown; submitterUserID: unknown; submitterPayrollID: unknown; orderDealNumbers: unknown; diff --git a/tests/unit/Search/SearchUIUtilsTest.ts b/tests/unit/Search/SearchUIUtilsTest.ts index 426a66f7d5b0..fdd7cdd6d8d6 100644 --- a/tests/unit/Search/SearchUIUtilsTest.ts +++ b/tests/unit/Search/SearchUIUtilsTest.ts @@ -8040,6 +8040,72 @@ describe('SearchUIUtils', () => { expect(item?.paid).toBe(receivedAt); }); + it('should populate paid from the report paid date when the response has no payment actions', () => { + // Given a paid report that carries its paid date but no payment actions, which is what Search returns when the Paid by column is not requested + const reportPaidAt = '2024-12-22 09:30:00'; + const data = makeReportFilterTestData({ + type: CONST.REPORT.TYPE.EXPENSE, + stateNum: CONST.REPORT.STATE_NUM.APPROVED, + statusNum: CONST.REPORT.STATUS_NUM.REIMBURSED, + paid: reportPaidAt, + }); + + // When the report sections are built + const [sections] = callGetReportSections(data); + const item = sections.find((s) => s.keyForList === rptFilterReportID); + + // Then the paid date comes from the report, so the column fills in without the payment actions + expect(item?.paid).toBe(reportPaidAt); + }); + + it('should prefer a newer payment action over a stale report paid date (offline re-payment)', () => { + // Given a paid report whose paid date predates a payment action that is not yet reflected on the report + const data = makeReportFilterTestData({ + type: CONST.REPORT.TYPE.EXPENSE, + stateNum: CONST.REPORT.STATE_NUM.APPROVED, + statusNum: CONST.REPORT.STATUS_NUM.REIMBURSED, + paid: '2024-12-20 08:00:00', + }); + const rePaidAt = '2024-12-22 09:30:00'; + + // When the report sections are built with the live payment action + 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: rePaidAt, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }, + ], + }, + }); + const item = sections.find((s) => s.keyForList === rptFilterReportID); + + // Then the newer payment wins, the same way a newer approval wins over a stale approved date + expect(item?.paid).toBe(rePaidAt); + }); + + it('should ignore the report paid date when the report is not paid', () => { + // Given a report that was paid and then reopened, so it still carries its old paid date + const data = makeReportFilterTestData({ + type: CONST.REPORT.TYPE.EXPENSE, + stateNum: CONST.REPORT.STATE_NUM.APPROVED, + statusNum: CONST.REPORT.STATUS_NUM.APPROVED, + paid: '2024-12-20 08:00:00', + }); + + // When the report sections are built + const [sections] = callGetReportSections(data); + const item = sections.find((s) => s.keyForList === rptFilterReportID); + + // Then the paid date is blank, because only a report in the Paid status has a paid date + expect(item?.paid).toBe(''); + }); + it('should leave paid blank when the payment was cancelled', () => { // Given a report still marked Paid whose only payment action precedes a reimbursement cancellation const [sections] = callGetReportSections( From 3a975126e71442d7593d1f43ae48fdcb535883bb Mon Sep 17 00:00:00 2001 From: Samiya Hamid Date: Mon, 19 Oct 2026 21:20:47 +0100 Subject: [PATCH 4/7] Add the Paid column to the Expenses table and size it from report.paid --- src/CONST/index.ts | 5 + .../ExpenseReportListItemRowWide.tsx | 3 +- .../TransactionListItemWide.tsx | 2 + .../Search/SearchList/ListItem/types.ts | 8 ++ src/components/Search/SearchTableHeader.tsx | 4 + .../TransactionItemRowWide.tsx | 17 +++ src/components/TransactionItemRow/index.tsx | 2 + src/components/TransactionItemRow/types.ts | 4 + src/libs/SearchUIUtils.ts | 45 ++++---- tests/unit/Search/ColumnAvailabilityTest.ts | 2 +- tests/unit/Search/SearchUIUtilsTest.ts | 100 ++++++++++++------ .../ColumnAvailabilityTest.ts.snap | 1 + 12 files changed, 139 insertions(+), 54 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index d908223653d0..23b5ce7ac3b0 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -7818,6 +7818,11 @@ const CONST = { search: true, reportView: false, }, + PAID: { + column: this.TABLE_COLUMNS.PAID, + search: true, + reportView: false, + }, POSTED: { column: this.TABLE_COLUMNS.POSTED, search: true, diff --git a/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowWide.tsx b/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowWide.tsx index db167fefcbfa..6ec6f3efbd16 100644 --- a/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowWide.tsx +++ b/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowWide.tsx @@ -60,8 +60,7 @@ function ExpenseReportListItemRowWide({ const submitterUserID = item.submitterUserID; const submitterPayrollID = item.submitterPayrollID; const orderDealNumbers = item.orderDealNumbers; - const {debitedAmount, debitedCurrency, creditedAmount, creditedCurrency} = item; - const {paid: paidDate = ''} = item; + const {debitedAmount, debitedCurrency, creditedAmount, creditedCurrency, paid: paidDate = ''} = item; const columnComponents = { [CONST.SEARCH.TABLE_COLUMNS.AVATAR]: , diff --git a/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemWide.tsx b/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemWide.tsx index cd41e1c5ee71..f5ed3b2b82db 100644 --- a/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemWide.tsx +++ b/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemWide.tsx @@ -141,6 +141,7 @@ function TransactionListItemWide({ const dateColumnSize = transactionItem.shouldShowYear ? CONST.SEARCH.TABLE_COLUMN_SIZES.WIDE : CONST.SEARCH.TABLE_COLUMN_SIZES.NORMAL; const submittedColumnSize = transactionItem.shouldShowYearSubmitted ? CONST.SEARCH.TABLE_COLUMN_SIZES.WIDE : CONST.SEARCH.TABLE_COLUMN_SIZES.NORMAL; const approvedColumnSize = transactionItem.shouldShowYearApproved ? CONST.SEARCH.TABLE_COLUMN_SIZES.WIDE : CONST.SEARCH.TABLE_COLUMN_SIZES.NORMAL; + const paidColumnSize = transactionItem.shouldShowYearPaid ? CONST.SEARCH.TABLE_COLUMN_SIZES.WIDE : CONST.SEARCH.TABLE_COLUMN_SIZES.NORMAL; const postedColumnSize = transactionItem.shouldShowYearPosted ? CONST.SEARCH.TABLE_COLUMN_SIZES.WIDE : CONST.SEARCH.TABLE_COLUMN_SIZES.NORMAL; const exportedColumnSize = transactionItem.shouldShowYearExported ? CONST.SEARCH.TABLE_COLUMN_SIZES.WIDE : CONST.SEARCH.TABLE_COLUMN_SIZES.NORMAL; @@ -222,6 +223,7 @@ function TransactionListItemWide({ dateColumnSize={dateColumnSize} submittedColumnSize={submittedColumnSize} approvedColumnSize={approvedColumnSize} + paidColumnSize={paidColumnSize} postedColumnSize={postedColumnSize} exportedColumnSize={exportedColumnSize} amountColumnSize={amountColumnSize} diff --git a/src/components/Search/SearchList/ListItem/types.ts b/src/components/Search/SearchList/ListItem/types.ts index 21116bf7cfee..246b833a2152 100644 --- a/src/components/Search/SearchList/ListItem/types.ts +++ b/src/components/Search/SearchList/ListItem/types.ts @@ -82,6 +82,9 @@ type TransactionListItemType = ListItem & /** The date the report was approved */ approved?: string; + /** The date the report was paid */ + paid?: string; + /** The date the report was posted */ posted?: string; @@ -145,6 +148,11 @@ type TransactionListItemType = ListItem & */ shouldShowYearApproved: boolean; + /** Whether we should show the year for the paid date. + * This is true if at least one transaction in the dataset belongs to a report paid in past years + */ + shouldShowYearPaid?: boolean; + /** Whether we should show the year for the posted date. * This is true if at least one transaction in the dataset was posted in past years */ diff --git a/src/components/Search/SearchTableHeader.tsx b/src/components/Search/SearchTableHeader.tsx index 01d2a18d7d95..15ed80da9fa0 100644 --- a/src/components/Search/SearchTableHeader.tsx +++ b/src/components/Search/SearchTableHeader.tsx @@ -64,6 +64,10 @@ const getExpenseHeaders = (groupBy?: SearchGroupBy): SearchColumnConfig[] => [ columnName: CONST.SEARCH.TABLE_COLUMNS.APPROVED, translationKey: 'search.filters.approved', }, + { + columnName: CONST.SEARCH.TABLE_COLUMNS.PAID, + translationKey: 'search.filters.paid', + }, { columnName: CONST.SEARCH.TABLE_COLUMNS.MERCHANT, translationKey: 'common.merchant', diff --git a/src/components/TransactionItemRow/TransactionItemRowWide.tsx b/src/components/TransactionItemRow/TransactionItemRowWide.tsx index 8c373a607527..d74199af2111 100644 --- a/src/components/TransactionItemRow/TransactionItemRowWide.tsx +++ b/src/components/TransactionItemRow/TransactionItemRowWide.tsx @@ -95,6 +95,7 @@ function TransactionItemRowWide({ isDateColumnCreated = false, submittedColumnSize, approvedColumnSize, + paidColumnSize, postedColumnSize, exportedColumnSize, amountColumnSize, @@ -164,6 +165,8 @@ function TransactionItemRowWide({ const isDateColumnWide = dateColumnSize === CONST.SEARCH.TABLE_COLUMN_SIZES.WIDE; const isSubmittedColumnWide = submittedColumnSize === CONST.SEARCH.TABLE_COLUMN_SIZES.WIDE; const isApprovedColumnWide = approvedColumnSize === CONST.SEARCH.TABLE_COLUMN_SIZES.WIDE; + const isPaidColumnWide = paidColumnSize === CONST.SEARCH.TABLE_COLUMN_SIZES.WIDE; + const {paid: paidDate = ''} = transactionItem; const isPostedColumnWide = postedColumnSize === CONST.SEARCH.TABLE_COLUMN_SIZES.WIDE; const isExportedColumnWide = exportedColumnSize === CONST.SEARCH.TABLE_COLUMN_SIZES.WIDE; const isAmountColumnWide = amountColumnSize === CONST.SEARCH.TABLE_COLUMN_SIZES.WIDE; @@ -285,6 +288,20 @@ function TransactionItemRowWide({ /> ); + case CONST.SEARCH.TABLE_COLUMNS.PAID: + return ( + + + + ); case CONST.SEARCH.TABLE_COLUMNS.POSTED: return ( = actionPaid ? reportPaid : actionPaid; } -/** - * Whether the report's paid date, taken from the snapshot's report actions, is from a past year, so the Paid column - * needs the wide width to fit the year. - */ -function isPaidDateFromAPastYear(report: OnyxTypes.Report, actions: Record | undefined): boolean { - const paidDate = getPaidDate(report, undefined, Object.values(actions ?? {})); - return !!paidDate && DateUtils.doesDateBelongToAPastYear(paidDate); -} - /** * 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 @@ -2844,7 +2848,7 @@ function getReportSections({ shouldShowYearSubmittedReport, shouldShowYearApprovedReport, shouldShowYearExportedReport, - shouldShowYearPaidReport, + shouldShowYearPaid, shouldShowAmountInWideColumn, shouldShowTaxAmountInWideColumn, hasDeletedTransaction, @@ -2973,7 +2977,7 @@ function getReportSections({ shouldShowYearSubmitted: shouldShowYearSubmittedReport, shouldShowYearApproved: shouldShowYearApprovedReport, shouldShowYearExported: shouldShowYearExportedReport, - shouldShowYearPaid: shouldShowYearPaidReport, + shouldShowYearPaid, hasVisibleViolations: hasVisibleViolationsForReport, isRejectedReport, totalDisplaySpend, @@ -3044,12 +3048,14 @@ function getReportSections({ date, submitted: report ? getSubmittedDate(report, actions) : undefined, approved: report ? getApprovedDate(report, actions) : undefined, + paid: report ? getPaidDate(report, undefined, actions) : undefined, exported: transactionItem.reportID ? (lastExportedActionByReportID.get(transactionItem.reportID)?.created ?? '') : '', exportedTo: getExportedToSortValue(exportedToNamesByReportID, transactionItem.reportID), shouldShowMerchant, shouldShowYear: shouldShowYearCreated || shouldShowYearCreatedReport, shouldShowYearSubmitted, shouldShowYearApproved, + shouldShowYearPaid, shouldShowYearPosted: shouldShowYearPostedTransaction, shouldShowYearExported, keyForList: transactionItem.transactionID, @@ -7190,6 +7196,7 @@ function getTableMinWidth( } else if ( column === CONST.SEARCH.TABLE_COLUMNS.SUBMITTED || column === CONST.SEARCH.TABLE_COLUMNS.APPROVED || + column === CONST.SEARCH.TABLE_COLUMNS.PAID || column === CONST.SEARCH.TABLE_COLUMNS.POSTED || column === CONST.SEARCH.TABLE_COLUMNS.WITHDRAWN || column === CONST.SEARCH.TABLE_COLUMNS.GROUP_WITHDRAWN diff --git a/tests/unit/Search/ColumnAvailabilityTest.ts b/tests/unit/Search/ColumnAvailabilityTest.ts index 3a5d62fbe925..b359d8632fbc 100644 --- a/tests/unit/Search/ColumnAvailabilityTest.ts +++ b/tests/unit/Search/ColumnAvailabilityTest.ts @@ -32,7 +32,6 @@ describe('Column availability single source of truth', () => { TABLE_COLUMNS.NON_REIMBURSABLE_TOTAL, TABLE_COLUMNS.FIRST_APPROVER, TABLE_COLUMNS.FIRST_APPROVED, - TABLE_COLUMNS.PAID, TABLE_COLUMNS.PAID_BY, TABLE_COLUMNS.PAID_STATUS, TABLE_COLUMNS.AMOUNT_DEBITED, @@ -118,6 +117,7 @@ describe('Column availability single source of truth', () => { TABLE_COLUMNS.PAID_STATUS, TABLE_COLUMNS.SUBMITTED, TABLE_COLUMNS.APPROVED, + TABLE_COLUMNS.PAID, TABLE_COLUMNS.EXPORTED, TABLE_COLUMNS.EXPORTED_TO, TABLE_COLUMNS.FROM, diff --git a/tests/unit/Search/SearchUIUtilsTest.ts b/tests/unit/Search/SearchUIUtilsTest.ts index fdd7cdd6d8d6..2a47a68be557 100644 --- a/tests/unit/Search/SearchUIUtilsTest.ts +++ b/tests/unit/Search/SearchUIUtilsTest.ts @@ -881,6 +881,7 @@ const transactionsListItems = createMock([ created: '2024-12-21', submitted: '', approved: '', + paid: '', posted: '', exported: '', exportedTo: '', @@ -910,6 +911,7 @@ const transactionsListItems = createMock([ shouldShowYear: true, shouldShowYearSubmitted: true, shouldShowYearApproved: false, + shouldShowYearPaid: false, shouldShowYearPosted: false, shouldShowYearExported: false, isAmountColumnWide: false, @@ -946,6 +948,7 @@ const transactionsListItems = createMock([ created: '2024-12-21', submitted: '2024-12-21 13:05:20', approved: '', + paid: '', posted: '', exported: '', exportedTo: '', @@ -974,6 +977,7 @@ const transactionsListItems = createMock([ shouldShowYear: true, shouldShowYearSubmitted: true, shouldShowYearApproved: false, + shouldShowYearPaid: false, shouldShowYearPosted: false, shouldShowYearExported: false, isAmountColumnWide: false, @@ -1021,6 +1025,7 @@ const transactionsListItems = createMock([ created: '2025-03-05', submitted: '2025-03-05', approved: '', + paid: '', posted: '', exported: '', exportedTo: '', @@ -1056,6 +1061,7 @@ const transactionsListItems = createMock([ shouldShowYear: true, shouldShowYearSubmitted: true, shouldShowYearApproved: false, + shouldShowYearPaid: false, shouldShowYearPosted: false, shouldShowYearExported: false, keyForList: '3', @@ -1091,6 +1097,7 @@ const transactionsListItems = createMock([ created: '2025-03-05', submitted: '2025-03-05', approved: '', + paid: '', posted: '', exported: '', exportedTo: '', @@ -1126,6 +1133,7 @@ const transactionsListItems = createMock([ shouldShowYear: true, shouldShowYearSubmitted: true, shouldShowYearApproved: false, + shouldShowYearPaid: false, shouldShowYearPosted: false, shouldShowYearExported: false, keyForList: '4', @@ -1240,6 +1248,7 @@ const transactionReportGroupListItems = createMock { expect(distanceTransaction).toBeDefined(); expect(distanceTransaction?.iouRequestType).toBe(CONST.IOU.REQUEST_TYPE.DISTANCE); - const expectedPropertyCount = 59; + const expectedPropertyCount = 61; expect(Object.keys(distanceTransaction ?? {}).length).toBe(expectedPropertyCount); }); @@ -3070,7 +3086,7 @@ describe('SearchUIUtils', () => { expect(distanceTransaction).toBeDefined(); expect(distanceTransaction?.iouRequestType).toBe(CONST.IOU.REQUEST_TYPE.DISTANCE); - const expectedPropertyCount = 58; + const expectedPropertyCount = 60; expect(Object.keys(distanceTransaction ?? {}).length).toBe(expectedPropertyCount); }); @@ -7285,6 +7301,42 @@ describe('SearchUIUtils', () => { expect(item?.submitted).toBe(''); }); + it("should show the report's paid date on its expenses and widen the Paid column when it was paid in a past year", () => { + // Given an expense on a report the backend returned as paid in a past year + const paidAt = '2024-12-22 09:30:00'; + const data = makeFilterTestData({type: CONST.REPORT.TYPE.EXPENSE, stateNum: CONST.REPORT.STATE_NUM.APPROVED, statusNum: CONST.REPORT.STATUS_NUM.REIMBURSED, paid: paidAt}); + + // When the expense rows and the header's year flags are built + const [sections] = callGetTransactionsSections(data); + const item = sections.find((s) => s.transactionID === filterTestTxID); + const yearFlags = SearchUIUtils.shouldShowYear(data); + + // Then the expense shows its report's paid date, and both the row and the header use the wide Paid column so the year fits + expect(item?.paid).toBe(paidAt); + expect(item?.shouldShowYearPaid).toBe(true); + expect(yearFlags.shouldShowYearPaid).toBe(true); + }); + + it('should keep the Paid date blank and the column narrow on expenses whose report is not paid', () => { + // Given an expense on an approved report, which the backend returns with a blank paid date + const data = makeFilterTestData({ + type: CONST.REPORT.TYPE.EXPENSE, + stateNum: CONST.REPORT.STATE_NUM.APPROVED, + statusNum: CONST.REPORT.STATUS_NUM.APPROVED, + paid: '', + }); + + // When the expense rows and the header's year flags are built + const [sections] = callGetTransactionsSections(data); + const item = sections.find((s) => s.transactionID === filterTestTxID); + const yearFlags = SearchUIUtils.shouldShowYear(data); + + // Then the cell is blank and the column stays narrow, since the report hasn't been paid + expect(item?.paid).toBe(''); + expect(item?.shouldShowYearPaid).toBe(false); + expect(yearFlags.shouldShowYearPaid).toBe(false); + }); + it('should keep the negative sign on formattedTotal for an unreported (tracked) credit', () => { const data = makeFilterTestData({}, {reportID: CONST.REPORT.UNREPORTED_REPORT_ID, amount: 5000}); const [sections] = callGetTransactionsSections(data); @@ -8163,21 +8215,13 @@ describe('SearchUIUtils', () => { }); it('should widen the Paid column when a report was paid in a past year', () => { - // Given a report paid in a past year - 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', - }, - }, - }, - ); + // Given a report the backend returned as paid in a past year + const data = makeReportFilterTestData({ + type: CONST.REPORT.TYPE.EXPENSE, + stateNum: CONST.REPORT.STATE_NUM.APPROVED, + statusNum: CONST.REPORT.STATUS_NUM.REIMBURSED, + paid: '2024-12-22 09:30:00', + }); // When the report sections and the header's year flags are built const [sections] = callGetReportSections(data); @@ -8190,21 +8234,13 @@ describe('SearchUIUtils', () => { }); it('should keep the Paid column narrow when every report was paid this year', () => { - // Given a report paid this year - 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: `${new Date().getFullYear()}-01-02 09:30:00`, - }, - }, - }, - ); + // Given a report the backend returned as paid this year + const data = makeReportFilterTestData({ + type: CONST.REPORT.TYPE.EXPENSE, + stateNum: CONST.REPORT.STATE_NUM.APPROVED, + statusNum: CONST.REPORT.STATUS_NUM.REIMBURSED, + paid: `${new Date().getFullYear()}-01-02 09:30:00`, + }); // When the report sections and the header's year flags are built const [sections] = callGetReportSections(data); diff --git a/tests/unit/Search/__snapshots__/ColumnAvailabilityTest.ts.snap b/tests/unit/Search/__snapshots__/ColumnAvailabilityTest.ts.snap index c85a7d23b544..01706baa612a 100644 --- a/tests/unit/Search/__snapshots__/ColumnAvailabilityTest.ts.snap +++ b/tests/unit/Search/__snapshots__/ColumnAvailabilityTest.ts.snap @@ -39,6 +39,7 @@ exports[`Column availability single source of truth the derived picker lists mat "status", "submitted", "approved", + "paid", "posted", "exported", "merchant", From 09246c668027246ee3d50edd6d500c4f5f7e386f Mon Sep 17 00:00:00 2001 From: Samiya Hamid Date: Tue, 20 Oct 2026 19:42:04 +0100 Subject: [PATCH 5/7] Pass the snapshot payment action when building expense rows --- src/libs/SearchUIUtils.ts | 5 ++-- tests/unit/Search/SearchUIUtilsTest.ts | 37 ++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 40fbc2f24ad0..f9f9d7a51283 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -1866,6 +1866,7 @@ function getTransactionsSections({ shouldShowMerchant, lastExportedActionByReportID, exportedToNamesByReportID, + lastReimbursedActionByReportID, moneyRequestReportActionsByTransactionID, holdReportActionsByTransactionID, shouldShowYearCreated, @@ -1938,7 +1939,7 @@ function getTransactionsSections({ const actions = getLiveOrSnapshotReportActions(reportActions, data, transactionItem.reportID); const submitted = report ? getSubmittedDate(report, actions) : undefined; const approved = report ? getApprovedDate(report, actions) : undefined; - const paid = report ? getPaidDate(report, undefined, actions) : undefined; + const paid = report ? getPaidDate(report, lastReimbursedActionByReportID.get(report.reportID), actions) : undefined; const reportMetadata = data[`${ONYXKEYS.COLLECTION.REPORT_METADATA}${transactionItem.reportID}`] ?? {}; const allActions = getActions(data, allViolations, key, currentSearch, currentUserEmail, currentAccountID, bankAccountList, reportMetadata, rules, actions); const transactionPendingAction = getTransactionPendingAction(transactionItem); @@ -3048,7 +3049,7 @@ function getReportSections({ date, submitted: report ? getSubmittedDate(report, actions) : undefined, approved: report ? getApprovedDate(report, actions) : undefined, - paid: report ? getPaidDate(report, undefined, actions) : undefined, + paid: report ? getPaidDate(report, lastReimbursedActionByReportID.get(report.reportID), actions) : undefined, exported: transactionItem.reportID ? (lastExportedActionByReportID.get(transactionItem.reportID)?.created ?? '') : '', exportedTo: getExportedToSortValue(exportedToNamesByReportID, transactionItem.reportID), shouldShowMerchant, diff --git a/tests/unit/Search/SearchUIUtilsTest.ts b/tests/unit/Search/SearchUIUtilsTest.ts index 2a47a68be557..46ef7903fd1a 100644 --- a/tests/unit/Search/SearchUIUtilsTest.ts +++ b/tests/unit/Search/SearchUIUtilsTest.ts @@ -7337,6 +7337,43 @@ describe('SearchUIUtils', () => { expect(yearFlags.shouldShowYearPaid).toBe(false); }); + it("should take an expense's paid date from the snapshot payment when the live actions don't include it", () => { + // Given a paid report with no paid date of its own, whose payment is only in the snapshot actions + const paidAt = '2024-12-22 09:30:00'; + const data = makeFilterTestData( + {type: CONST.REPORT.TYPE.EXPENSE, stateNum: CONST.REPORT.STATE_NUM.APPROVED, statusNum: CONST.REPORT.STATUS_NUM.REIMBURSED}, + {}, + { + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${filterTestReportID}`]: { + 'reimbursed-1': { + reportActionID: 'reimbursed-1', + actionName: CONST.REPORT.ACTIONS.TYPE.MARKED_REIMBURSED, + actorAccountID: adminAccountID, + created: paidAt, + }, + }, + }, + ); + + // When the expense rows are built with live actions that only hold a later comment, as when only the latest messages are loaded + const [sections] = callGetTransactionsSections(data, { + reportActions: { + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${filterTestReportID}`]: [ + { + reportActionID: 'comment-1', + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + actorAccountID: adminAccountID, + created: '2024-12-23 10:00:00', + }, + ], + }, + }); + const item = sections.find((s) => s.transactionID === filterTestTxID); + + // Then the expense still shows the snapshot's payment date, the same way the report row does + expect(item?.paid).toBe(paidAt); + }); + it('should keep the negative sign on formattedTotal for an unreported (tracked) credit', () => { const data = makeFilterTestData({}, {reportID: CONST.REPORT.UNREPORTED_REPORT_ID, amount: 5000}); const [sections] = callGetTransactionsSections(data); From 6a91983bc0106ee5c1245f3640e382facebe681c Mon Sep 17 00:00:00 2001 From: Samiya Hamid Date: Thu, 22 Oct 2026 03:54:53 +0100 Subject: [PATCH 6/7] Keep the first received date when a payment is marked as received again --- src/libs/SearchUIUtils.ts | 72 +++++++++++++++--------- tests/unit/Search/SearchUIUtilsTest.ts | 77 ++++++++++++++++++++++++++ 2 files changed, 122 insertions(+), 27 deletions(-) diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index f9f9d7a51283..3276d03c82a1 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -2686,13 +2686,14 @@ function getFirstApprovedAction(snapshotApprovedAction: OnyxTypes.ReportAction | } /** - * Returns the latest payment action between the snapshot-derived one and the given report actions. Payment actions - * at or before the latest reimbursement cancellation don't count, because that payment was reversed. + * Returns the earliest or latest payment action between the snapshot-derived one and the given report actions. Payment + * actions at or before the latest reimbursement cancellation don't count, because that payment was reversed. * `shouldCountPayment` lets a caller leave out payments that don't answer its question. */ -function getLastPaymentAction( +function getPaymentAction( snapshotPaidAction: OnyxTypes.ReportAction | undefined, actions: OnyxTypes.ReportAction[], + sortBy: 'earliest' | 'latest', shouldCountPayment: (action: OnyxTypes.ReportAction) => boolean = () => true, ): OnyxTypes.ReportAction | undefined { const latestCancellation = findActionByCreated( @@ -2717,50 +2718,67 @@ function getLastPaymentAction( 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', + sortBy, seed, ); } /** - * Returns the latest payment action that identifies a payer, mirroring the backend paid-by rules: 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. + * Whether the action is the pay action the App records when the submitter marks the report as paid outside Expensify. + */ +function isReceivedPayAction(action: OnyxTypes.ReportAction): boolean { + return isMoneyRequestAction(action) && !!getOriginalMessage(action)?.isSubmitterMarkedPaymentReceived; +} + +/** + * Whether the payment action is the submitter marking the report as paid outside Expensify ("payment received"), + * mirroring the backend paid-by rules: the flagged pay action, or a payment action recorded by the report owner that + * is paired with a MARKED_REDEEMED action. The flagged pay action pairs it too, because right after marking only it is + * stored; MARKED_REDEEMED arrives when the report's actions are next loaded. + */ +function isPaymentMarkedAsReceived(action: OnyxTypes.ReportAction, actions: OnyxTypes.ReportAction[], ownerAccountID: number | undefined): boolean { + if (isReceivedPayAction(action)) { + return true; + } + if (action.actorAccountID !== ownerAccountID) { + return false; + } + return actions.some( + (pairedAction) => + (pairedAction.actionName === CONST.REPORT.ACTIONS.TYPE.MARKED_REDEEMED || isReceivedPayAction(pairedAction)) && + pairedAction.actorAccountID === action.actorAccountID && + pairedAction.created >= action.created, + ); +} + +/** + * Returns the latest payment action that identifies a payer. A payment marked as received is a self-attestation by + * the submitter, which doesn't identify a payer. */ function getLastPaidAction( snapshotPaidAction: OnyxTypes.ReportAction | undefined, actions: OnyxTypes.ReportAction[], ownerAccountID: number | undefined, ): OnyxTypes.ReportAction | undefined { - return getLastPaymentAction(snapshotPaidAction, actions, (action) => { - if (isMoneyRequestAction(action) && getOriginalMessage(action)?.isSubmitterMarkedPaymentReceived) { - 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, - ); - }); + return getPaymentAction(snapshotPaidAction, actions, 'latest', (action) => !isPaymentMarkedAsReceived(action, actions, ownerAccountID)); } /** - * Returns the report's paid date or the latest payment action's created time, whichever is newer, or blank when the - * report is not Paid. The Search response only includes payment actions when the Paid by column is requested, so the - * report's own date is what fills the column on its own; an offline re-payment leaves that date stale, so a newer - * action wins. Unlike the paid-by lookup, a payment the submitter marked as received counts: the report was still - * paid on that date. + * Returns the report's paid date or its payment actions' date, whichever is newer, or blank when the report is not + * Paid. The Search response only includes payment actions when the Paid by column is requested, so the report's own + * date is what fills the column on its own; an offline re-payment leaves that date stale, so a newer action wins. + * Unlike the paid-by lookup, a payment the submitter marked as received counts: the report was still paid on that date. */ function getPaidDate(reportItem: OnyxTypes.Report, snapshotPaidAction: OnyxTypes.ReportAction | undefined, actions: OnyxTypes.ReportAction[]): string { if (reportItem.statusNum !== CONST.REPORT.STATUS_NUM.REIMBURSED) { return ''; } const {paid: reportPaid = ''} = reportItem; - const actionPaid = getLastPaymentAction(snapshotPaidAction, actions)?.created ?? ''; + // Marking a payment as received again keeps the date it was first marked, like the backend's paid date, while a real payment moves it to the latest one + const isReceived = (action: OnyxTypes.ReportAction) => isPaymentMarkedAsReceived(action, actions, reportItem.ownerAccountID); + const firstReceivedPaid = getPaymentAction(undefined, actions, 'earliest', isReceived)?.created ?? ''; + const lastPaymentPaid = getPaymentAction(snapshotPaidAction, actions, 'latest', (action) => !isReceived(action))?.created ?? ''; + const actionPaid = firstReceivedPaid >= lastPaymentPaid ? firstReceivedPaid : lastPaymentPaid; return reportPaid >= actionPaid ? reportPaid : actionPaid; } diff --git a/tests/unit/Search/SearchUIUtilsTest.ts b/tests/unit/Search/SearchUIUtilsTest.ts index 46ef7903fd1a..88874d501e71 100644 --- a/tests/unit/Search/SearchUIUtilsTest.ts +++ b/tests/unit/Search/SearchUIUtilsTest.ts @@ -8129,6 +8129,83 @@ describe('SearchUIUtils', () => { expect(item?.paid).toBe(receivedAt); }); + it('should keep the first received date when the submitter marks the payment as received again', () => { + // Given a report marked as received on two days, which the backend dates from the first marking + const firstReceivedAt = '2024-12-22 09:30:00'; + const secondReceivedAt = '2024-12-23 10:00:00'; + const receivedActions = (id: string, created: string) => [ + { + reportActionID: `received-pay-${id}`, + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + originalMessage: {type: CONST.IOU.REPORT_ACTION_TYPE.PAY, isSubmitterMarkedPaymentReceived: true}, + actorAccountID: adminAccountID, + created, + }, + {reportActionID: `received-reimbursed-${id}`, actionName: CONST.REPORT.ACTIONS.TYPE.MARKED_REIMBURSED, actorAccountID: adminAccountID, created}, + {reportActionID: `received-redeemed-${id}`, actionName: CONST.REPORT.ACTIONS.TYPE.MARKED_REDEEMED, actorAccountID: adminAccountID, created}, + ]; + const data = makeReportFilterTestData({ + type: CONST.REPORT.TYPE.EXPENSE, + stateNum: CONST.REPORT.STATE_NUM.APPROVED, + statusNum: CONST.REPORT.STATUS_NUM.REIMBURSED, + ownerAccountID: adminAccountID, + paid: firstReceivedAt, + }); + + // When the row is built after the report's actions are loaded + const [sections] = callGetReportSections(data, { + reportActions: {[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${rptFilterReportID}`]: [...receivedActions('1', firstReceivedAt), ...receivedActions('2', secondReceivedAt)]}, + }); + const item = sections.find((s) => s.keyForList === rptFilterReportID); + + // Then Paid keeps the first marking, so it doesn't change once the actions load + expect(item?.paid).toBe(firstReceivedAt); + }); + + it('should keep the first received date and no payer right after the submitter marks the payment as received again', () => { + // Given a report marked as received twice, where the second marking was just made in this session, so only its + // flagged pay action and MARKED_REIMBURSED are stored yet; its MARKED_REDEEMED only arrives once the report reloads + const firstReceivedAt = '2024-12-22 09:30:00'; + const secondReceivedAt = '2024-12-23 10:00:00'; + const data = makeReportFilterTestData({ + type: CONST.REPORT.TYPE.EXPENSE, + stateNum: CONST.REPORT.STATE_NUM.APPROVED, + statusNum: CONST.REPORT.STATUS_NUM.REIMBURSED, + ownerAccountID: adminAccountID, + paid: firstReceivedAt, + }); + + // When the row is built from those stored actions + const [sections] = callGetReportSections(data, { + 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: firstReceivedAt, + }, + {reportActionID: 'received-reimbursed-1', actionName: CONST.REPORT.ACTIONS.TYPE.MARKED_REIMBURSED, actorAccountID: adminAccountID, created: firstReceivedAt}, + {reportActionID: 'received-redeemed-1', actionName: CONST.REPORT.ACTIONS.TYPE.MARKED_REDEEMED, actorAccountID: adminAccountID, created: firstReceivedAt}, + {reportActionID: 'received-reimbursed-2', actionName: CONST.REPORT.ACTIONS.TYPE.MARKED_REIMBURSED, actorAccountID: adminAccountID, created: secondReceivedAt}, + { + reportActionID: 'received-pay-2', + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + originalMessage: {type: CONST.IOU.REPORT_ACTION_TYPE.PAY, isSubmitterMarkedPaymentReceived: true}, + actorAccountID: adminAccountID, + created: '2024-12-23 10:00:01', + }, + ], + }, + }); + const item = sections.find((s) => s.keyForList === rptFilterReportID); + + // Then the second marking is still recognised as received: Paid keeps the first date and Paid by stays blank + expect(item?.paid).toBe(firstReceivedAt); + expect(item?.paidByAccountID).toBeUndefined(); + }); + it('should populate paid from the report paid date when the response has no payment actions', () => { // Given a paid report that carries its paid date but no payment actions, which is what Search returns when the Paid by column is not requested const reportPaidAt = '2024-12-22 09:30:00'; From 5b36c8179094902c62134f74ba19a798c95a260a Mon Sep 17 00:00:00 2001 From: Samiya Hamid Date: Fri, 23 Oct 2026 01:14:44 +0100 Subject: [PATCH 7/7] List the Paid row fields for main's new transaction row check --- src/libs/getTransactionFromTransactionListItem.ts | 2 ++ tests/unit/Search/SearchUIUtilsTest.ts | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/libs/getTransactionFromTransactionListItem.ts b/src/libs/getTransactionFromTransactionListItem.ts index 7be736d4dfa2..55b4e2d08255 100644 --- a/src/libs/getTransactionFromTransactionListItem.ts +++ b/src/libs/getTransactionFromTransactionListItem.ts @@ -40,6 +40,7 @@ const SEARCH_TRANSACTION_ROW_ONLY_FIELDS: Record { }); const item = sections.find((s) => s.keyForList === rptFilterReportID); - // Then the second marking is still recognised as received: Paid keeps the first date and Paid by stays blank + // Then the second marking is still recognized as received: Paid keeps the first date and Paid by stays blank expect(item?.paid).toBe(firstReceivedAt); expect(item?.paidByAccountID).toBeUndefined(); });