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",