Skip to content

[CFI] [$250] Spend - Add a Paid column option to the reports table #103650

Open

Description

@melvin-bot

Problem

On the Spend page, the reports table lets you add Submitted and Approved date columns. It has no Paid column, so you have to open the report and read its messages to see when it was paid.

Solution

Add a Paid column option, like the Submitted and Approved columns. When enabled, it shows the timestamp of when each report was paid.

Original discussion: https://expensify.slack.com/archives/C05LX9D6E07/p1791566702471949

Reported in Slack.

Upwork Automation - Do Not Edit
Issue OwnerCurrent Issue Owner: @sam5235

Activity

  1. added
    ExternalAdded to denote the issue can be worked on by a contributor
    on Oct 9, 2026
  2. melvin-bot commented on Oct 9, 2026

    @melvin-bot
    Author

    Current assignee @FitseTLT is eligible for the External assigner, not assigning anyone new.

  3. changed the title [-]Spend - Add a Paid column option to the reports table[/-] [+][$175] Spend - Add a Paid column option to the reports table[/+] on Oct 9, 2026
  4. melvin-bot commented on Oct 9, 2026

    @melvin-bot
    Author
  5. changed the title [-][$175] Spend - Add a Paid column option to the reports table[/-] [+][CFI] [$175] Spend - Add a Paid column option to the reports table[/+] on Oct 9, 2026
  6. yuvrajangadsingh commented on Oct 9, 2026

    @yuvrajangadsingh
    Contributor

    Proposal

    Edit (Oct 12): the paid date now has its own lookup instead of reusing the Paid by action, and the column uses the fixed wide size. Both from @trasnake87's proposal below, who caught that the Paid by lookup skips some payments.

    Re-state problem

    The Spend reports table can show Submitted and Approved date columns but has no Paid column. To see when a report was paid, you have to open it and read the messages.

    Root cause

    The reports view gets its column options from getCustomColumns, which returns the hand-written TYPE_CUSTOM_COLUMNS.EXPENSE_REPORT list. That list has Submitted, Approved, First approved and Paid by, but no paid date, so the column picker can't offer one.

    Reports column list

    getCustomColumns reading it

    When getReportSections builds each report row, it resolves the latest payment action for paid reports and only uses its actor for the Paid by column. Its created timestamp is never copied onto the row.

    Payment-action handling in SearchUIUtils.ts

    That action can't supply the date, though. getLastPaidAction is a payer lookup, so it skips payments that don't name a payer. One is the IOU PAY action that markReportPaymentReceived adds with isSubmitterMarkedPaymentReceived, while the report still moves to REIMBURSED. The other is an owner's payment followed by the owner's MARKED_REDEEMED. Leaving those out is right for Paid by, but a date taken from the same action comes out blank, or points at an older payment.

    getLastPaidAction

    markReportPaymentReceived building that action

    and setting the report to REIMBURSED

    The per-report snapshot map doesn't help either. It only collects REIMBURSED, MARKED_REIMBURSED and MARK_REIMBURSED_FROM_INTEGRATION actions, never IOU PAY ones.

    lastReimbursedActionByReportID collection

    The row only sets submitted and approved, so a Paid cell has nothing to read.

    Report-row date fields

    The wide row renders its date cells from a per-column map, and PAID has no entry in it.

    Wide-row column map

    Changes to make

    1. Register the column

    • Add PAID: 'paid' to TABLE_COLUMNS in src/CONST/index.ts, next to PAID_BY.
    • Add PAID: this.TABLE_COLUMNS.PAID to TYPE_CUSTOM_COLUMNS.EXPENSE_REPORT, right after PAID_BY.
    • Leave COLUMN_AVAILABILITY alone. Its search entries only build the expenses list (TYPE_CUSTOM_COLUMNS.EXPENSE), not the reports one.
    • Add PAID to expenseReportColumnNamesToSortingProperty and getSearchColumnTranslationKey, next to the existing SUBMITTED and APPROVED entries.

    2. Put the paid date on the row

    • Add getPaidDate(reportItem, snapshotPaidAction, actions) in SearchUIUtils.ts, next to getApprovedDate. It returns '' unless statusNum is REIMBURSED.
    • For a paid report it returns the created time of the newest payment action: REIMBURSED, MARKED_REIMBURSED, MARK_REIMBURSED_FROM_INTEGRATION, or an IOU action of type PAY, including the submitter-confirmed one.
    • It keeps the cancellation rule from getLastPaidAction, so a payment at or before the latest REIMBURSEMENT_DEQUEUED, ACH canceled or ACH bounce action doesn't count. It drops the payer checks.
    • It seeds from the snapshot action, like getLastPaidAction does, and scans the report's actions for the IOU PAY ones the snapshot never collects.
    • In the row object, add paid: getPaidDate(reportItem, lastReimbursedActionByReportID.get(reportItem.reportID), actions) next to submitted and approved.
    • Leave getLastPaidAction and Paid by as they are.
    • Add paid?: string to ExpenseReportListItemType in ListItem/types.ts.

    3. Render and size it

    • In ExpenseReportListItemRowWide.tsx, add a [CONST.SEARCH.TABLE_COLUMNS.PAID] cell copied from the Approved one: DateCell with item.paid ?? '', showTooltip, isLargeScreenWidth, and shouldUseLocalTimeZone.
    • In SearchTableHeader.tsx, add a header entry after Approved using the existing search.filters.paid key ('Paid' in en.ts). Use the same key in getSearchColumnTranslationKey, so no new strings are needed.
    • In styles/utils/index.ts, add PAID to the fixed w102 case that First approved uses. DateCell adds the year for a past-year date, which the w62 default would clip. Approved avoids that with a shouldShowYearApproved flag, but that flag runs through Search/index.tsx and the header, which is a lot of plumbing for one column.

    First approved width

    4. Tests

    • Add SearchUIUtils tests for getPaidDate:
      • a REIMBURSED report with a REIMBURSED action gets that action's created
      • a REIMBURSED report whose only payment is a submitter-confirmed IOU PAY gets that action's created
      • a payment followed by a REIMBURSEMENT_DEQUEUED action gets ''
      • an unpaid report gets ''
    • Add a test that getCustomColumns returns PAID for the expense report type.

    Alternatives

    • Reuse exported or approved for the date. They are different events, so the date would be wrong.
    • Add a backend paid field on the report. It would need a server change, and the payment actions are already on the client.
    • Read the date off the Paid by action. It's shorter, but it's blank for submitter-confirmed and owner-redeemed payments.
  7. added
    Help WantedApply this label when an issue is open to proposals by contributors
    on Oct 9, 2026
  8. changed the title [-][CFI] [$175] Spend - Add a Paid column option to the reports table[/-] [+][CFI] [$250] Spend - Add a Paid column option to the reports table[/+] on Oct 9, 2026
  9. wildan-m commented on Oct 9, 2026

    @wildan-m
    Contributor

    🚨 Edited by proposal-police: This proposal was edited at 2026-10-09 19:29:14 UTC.

    Proposal

    Please re-state the problem that we are trying to solve in this issue.

    On the Spend page, the reports table offers Submitted and Approved date columns but no Paid column, so users have to open each report and read its actions to find out when it was paid.

    What is the root cause of that problem?

    This is a missing feature. The reports (expense report) table only knows about the columns listed in TYPE_CUSTOM_COLUMNS.EXPENSE_REPORT, and there is no paid-date entry there or in TABLE_COLUMNS:

    App/src/CONST/index.ts

    Lines 7912 to 7920 in 7155d25

    EXPENSE_REPORT: {
    AVATAR: this.TABLE_COLUMNS.AVATAR,
    DATE: this.TABLE_COLUMNS.DATE,
    SUBMITTED: this.TABLE_COLUMNS.SUBMITTED,
    APPROVED: this.TABLE_COLUMNS.APPROVED,
    FIRST_APPROVER: this.TABLE_COLUMNS.FIRST_APPROVER,
    FIRST_APPROVED: this.TABLE_COLUMNS.FIRST_APPROVED,
    PAID_BY: this.TABLE_COLUMNS.PAID_BY,
    EXPORTED: this.TABLE_COLUMNS.EXPORTED,

    App/src/CONST/index.ts

    Lines 8115 to 8124 in 7155d25

    TABLE_COLUMNS: {
    RECEIPT: 'receipt',
    DATE: 'date',
    SUBMITTED: 'submitted',
    APPROVED: 'approved',
    FIRST_APPROVER: 'firstapprover',
    FIRST_APPROVED: 'firstapproved',
    PAID_BY: 'paidBy',
    POSTED: 'posted',
    EXPORTED: 'exported',

    The data we need is already computed. When building each report row, getReportSections resolves the latest valid payment action (via getLastPaidAction, which skips submitter "marked as received" actions and payments that were later dequeued, canceled or bounced) to fill the existing Paid by column. Only the actor of that action is used; its created timestamp, which is exactly the paid date, is thrown away:

    App/src/libs/SearchUIUtils.ts

    Lines 2837 to 2844 in 7155d25

    // The paid-by user is the actor on the latest payment action. It stays blank until the report is paid.
    const lastReimbursedAction =
    reportItem.statusNum === CONST.REPORT.STATUS_NUM.REIMBURSED
    ? getLastPaidAction(lastReimbursedActionByReportID.get(reportItem.reportID), actions, reportItem.ownerAccountID)
    : undefined;
    const paidByAccountID = lastReimbursedAction?.actorAccountID;
    const paidByDetails = paidByAccountID ? mergedPersonalDetails?.[paidByAccountID] : undefined;
    const formattedPaidBy = paidByAccountID ? temporaryGetDisplayNameOrDefault({passedPersonalDetails: paidByDetails, translate, formatPhoneNumber}) : '';

    What changes do you think we should make in order to solve the problem?

    Add a Paid date column to the reports table, wired the same way as the Approved / First approved date columns:

    1. Constants (src/CONST/index.ts): add PAID: 'paid' to SEARCH.TABLE_COLUMNS, and add it to TYPE_CUSTOM_COLUMNS.EXPENSE_REPORT right after PAID_BY, so it appears in the column picker next to the other approval/payment columns. It stays off by default (it is not added to the default visible columns).
    2. Row data (getReportSections in src/libs/SearchUIUtils.ts): set a new paid field on the report list item from the created of the same lastReimbursedAction already used for Paid by, falling back to an empty string. Because that action is only resolved when the report is in the Reimbursed status and ignores canceled/bounced payments, the cell is blank for unpaid reports and for reports whose payment was reversed. Add the optional paid field to the expense report list item type in src/components/Search/SearchList/ListItem/types.ts.
    3. Header and translation: add the column to the expense report header list in SearchTableHeader.tsx, and map it in the column-to-translation switch in SearchUIUtils.ts. The existing search.filters.paid ("Paid") string can be reused, so no new translations are needed.
    4. Cell rendering (ExpenseReportListItemRowWide.tsx): render a DateCell for the new column showing item.paid, exactly like the First approved cell.
    5. Width and sorting: give the column a fixed date width in getSearchColumnStyles (same as First approved, wide enough for a past-year date; like the other date columns it is not content-measured, so getSearchColumnContentToMeasure.ts needs no change), and add it to nonSortableColumns (plus isColumnSortable: false on its header). The Search API rejects sortBy:paid as an invalid query (I confirmed this on dev: clicking a sortable Paid header showed "That search isn't valid"), so the column is display-only until the backend accepts that sort key; making it sortable later only needs the column removed from that set and mapped to paid in expenseReportColumnNamesToSortingProperty.
    6. Tests: classify the new column next to Paid by in ColumnAvailabilityTest, and assert the paid date in the existing Paid by cases of SearchUIUtilsTest (set on paid reports, blank when the payment was marked received or canceled).

    [CONST.SEARCH.TABLE_COLUMNS.FIRST_APPROVED]: (
    <View style={[getSearchColumnStyles(CONST.SEARCH.TABLE_COLUMNS.FIRST_APPROVED)]}>
    <DateCell
    date={item.firstApproved ?? ''}
    showTooltip
    isLargeScreenWidth
    />
    </View>
    ),

    const expenseReportColumnNamesToSortingProperty: ExpenseReportSorting = {
    [CONST.SEARCH.TABLE_COLUMNS.AVATAR]: null,
    [CONST.SEARCH.TABLE_COLUMNS.DATE]: 'created' as const,
    [CONST.SEARCH.TABLE_COLUMNS.SUBMITTED]: 'submitted' as const,
    [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_BY]: 'formattedPaidBy' as const,
    [CONST.SEARCH.TABLE_COLUMNS.EXPORTED]: 'exported' as const,

    The date is derived on the frontend from report actions already in the search snapshot, so no backend change is needed.

    Compare branch: https://github.com/Expensify/App/compare/main...wildan-m:App:wildan/103650-paid-date-column?expand=1

    What alternative solutions did you explore? (Optional)

    Have the backend return a paid timestamp on each report in the search snapshot and read it directly, like the submitted / approved report fields. That requires a backend change, while the frontend already resolves the correct payment action for the Paid by column, so deriving the date from that same action keeps both columns consistent for every row and ships without backend work. A backend field would still be the way to make the column sortable server-side.

  10. KJ21-ENG commented on Oct 9, 2026

    @KJ21-ENG
    Contributor

    🚨 Edited by proposal-police: This proposal was edited at 2026-10-09 19:27:30 UTC.

    Proposal

    What is the root cause of that problem?

    This is a missing Reports-table capability, not an incorrect payment write. The selectable report-column registry includes Submitted, Approved, Paid by and Paid status, but no Paid date. getCustomColumns() builds the picker from this registry, and getColumnsToShow() rejects selected columns outside it, so adding a cell alone would not make Paid selectable or reachable.

    App/src/CONST/index.ts

    Lines 7912 to 7939 in d216f70

    EXPENSE_REPORT: {
    AVATAR: this.TABLE_COLUMNS.AVATAR,
    DATE: this.TABLE_COLUMNS.DATE,
    SUBMITTED: this.TABLE_COLUMNS.SUBMITTED,
    APPROVED: this.TABLE_COLUMNS.APPROVED,
    FIRST_APPROVER: this.TABLE_COLUMNS.FIRST_APPROVER,
    FIRST_APPROVED: this.TABLE_COLUMNS.FIRST_APPROVED,
    PAID_BY: this.TABLE_COLUMNS.PAID_BY,
    EXPORTED: this.TABLE_COLUMNS.EXPORTED,
    STATUS: this.TABLE_COLUMNS.STATUS,
    PAID_STATUS: this.TABLE_COLUMNS.PAID_STATUS,
    TITLE: this.TABLE_COLUMNS.TITLE,
    FROM: this.TABLE_COLUMNS.FROM,
    TO: this.TABLE_COLUMNS.TO,
    POLICY_NAME: this.TABLE_COLUMNS.POLICY_NAME,
    REIMBURSABLE_TOTAL: this.TABLE_COLUMNS.REIMBURSABLE_TOTAL,
    NON_REIMBURSABLE_TOTAL: this.TABLE_COLUMNS.NON_REIMBURSABLE_TOTAL,
    REPORT_ID: this.TABLE_COLUMNS.REPORT_ID,
    BASE_62_REPORT_ID: this.TABLE_COLUMNS.BASE_62_REPORT_ID,
    AMOUNT: this.TABLE_COLUMNS.TOTAL,
    SUBMITTER_USER_ID: this.TABLE_COLUMNS.SUBMITTER_USER_ID,
    SUBMITTER_PAYROLL_ID: this.TABLE_COLUMNS.SUBMITTER_PAYROLL_ID,
    ORDER_DEAL_NUMBERS: this.TABLE_COLUMNS.ORDER_DEAL_NUMBERS,
    AMOUNT_DEBITED: this.TABLE_COLUMNS.AMOUNT_DEBITED,
    AMOUNT_REIMBURSED: this.TABLE_COLUMNS.AMOUNT_REIMBURSED,
    EXPORTED_ICON: this.TABLE_COLUMNS.EXPORTED_TO,
    ACTION: this.TABLE_COLUMNS.ACTION,
    },

    App/src/libs/SearchUIUtils.ts

    Lines 4361 to 4366 in d216f70

    function getCustomColumns(value?: SearchDataTypes | SearchGroupBy): SearchCustomColumnIds[] {
    switch (value) {
    case CONST.SEARCH.DATA_TYPES.EXPENSE:
    return Object.values(CONST.SEARCH.TYPE_CUSTOM_COLUMNS.EXPENSE);
    case CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT:
    return Object.values(CONST.SEARCH.TYPE_CUSTOM_COLUMNS.EXPENSE_REPORT);

    App/src/libs/SearchUIUtils.ts

    Lines 6421 to 6454 in d216f70

    if (type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT) {
    const defaultReportColumns: SearchColumnType[] = [
    CONST.SEARCH.TABLE_COLUMNS.AVATAR,
    CONST.SEARCH.TABLE_COLUMNS.DATE,
    CONST.SEARCH.TABLE_COLUMNS.STATUS,
    CONST.SEARCH.TABLE_COLUMNS.TITLE,
    CONST.SEARCH.TABLE_COLUMNS.FROM,
    CONST.SEARCH.TABLE_COLUMNS.TO,
    CONST.SEARCH.TABLE_COLUMNS.TOTAL,
    CONST.SEARCH.TABLE_COLUMNS.ACTION,
    ];
    // If there are no visible columns, everything should be visible
    const filteredVisibleColumns = visibleColumns.filter((column) => {
    return Object.values(CONST.SEARCH.TYPE_CUSTOM_COLUMNS.EXPENSE_REPORT).includes(column as ValueOf<typeof CONST.SEARCH.TYPE_CUSTOM_COLUMNS.EXPENSE_REPORT>);
    });
    if (!filteredVisibleColumns.length) {
    return defaultReportColumns;
    }
    // If the user has set custom columns, use their order then add required columns
    const requiredColumns = new Set<SearchColumnType>([CONST.SEARCH.TABLE_COLUMNS.TOTAL]);
    const result: SearchColumnType[] = [];
    for (const col of requiredColumns) {
    if (!filteredVisibleColumns.includes(col as SearchCustomColumnIds)) {
    result.push(col);
    }
    }
    for (const col of filteredVisibleColumns) {
    result.push(col);
    }

    The report-row producer already resolves a payment action for Paid by, but only emits the payer identity, not its timestamp. The header and wide row likewise define Submitted and Approved without a Paid date.

    App/src/libs/SearchUIUtils.ts

    Lines 2837 to 2844 in d216f70

    // The paid-by user is the actor on the latest payment action. It stays blank until the report is paid.
    const lastReimbursedAction =
    reportItem.statusNum === CONST.REPORT.STATUS_NUM.REIMBURSED
    ? getLastPaidAction(lastReimbursedActionByReportID.get(reportItem.reportID), actions, reportItem.ownerAccountID)
    : undefined;
    const paidByAccountID = lastReimbursedAction?.actorAccountID;
    const paidByDetails = paidByAccountID ? mergedPersonalDetails?.[paidByAccountID] : undefined;
    const formattedPaidBy = paidByAccountID ? temporaryGetDisplayNameOrDefault({passedPersonalDetails: paidByDetails, translate, formatPhoneNumber}) : '';

    App/src/libs/SearchUIUtils.ts

    Lines 2882 to 2892 in d216f70

    exported: lastExportedActionByReportID.get(reportItem.reportID)?.created ?? '',
    submitted: getSubmittedDate(reportItem, actions),
    approved: getApprovedDate(reportItem, actions),
    exportedTo: getExportedToSortValue(exportedToNamesByReportID, reportItem.reportID),
    firstApproved,
    firstApproverAvatar: firstApproverDetails?.avatar,
    firstApproverAccountID,
    formattedFirstApprover,
    paidByAvatar: paidByDetails?.avatar,
    paidByAccountID,
    formattedPaidBy,

    const getExpenseReportHeaders = (profileIcon?: IconAsset): SearchColumnConfig[] => [
    {
    columnName: CONST.SEARCH.TABLE_COLUMNS.AVATAR,
    translationKey: undefined,
    icon: profileIcon,
    isColumnSortable: false,
    },
    {
    columnName: CONST.SEARCH.TABLE_COLUMNS.DATE,
    translationKey: 'search.filters.created',
    },
    {
    columnName: CONST.SEARCH.TABLE_COLUMNS.SUBMITTED,
    translationKey: 'common.submitted',
    },
    {
    columnName: CONST.SEARCH.TABLE_COLUMNS.APPROVED,
    translationKey: 'search.filters.approved',
    },
    {
    columnName: CONST.SEARCH.TABLE_COLUMNS.FIRST_APPROVER,
    translationKey: 'search.filters.firstApprover',
    },
    {
    columnName: CONST.SEARCH.TABLE_COLUMNS.FIRST_APPROVED,
    translationKey: 'search.filters.firstApproved',
    },
    {
    columnName: CONST.SEARCH.TABLE_COLUMNS.PAID_BY,
    translationKey: 'search.filters.paidBy',
    },
    {
    columnName: CONST.SEARCH.TABLE_COLUMNS.EXPORTED,
    translationKey: 'search.filters.exported',

    [CONST.SEARCH.TABLE_COLUMNS.SUBMITTED]: (
    <View style={[getSearchColumnStyles(CONST.SEARCH.TABLE_COLUMNS.SUBMITTED, {isSubmittedColumnWide: item.shouldShowYearSubmitted})]}>
    <DateCell
    date={item.submitted ?? ''}
    showTooltip
    isLargeScreenWidth
    shouldUseLocalTimeZone
    />
    </View>
    ),
    [CONST.SEARCH.TABLE_COLUMNS.APPROVED]: (
    <View style={[getSearchColumnStyles(CONST.SEARCH.TABLE_COLUMNS.APPROVED, {isApprovedColumnWide: item.shouldShowYearApproved})]}>
    <DateCell
    date={item.approved ?? ''}
    showTooltip
    isLargeScreenWidth
    shouldUseLocalTimeZone
    />
    </View>
    ),

    Producer check: payment actions are legitimate history. The optimistic action builder supplies created; the payment flow writes that action and the report's reimbursed status, clears pending state on success, and restores the report on failure. These writes should remain unchanged; the missing state is the derived table date, not payment history.

    App/src/libs/ReportUtils.ts

    Lines 8419 to 8439 in d216f70

    function buildOptimisticIOUReportAction(params: BuildOptimisticIOUReportActionParams): OptimisticIOUReportAction {
    const {
    type,
    amount,
    currency,
    comment,
    participants,
    transactionID,
    paymentType,
    iouReportID = '',
    isSettlingUp = false,
    isSendMoneyFlow = false,
    isOwnPolicyExpenseChat = false,
    created = DateUtils.getDBTime(),
    linkedExpenseReportAction,
    isPersonalTrackingExpense = false,
    payAsBusiness,
    bankAccountID,
    reportActionID,
    delegateAccountIDParam,
    isSubmitterMarkedPaymentReceived,

    onyxData.optimisticData?.push(
    {
    onyxMethod: Onyx.METHOD.MERGE,
    key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport?.reportID}`,
    value: {
    [optimisticIOUReportAction.reportActionID]: {
    ...(optimisticIOUReportAction as OnyxTypes.ReportAction),
    pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD,
    },
    },
    },
    {
    onyxMethod: Onyx.METHOD.MERGE,
    key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`,
    value: {
    lastMessageText: getReportActionText(optimisticIOUReportAction),
    lastMessageHtml: getReportActionHtml(optimisticIOUReportAction),
    lastVisibleActionCreated: optimisticIOUReportAction.created,
    hasOutstandingChildRequest: false,
    statusNum: CONST.REPORT.STATUS_NUM.REIMBURSED,
    stateNum: CONST.REPORT.STATE_NUM.APPROVED,
    pendingFields: {
    preview: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE,
    reimbursed: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE,
    partial: full ? null : CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE,
    nextStep: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE,

    {
    onyxMethod: Onyx.METHOD.MERGE,
    key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport?.reportID}`,
    value: {
    [optimisticIOUReportAction.reportActionID]: {
    pendingAction: null,
    },
    },
    },
    );
    onyxData.failureData?.push(
    {
    onyxMethod: Onyx.METHOD.MERGE,
    key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport?.reportID}`,
    value: {
    [optimisticIOUReportAction.reportActionID]: {
    // Same key as the preview copy below, so dismissing this one clears that one too.
    errors: getMicroSecondOnyxErrorWithTranslationKey('iou.error.other', CONST.IOU.PAY_FAILURE_PREVIEW_ERROR_KEY),
    },
    },
    },
    {
    onyxMethod: Onyx.METHOD.MERGE,
    key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`,
    value: {
    ...iouReport,
    },
    },
    );

    An important distinction is that getLastPaidAction() is currently a payer resolver: it rejects cancelled payments and submitter-confirmed receipts. A receipt does not identify a payer, but the receipt flow still legitimately marks the report paid. Therefore blindly using the existing Paid-by result for Paid would leave those reports without a date.

    App/src/libs/SearchUIUtils.ts

    Lines 2643 to 2690 in d216f70

    /**
    * Returns the latest payment action between the snapshot-derived one and the given report actions, mirroring the
    * backend paid-by rules: payment actions at or before the latest reimbursement cancellation don't count, and a
    * payment action recorded by the report owner that is paired with a MARKED_REDEEMED action is a "received payment"
    * self-attestation, which doesn't identify a payer.
    */
    function getLastPaidAction(
    snapshotPaidAction: OnyxTypes.ReportAction | undefined,
    actions: OnyxTypes.ReportAction[],
    ownerAccountID: number | undefined,
    ): OnyxTypes.ReportAction | undefined {
    const latestCancellation = findActionByCreated(
    actions,
    [CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENT_DEQUEUED, CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENT_ACH_CANCELED, CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENT_ACH_BOUNCE],
    'latest',
    );
    const isValidPaymentAction = (action: OnyxTypes.ReportAction | undefined): action is OnyxTypes.ReportAction => {
    if (!action) {
    return false;
    }
    if (isMoneyRequestAction(action)) {
    const originalMessage = getOriginalMessage<typeof CONST.REPORT.ACTIONS.TYPE.IOU>(action);
    if (originalMessage?.type !== CONST.IOU.REPORT_ACTION_TYPE.PAY || originalMessage.isSubmitterMarkedPaymentReceived) {
    return false;
    }
    }
    if (latestCancellation && action.created <= latestCancellation.created) {
    return false;
    }
    if (action.actorAccountID !== ownerAccountID) {
    return true;
    }
    return !actions.some(
    (redeemedAction) =>
    redeemedAction.actionName === CONST.REPORT.ACTIONS.TYPE.MARKED_REDEEMED &&
    redeemedAction.actorAccountID === action.actorAccountID &&
    redeemedAction.created >= action.created,
    );
    };
    const seed = isValidPaymentAction(snapshotPaidAction) ? snapshotPaidAction : undefined;
    const candidates = actions.filter(isValidPaymentAction);
    return findActionByCreated(
    candidates,
    [CONST.REPORT.ACTIONS.TYPE.REIMBURSED, CONST.REPORT.ACTIONS.TYPE.MARKED_REIMBURSED, CONST.REPORT.ACTIONS.TYPE.MARK_REIMBURSED_FROM_INTEGRATION, CONST.REPORT.ACTIONS.TYPE.IOU],
    'latest',
    seed,
    );
    }

    const optimisticIOUReportAction = buildOptimisticIOUReportAction({
    type: CONST.IOU.REPORT_ACTION_TYPE.PAY,
    amount: -total,
    currency: iouReport.currency ?? '',
    comment: '',
    participants: [recipient],
    transactionID: '',
    paymentType: CONST.IOU.PAYMENT_TYPE.ELSEWHERE,
    iouReportID: iouReport.reportID,
    isSettlingUp: true,
    isSubmitterMarkedPaymentReceived: true,
    getCurrencyDecimals,
    });
    // buildOptimisticIOUReportAction formats the action's `message` as "paid ... elsewhere", so override it with
    // the "received payment" copy here. Keeping the action's message in sync fixes the optimistic chat/report
    // preview, which derives lastMessageText/lastMessageHtml from this action.
    // translateLocal is used because this runs outside a React component where the useLocalize hook is unavailable.
    // eslint-disable-next-line @typescript-eslint/no-deprecated
    const receivedPaymentMessage = getElsewherePaymentReportActionMessage(translateLocal, optimisticIOUReportAction.originalMessage);
    optimisticIOUReportAction.message = [{html: receivedPaymentMessage, text: receivedPaymentMessage, isEdited: false, type: CONST.REPORT.MESSAGE.TYPE.COMMENT}];

    {
    onyxMethod: Onyx.METHOD.MERGE,
    key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport.reportID}`,
    value: {
    [optimisticIOUReportAction.reportActionID]: {
    ...(optimisticIOUReportAction as OnyxTypes.ReportAction),
    pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD,
    },
    },
    },
    {
    onyxMethod: Onyx.METHOD.MERGE,
    key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`,
    value: {
    lastMessageText: getReportActionText(optimisticIOUReportAction),
    lastMessageHtml: getReportActionHtml(optimisticIOUReportAction),
    lastVisibleActionCreated: optimisticIOUReportAction.created,
    hasOutstandingChildRequest: false,
    statusNum: CONST.REPORT.STATUS_NUM.REIMBURSED,
    stateNum: CONST.REPORT.STATE_NUM.APPROVED,
    pendingFields: {
    preview: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE,
    reimbursed: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE,
    partial: null,
    nextStep: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE,
    },
    nextStep: optimisticNextStep,
    errors: null,

    History inspection found adjacent feature additions, not a removal of Paid: commit 7d95877 added First approver/First approved, and 0b38622 repaired Paid by to use live payment actions. This request extends that pipeline; neither commit is evidence that a Paid date previously existed.
    7d95877
    0b38622

    What changes do you think we should make in order to solve the problem?

    1. Add TABLE_COLUMNS.PAID = 'paid' and include it after Approved in TYPE_CUSTOM_COLUMNS.EXPENSE_REPORT only. Add its column-label mapping to getSearchColumnTranslationKey() using the existing search.filters.paid translation. Keep it opt-in: do not add it to default columns, expense columns, or report-detail expense columns. The column ID types already derive from the constants.

      App/src/CONST/index.ts

      Lines 8115 to 8124 in d216f70

      TABLE_COLUMNS: {
      RECEIPT: 'receipt',
      DATE: 'date',
      SUBMITTED: 'submitted',
      APPROVED: 'approved',
      FIRST_APPROVER: 'firstapprover',
      FIRST_APPROVED: 'firstapproved',
      PAID_BY: 'paidBy',
      POSTED: 'posted',
      EXPORTED: 'exported',

      type SortOrder = ValueOf<typeof CONST.SEARCH.SORT_ORDER>;
      type SearchColumnType = ValueOf<typeof CONST.SEARCH.TABLE_COLUMNS>;
      type SearchSortBy = SearchColumnType | ValueOf<typeof CONST.SEARCH.SORT_BY_COLUMNS>;
      type ExpenseSearchStatus = ValueOf<typeof CONST.SEARCH.STATUS.EXPENSE>;
      type ExpenseReportSearchStatus = ValueOf<typeof CONST.SEARCH.STATUS.EXPENSE_REPORT>;
      type InvoiceSearchStatus = ValueOf<typeof CONST.SEARCH.STATUS.INVOICE>;
      type TripSearchStatus = ValueOf<typeof CONST.SEARCH.STATUS.TRIP>;
      type TaskSearchStatus = ValueOf<typeof CONST.SEARCH.STATUS.TASK>;
      type SingularSearchStatus = ExpenseSearchStatus | ExpenseReportSearchStatus | InvoiceSearchStatus | TripSearchStatus | TaskSearchStatus;
      type SearchGroupBy = ValueOf<typeof CONST.SEARCH.GROUP_BY>;
      type SearchView = ValueOf<typeof CONST.SEARCH.VIEW>;
      type SearchCompareMode = ValueOf<typeof CONST.SEARCH.COMPARE>;
      // PieChart is not implemented so we exclude it here to prevent TypeScript errors in `SearchChartView.tsx`.
      type ChartView = Exclude<SearchView, 'table'>;
      type TableColumnSize = ValueOf<typeof CONST.SEARCH.TABLE_COLUMN_SIZES>;
      type SearchDatePreset = ValueOf<typeof CONST.SEARCH.DATE_PRESETS>;
      type SearchWithdrawalType = ValueOf<typeof CONST.SEARCH.WITHDRAWAL_TYPE>;
      type SearchWithdrawalStatus = Array<ValueOf<typeof CONST.SEARCH.SETTLEMENT_STATUS>>;
      type SearchPaidStatus = Array<ValueOf<typeof CONST.SEARCH.PAID_STATUS>>;
      type SyntaxFilterKey = ValueOf<typeof CONST.SEARCH.SYNTAX_FILTER_KEYS>;
      type SearchCustomColumnIds =
      | ValueOf<typeof CONST.SEARCH.TYPE_CUSTOM_COLUMNS.EXPENSE>
      | ValueOf<typeof CONST.SEARCH.TYPE_CUSTOM_COLUMNS.EXPENSE_REPORT>

      App/src/libs/SearchUIUtils.ts

      Lines 4448 to 4465 in d216f70

      function getSearchColumnTranslationKey(column: SearchSortBy, type?: SearchDataTypes): TranslationPaths {
      switch (column) {
      case CONST.SEARCH.TABLE_COLUMNS.AVATAR:
      return 'common.avatar';
      case CONST.SEARCH.TABLE_COLUMNS.DATE:
      // Only the table column header shows the short "Created"; every other label source (Sort by, Edit columns, saved search, CSV) uses the full "Created date".
      return isCreatedDateType(type) ? 'search.filters.createdDate' : 'common.date';
      case CONST.SEARCH.TABLE_COLUMNS.SUBMITTED:
      return 'common.submitted';
      case CONST.SEARCH.TABLE_COLUMNS.APPROVED:
      return 'search.filters.approved';
      case CONST.SEARCH.TABLE_COLUMNS.FIRST_APPROVER:
      return 'search.filters.firstApprover';
      case CONST.SEARCH.TABLE_COLUMNS.FIRST_APPROVED:
      return 'search.filters.firstApproved';
      case CONST.SEARCH.TABLE_COLUMNS.PAID_BY:
      return 'search.filters.paidBy';
      case CONST.SEARCH.TABLE_COLUMNS.POSTED:

      App/src/languages/en.ts

      Lines 10346 to 10352 in d216f70

      submitted: 'Submitted',
      approved: 'Approved',
      firstApprover: 'First approver',
      firstApproved: 'First approved',
      paidBy: 'Paid by',
      paid: 'Paid',
      exported: 'Exported',

    2. Produce a derived paid?: string on TransactionReportGroupListItemType in getReportSections(). Refactor the payment resolver to share payment-type selection, latest-action ordering and cancellation cutoffs, while making payer attribution a separate predicate. Paid takes the latest valid payment action's created when the report has reimbursed status; otherwise it is blank. Recognize REIMBURSED, MARKED_REIMBURSED, MARK_REIMBURSED_FROM_INTEGRATION and IOU/PAY. Exclude actions at or before the latest dequeued/cancelled/bounced reimbursement, failed actions and pending deletions. Include submitter-confirmed IOU/PAY or MARKED_REIMBURSED events for the date without changing the existing Paid-by exclusions. MARKED_REDEEMED is attribution context, not an additional later payment timestamp. If no qualifying timestamp exists, leave the cell blank rather than inventing one from report creation or last activity.

    For the new date resolver, reconcile the complete snapshot and live payment/cancellation histories by reportActionID, with live versions (including errors and pending deletions) overriding snapshot versions before eligibility checks. Do not treat an empty or partial live list as complete history, or reduce the snapshot to one payment seed. The current fallback selects a present live array even when it is empty, and the snapshot preprocessing retains only the latest of three settled action types—not IOU/PAY or the cancellation history. For example, a snapshot-only received-payment PAY event must still supply Paid when an empty live list exists. Snapshot cancellations must also invalidate older live payments.

    App/src/libs/SearchUIUtils.ts

    Lines 1498 to 1508 in 4d934ce

    if (
    isReportPaid &&
    (action.actionName === CONST.REPORT.ACTIONS.TYPE.REIMBURSED ||
    action.actionName === CONST.REPORT.ACTIONS.TYPE.MARKED_REIMBURSED ||
    action.actionName === CONST.REPORT.ACTIONS.TYPE.MARK_REIMBURSED_FROM_INTEGRATION)
    ) {
    const currentTime = new Date(action.created).getTime();
    if (currentTime > lastReimbursedTime) {
    lastReimbursedTime = currentTime;
    lastReimbursedAction = action;
    }

    App/src/libs/SearchUIUtils.ts

    Lines 1795 to 1805 in 4d934ce

    /**
    * @private
    * Returns the report's actions from the live filtered collection when available, falling back to the snapshot's.
    */
    function getLiveOrSnapshotReportActions(
    reportActions: Record<string, OnyxTypes.ReportAction[]>,
    data: OnyxTypes.SearchResults['data'],
    reportID: string | undefined,
    ): OnyxTypes.ReportAction[] {
    return reportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`] ?? Object.values(data[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`] ?? {});
    }

    App/src/libs/ReportUtils.ts

    Lines 14391 to 14410 in 4d934ce

    function selectFilteredReportActions(
    reportActions: Record<string, Record<string, OnyxInputOrEntry<ReportAction>> | undefined> | null | undefined,
    ): Record<string, ReportAction[]> | undefined {
    if (!reportActions) {
    return {};
    }
    return Object.fromEntries(
    Object.entries(reportActions).map(([reportActionsKey, actionsGroup]) => [reportActionsKey, Object.values(actionsGroup ?? {}).filter(isSearchRelevantReportAction)]),
    );
    }
    function selectFilteredReportActionsForReports(
    reportActions: Record<string, Record<string, OnyxInputOrEntry<ReportAction>> | undefined> | null | undefined,
    reportActionsKeys: string[],
    ): Record<string, ReportAction[]> {
    const result: Record<string, ReportAction[]> = {};
    for (const reportActionsKey of reportActionsKeys) {
    result[reportActionsKey] = Object.values(reportActions?.[reportActionsKey] ?? {}).filter(isSearchRelevantReportAction);
    }

    const [filtered, allLength, hasDeletedTransactionFromSections] = getSections({
    dateFnsLocale,
    type,
    data: searchDataWithOptimisticTransaction,
    currentAccountID: accountID,
    currentUserEmail: email ?? '',
    translate,
    formatPhoneNumber,
    bankAccountList,
    rules,
    groupBy: validGroupBy,
    reportActions: exportReportActions,
    currentSearch: currentSearchKey,
    reportNameValuePairs,
    queryJSON,
    isActionLoadingSet,
    cardFeeds,
    cardList: personalAndWorkspaceCards,
    nonPersonalAndWorkspaceCardList: nonPersonalAndWorkspaceCards,
    isOffline,
    customCardNames,
    conciergeReportID,
    onyxPersonalDetailsList,
    isAttendeesEnabledForMovingPolicy,
    convertToDisplayString,
    reportAttributesDerivedValue: reportAttributesForSections,

    Apply failed/pending-deletion exclusion to cancellation events before calculating the cutoff as well as to payment events: a failed cancel restores REIMBURSED while leaving an errored dequeue action. That failed reversal must not erase the still-valid payment date. Keep this reconciliation scoped to producing Paid; preserve the existing Paid-by predicate, behavior and tests. Payment from Search still updates the date from live optimistic actions without requiring a new search response.

    App/src/libs/ReportUtils.ts

    Lines 4862 to 4865 in 4d934ce

    function buildOptimisticCancelPaymentReportAction(expenseReportID: string, amount: number, currency: string, currentUserAccountID: number): OptimisticCancelPaymentReportAction {
    return {
    actionName: CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENT_DEQUEUED,
    actorAccountID: currentUserAccountID,

    const failureData: Array<OnyxUpdate<typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS | typeof ONYXKEYS.COLLECTION.REPORT>> = [
    {
    onyxMethod: Onyx.METHOD.MERGE,
    key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport.reportID}`,
    value: {
    [optimisticReportAction.reportActionID]: {
    errors: getMicroSecondOnyxErrorWithTranslationKey('iou.error.other'),
    },
    },
    },
    {
    onyxMethod: Onyx.METHOD.MERGE,
    key: `${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`,
    value: {
    statusNum: CONST.REPORT.STATUS_NUM.REIMBURSED,
    isWaitingOnBankAccount: expenseReport.isWaitingOnBankAccount,
    canCancelReimbursement: expenseReport.canCancelReimbursement,
    isCancelledIOU: false,
    nextStep:
    buildOptimisticNextStep({
    report: expenseReport,
    predictedNextStatus: CONST.REPORT.STATUS_NUM.REIMBURSED,
    isTrackIntentUser,
    rules,
    }) ?? null,
    pendingFields: {
    nextStep: null,
    },
    },

    App/src/libs/SearchUIUtils.ts

    Lines 1795 to 1805 in d216f70

    /**
    * @private
    * Returns the report's actions from the live filtered collection when available, falling back to the snapshot's.
    */
    function getLiveOrSnapshotReportActions(
    reportActions: Record<string, OnyxTypes.ReportAction[]>,
    data: OnyxTypes.SearchResults['data'],
    reportID: string | undefined,
    ): OnyxTypes.ReportAction[] {
    return reportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`] ?? Object.values(data[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`] ?? {});
    }

    App/src/libs/SearchUIUtils.ts

    Lines 2643 to 2690 in d216f70

    /**
    * Returns the latest payment action between the snapshot-derived one and the given report actions, mirroring the
    * backend paid-by rules: payment actions at or before the latest reimbursement cancellation don't count, and a
    * payment action recorded by the report owner that is paired with a MARKED_REDEEMED action is a "received payment"
    * self-attestation, which doesn't identify a payer.
    */
    function getLastPaidAction(
    snapshotPaidAction: OnyxTypes.ReportAction | undefined,
    actions: OnyxTypes.ReportAction[],
    ownerAccountID: number | undefined,
    ): OnyxTypes.ReportAction | undefined {
    const latestCancellation = findActionByCreated(
    actions,
    [CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENT_DEQUEUED, CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENT_ACH_CANCELED, CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENT_ACH_BOUNCE],
    'latest',
    );
    const isValidPaymentAction = (action: OnyxTypes.ReportAction | undefined): action is OnyxTypes.ReportAction => {
    if (!action) {
    return false;
    }
    if (isMoneyRequestAction(action)) {
    const originalMessage = getOriginalMessage<typeof CONST.REPORT.ACTIONS.TYPE.IOU>(action);
    if (originalMessage?.type !== CONST.IOU.REPORT_ACTION_TYPE.PAY || originalMessage.isSubmitterMarkedPaymentReceived) {
    return false;
    }
    }
    if (latestCancellation && action.created <= latestCancellation.created) {
    return false;
    }
    if (action.actorAccountID !== ownerAccountID) {
    return true;
    }
    return !actions.some(
    (redeemedAction) =>
    redeemedAction.actionName === CONST.REPORT.ACTIONS.TYPE.MARKED_REDEEMED &&
    redeemedAction.actorAccountID === action.actorAccountID &&
    redeemedAction.created >= action.created,
    );
    };
    const seed = isValidPaymentAction(snapshotPaidAction) ? snapshotPaidAction : undefined;
    const candidates = actions.filter(isValidPaymentAction);
    return findActionByCreated(
    candidates,
    [CONST.REPORT.ACTIONS.TYPE.REIMBURSED, CONST.REPORT.ACTIONS.TYPE.MARKED_REIMBURSED, CONST.REPORT.ACTIONS.TYPE.MARK_REIMBURSED_FROM_INTEGRATION, CONST.REPORT.ACTIONS.TYPE.IOU],
    'latest',
    seed,
    );
    }

    App/src/libs/SearchUIUtils.ts

    Lines 2792 to 2799 in d216f70

    const transactions = reportIDToTransactions[reportKey]?.transactions ?? [];
    const isIOUReport = reportItem.type === CONST.REPORT.TYPE.IOU;
    const actions = getLiveOrSnapshotReportActions(reportActions, data, reportItem.reportID);
    const isActionLoading = !!isActionLoadingSet?.has(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${reportItem.reportID}`);
    const shouldShow = isActionLoading || isEligibleForStatus(currentQueryJSON, reportItem);
    if (shouldShow) {

    /** The date the report was exported */
    exported?: string;
    /** Names of the integrations/templates the report was exported to, used for sorting the "Exported to" column */
    exportedTo?: string;
    /** The date of the report's first approval (created date of the earliest APPROVED/FORWARDED report action) */
    firstApproved?: string;
    firstApproverAvatar?: AvatarSource;
    /** Account ID of the first approver (actor on the earliest APPROVED/FORWARDED report action) */
    firstApproverAccountID?: number;
    /** Final and formatted "first approver" value used for displaying and sorting */
    formattedFirstApprover?: string;
    /** The avatar of the user the report was paid by */
    paidByAvatar?: AvatarSource;
    /** Account ID of the user the report was paid by (actor on the latest payment report action) */
    paidByAccountID?: number;
    /** Final and formatted "paid by" value used for displaying and sorting */
    formattedPaidBy?: string;

    it('should populate paidBy from a snapshot payment action on a paid report', () => {
    const data = makeReportFilterTestData(
    {type: CONST.REPORT.TYPE.EXPENSE, stateNum: CONST.REPORT.STATE_NUM.APPROVED, statusNum: CONST.REPORT.STATUS_NUM.REIMBURSED},
    {},
    {
    [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${rptFilterReportID}`]: {
    'reimbursed-1': {
    reportActionID: 'reimbursed-1',
    actionName: CONST.REPORT.ACTIONS.TYPE.MARKED_REIMBURSED,
    actorAccountID: approverAccountID,
    created: '2024-12-22 09:30:00',
    },
    },
    },
    );
    const [sections] = callGetReportSections(data);
    const item = sections.find((s) => s.keyForList === rptFilterReportID);
    expect(item?.paidByAccountID).toBe(approverAccountID);
    });
    it('should populate paidBy from a live pay action missing from the snapshot (pay from Search)', () => {
    const data = makeReportFilterTestData({type: CONST.REPORT.TYPE.EXPENSE, stateNum: CONST.REPORT.STATE_NUM.APPROVED, statusNum: CONST.REPORT.STATUS_NUM.REIMBURSED});
    const [sections] = callGetReportSections(data, {
    reportActions: {
    [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${rptFilterReportID}`]: [
    {
    reportActionID: 'optimistic-pay-1',
    actionName: CONST.REPORT.ACTIONS.TYPE.IOU,
    originalMessage: {type: CONST.IOU.REPORT_ACTION_TYPE.PAY},
    actorAccountID: approverAccountID,
    created: '2024-12-22 09:30:00',
    pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD,
    },
    ],
    },
    });
    const item = sections.find((s) => s.keyForList === rptFilterReportID);
    expect(item?.paidByAccountID).toBe(approverAccountID);
    });
    it('should leave paidBy blank when the submitter marked the payment as received', () => {
    const [sections] = callGetReportSections(
    makeReportFilterTestData({type: CONST.REPORT.TYPE.EXPENSE, stateNum: CONST.REPORT.STATE_NUM.APPROVED, statusNum: CONST.REPORT.STATUS_NUM.REIMBURSED}),
    {
    reportActions: {
    [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${rptFilterReportID}`]: [
    {
    reportActionID: 'received-pay-1',
    actionName: CONST.REPORT.ACTIONS.TYPE.IOU,
    originalMessage: {type: CONST.IOU.REPORT_ACTION_TYPE.PAY, isSubmitterMarkedPaymentReceived: true},
    actorAccountID: adminAccountID,
    created: '2024-12-22 09:30:00',
    },
    {
    reportActionID: 'received-reimbursed-1',
    actionName: CONST.REPORT.ACTIONS.TYPE.MARKED_REIMBURSED,
    actorAccountID: adminAccountID,
    created: '2024-12-22 09:30:00',
    },
    {
    reportActionID: 'received-redeemed-1',
    actionName: CONST.REPORT.ACTIONS.TYPE.MARKED_REDEEMED,
    actorAccountID: adminAccountID,
    created: '2024-12-22 09:30:01',
    },
    ],
    },
    },
    );
    const item = sections.find((s) => s.keyForList === rptFilterReportID);
    expect(item?.paidByAccountID).toBeUndefined();
    expect(item?.formattedPaidBy).toBe('');
    });
    it('should ignore payment actions at or before the latest reimbursement cancellation', () => {
    const [sections] = callGetReportSections(
    makeReportFilterTestData({type: CONST.REPORT.TYPE.EXPENSE, stateNum: CONST.REPORT.STATE_NUM.APPROVED, statusNum: CONST.REPORT.STATUS_NUM.REIMBURSED}),
    {
    reportActions: {
    [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${rptFilterReportID}`]: [
    {
    reportActionID: 'reimbursed-1',
    actionName: CONST.REPORT.ACTIONS.TYPE.REIMBURSED,
    actorAccountID: approverAccountID,
    created: '2024-12-20 08:00:00',
    },
    {
    reportActionID: 'dequeued-1',
    actionName: CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENT_DEQUEUED,
    actorAccountID: approverAccountID,
    created: '2024-12-21 10:00:00',
    },
    ],
    },
    },
    );
    const item = sections.find((s) => s.keyForList === rptFilterReportID);
    expect(item?.paidByAccountID).toBeUndefined();

    1. Register Paid in getExpenseReportHeaders() and the wide row's column-component map. Render the raw timestamp with the shared DateCell, showTooltip, isLargeScreenWidth and shouldUseLocalTimeZone, matching Submitted/Approved's date presentation. Use a fixed past-year-capable width, following First approved's existing 102px pattern, for header and row; add the same width to getTableMinWidth()'s fallback. This avoids introducing another year flag through every header and list caller.
      [CONST.SEARCH.TABLE_COLUMNS.SUBMITTED]: (
      <View style={[getSearchColumnStyles(CONST.SEARCH.TABLE_COLUMNS.SUBMITTED, {isSubmittedColumnWide: item.shouldShowYearSubmitted})]}>
      <DateCell
      date={item.submitted ?? ''}
      showTooltip
      isLargeScreenWidth
      shouldUseLocalTimeZone
      />
      </View>
      ),
      [CONST.SEARCH.TABLE_COLUMNS.APPROVED]: (
      <View style={[getSearchColumnStyles(CONST.SEARCH.TABLE_COLUMNS.APPROVED, {isApprovedColumnWide: item.shouldShowYearApproved})]}>
      <DateCell
      date={item.approved ?? ''}
      showTooltip
      isLargeScreenWidth
      shouldUseLocalTimeZone
      />
      </View>
      ),

      /**
      * When true, `date` is a full UTC datetime representing a real instant (e.g. submitted/approved/exported) and is rendered
      * in the user's selected timezone, so the day shown matches report history. When false (default), `date` is treated as a
      * calendar value (e.g. transaction date/posted) and rendered with UTC formatting so the displayed day never shifts.
      */
      shouldUseLocalTimeZone?: boolean;
      } & EditableProps<string>;
      function DateCell({date, showTooltip, isLargeScreenWidth, suffixText, shouldUseLocalTimeZone = false, canEdit, onSave}: DateCellProps) {
      const styles = useThemeStyles();
      const {isInNarrowPaneModal} = useResponsiveLayout();
      const {getLocalDateFromDatetime, dateFnsLocale} = useLocalize();
      const {isEditing, anchorRef, isPopoverVisible, popoverPosition, isInverted, startEditing, cancelEditing, handleSave} = usePopoverEditState({
      canEdit,
      value: date,
      onSave,
      });
      let formattedDate: string;
      if (shouldUseLocalTimeZone && date) {
      const localDate = getLocalDateFromDatetime(date);
      const isPastYear = localDate.getFullYear() !== getLocalDateFromDatetime().getFullYear();
      formattedDate = format(localDate, isPastYear ? CONST.DATE.MONTH_DAY_YEAR_ABBR_FORMAT : CONST.DATE.MONTH_DAY_ABBR_FORMAT, {locale: dateFnsLocale});
      } else {
      formattedDate = DateUtils.formatWithUTCTimeZone(
      date,
      DateUtils.doesDateBelongToAPastYear(date) ? CONST.DATE.MONTH_DAY_YEAR_ABBR_FORMAT : CONST.DATE.MONTH_DAY_ABBR_FORMAT,
      dateFnsLocale,
      );
      }
      const displayText = suffixText ? `${formattedDate} • ${suffixText}` : formattedDate;
      const displayContent = (
      <TextWithTooltip
      text={displayText}
      shouldShowTooltip={showTooltip}
      style={[styles.lineHeightLarge, styles.pre, styles.justifyContentCenter, isLargeScreenWidth ? undefined : styles.mutedNormalTextLabel, !!suffixText && styles.flexShrink1]}

      App/src/styles/utils/index.ts

      Lines 2040 to 2049 in d216f70

      case CONST.SEARCH.TABLE_COLUMNS.SUBMITTED:
      columnWidth = {...getWidthStyle(isSubmittedColumnWide ? variables.w102 : variables.w62)};
      break;
      case CONST.SEARCH.TABLE_COLUMNS.APPROVED:
      columnWidth = {...getWidthStyle(isApprovedColumnWide ? 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;

      App/src/libs/SearchUIUtils.ts

      Lines 6998 to 7038 in d216f70

      for (const column of columns) {
      // A caller that knows a column's real minimum passes it in, so use that over the estimate below. The estimates
      // are a second copy of widths that live in the column styles, and several of them are off by 70px or more, so
      // the table scrolls well before it has actually run out of room.
      const knownMinWidth = columnMinWidths?.[column];
      if (knownMinWidth !== undefined) {
      minWidth += knownMinWidth;
      } else if (column === CONST.SEARCH.TABLE_COLUMNS.COMMENTS) {
      minWidth += 36;
      } else if (column === CONST.SEARCH.TABLE_COLUMNS.RECEIPT) {
      minWidth += 28;
      } else if (column === CONST.SEARCH.TABLE_COLUMNS.AVATAR) {
      minWidth += 28;
      } else if (column === CONST.SEARCH.TABLE_COLUMNS.STATUS) {
      minWidth += 80;
      } else if (column === CONST.SEARCH.TABLE_COLUMNS.ACTION) {
      minWidth += (isActionColumnWide ?? type === CONST.SEARCH.DATA_TYPES.TASK) ? 80 : 68;
      } else if (column === CONST.SEARCH.TABLE_COLUMNS.DATE) {
      minWidth += isCreatedDateType(type) ? 80 : 62;
      } else if (
      column === CONST.SEARCH.TABLE_COLUMNS.SUBMITTED ||
      column === CONST.SEARCH.TABLE_COLUMNS.APPROVED ||
      column === CONST.SEARCH.TABLE_COLUMNS.POSTED ||
      column === CONST.SEARCH.TABLE_COLUMNS.WITHDRAWN ||
      column === CONST.SEARCH.TABLE_COLUMNS.GROUP_WITHDRAWN
      ) {
      minWidth += 72;
      } else if (column === CONST.SEARCH.TABLE_COLUMNS.TYPE) {
      minWidth += 16;
      } else if (
      column === CONST.SEARCH.TABLE_COLUMNS.REIMBURSABLE ||
      column === CONST.SEARCH.TABLE_COLUMNS.BILLABLE ||
      column === CONST.SEARCH.TABLE_COLUMNS.MCC ||
      column === CONST.SEARCH.TABLE_COLUMNS.TAX_CODE
      ) {
      minWidth += 80;
      } else if (column === CONST.SEARCH.TABLE_COLUMNS.CATEGORY_GL_CODE || column === CONST.SEARCH.TABLE_COLUMNS.TAG_GL_CODE) {
      minWidth += 130;
      } else {
      minWidth += 200;

    Path coverage and preserved behavior:

    • The Edit columns picker, save handler, query serialization and direct/saved columns:paid selections use the shared registry/column pipeline. Keep Paid visible when selected even if every row is unpaid; only its cells are blank. Existing column order and required Total remain intact.

      const applyChanges = (selectedColumnIds: SearchCustomColumnIds[]) => {
      const updatedAdvancedFilters: Partial<SearchAdvancedFiltersForm> = {
      ...searchAdvancedFiltersForm,
      columns: selectedColumnIds,
      };
      const currentQueryJSON = getCurrentSearchQueryJSON();
      const queryString = buildQueryStringFromFilterFormValues(updatedAdvancedFilters, {
      sortBy: currentQueryJSON?.sortBy,
      sortOrder: currentQueryJSON?.sortOrder,
      });
      // Only the columns change, so it's still the same search - carry the key over rather than letting it be
      // re-derived from the new query.
      Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: queryString, searchKey: currentSearchKey}), {forceReplace: true});
      };
      if (isSeedPending) {
      return <FullScreenLoadingIndicator shouldUseGoBackButton />;
      }
      return (
      <ColumnsSettingsList
      allColumns={allTypeCustomColumns}
      defaultSelectedColumns={defaultTypeCustomColumns}
      currentColumns={currentColumns}
      requiredColumns={requiredColumns}
      groupBy={groupBy}
      groupColumns={allGroupCustomColumns}
      defaultGroupColumns={defaultGroupCustomColumns}
      onSave={applyChanges}
      type={queryType}
      />

      if (columns?.length) {
      // Violations is only meaningful with has:submitted-violation / has:approved-violation.
      const shouldIncludeViolationsColumn = hasValuesIncludeViolationFilter(supportedFilterValues.has);
      const filterValueArray = [...new Set<string>(columns)].filter((column) => shouldIncludeViolationsColumn || column !== CONST.SEARCH.TABLE_COLUMNS.VIOLATIONS);
      if (filterValueArray.length) {
      filtersString.push(`${CONST.SEARCH.SYNTAX_ROOT_KEYS.COLUMNS}:${filterValueArray.map((value) => sanitizeSearchValue(value)).join(',')}`);
      }

      App/src/libs/SearchUIUtils.ts

      Lines 6433 to 6477 in d216f70

      // If there are no visible columns, everything should be visible
      const filteredVisibleColumns = visibleColumns.filter((column) => {
      return Object.values(CONST.SEARCH.TYPE_CUSTOM_COLUMNS.EXPENSE_REPORT).includes(column as ValueOf<typeof CONST.SEARCH.TYPE_CUSTOM_COLUMNS.EXPENSE_REPORT>);
      });
      if (!filteredVisibleColumns.length) {
      return defaultReportColumns;
      }
      // If the user has set custom columns, use their order then add required columns
      const requiredColumns = new Set<SearchColumnType>([CONST.SEARCH.TABLE_COLUMNS.TOTAL]);
      const result: SearchColumnType[] = [];
      for (const col of requiredColumns) {
      if (!filteredVisibleColumns.includes(col as SearchCustomColumnIds)) {
      result.push(col);
      }
      }
      for (const col of filteredVisibleColumns) {
      result.push(col);
      }
      if (Array.isArray(data)) {
      return result;
      }
      return result.filter((column) => {
      if (!reportCustomColumns.has(column)) {
      return true;
      }
      // Keep the sorted column even when empty, otherwise the sort can't be changed.
      if (column === sortBy) {
      return true;
      }
      return Object.keys(data).some((key) => {
      if (!isReportEntry(key)) {
      return false;
      }
      const reportToCheck = data[key];
      return reportToCheck?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && hasReportCustomColumnValue(column, reportToCheck);
      });

    • Cover payment elsewhere, reimbursement, integration-marked payments, submitter-confirmed receipts, snapshot-only history and live optimistic payments through the shared date resolver. Unpaid/cancelled reports stay blank; a later re-payment replaces the old date. Failure rollback restores the prior status, so the date clears without changing payment actions.

      App/src/libs/SearchUIUtils.ts

      Lines 2643 to 2690 in d216f70

      /**
      * Returns the latest payment action between the snapshot-derived one and the given report actions, mirroring the
      * backend paid-by rules: payment actions at or before the latest reimbursement cancellation don't count, and a
      * payment action recorded by the report owner that is paired with a MARKED_REDEEMED action is a "received payment"
      * self-attestation, which doesn't identify a payer.
      */
      function getLastPaidAction(
      snapshotPaidAction: OnyxTypes.ReportAction | undefined,
      actions: OnyxTypes.ReportAction[],
      ownerAccountID: number | undefined,
      ): OnyxTypes.ReportAction | undefined {
      const latestCancellation = findActionByCreated(
      actions,
      [CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENT_DEQUEUED, CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENT_ACH_CANCELED, CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENT_ACH_BOUNCE],
      'latest',
      );
      const isValidPaymentAction = (action: OnyxTypes.ReportAction | undefined): action is OnyxTypes.ReportAction => {
      if (!action) {
      return false;
      }
      if (isMoneyRequestAction(action)) {
      const originalMessage = getOriginalMessage<typeof CONST.REPORT.ACTIONS.TYPE.IOU>(action);
      if (originalMessage?.type !== CONST.IOU.REPORT_ACTION_TYPE.PAY || originalMessage.isSubmitterMarkedPaymentReceived) {
      return false;
      }
      }
      if (latestCancellation && action.created <= latestCancellation.created) {
      return false;
      }
      if (action.actorAccountID !== ownerAccountID) {
      return true;
      }
      return !actions.some(
      (redeemedAction) =>
      redeemedAction.actionName === CONST.REPORT.ACTIONS.TYPE.MARKED_REDEEMED &&
      redeemedAction.actorAccountID === action.actorAccountID &&
      redeemedAction.created >= action.created,
      );
      };
      const seed = isValidPaymentAction(snapshotPaidAction) ? snapshotPaidAction : undefined;
      const candidates = actions.filter(isValidPaymentAction);
      return findActionByCreated(
      candidates,
      [CONST.REPORT.ACTIONS.TYPE.REIMBURSED, CONST.REPORT.ACTIONS.TYPE.MARKED_REIMBURSED, CONST.REPORT.ACTIONS.TYPE.MARK_REIMBURSED_FROM_INTEGRATION, CONST.REPORT.ACTIONS.TYPE.IOU],
      'latest',
      seed,
      );
      }

      onyxData.failureData?.push(
      {
      onyxMethod: Onyx.METHOD.MERGE,
      key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport?.reportID}`,
      value: {
      [optimisticIOUReportAction.reportActionID]: {
      // Same key as the preview copy below, so dismissing this one clears that one too.
      errors: getMicroSecondOnyxErrorWithTranslationKey('iou.error.other', CONST.IOU.PAY_FAILURE_PREVIEW_ERROR_KEY),
      },
      },
      },
      {
      onyxMethod: Onyx.METHOD.MERGE,
      key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`,
      value: {
      ...iouReport,
      },
      },
      );

    • This change adds display/selection, not a new server sorting contract. Mark the Paid header non-sortable and add Paid to nonSortableColumns so the separate Sort by dropdown cannot offer it either. Server Paid sorting is not established by this client checkout; sorting only the loaded page would not establish correct paginated order. Existing sort choices and date filters remain unchanged.

      const nonSortableColumns = new Set<SearchColumnType>([
      CONST.SEARCH.TABLE_COLUMNS.RECEIPT,
      CONST.SEARCH.TABLE_COLUMNS.TYPE,
      CONST.SEARCH.TABLE_COLUMNS.ACTION,
      CONST.SEARCH.TABLE_COLUMNS.IN,
      CONST.SEARCH.TABLE_COLUMNS.AVATAR,
      CONST.SEARCH.TABLE_COLUMNS.VIOLATIONS,
      ]);

      {columns.map(({columnName, translationKey, icon, isColumnSortable, sortColumnName, canEdit}) => {
      if (!shouldShowColumn(columnName)) {
      return null;
      }
      const isSortable = shouldShowSorting && isColumnSortable;
      const sortByColumnName = sortColumnName ?? columnName;
      const isActive = sortBy === sortByColumnName;

      App/src/libs/SearchUIUtils.ts

      Lines 5302 to 5309 in d216f70

      function getSortByOptions(columns: SearchColumnType[], translate: LocalizedTranslate, type?: SearchDataTypes) {
      const sortableColumns: Array<SingleSelectItem<SearchSortBy>> = [];
      for (const column of columns) {
      if (isColumnSortable(column)) {
      sortableColumns.push({text: translate(getSearchColumnTranslationKey(column, type)), value: getSortByForColumn(column)});
      }
      }
      return sortableColumns;

      App/src/libs/actions/Search.ts

      Lines 1245 to 1264 in d216f70

      /**
      * Builds the backend-facing query from a client queryJSON: strips client-only fields and rewrites client-side-sorted
      * columns to a backend-supported date sort.
      */
      function getBackendQueryJSON(queryJSON: Readonly<SearchQueryJSON>) {
      const {exactMatchFilterKeys, flatFilters, limit, ...queryJSONWithoutFlatFilters} = queryJSON;
      const backendQueryJSON = shouldUseBackendDateSortFallback(queryJSON.sortBy)
      ? {
      ...queryJSONWithoutFlatFilters,
      sortBy: CONST.SEARCH.TABLE_COLUMNS.DATE,
      inputQuery: buildSearchQueryString({
      ...queryJSON,
      sortBy: CONST.SEARCH.TABLE_COLUMNS.DATE,
      }),
      rawFilterList: queryJSONWithoutFlatFilters.rawFilterList?.map((filter) =>
      filter.key === CONST.SEARCH.SYNTAX_ROOT_KEYS.SORT_BY ? {...filter, value: CONST.SEARCH.TABLE_COLUMNS.DATE} : filter,
      ),
      }
      : queryJSONWithoutFlatFilters;
      return {backendQueryJSON, limit, exactMatchFilterKeys};

    • Keep narrow report cards unchanged, as with existing optional Submitted/Approved columns. The wide table branch receives columns; the narrow card branch does not. Other search types and the individual report's expense table are outside this Reports-column request.

      function ExpenseReportListItemRow(props: ExpenseReportListItemRowProps) {
      const {isLargeScreenWidth} = useResponsiveLayout();
      if (isLargeScreenWidth) {
      return (
      <ExpenseReportListItemRowWide
      item={props.item}
      reportActions={props.reportActions}
      canSelectMultiple={props.canSelectMultiple}
      isActionLoading={props.isActionLoading}
      onButtonPress={props.onButtonPress}
      onCheckboxPress={props.onCheckboxPress}
      chatReport={props.chatReport}
      isSelectAllChecked={props.isSelectAllChecked}
      isIndeterminate={props.isIndeterminate}
      isDisabledCheckbox={props.isDisabledCheckbox}
      shouldDisableActionPointerEvents={props.shouldDisableActionPointerEvents}
      columns={props.columns}
      shouldShowMarkAsDoneCopy={props.shouldShowMarkAsDoneCopy}
      />
      );
      }
      return (
      <ExpenseReportListItemRowNarrow
      item={props.item}
      onCheckboxPress={props.onCheckboxPress}
      canSelectMultiple={props.canSelectMultiple}
      isSelectAllChecked={props.isSelectAllChecked}
      isIndeterminate={props.isIndeterminate}
      isDisabledCheckbox={props.isDisabledCheckbox}
      />

      function getSearchColumns(type: ValueOf<typeof CONST.SEARCH.DATA_TYPES>, icons: SearchHeaderIcons, groupBy?: SearchGroupBy, isExpenseReportView?: boolean): SearchColumnConfig[] | null {
      switch (type) {
      case CONST.SEARCH.DATA_TYPES.EXPENSE:
      if (!isExpenseReportView && groupBy) {
      return getTransactionGroupHeaders(groupBy, icons);
      }
      return getExpenseHeaders(groupBy);
      case CONST.SEARCH.DATA_TYPES.INVOICE:
      return getExpenseHeaders(groupBy);
      case CONST.SEARCH.DATA_TYPES.TRIP:
      return getExpenseHeaders(groupBy);
      case CONST.SEARCH.DATA_TYPES.TASK:
      return taskHeaders;
      case CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT:
      return getExpenseReportHeaders(icons.Profile);

    Test coverage:

    Observed checks: static tracing of column selection, row production, live/snapshot actions, payment success/failure writes, responsive branches and history diffs. No tests were executed.

    Planned regressions:

    • Extend tests/unit/Search/SearchUIUtilsTest.ts: exact Paid timestamps for each supported action type; newest valid payment after cancellation; blank unpaid/no-history rows; submitter receipt date populated while Paid by remains blank; live optimistic payment absent from the snapshot; failed payment rollback; no mutation of source reports/actions.
    • Add partial-history fixtures: a snapshot IOU/PAY receipt with an empty live array; snapshot cancellation plus an older live payment; same-ID live error/deletion overriding a snapshot payment; failed cancellation rollback retaining the original Paid date. Exercise both snapshot-only history and uncached reports with no qualifying timestamp.
    • Extend tests/unit/Search/SearchColumnsPageTest.tsx: Paid available only for Reports, unchecked by default, selection/order persisted across save/reopen, and an all-unpaid table retains the selected column.
    • Verify the header and row appear in the same position and both header sorting and Sort by omit Paid.
    • Verify localized dates near midnight/year boundaries and past-year width; manually check web/desktop wide tables, resizing/scrolling, reload/deep-linked column selections, offline pay followed by success/failure, and unchanged narrow cards.

    What alternative solutions did you explore? (Optional)

    The smallest rival is adding a Paid cell fed by lastReimbursedAction?.created beside Paid by. It reuses an available value, but it misses submitter-confirmed receipts because the current resolver intentionally excludes them for payer attribution. Removing those exclusions outright would instead regress Paid by. Sharing the payment/cancellation scan while separating timestamp selection from payer attribution covers both contracts.

    App/src/libs/SearchUIUtils.ts

    Lines 2650 to 2689 in d216f70

    snapshotPaidAction: OnyxTypes.ReportAction | undefined,
    actions: OnyxTypes.ReportAction[],
    ownerAccountID: number | undefined,
    ): OnyxTypes.ReportAction | undefined {
    const latestCancellation = findActionByCreated(
    actions,
    [CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENT_DEQUEUED, CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENT_ACH_CANCELED, CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENT_ACH_BOUNCE],
    'latest',
    );
    const isValidPaymentAction = (action: OnyxTypes.ReportAction | undefined): action is OnyxTypes.ReportAction => {
    if (!action) {
    return false;
    }
    if (isMoneyRequestAction(action)) {
    const originalMessage = getOriginalMessage<typeof CONST.REPORT.ACTIONS.TYPE.IOU>(action);
    if (originalMessage?.type !== CONST.IOU.REPORT_ACTION_TYPE.PAY || originalMessage.isSubmitterMarkedPaymentReceived) {
    return false;
    }
    }
    if (latestCancellation && action.created <= latestCancellation.created) {
    return false;
    }
    if (action.actorAccountID !== ownerAccountID) {
    return true;
    }
    return !actions.some(
    (redeemedAction) =>
    redeemedAction.actionName === CONST.REPORT.ACTIONS.TYPE.MARKED_REDEEMED &&
    redeemedAction.actorAccountID === action.actorAccountID &&
    redeemedAction.created >= action.created,
    );
    };
    const seed = isValidPaymentAction(snapshotPaidAction) ? snapshotPaidAction : undefined;
    const candidates = actions.filter(isValidPaymentAction);
    return findActionByCreated(
    candidates,
    [CONST.REPORT.ACTIONS.TYPE.REIMBURSED, CONST.REPORT.ACTIONS.TYPE.MARKED_REIMBURSED, CONST.REPORT.ACTIONS.TYPE.MARK_REIMBURSED_FROM_INTEGRATION, CONST.REPORT.ACTIONS.TYPE.IOU],
    'latest',
    seed,
    );

    it('should leave paidBy blank when the submitter marked the payment as received', () => {
    const [sections] = callGetReportSections(
    makeReportFilterTestData({type: CONST.REPORT.TYPE.EXPENSE, stateNum: CONST.REPORT.STATE_NUM.APPROVED, statusNum: CONST.REPORT.STATUS_NUM.REIMBURSED}),
    {
    reportActions: {
    [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${rptFilterReportID}`]: [
    {
    reportActionID: 'received-pay-1',
    actionName: CONST.REPORT.ACTIONS.TYPE.IOU,
    originalMessage: {type: CONST.IOU.REPORT_ACTION_TYPE.PAY, isSubmitterMarkedPaymentReceived: true},
    actorAccountID: adminAccountID,
    created: '2024-12-22 09:30:00',
    },
    {
    reportActionID: 'received-reimbursed-1',
    actionName: CONST.REPORT.ACTIONS.TYPE.MARKED_REIMBURSED,
    actorAccountID: adminAccountID,
    created: '2024-12-22 09:30:00',
    },
    {
    reportActionID: 'received-redeemed-1',
    actionName: CONST.REPORT.ACTIONS.TYPE.MARKED_REDEEMED,
    actorAccountID: adminAccountID,
    created: '2024-12-22 09:30:01',
    },
    ],
    },
    },
    );
    const item = sections.find((s) => s.keyForList === rptFilterReportID);
    expect(item?.paidByAccountID).toBeUndefined();
    expect(item?.formattedPaidBy).toBe('');

    Another concrete alternative is adding a persisted Report.paid field and optimistically writing it in PayMoneyRequest.ts. That requires an agreed server field for all payment producers, cancellation/re-payment and rollback, while the current client already has timestamped payment actions and live-action subscriptions. A future canonical server timestamp would be useful for history-free results, but this proposal does not invent or persist a field unsupported by the current contract.

    /** The date the report was created */
    created?: string;
    /** The date the report was submitted */
    submitted?: string;
    /** The date the report was approved */
    approved?: string;

    onyxData.optimisticData?.push(
    {
    onyxMethod: Onyx.METHOD.MERGE,
    key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport?.reportID}`,
    value: {
    [optimisticIOUReportAction.reportActionID]: {
    ...(optimisticIOUReportAction as OnyxTypes.ReportAction),
    pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD,
    },
    },
    },
    {
    onyxMethod: Onyx.METHOD.MERGE,
    key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`,
    value: {
    lastMessageText: getReportActionText(optimisticIOUReportAction),
    lastMessageHtml: getReportActionHtml(optimisticIOUReportAction),
    lastVisibleActionCreated: optimisticIOUReportAction.created,
    hasOutstandingChildRequest: false,
    statusNum: CONST.REPORT.STATUS_NUM.REIMBURSED,
    stateNum: CONST.REPORT.STATE_NUM.APPROVED,
    pendingFields: {
    preview: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE,
    reimbursed: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE,
    partial: full ? null : CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE,
    nextStep: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE,
    },
    nextStep: optimisticNextStep,
    errors: null,
    },

    A page-local scan in ExpenseReportListItemRowWide.tsx would also show dates, but it bypasses the shared row-data owner and duplicates action selection per rendered row. The shared producer gives every report row one consistent date before rendering.

    App/src/libs/SearchUIUtils.ts

    Lines 2873 to 2903 in 4d934ce

    reportIDToTransactions[reportKey] = {
    ...reportItem,
    action: getAction(allActions, primaryActionExclusions),
    allActions,
    ...getRowCapabilities(allActions),
    keyForList: String(reportItem.reportID),
    groupedBy: CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT,
    from: (fromDetails ?? emptyPersonalDetails) as OnyxTypes.PersonalDetails,
    to: (toDetails ?? emptyPersonalDetails) as OnyxTypes.PersonalDetails,
    exported: lastExportedActionByReportID.get(reportItem.reportID)?.created ?? '',
    submitted: getSubmittedDate(reportItem, actions),
    approved: getApprovedDate(reportItem, actions),
    exportedTo: getExportedToSortValue(exportedToNamesByReportID, reportItem.reportID),
    firstApproved,
    firstApproverAvatar: firstApproverDetails?.avatar,
    firstApproverAccountID,
    formattedFirstApprover,
    paidByAvatar: paidByDetails?.avatar,
    paidByAccountID,
    formattedPaidBy,
    formattedFrom,
    formattedTo,
    formattedStatus,
    formattedPaidStatus,
    transactions,
    shouldShowStatusAsPending,
    ...(reportPendingAction ? {pendingAction: reportPendingAction} : {}),
    shouldShowYear: shouldShowYearCreatedReport,
    shouldShowYearSubmitted: shouldShowYearSubmittedReport,
    shouldShowYearApproved: shouldShowYearApprovedReport,
    shouldShowYearExported: shouldShowYearExportedReport,

  11. Dip-G-Git commented on Oct 9, 2026

    @Dip-G-Git
  12. github-actions commented on Oct 9, 2026

    @github-actions
    Contributor

    🤖 ProposalPolice™ is tracking duplicate proposals for this issue using an OpenAI Conversation. This comment stores that Conversation's ID and can be safely ignored.

  13. 84 remaining items

  14. removed
    Help WantedApply this label when an issue is open to proposals by contributors
    on Oct 13, 2026
  15. melvin-bot commented on Oct 13, 2026

    @melvin-bot
    Author

    📣 @sam5235 You have been assigned to this job!
    Please apply to the Upwork job and leave a comment on the Github issue letting us know when we can expect a PR to be ready for review 🧑‍💻
    Once you apply to this job, your Upwork ID will be stored and you will be automatically hired for future jobs!
    Keep in mind: Code of Conduct | Contributing 📖

  16. sam5235 commented on Oct 13, 2026

    @sam5235
    Contributor

    Thanks! The PR will be ready for review within 24 hours.

    @JS00001, thanks for confirming. I'll build sorting in the App now and we can test it against staging once the backend change is there.

    @JS00001, the Upwork job link gives me "Only invited users can find, view and apply to the job". Could someone invite me? My profile: https://www.upwork.com/freelancers/~0123dda34004da49e6. If you're having issue with Upwork profile link, you can try inviting me using https://www.upwork.com/freelancers/samiyahamid.

  17. JS00001 commented on Oct 13, 2026

    @JS00001
    Contributor

    I'm not sure, cc @mallenexpensify do you know more about this ^ ?

  18. sam5235 commented on Oct 14, 2026

    @sam5235
    Contributor

    @JS00001 PR is up and held on the backend: #103956

    Testing it found that the backend is needed for more than sorting:

    1. Sorting: sortBy:paid still returns 401 Invalid Query.
    2. Display: the App reads the paid date from each report's payment action, but Search only returns those actions when Paid by is also selected. With only Paid selected, the response has the IOU create and APPROVED actions but no MARKEDREIMBURSED, so the cell is blank until the report is opened. This corrects my proposal, which said display works without backend changes; that only holds when Paid by is selected.

    Suggested fix: accept sortBy:paid, and return a paid date on each report like submitted and approved, whatever columns are selected. Once you confirm the field name, I'll add it to the App (prefer report.paid, fall back to the payment actions), since a new report key needs your OK.

    If that will take a while, a stopgap is for the App to also request paidBy whenever paid is selected, so the actions come back. I'd only do that if you are OK with it, since it relies on how the backend picks which actions to return.

  19. sam5235 commented on Oct 14, 2026

    @sam5235
    Contributor

    @mallenexpensify following up on the Upwork job for this issue: https://www.upwork.com/jobs/~022108633277897041341 shows "Only invited users can find, view and apply to the job" for me, while other Expensify jobs open and accept applications normally, so it seems specific to this posting. Could you look into it?

    upwork-job-issue.mp4
  20. mallenexpensify commented on Oct 18, 2026

    @mallenexpensify
    Contributor

    @sam5235 can you please accept the job below? Please reply here and tag me once you have.
    https://www.upwork.com/jobs/~022108633277897041341

    After being hired the first time, it'll be quicker/easier in the future (assuming automation is working, we've been having issues) Thx

  21. sam5235 commented on Oct 18, 2026

    @sam5235
    Contributor

    @mallenexpensify, thanks for looking into the issue. It is resolved and I have now accepted the job offer.

  22. sam5235 commented on Oct 19, 2026

    @sam5235
    Contributor

    @JS00001 the backend change is live.
    Search returns paid on reports and sortBy:paid works on staging and production. The PR now reads report.paid and It adds paid to the report key whitelist.

  23. FitseTLT commented on Oct 19, 2026

    @FitseTLT
    Contributor

    @JS00001 Can you confirm the BE change is done? Should we proceed?

  24. JS00001 commented on Oct 19, 2026

    @JS00001
    Contributor

    Yep looks like the BE changes are done

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

ExternalAdded to denote the issue can be worked on by a contributorReviewingHas a PR in reviewWeeklyKSv2

Type

No type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions