diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 30411005dde3..8c6faec20ecc 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -8215,6 +8215,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, @@ -8400,6 +8405,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, @@ -8604,6 +8610,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 548e413fcb48..a3561451857e 100644 --- a/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowWide.tsx +++ b/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowWide.tsx @@ -60,7 +60,7 @@ function ExpenseReportListItemRowWide({ const submitterUserID = item.submitterUserID; const submitterPayrollID = item.submitterPayrollID; const orderDealNumbers = item.orderDealNumbers; - const {debitedAmount, debitedCurrency, creditedAmount, creditedCurrency} = item; + const {debitedAmount, debitedCurrency, creditedAmount, creditedCurrency, paid: paidDate = ''} = item; const columnComponents = { [CONST.SEARCH.TABLE_COLUMNS.AVATAR]: , @@ -114,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/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 25a0db59e6f8..fe9799aa4059 100644 --- a/src/components/Search/SearchList/ListItem/types.ts +++ b/src/components/Search/SearchList/ListItem/types.ts @@ -81,6 +81,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; @@ -144,6 +147,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 */ @@ -255,6 +263,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) */ @@ -298,6 +309,12 @@ type TransactionReportGroupListItemType = TransactionGroupListItemType & {groupe */ shouldShowYearExported: boolean; + /** + * Whether we should show the year for the paid date. + * This is true if at least one report in the dataset was paid in past years + */ + shouldShowYearPaid?: boolean; + /** The main action that can be performed for the report */ action: SearchTransactionAction | undefined; diff --git a/src/components/Search/SearchTableHeader.tsx b/src/components/Search/SearchTableHeader.tsx index b53ed1a71a76..d8768f011de4 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', @@ -280,6 +284,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', @@ -558,6 +566,7 @@ type SearchTableHeaderProps = { shouldShowYearApproved?: boolean; shouldShowYearPosted?: boolean; shouldShowYearExported?: boolean; + shouldShowYearPaid?: boolean; shouldShowYearWithdrawn?: boolean; isAmountColumnWide: boolean; isTaxAmountColumnWide: boolean; @@ -583,6 +592,7 @@ function SearchTableHeader({ shouldShowYearApproved, shouldShowYearPosted, shouldShowYearExported, + shouldShowYearPaid, shouldShowYearWithdrawn, shouldShowSorting, canSelectMultiple, @@ -657,6 +667,7 @@ function SearchTableHeader({ approvedColumnSize={shouldShowYearApproved ? CONST.SEARCH.TABLE_COLUMN_SIZES.WIDE : CONST.SEARCH.TABLE_COLUMN_SIZES.NORMAL} postedColumnSize={shouldShowYearPosted ? CONST.SEARCH.TABLE_COLUMN_SIZES.WIDE : CONST.SEARCH.TABLE_COLUMN_SIZES.NORMAL} exportedColumnSize={shouldShowYearExported ? CONST.SEARCH.TABLE_COLUMN_SIZES.WIDE : CONST.SEARCH.TABLE_COLUMN_SIZES.NORMAL} + paidColumnSize={shouldShowYearPaid ? CONST.SEARCH.TABLE_COLUMN_SIZES.WIDE : CONST.SEARCH.TABLE_COLUMN_SIZES.NORMAL} withdrawnColumnSize={shouldShowYearWithdrawn ? CONST.SEARCH.TABLE_COLUMN_SIZES.WIDE : CONST.SEARCH.TABLE_COLUMN_SIZES.NORMAL} amountColumnSize={isAmountColumnWide ? CONST.SEARCH.TABLE_COLUMN_SIZES.WIDE : CONST.SEARCH.TABLE_COLUMN_SIZES.NORMAL} taxAmountColumnSize={isTaxAmountColumnWide ? CONST.SEARCH.TABLE_COLUMN_SIZES.WIDE : CONST.SEARCH.TABLE_COLUMN_SIZES.NORMAL} diff --git a/src/components/Search/SortableTableHeader.tsx b/src/components/Search/SortableTableHeader.tsx index 868623144a45..8ea4f7a3cebb 100644 --- a/src/components/Search/SortableTableHeader.tsx +++ b/src/components/Search/SortableTableHeader.tsx @@ -36,6 +36,7 @@ type SearchTableHeaderProps = { approvedColumnSize?: TableColumnSize; postedColumnSize?: TableColumnSize; exportedColumnSize?: TableColumnSize; + paidColumnSize?: TableColumnSize; withdrawnColumnSize?: TableColumnSize; amountColumnSize: TableColumnSize; taxAmountColumnSize: TableColumnSize; @@ -58,6 +59,7 @@ function SortableTableHeader({ approvedColumnSize, postedColumnSize, exportedColumnSize, + paidColumnSize, withdrawnColumnSize, containerStyles, shouldShowSorting, @@ -113,6 +115,7 @@ function SortableTableHeader({ isApprovedColumnWide: approvedColumnSize === CONST.SEARCH.TABLE_COLUMN_SIZES.WIDE, isPostedColumnWide: postedColumnSize === CONST.SEARCH.TABLE_COLUMN_SIZES.WIDE, isExportedColumnWide: exportedColumnSize === CONST.SEARCH.TABLE_COLUMN_SIZES.WIDE, + isPaidColumnWide: paidColumnSize === CONST.SEARCH.TABLE_COLUMN_SIZES.WIDE, isTaxAmountColumnWide: taxAmountColumnSize === CONST.SEARCH.TABLE_COLUMN_SIZES.WIDE, isAmountColumnWide: amountColumnSize === CONST.SEARCH.TABLE_COLUMN_SIZES.WIDE, shouldRemoveTotalColumnFlex, diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 2a7e940d3009..69573353f0ca 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -1271,6 +1271,7 @@ function Search({ shouldShowYearApproved: false, shouldShowYearPosted: false, shouldShowYearExported: false, + shouldShowYearPaid: false, shouldShowYearWithdrawn: false, }, [searchResults?.data, isExpenseReportType, type], @@ -1299,6 +1300,7 @@ function Search({ isApprovedColumnWide: yearIndicators.shouldShowYearApproved, isPostedColumnWide: yearIndicators.shouldShowYearPosted, isExportedColumnWide: yearIndicators.shouldShowYearExported, + isPaidColumnWide: yearIndicators.shouldShowYearPaid, isWithdrawnColumnWide: yearIndicators.shouldShowYearWithdrawn, isAmountColumnWide: amountIndicators.shouldShowAmountInWideColumn, isTaxAmountColumnWide: amountIndicators.shouldShowTaxAmountInWideColumn, @@ -1310,6 +1312,7 @@ function Search({ yearIndicators.shouldShowYearApproved, yearIndicators.shouldShowYearPosted, yearIndicators.shouldShowYearExported, + yearIndicators.shouldShowYearPaid, yearIndicators.shouldShowYearWithdrawn, amountIndicators.shouldShowAmountInWideColumn, amountIndicators.shouldShowTaxAmountInWideColumn, @@ -1458,7 +1461,8 @@ function Search({ ); } - const {shouldShowYearCreated, shouldShowYearSubmitted, shouldShowYearApproved, shouldShowYearPosted, shouldShowYearExported, shouldShowYearWithdrawn} = yearIndicators; + const {shouldShowYearCreated, shouldShowYearSubmitted, shouldShowYearApproved, shouldShowYearPosted, shouldShowYearExported, shouldShowYearPaid, shouldShowYearWithdrawn} = + yearIndicators; const {shouldShowAmountInWideColumn, shouldShowTaxAmountInWideColumn} = amountIndicators; const shouldShowTableHeader = isLargeScreenWidth && !isChat; const tableHeaderVisible = canSelectMultiple || shouldShowTableHeader; @@ -1534,6 +1538,7 @@ function Search({ shouldShowYearApproved={shouldShowYearApproved} shouldShowYearPosted={shouldShowYearPosted} shouldShowYearExported={shouldShowYearExported} + shouldShowYearPaid={shouldShowYearPaid} shouldShowYearWithdrawn={shouldShowYearWithdrawn} isAmountColumnWide={shouldShowAmountInWideColumn} isTaxAmountColumnWide={shouldShowTaxAmountInWideColumn} diff --git a/src/components/TransactionItemRow/TransactionItemRowWide.tsx b/src/components/TransactionItemRow/TransactionItemRowWide.tsx index 629dfcefb562..512ee3f7c622 100644 --- a/src/components/TransactionItemRow/TransactionItemRowWide.tsx +++ b/src/components/TransactionItemRow/TransactionItemRowWide.tsx @@ -97,6 +97,7 @@ function TransactionItemRowWide({ isDateColumnCreated = false, submittedColumnSize, approvedColumnSize, + paidColumnSize, postedColumnSize, exportedColumnSize, amountColumnSize, @@ -166,6 +167,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; @@ -309,6 +312,20 @@ function TransactionItemRowWide({ /> ); + case CONST.SEARCH.TABLE_COLUMNS.PAID: + return ( + + + + ); case CONST.SEARCH.TABLE_COLUMNS.POSTED: return ( boolean = () => true, ): OnyxTypes.ReportAction | undefined { const latestCancellation = findActionByCreated( actions, @@ -2700,35 +2728,83 @@ 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); 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, ); } +/** + * 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 getPaymentAction(snapshotPaidAction, actions, 'latest', (action) => !isPaymentMarkedAsReceived(action, actions, ownerAccountID)); +} + +/** + * 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; + // 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; +} + /** * 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 @@ -2814,6 +2890,7 @@ function getReportSections({ shouldShowYearSubmittedReport, shouldShowYearApprovedReport, shouldShowYearExportedReport, + shouldShowYearPaid, shouldShowAmountInWideColumn, shouldShowTaxAmountInWideColumn, hasDeletedTransaction, @@ -2924,6 +3001,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, @@ -2941,6 +3019,7 @@ function getReportSections({ shouldShowYearSubmitted: shouldShowYearSubmittedReport, shouldShowYearApproved: shouldShowYearApprovedReport, shouldShowYearExported: shouldShowYearExportedReport, + shouldShowYearPaid, hasVisibleViolations: hasVisibleViolationsForReport, isRejectedReport, totalDisplaySpend, @@ -3011,12 +3090,14 @@ function getReportSections({ date, submitted: report ? getSubmittedDate(report, actions) : undefined, approved: report ? getApprovedDate(report, 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, shouldShowYear: shouldShowYearCreated || shouldShowYearCreatedReport, shouldShowYearSubmitted, shouldShowYearApproved, + shouldShowYearPaid, shouldShowYearPosted: shouldShowYearPostedTransaction, shouldShowYearExported, keyForList: transactionItem.transactionID, @@ -4511,6 +4592,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: @@ -7174,6 +7257,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/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) { 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/styles/utils/index.ts b/src/styles/utils/index.ts index 82bf9682d8f7..19e1bc12a28d 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; @@ -1995,6 +1996,7 @@ const createStyleUtils = (theme: ThemeColors, styles: ThemeStyles) => ({ isApprovedColumnWide, isPostedColumnWide, isExportedColumnWide, + isPaidColumnWide, isDateColumnWide, isDateColumnCreated, isTaxAmountColumnWide, @@ -2045,6 +2047,9 @@ 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.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)}; diff --git a/src/types/onyx/Report.ts b/src/types/onyx/Report.ts index 7f293d02e8a1..2b8060efbab8 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 ba206557b9ac..cf6574eda101 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/ColumnAvailabilityTest.ts b/tests/unit/Search/ColumnAvailabilityTest.ts index fa2b76501de2..b359d8632fbc 100644 --- a/tests/unit/Search/ColumnAvailabilityTest.ts +++ b/tests/unit/Search/ColumnAvailabilityTest.ts @@ -117,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 b57e0affa97b..81ccbd4e27ea 100644 --- a/tests/unit/Search/SearchUIUtilsTest.ts +++ b/tests/unit/Search/SearchUIUtilsTest.ts @@ -883,6 +883,7 @@ const transactionsListItems = createMock([ created: '2024-12-21', submitted: '', approved: '', + paid: '', posted: '', exported: '', exportedTo: '', @@ -912,6 +913,7 @@ const transactionsListItems = createMock([ shouldShowYear: true, shouldShowYearSubmitted: true, shouldShowYearApproved: false, + shouldShowYearPaid: false, shouldShowYearPosted: false, shouldShowYearExported: false, isAmountColumnWide: false, @@ -948,6 +950,7 @@ const transactionsListItems = createMock([ created: '2024-12-21', submitted: '2024-12-21 13:05:20', approved: '', + paid: '', posted: '', exported: '', exportedTo: '', @@ -976,6 +979,7 @@ const transactionsListItems = createMock([ shouldShowYear: true, shouldShowYearSubmitted: true, shouldShowYearApproved: false, + shouldShowYearPaid: false, shouldShowYearPosted: false, shouldShowYearExported: false, isAmountColumnWide: false, @@ -1023,6 +1027,7 @@ const transactionsListItems = createMock([ created: '2025-03-05', submitted: '2025-03-05', approved: '', + paid: '', posted: '', exported: '', exportedTo: '', @@ -1058,6 +1063,7 @@ const transactionsListItems = createMock([ shouldShowYear: true, shouldShowYearSubmitted: true, shouldShowYearApproved: false, + shouldShowYearPaid: false, shouldShowYearPosted: false, shouldShowYearExported: false, keyForList: '3', @@ -1093,6 +1099,7 @@ const transactionsListItems = createMock([ created: '2025-03-05', submitted: '2025-03-05', approved: '', + paid: '', posted: '', exported: '', exportedTo: '', @@ -1128,6 +1135,7 @@ const transactionsListItems = createMock([ shouldShowYear: true, shouldShowYearSubmitted: true, shouldShowYearApproved: false, + shouldShowYearPaid: false, shouldShowYearPosted: false, shouldShowYearExported: false, keyForList: '4', @@ -1197,7 +1205,9 @@ 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); }); @@ -3064,7 +3088,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); }); @@ -7279,6 +7303,79 @@ 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 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); @@ -7942,6 +8039,335 @@ describe('SearchUIUtils', () => { 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 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 recognized 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'; + 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( + 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 widen the Paid column when a report was paid in a past year', () => { + // 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); + 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 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); + 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( @@ -8817,6 +9243,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 expense reports by their lowest withdrawal ID, leaving the reports without a withdrawal at the empty end', () => { // Given reports whose comma separated withdrawal IDs start with numbers of different lengths, and one report without a withdrawal const withWithdrawals = (keyForList: string, withdrawalIDs?: string) => diff --git a/tests/unit/Search/__snapshots__/ColumnAvailabilityTest.ts.snap b/tests/unit/Search/__snapshots__/ColumnAvailabilityTest.ts.snap index d4443f54c67c..1e4d876939ac 100644 --- a/tests/unit/Search/__snapshots__/ColumnAvailabilityTest.ts.snap +++ b/tests/unit/Search/__snapshots__/ColumnAvailabilityTest.ts.snap @@ -40,6 +40,7 @@ exports[`Column availability single source of truth the derived picker lists mat "status", "submitted", "approved", + "paid", "posted", "exported", "merchant",