diff --git a/src/components/Search/SearchBulkActionsButton.tsx b/src/components/Search/SearchBulkActionsButton.tsx
index 02f8d02e93ab..d588a4e7d4fb 100644
--- a/src/components/Search/SearchBulkActionsButton.tsx
+++ b/src/components/Search/SearchBulkActionsButton.tsx
@@ -126,8 +126,8 @@ function SearchBulkActionsButton({queryJSON}: SearchBulkActionsButtonProps) {
return Object.keys(transactionsToCount).reduce((count, key) => {
if (key.startsWith(CONST.SEARCH.GROUP_PREFIX)) {
- const group = searchData?.[key as keyof typeof searchData] as {count?: number} | undefined;
- return count + (group?.count ?? 0);
+ const group = searchData?.[key as keyof typeof searchData] as {count?: number; isCashBack?: boolean} | undefined;
+ return count + (group?.isCashBack ? 1 : (group?.count ?? 0));
}
return count + 1;
}, 0);
diff --git a/src/components/Search/SearchList/ListItem/GroupHeader.tsx b/src/components/Search/SearchList/ListItem/GroupHeader.tsx
index b2d4635acbae..ebb6f70ab271 100644
--- a/src/components/Search/SearchList/ListItem/GroupHeader.tsx
+++ b/src/components/Search/SearchList/ListItem/GroupHeader.tsx
@@ -21,9 +21,11 @@ import useThemeStyles from '@hooks/useThemeStyles';
import type {TransactionPreviewData} from '@libs/actions/Search';
import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID';
import type {ModifiedMouseEvent} from '@libs/Navigation/helpers/openInternalRouteInNewTab';
-import {getColumnsToShow} from '@libs/SearchUIUtils';
+import {getColumnsToShow, isCashBackWithdrawalGroup} from '@libs/SearchUIUtils';
import {isDeletedTransaction} from '@libs/TransactionUtils';
+import variables from '@styles/variables';
+
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {ReportAction, ReportActions, Transaction} from '@src/types/onyx';
@@ -176,6 +178,7 @@ function GroupHeader({
const isEmpty = groupItem.transactions.length === 0 && !hasSnapshotTransactions && !groupItem.transactionsQueryJSON;
const isDisabled = item.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE;
const isDisabledOrEmpty = isEmpty || isDisabled;
+ const isCashBackWithdrawal = isCashBackWithdrawalGroup(groupItem);
const effectiveTransactions = useMemo((): TransactionListItemType[] => {
if (isExpenseReportType || groupItem.transactions.length > 0) {
@@ -301,6 +304,7 @@ function GroupHeader({
);
case CONST.SEARCH.GROUP_BY.CATEGORY:
@@ -378,7 +382,7 @@ function GroupHeader({
if (isExpenseReportType) {
onSelectRow(withOriginalKey(item), transactionPreviewData, event);
}
- if (!isExpenseReportType) {
+ if (!isExpenseReportType && !isCashBackWithdrawal) {
onToggle();
}
};
@@ -398,7 +402,9 @@ function GroupHeader({
accessibilityLabel={item.text ?? ''}
role={getButtonRole(true)}
isNested
- hoverStyle={[!isExpanded && !item.isDisabled && styles.hoveredComponentBG, isItemSelected && styles.activeComponentBG]}
+ interactive={!isCashBackWithdrawal}
+ pressDimmingValue={isCashBackWithdrawal ? 1 : undefined}
+ hoverStyle={[!isExpanded && !item.isDisabled && !isCashBackWithdrawal && styles.hoveredComponentBG, isItemSelected && styles.activeComponentBG]}
dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true, [CONST.INNER_BOX_SHADOW_ELEMENT]: false}}
onMouseDown={(e) => e.preventDefault()}
id={item.keyForList ?? ''}
@@ -424,29 +430,35 @@ function GroupHeader({
{renderHeader(hovered)}
- {isLargeScreenWidth && (
- {
- if (isEmpty && !shouldDisplayEmptyView) {
- handlePress();
- return;
- }
- onToggle();
- }}
- style={[styles.p3Half, styles.justifyContentCenter, styles.alignItemsCenter, styles.pv2]}
- accessibilityRole={CONST.ROLE.BUTTON}
- accessibilityLabel={isExpanded ? CONST.ACCESSIBILITY_LABELS.COLLAPSE : CONST.ACCESSIBILITY_LABELS.EXPAND}
- sentryLabel={CONST.SENTRY_LABEL.SEARCH.GROUP_EXPAND_TOGGLE}
- >
- {({hovered: arrowHovered}) => (
-
- )}
-
- )}
+ {isLargeScreenWidth &&
+ (isCashBackWithdrawal ? (
+ // Reserves the toggle's footprint so the Total column stays aligned with the settlement rows.
+
+
+
+ ) : (
+ {
+ if (isEmpty && !shouldDisplayEmptyView) {
+ handlePress();
+ return;
+ }
+ onToggle();
+ }}
+ style={[styles.p3Half, styles.justifyContentCenter, styles.alignItemsCenter, styles.pv2]}
+ accessibilityRole={CONST.ROLE.BUTTON}
+ accessibilityLabel={isExpanded ? CONST.ACCESSIBILITY_LABELS.COLLAPSE : CONST.ACCESSIBILITY_LABELS.EXPAND}
+ sentryLabel={CONST.SENTRY_LABEL.SEARCH.GROUP_EXPAND_TOGGLE}
+ >
+ {({hovered: arrowHovered}) => (
+
+ )}
+
+ ))}
{isLargeScreenWidth && subHeaderColumns.length > 0 && (
diff --git a/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx b/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx
index 2b2e3dad35e1..9fa74ac92856 100644
--- a/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx
+++ b/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx
@@ -25,7 +25,7 @@ import type {TransactionPreviewData} from '@libs/actions/Search';
import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID';
import type {ModifiedMouseEvent} from '@libs/Navigation/helpers/openInternalRouteInNewTab';
import {getLoginByAccountID} from '@libs/PersonalDetailsUtils';
-import {getSections} from '@libs/SearchUIUtils';
+import {getSections, isCashBackWithdrawalGroup} from '@libs/SearchUIUtils';
import {getVisibleTransactionViolations} from '@libs/TransactionUtils';
import variables from '@styles/variables';
@@ -311,7 +311,13 @@ function TransactionGroupListItemImpl({
});
};
+ const isCashBackWithdrawal = isCashBackWithdrawalGroup(groupItem);
+
const onPress = (event?: ModifiedMouseEvent) => {
+ // A cash back row has no children to drill into.
+ if (isCashBackWithdrawal) {
+ return;
+ }
const isEmptyGroupWithoutTransactionsQuery = transactions.length === 0 && !groupItem.transactionsQueryJSON;
if (isExpenseReportType || isEmptyGroupWithoutTransactionsQuery) {
onSelectRow(item, transactionPreviewData, event);
@@ -334,6 +340,9 @@ function TransactionGroupListItemImpl({
};
const onExpandIconPress = () => {
+ if (isCashBackWithdrawal) {
+ return;
+ }
if (isEmpty && !shouldDisplayEmptyView) {
onPress();
// onPress handles handleToggle() for us, so we return early to avoid calling it twice
@@ -381,7 +390,7 @@ function TransactionGroupListItemImpl({
canSelectMultiple={canSelectMultiple}
isSelectAllChecked={isSelectAllChecked}
isIndeterminate={isIndeterminate}
- onDownArrowClick={onExpandIconPress}
+ onDownArrowClick={isCashBackWithdrawal ? undefined : onExpandIconPress}
isExpanded={isExpanded}
/>
),
@@ -570,7 +579,9 @@ function TransactionGroupListItemImpl({
accessibilityLabel={item.text ?? ''}
role={getButtonRole(true)}
isNested
- hoverStyle={[!isExpanded && !item.isDisabled && styles.hoveredComponentBG, isItemSelected && styles.activeComponentBG]}
+ interactive={!isCashBackWithdrawal}
+ pressDimmingValue={isCashBackWithdrawal ? 1 : undefined}
+ hoverStyle={[!isExpanded && !item.isDisabled && !isCashBackWithdrawal && styles.hoveredComponentBG, isItemSelected && styles.activeComponentBG]}
dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true, [CONST.INNER_BOX_SHADOW_ELEMENT]: false}}
onMouseDown={(e) => e.preventDefault()}
id={item.keyForList ?? ''}
@@ -599,7 +610,7 @@ function TransactionGroupListItemImpl({
header={getHeader(hovered)}
onPress={onExpandIconPress}
expandButtonStyle={isLargeScreenWidth ? styles.pv2 : styles.pv4Half}
- shouldShowToggleButton={isLargeScreenWidth}
+ shouldShowToggleButton={isLargeScreenWidth && !isCashBackWithdrawal}
borderBottomStyle={isLargeScreenWidth ? styles.borderNone : isItemSelected && {borderColor: theme.buttonHoveredBG}}
sentryLabel={CONST.SENTRY_LABEL.SEARCH.GROUP_EXPAND_TOGGLE}
>
diff --git a/src/components/Search/SearchList/ListItem/WithdrawalIDListItemHeader.tsx b/src/components/Search/SearchList/ListItem/WithdrawalIDListItemHeader.tsx
index 648cef0226e4..931941e04948 100644
--- a/src/components/Search/SearchList/ListItem/WithdrawalIDListItemHeader.tsx
+++ b/src/components/Search/SearchList/ListItem/WithdrawalIDListItemHeader.tsx
@@ -96,8 +96,9 @@ function WithdrawalIDListItemHeaderImpl({
);
const {debitedAmount, debitedCurrency, creditedAmount, creditedCurrency} = withdrawalIDItem;
- const badgeProps = getSettlementStatusBadgeProps(withdrawalIDItem.state, translate, theme);
- const settlementStatus = getSettlementStatus(withdrawalIDItem.state);
+ const isCashBack = !!withdrawalIDItem.isCashBack;
+ const badgeProps = getSettlementStatusBadgeProps(withdrawalIDItem.state, translate, theme, isCashBack);
+ const settlementStatus = isCashBack ? undefined : getSettlementStatus(withdrawalIDItem.state);
const statusBadge = !!badgeProps && (
-
+
),
// A settlement that did not convert currencies reports neither amount, and an amount says nothing without the currency it moved in.
@@ -271,6 +272,13 @@ function WithdrawalIDListItemHeaderImpl({
onPress={onDownArrowClick}
/>
)}
+ {!onDownArrowClick &&
+ isCashBack && (
+ // Reserves the arrow's footprint so the total stays aligned with the settlement rows.
+
+
+
+ )}
)}
diff --git a/src/components/Search/SearchList/ListItem/types.ts b/src/components/Search/SearchList/ListItem/types.ts
index 9baaa9850982..829b0762a61f 100644
--- a/src/components/Search/SearchList/ListItem/types.ts
+++ b/src/components/Search/SearchList/ListItem/types.ts
@@ -428,6 +428,9 @@ type TransactionWithdrawalIDGroupListItemType = TransactionGroupListItemType & {
/** Final and formatted "withdrawalID" value used for displaying and sorting */
formattedWithdrawalID?: string;
+ /** Value used for sorting the "withdrawal status" column */
+ settlementStatusRank?: number;
+
/** Whether any withdrawn date in the current results belongs to a past year */
shouldShowYearWithdrawn?: boolean;
};
diff --git a/src/components/Search/SearchSelectionFooter.tsx b/src/components/Search/SearchSelectionFooter.tsx
index 0daa952154c3..c957e5fe8e74 100644
--- a/src/components/Search/SearchSelectionFooter.tsx
+++ b/src/components/Search/SearchSelectionFooter.tsx
@@ -202,7 +202,8 @@ function SearchSelectionFooter({searchResults}: SearchSelectionFooterProps) {
// Source figures for every loaded group, not just the selected ones. The grouped response caches every group's
// converted value, so stamping them all lets a later selection of another group reuse the cache instead of
// re-running the grouped query. Uses the same expense-signed figure as getEntrySource so a stamp always matches
- // the live source the freshness checks compare against.
+ // the live source the freshness checks compare against, which is why cash back is signed the other way here:
+ // selectionBuilders gives a selected cash back row a positive groupAmount, and a mismatch would never go fresh.
const loadedGroupSourceByKey = useMemo(() => {
const data = currentSearchResults?.data;
if (!isGroupedSearch || !data) {
@@ -215,7 +216,8 @@ function SearchSelectionFooter({searchResults}: SearchSelectionFooterProps) {
}
const group: unknown = data[key];
if (group && typeof group === 'object' && 'total' in group && typeof group.total === 'number') {
- sources[key] = -Math.abs(group.total);
+ const isCashBack = 'isCashBack' in group && group.isCashBack === true;
+ sources[key] = isCashBack ? Math.abs(group.total) : -Math.abs(group.total);
}
}
return sources;
diff --git a/src/components/Search/selectionBuilders.ts b/src/components/Search/selectionBuilders.ts
index 9cd195d835b6..d3901fe4e250 100644
--- a/src/components/Search/selectionBuilders.ts
+++ b/src/components/Search/selectionBuilders.ts
@@ -1,6 +1,6 @@
import {isSplitAction} from '@libs/ReportSecondaryActionUtils';
import {canEditFieldOfMoneyRequest, canHoldUnholdReportAction, canRejectReportAction, getReimbursableTotal, isMoneyRequestReport, isOneTransactionReport} from '@libs/ReportUtils';
-import {isTransactionListItemType, isTransactionReportGroupListItemType} from '@libs/SearchUIUtils';
+import {isCashBackWithdrawalGroup, isTransactionListItemType, isTransactionReportGroupListItemType} from '@libs/SearchUIUtils';
import {getOriginalTransactionWithSplitInfo, hasValidModifiedAmount, isExpenseUnreported, isOnHold} from '@libs/TransactionUtils';
import CONST from '@src/CONST';
@@ -133,6 +133,7 @@ function mapEmptyReportToSelectedEntry(item: TransactionReportGroupListItemType
}
const currency = item.currency ?? '';
+ const isCashBack = isCashBackWithdrawalGroup(item);
return [
item.keyForList ?? '',
@@ -151,6 +152,8 @@ function mapEmptyReportToSelectedEntry(item: TransactionReportGroupListItemType
policyID: item.policyID ?? CONST.POLICY.ID_FAKE,
amount: item.total ?? 0,
currency,
+ // Without groupAmount the footer falls back to -Math.abs(amount), flipping the credit's sign.
+ ...(isCashBack ? {groupAmount: Math.abs(item.total ?? 0)} : {}),
...(currency ? {groupCurrency: currency} : {}),
},
];
diff --git a/src/languages/de.ts b/src/languages/de.ts
index cf8dacb2f0de..fc21cc3d208e 100644
--- a/src/languages/de.ts
+++ b/src/languages/de.ts
@@ -9218,12 +9218,7 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc
},
},
settlement: {
- status: {
- pending: 'Ausstehend',
- cleared: 'Ausgeglichen',
- failed: 'Fehlgeschlagen',
- never: 'Nie',
- },
+ status: {pending: 'Ausstehend', cleared: 'Ausgeglichen', failed: 'Fehlgeschlagen', never: 'Nie', cashBack: 'Cashback'},
failedError: ({link}: {link: string}) => `Wir versuchen diese Abrechnung erneut, sobald du dein Konto entsperrst.`,
withdrawalInfo: ({date, withdrawalID}: {date: string; withdrawalID: number}) => `${date} • Auszahlungs-ID: ${withdrawalID}`,
},
diff --git a/src/languages/el.ts b/src/languages/el.ts
index 5b6b8391c4d1..a09702c1351f 100644
--- a/src/languages/el.ts
+++ b/src/languages/el.ts
@@ -9437,12 +9437,7 @@ ${reportName}`,
},
},
settlement: {
- status: {
- pending: 'Σε εκκρεμότητα',
- cleared: 'Εκκαθαρισμένο',
- failed: 'Απέτυχε',
- never: 'Ποτέ',
- },
+ status: {pending: 'Σε εκκρεμότητα', cleared: 'Εκκαθαρισμένο', failed: 'Απέτυχε', never: 'Ποτέ', cashBack: 'Επιστροφή μετρητών'},
failedError: ({link}: {link: string}) => `Θα προσπαθήσουμε ξανά για αυτόν τον διακανονισμό όταν ξεκλειδώσετε τον λογαριασμό σας.`,
withdrawalInfo: ({date, withdrawalID}: {date: string; withdrawalID: number}) => `${date} • Αναγνωριστικό ανάληψης: ${withdrawalID}`,
},
diff --git a/src/languages/en.ts b/src/languages/en.ts
index 2884f2968382..8345f27ea42e 100644
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -9349,6 +9349,7 @@ const translations = {
cleared: 'Cleared',
failed: 'Failed',
never: 'Never',
+ cashBack: 'Cash back',
},
failedError: ({link}: {link: string}) => `We'll retry this settlement when you unlock your account.`,
withdrawalInfo: ({date, withdrawalID}: {date: string; withdrawalID: number}) => `${date} • Withdrawal ID: ${withdrawalID}`,
diff --git a/src/languages/es.ts b/src/languages/es.ts
index db2d74a3dbf0..f94dd3e9fd4f 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -9045,12 +9045,7 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`,
},
},
settlement: {
- status: {
- pending: 'Pendiente',
- cleared: 'Liquidado',
- failed: 'Fallido',
- never: 'Nunca',
- },
+ status: {pending: 'Pendiente', cleared: 'Liquidado', failed: 'Fallido', never: 'Nunca', cashBack: 'Devolución de dinero'},
failedError: ({link}: {link: string}) => `Reintentaremos esta liquidación cuando desbloquees tu cuenta.`,
withdrawalInfo: ({date, withdrawalID}: {date: string; withdrawalID: number}) => `${date} • ID de retiro: ${withdrawalID}`,
},
diff --git a/src/languages/fr.ts b/src/languages/fr.ts
index 0a7e3518a21d..8691770c90cd 100644
--- a/src/languages/fr.ts
+++ b/src/languages/fr.ts
@@ -9253,12 +9253,7 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e
},
},
settlement: {
- status: {
- pending: 'En attente',
- cleared: 'Compensé',
- failed: 'Échec',
- never: 'Jamais',
- },
+ status: {pending: 'En attente', cleared: 'Compensé', failed: 'Échec', never: 'Jamais', cashBack: 'Remboursement en espèces'},
failedError: ({link}: {link: string}) => `Nous réessaierons ce règlement lorsque vous déverrouillerez votre compte.`,
withdrawalInfo: ({date, withdrawalID}: {date: string; withdrawalID: number}) => `${date} • ID de retrait : ${withdrawalID}`,
},
diff --git a/src/languages/it.ts b/src/languages/it.ts
index b6ed31040144..58eb16227626 100644
--- a/src/languages/it.ts
+++ b/src/languages/it.ts
@@ -9190,12 +9190,7 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`,
},
},
settlement: {
- status: {
- pending: 'In sospeso',
- cleared: 'Compensato',
- failed: 'Non riuscito',
- never: 'Mai',
- },
+ status: {pending: 'In sospeso', cleared: 'Compensato', failed: 'Non riuscito', never: 'Mai', cashBack: 'Cashback'},
failedError: ({link}: {link: string}) => `Riproveremo a effettuare questa liquidazione quando sblocchi il tuo conto.`,
withdrawalInfo: ({date, withdrawalID}: {date: string; withdrawalID: number}) => `${date} • ID prelievo: ${withdrawalID}`,
},
diff --git a/src/languages/ja.ts b/src/languages/ja.ts
index ee2e82bc9ddd..c33fa017f34a 100644
--- a/src/languages/ja.ts
+++ b/src/languages/ja.ts
@@ -9068,12 +9068,7 @@ ${reportName}`,
},
},
settlement: {
- status: {
- pending: '保留中',
- cleared: '支払済み',
- failed: '失敗しました',
- never: 'なし',
- },
+ status: {pending: '保留中', cleared: '支払済み', failed: '失敗しました', never: 'なし', cashBack: 'キャッシュバック'},
failedError: ({link}: {link: string}) => `アカウントのロックを解除すると、この精算を再試行します。`,
withdrawalInfo: ({date, withdrawalID}: {date: string; withdrawalID: number}) => `${date}・出金 ID:${withdrawalID}`,
},
diff --git a/src/languages/nl.ts b/src/languages/nl.ts
index 24defae3e317..968ea9c058e4 100644
--- a/src/languages/nl.ts
+++ b/src/languages/nl.ts
@@ -9161,12 +9161,7 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`,
},
},
settlement: {
- status: {
- pending: 'In behandeling',
- cleared: 'Verrekend',
- failed: 'Mislukt',
- never: 'Nooit',
- },
+ status: {pending: 'In behandeling', cleared: 'Verrekend', failed: 'Mislukt', never: 'Nooit', cashBack: 'Cashback'},
failedError: ({link}: {link: string}) => `We proberen deze afrekening opnieuw zodra je je account ontgrendelt.`,
withdrawalInfo: ({date, withdrawalID}: {date: string; withdrawalID: number}) => `${date} • Opname-ID: ${withdrawalID}`,
},
diff --git a/src/languages/pl.ts b/src/languages/pl.ts
index d260cc752367..f34b76c0c387 100644
--- a/src/languages/pl.ts
+++ b/src/languages/pl.ts
@@ -9138,12 +9138,7 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`,
},
},
settlement: {
- status: {
- pending: 'Oczekujące',
- cleared: 'Wyczyszczono',
- failed: 'Niepowodzenie',
- never: 'Nigdy',
- },
+ status: {pending: 'Oczekujące', cleared: 'Wyczyszczono', failed: 'Niepowodzenie', never: 'Nigdy', cashBack: 'Zwrot gotówki'},
failedError: ({link}: {link: string}) => `Spróbujemy ponownie rozliczyć tę płatność, gdy odblokujesz swoje konto.`,
withdrawalInfo: ({date, withdrawalID}: {date: string; withdrawalID: number}) => `${date} • ID wypłaty: ${withdrawalID}`,
},
diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts
index 771050f8bcad..39439220ab3e 100644
--- a/src/languages/pt-BR.ts
+++ b/src/languages/pt-BR.ts
@@ -9156,12 +9156,7 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`,
},
},
settlement: {
- status: {
- pending: 'Pendente',
- cleared: 'Compensado',
- failed: 'Falhou',
- never: 'Nunca',
- },
+ status: {pending: 'Pendente', cleared: 'Compensado', failed: 'Falhou', never: 'Nunca', cashBack: 'Cashback'},
failedError: ({link}: {link: string}) => `Tentaremos processar este acerto novamente quando você desbloquear sua conta.`,
withdrawalInfo: ({date, withdrawalID}: {date: string; withdrawalID: number}) => `${date} • ID de saque: ${withdrawalID}`,
},
diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts
index e29622ce0acf..530a847b2f9b 100644
--- a/src/languages/zh-hans.ts
+++ b/src/languages/zh-hans.ts
@@ -8831,12 +8831,7 @@ ${reportName}`,
},
},
settlement: {
- status: {
- pending: '待处理',
- cleared: '已入账',
- failed: '失败',
- never: '从不',
- },
+ status: {pending: '待处理', cleared: '已入账', failed: '失败', never: '从不', cashBack: '返现'},
failedError: ({link}: {link: string}) => `当你解锁你的账户后,我们会重试此结算。`,
withdrawalInfo: ({date, withdrawalID}: {date: string; withdrawalID: number}) => `${date} • 提现 ID:${withdrawalID}`,
},
diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts
index fb57d415d458..7fa7737c51fb 100644
--- a/src/libs/SearchUIUtils.ts
+++ b/src/libs/SearchUIUtils.ts
@@ -373,7 +373,7 @@ const transactionWithdrawalIDGroupColumnNamesToSortingProperty: TransactionWithd
[CONST.SEARCH.TABLE_COLUMNS.GROUP_BANK_ACCOUNT]: 'bankName' as const,
[CONST.SEARCH.TABLE_COLUMNS.GROUP_WITHDRAWN]: 'debitPosted' as const,
[CONST.SEARCH.TABLE_COLUMNS.WITHDRAWN]: 'debitPosted' as const,
- [CONST.SEARCH.TABLE_COLUMNS.GROUP_WITHDRAWAL_STATUS]: 'state' as const,
+ [CONST.SEARCH.TABLE_COLUMNS.GROUP_WITHDRAWAL_STATUS]: 'settlementStatusRank' as const,
[CONST.SEARCH.TABLE_COLUMNS.GROUP_WITHDRAWAL_ID]: 'formattedWithdrawalID' as const,
// Both the backend page selection and this local sort rank the amounts as stored, without converting between
// currencies, so a group can outrank one that is worth more in another currency.
@@ -1312,6 +1312,20 @@ function isTransactionReportGroupListItemType(item: ListItem): item is Transacti
return isTransactionGroupListItemType(item) && 'groupedBy' in item && item.groupedBy === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT;
}
+/**
+ * Type guard that checks if something is a TransactionWithdrawalIDGroupListItemType
+ */
+function isTransactionWithdrawalIDGroupListItemType(item: ListItem): item is TransactionWithdrawalIDGroupListItemType {
+ return isTransactionGroupListItemType(item) && 'groupedBy' in item && item.groupedBy === CONST.SEARCH.GROUP_BY.WITHDRAWAL_ID;
+}
+
+/**
+ * Checks if a row is a cash back credit rather than a card settlement withdrawal
+ */
+function isCashBackWithdrawalGroup(item: ListItem): boolean {
+ return isTransactionWithdrawalIDGroupListItemType(item) && !!item.isCashBack;
+}
+
/**
* Type guard that checks if something is a TransactionCategoryGroupListItemType
*/
@@ -3465,6 +3479,9 @@ function getCardSections(
return [cardSectionsValues, cardSectionsValues.length, hasDeletedTransactionInData(data)];
}
+// Cash back is not a settlement state, so it ranks past all of them instead of taking a slot between them.
+const CASH_BACK_STATUS_SORT_RANK = Number.MAX_SAFE_INTEGER;
+
/**
* @private
* Organizes data into List Sections grouped by card for display, for the TransactionWithdrawalIDGroupListItemType of Search Results.
@@ -3492,6 +3509,7 @@ function getWithdrawalIDSections(data: OnyxTypes.SearchResults['data'], queryJSO
...withdrawalIDGroup,
shouldShowYearWithdrawn,
formattedWithdrawalID: String(withdrawalIDGroup.entryID),
+ settlementStatusRank: withdrawalIDGroup.isCashBack ? CASH_BACK_STATUS_SORT_RANK : withdrawalIDGroup.state,
keyForList: key,
};
}
@@ -6503,11 +6521,20 @@ function getSettlementStatusBadgeProps(
state: number | undefined,
translate: LocaleContextProps['translate'],
theme: ThemeColors,
+ isCashBack = false,
): {
text: string;
badgeStyles: ViewStyle;
textStyles: TextStyle;
} | null {
+ if (isCashBack) {
+ return {
+ text: translate('settlement.status.cashBack'),
+ badgeStyles: {backgroundColor: theme.reportStatusBadge.paid.backgroundColor},
+ textStyles: {color: theme.reportStatusBadge.paid.textColor},
+ };
+ }
+
const status = getSettlementStatus(state);
if (!status) {
return null;
@@ -6820,6 +6847,7 @@ export {
isTransactionMatchWithGroupItem,
isTransactionGroupListItemType,
isTransactionReportGroupListItemType,
+ isCashBackWithdrawalGroup,
isTransactionCategoryGroupListItemType,
isTransactionMerchantGroupListItemType,
isTransactionTagGroupListItemType,
diff --git a/src/types/onyx/SearchResults.ts b/src/types/onyx/SearchResults.ts
index 70d83704e470..01fd21033155 100644
--- a/src/types/onyx/SearchResults.ts
+++ b/src/types/onyx/SearchResults.ts
@@ -224,6 +224,9 @@ type SearchWithdrawalIDGroup = {
/** Whether the current user may export this settlement as a statement PDF (set by the backend, which applies the same admin authorization it uses to generate the PDF) */
canExportStatement?: boolean;
+
+ /** Whether this group is an ACH cash back credit rather than a card settlement withdrawal */
+ isCashBack?: boolean;
};
/** Model of category grouped search result */
diff --git a/tests/ui/WithdrawalIDListItemHeaderTest.tsx b/tests/ui/WithdrawalIDListItemHeaderTest.tsx
new file mode 100644
index 000000000000..024064f8ee26
--- /dev/null
+++ b/tests/ui/WithdrawalIDListItemHeaderTest.tsx
@@ -0,0 +1,212 @@
+import {act, render, screen} from '@testing-library/react-native';
+
+import ComposeProviders from '@components/ComposeProviders';
+import {CurrencyListContextProvider} from '@components/CurrencyListContextProvider';
+import {LocaleContextProvider} from '@components/LocaleContextProvider';
+import OnyxListItemProvider from '@components/OnyxListItemProvider';
+import type {TransactionWithdrawalIDGroupListItemType} from '@components/Search/SearchList/ListItem/types';
+import WithdrawalIDListItemHeader from '@components/Search/SearchList/ListItem/WithdrawalIDListItemHeader';
+import type {SearchActionsContextValue, SearchColumnType, SearchStateContextValue} from '@components/Search/types';
+
+import useResponsiveLayout from '@hooks/useResponsiveLayout';
+
+import {getSuggestedSearches} from '@libs/SearchUIUtils';
+
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type {SearchWithdrawalIDGroup} from '@src/types/onyx/SearchResults';
+
+import React from 'react';
+import Onyx from 'react-native-onyx';
+
+import {makeSettlementGroup} from '../utils/ExpensifyCardStatementTestUtils';
+import MockSearchContextProvider from '../utils/MockSearchContextProvider';
+import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct';
+
+jest.mock('@components/ConfirmedRoute.tsx');
+jest.mock('@libs/Navigation/Navigation');
+
+jest.mock('@hooks/useResponsiveLayout', () => jest.fn());
+const mockedUseResponsiveLayout = jest.mocked(useResponsiveLayout);
+
+const mockSearchStateContext = {
+ currentSearchHash: 12345,
+ currentSearchKey: undefined,
+ currentSearchQueryJSON: undefined,
+ currentSearchResults: undefined,
+ currentSearchTransactionsByReportID: new Map(),
+ currentSearchViolations: undefined,
+ currentSelectedTransactionReportID: undefined,
+ selectedReports: [],
+ selectedTransactionIDs: [],
+ selectedTransactions: {},
+ excludedTransactions: {},
+ shouldTurnOffSelectionMode: false,
+ shouldResetSearchQuery: false,
+ lastSearchType: undefined,
+ areAllMatchingItemsSelected: false,
+ shouldShowFiltersBarLoading: false,
+ shouldUseLiveData: false,
+ currentSimilarSearchHash: -1,
+ suggestedSearches: getSuggestedSearches(),
+ sortedReportIDs: [],
+ hasSelectedTransactions: false,
+} satisfies SearchStateContextValue;
+
+const mockSearchActionsContext = {
+ setLastSearchType: jest.fn(),
+ setCurrentSelectedTransactionReportID: jest.fn(),
+ setSelectedTransactions: jest.fn(),
+ applySelection: jest.fn(),
+ setSelectedReports: jest.fn(),
+ removeTransaction: jest.fn(),
+ clearSelectedTransactions: jest.fn(),
+ setShouldShowFiltersBarLoading: jest.fn(),
+ selectAllMatchingItems: jest.fn(),
+ setShouldResetSearchQuery: jest.fn(),
+ setSortedReportIDs: jest.fn(),
+} satisfies SearchActionsContextValue;
+
+const WIDE_COLUMNS: SearchColumnType[] = [
+ CONST.SEARCH.TABLE_COLUMNS.GROUP_WITHDRAWN,
+ CONST.SEARCH.TABLE_COLUMNS.GROUP_WITHDRAWAL_STATUS,
+ CONST.SEARCH.TABLE_COLUMNS.GROUP_WITHDRAWAL_ID,
+ CONST.SEARCH.TABLE_COLUMNS.GROUP_EXPENSES,
+ CONST.SEARCH.TABLE_COLUMNS.GROUP_TOTAL,
+];
+
+/** A settlement row by default; pass `{isCashBack: true, count: 0, total: -2500}` for the cash back variant. */
+const createWithdrawalItem = (overrides: Partial = {}): TransactionWithdrawalIDGroupListItemType => ({
+ ...makeSettlementGroup(overrides),
+ groupedBy: CONST.SEARCH.GROUP_BY.WITHDRAWAL_ID,
+ transactions: [],
+ transactionsQueryJSON: undefined,
+ formattedWithdrawalID: String(overrides.entryID ?? 123),
+ keyForList: `group_${overrides.entryID ?? 123}`,
+});
+
+const renderHeader = (item: TransactionWithdrawalIDGroupListItemType, onDownArrowClick?: jest.Mock) =>
+ render(
+
+
+
+
+ ,
+ );
+
+describe('WithdrawalIDListItemHeader', () => {
+ beforeAll(() =>
+ Onyx.init({
+ keys: ONYXKEYS,
+ evictableKeys: [ONYXKEYS.COLLECTION.REPORT_ACTIONS],
+ }),
+ );
+
+ beforeEach(() => {
+ mockedUseResponsiveLayout.mockReturnValue({
+ isLargeScreenWidth: true,
+ shouldUseNarrowLayout: false,
+ isSmallScreenWidth: false,
+ isMediumScreenWidth: false,
+ isExtraSmallScreenWidth: false,
+ isExtraSmallScreenHeight: false,
+ isExtraLargeScreenWidth: true,
+ isSmallScreen: false,
+ isInNarrowPaneModal: false,
+ onboardingIsMediumOrLargerScreenWidth: true,
+ isInLandscapeMode: false,
+ });
+ });
+
+ afterEach(async () => {
+ await act(async () => {
+ await Onyx.clear();
+ });
+ jest.clearAllMocks();
+ });
+
+ describe('Cash back row', () => {
+ it('should show the Cash back badge instead of the settlement status', async () => {
+ // state 8 would otherwise render "Cleared", so cash back has to win over the settlement state.
+ renderHeader(createWithdrawalItem({isCashBack: true, count: 0, total: -2500, state: 8}));
+ await waitForBatchedUpdatesWithAct();
+
+ expect(screen.getByText('Cash back')).toBeOnTheScreen();
+ expect(screen.queryByText('Cleared')).not.toBeOnTheScreen();
+ });
+
+ it('should leave the Expenses cell blank rather than showing a count', async () => {
+ renderHeader(createWithdrawalItem({isCashBack: true, count: 0, total: -2500}));
+ await waitForBatchedUpdatesWithAct();
+
+ expect(screen.queryByText('0')).not.toBeOnTheScreen();
+ });
+
+ it('should render the backend-signed total as a negative amount', async () => {
+ renderHeader(createWithdrawalItem({isCashBack: true, count: 0, total: -2500}));
+ await waitForBatchedUpdatesWithAct();
+
+ expect(screen.getByText('-$25.00')).toBeOnTheScreen();
+ });
+ });
+
+ describe('Settlement row is unaffected', () => {
+ it('should show the settlement status badge', async () => {
+ renderHeader(createWithdrawalItem({count: 4, total: 40000, state: 8}));
+ await waitForBatchedUpdatesWithAct();
+
+ expect(screen.getByText('Cleared')).toBeOnTheScreen();
+ expect(screen.queryByText('Cash back')).not.toBeOnTheScreen();
+ });
+
+ it('should show the expense count and a positive total', async () => {
+ renderHeader(createWithdrawalItem({count: 4, total: 40000}));
+ await waitForBatchedUpdatesWithAct();
+
+ expect(screen.getByText('4')).toBeOnTheScreen();
+ expect(screen.getByText('$400.00')).toBeOnTheScreen();
+ });
+ });
+
+ // The arrow only lives in this component on narrow layouts; on wide screens the parent owns the toggle.
+ describe('Narrow layout expand arrow', () => {
+ beforeEach(() => {
+ mockedUseResponsiveLayout.mockReturnValue({
+ isLargeScreenWidth: false,
+ shouldUseNarrowLayout: true,
+ isSmallScreenWidth: true,
+ isMediumScreenWidth: false,
+ isExtraSmallScreenWidth: false,
+ isExtraSmallScreenHeight: false,
+ isExtraLargeScreenWidth: false,
+ isSmallScreen: true,
+ isInNarrowPaneModal: false,
+ onboardingIsMediumOrLargerScreenWidth: false,
+ isInLandscapeMode: false,
+ });
+ });
+
+ it('should render the arrow for a settlement row', async () => {
+ renderHeader(createWithdrawalItem({count: 4, total: 40000}), jest.fn());
+ await waitForBatchedUpdatesWithAct();
+
+ expect(screen.getByLabelText('Expand')).toBeOnTheScreen();
+ });
+
+ it('should not render the arrow for a cash back row', async () => {
+ renderHeader(createWithdrawalItem({isCashBack: true, count: 0, total: -2500}));
+ await waitForBatchedUpdatesWithAct();
+
+ expect(screen.queryByLabelText('Expand')).not.toBeOnTheScreen();
+ });
+ });
+});
diff --git a/tests/unit/Search/SearchBulkActionsButtonTest.tsx b/tests/unit/Search/SearchBulkActionsButtonTest.tsx
index 5e876676a443..5bf0d9c4265b 100644
--- a/tests/unit/Search/SearchBulkActionsButtonTest.tsx
+++ b/tests/unit/Search/SearchBulkActionsButtonTest.tsx
@@ -16,6 +16,8 @@ type MockButtonProps = {
const mockButtonWithDropdownMenu = jest.fn(() => null);
let mockExcludedTransactions: SelectedTransactions = {};
+let mockSelectedTransactions: SelectedTransactions = {};
+let mockSearchData: Record = {};
let mockSearchCount: number | undefined;
let mockSearchIsLoading = false;
let mockIsOffline = false;
@@ -67,12 +69,12 @@ jest.mock('@hooks/useSearchBulkActions', () => ({
isDuplicateReportOptionVisible: false,
allTransactions: {},
allReports: {},
- searchData: {},
+ searchData: mockSearchData,
}),
}));
jest.mock('@components/Search/SearchContext', () => ({
useSearchSelectionContext: () => ({
- selectedTransactions: {tx1: {isSelected: true}},
+ selectedTransactions: mockSelectedTransactions,
excludedTransactions: mockExcludedTransactions,
selectedReports: [],
areAllMatchingItemsSelected: true,
@@ -127,6 +129,8 @@ describe('SearchBulkActionsButton all-matching label', () => {
beforeEach(() => {
jest.clearAllMocks();
mockExcludedTransactions = {};
+ mockSelectedTransactions = {tx1: {...makeTransaction(), reportID: undefined}};
+ mockSearchData = {};
mockSearchCount = undefined;
mockSearchIsLoading = false;
mockIsOffline = false;
@@ -192,3 +196,49 @@ describe('SearchBulkActionsButton all-matching label', () => {
expect(getButtonProps()).toEqual({customText: 'workspace.common.selected:320', isLoading: false});
});
});
+
+describe('SearchBulkActionsButton group selection label', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockExcludedTransactions = {};
+ mockSelectedTransactions = {};
+ mockSearchData = {};
+ mockSearchCount = undefined;
+ mockSearchIsLoading = false;
+ mockIsOffline = false;
+ });
+
+ const selectGroups = (...keys: string[]) => {
+ mockSelectedTransactions = Object.fromEntries(keys.map((key) => [key, makeTransaction()]));
+ };
+
+ it('counts the expenses a selected settlement group holds', () => {
+ mockSearchData = {[`${CONST.SEARCH.GROUP_PREFIX}cleared`]: {count: 12}};
+ selectGroups(`${CONST.SEARCH.GROUP_PREFIX}cleared`);
+
+ render();
+
+ expect(getButtonProps().customText).toBe('workspace.common.selected:12');
+ });
+
+ it('counts a selected cash back group as one item even though it holds no expenses', () => {
+ mockSearchData = {[`${CONST.SEARCH.GROUP_PREFIX}cashBack`]: {count: 0, isCashBack: true}};
+ selectGroups(`${CONST.SEARCH.GROUP_PREFIX}cashBack`);
+
+ render();
+
+ expect(getButtonProps().customText).toBe('workspace.common.selected:1');
+ });
+
+ it('adds the cash back row to the expenses of the settlements selected alongside it', () => {
+ mockSearchData = {
+ [`${CONST.SEARCH.GROUP_PREFIX}cashBack`]: {count: 0, isCashBack: true},
+ [`${CONST.SEARCH.GROUP_PREFIX}cleared`]: {count: 12},
+ };
+ selectGroups(`${CONST.SEARCH.GROUP_PREFIX}cashBack`, `${CONST.SEARCH.GROUP_PREFIX}cleared`);
+
+ render();
+
+ expect(getButtonProps().customText).toBe('workspace.common.selected:13');
+ });
+});
diff --git a/tests/unit/Search/SearchSelectionFooterTest.tsx b/tests/unit/Search/SearchSelectionFooterTest.tsx
index c356272eb005..d2d6591824bf 100644
--- a/tests/unit/Search/SearchSelectionFooterTest.tsx
+++ b/tests/unit/Search/SearchSelectionFooterTest.tsx
@@ -1,7 +1,7 @@
import {act, render} from '@testing-library/react-native';
import SearchSelectionFooter from '@components/Search/SearchSelectionFooter';
-import type {SelectedTransactionInfo, SelectedTransactions} from '@components/Search/types';
+import type {SearchGroupBy, SelectedTransactionInfo, SelectedTransactions} from '@components/Search/types';
import {getFooterConvertedAmounts} from '@libs/actions/Search';
@@ -11,6 +11,7 @@ import type {SearchResults} from '@src/types/onyx';
import Onyx from 'react-native-onyx';
+import {makeSettlementGroup} from '../../utils/ExpensifyCardStatementTestUtils';
import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates';
jest.mock('@hooks/useNetwork', () => jest.fn(() => ({isOffline: false})));
@@ -24,7 +25,7 @@ jest.mock('@libs/actions/Search', () => ({
type MockSearchQueryContext = {
currentSearchHash: number;
currentSearchKey: undefined;
- currentSearchQueryJSON: {hash: number; type: SearchResults['search']['type']} | undefined;
+ currentSearchQueryJSON: {hash: number; type: SearchResults['search']['type']; groupBy?: SearchGroupBy} | undefined;
};
const mockSearchQueryContext: {current: MockSearchQueryContext} = {
@@ -33,9 +34,10 @@ const mockSearchQueryContext: {current: MockSearchQueryContext} = {
const mockSelectedTransactions: {current: SelectedTransactions} = {current: {}};
const mockExcludedTransactions: {current: SelectedTransactions} = {current: {}};
const mockAreAllMatchingItemsSelected = {current: false};
+const mockCurrentSearchResults: {current: SearchResults | undefined} = {current: undefined};
jest.mock('@components/Search/SearchContext', () => ({
useSearchQueryContext: () => mockSearchQueryContext.current,
- useSearchResultsContext: () => ({currentSearchResults: undefined}),
+ useSearchResultsContext: () => ({currentSearchResults: mockCurrentSearchResults.current}),
useSearchSelectionContext: () => ({
selectedTransactions: mockSelectedTransactions.current,
excludedTransactions: mockExcludedTransactions.current,
@@ -121,6 +123,7 @@ describe('SearchSelectionFooter', () => {
mockSelectedTransactions.current = {transaction1: buildSelectedTransaction(SELECTED_EXPENSE_CURRENCY)};
mockExcludedTransactions.current = {};
mockAreAllMatchingItemsSelected.current = false;
+ mockCurrentSearchResults.current = undefined;
mockCapturedFooterProps.current = undefined;
// Clear here rather than in afterEach: Onyx.clear() there re-renders the previous test's still-mounted
// component (testing-library only unmounts it afterwards), and those renders can record mock calls.
@@ -227,4 +230,54 @@ describe('SearchSelectionFooter', () => {
expect(getFooterConvertedAmounts).not.toHaveBeenCalled();
expect(mockCapturedFooterProps.current?.currency).toBe(PAYMENT_CURRENCY);
});
+
+ describe('stamping the loaded groups of a grouped search', () => {
+ const CASH_BACK_KEY = `${CONST.SEARCH.GROUP_PREFIX}cashBack` as const;
+ const SETTLEMENT_KEY = `${CONST.SEARCH.GROUP_PREFIX}settlement` as const;
+
+ const renderGroupedSearch = async () => {
+ mockSearchQueryContext.current = {
+ currentSearchHash: 1,
+ currentSearchKey: undefined,
+ currentSearchQueryJSON: {hash: 1, type: CONST.SEARCH.DATA_TYPES.EXPENSE, groupBy: CONST.SEARCH.GROUP_BY.WITHDRAWAL_ID},
+ };
+ const searchResults = buildSearchResults(undefined, 2);
+ mockCurrentSearchResults.current = {
+ ...searchResults,
+ data: {
+ [CASH_BACK_KEY]: makeSettlementGroup({total: -2500, isCashBack: true}),
+ [SETTLEMENT_KEY]: makeSettlementGroup({total: 40000}),
+ },
+ };
+ // Only the settlement is selected, so the cash back row is stamped by the bulk path rather than from its entry.
+ mockSelectedTransactions.current = {[SETTLEMENT_KEY]: buildSelectedTransaction(SELECTED_EXPENSE_CURRENCY, 'INR', -40000)};
+
+ render();
+ await waitForBatchedUpdates();
+
+ await act(async () => {
+ mockCapturedFooterProps.current?.onCurrencyChange?.(PAYMENT_CURRENCY);
+ await waitForBatchedUpdates();
+ });
+
+ return jest
+ .mocked(getFooterConvertedAmounts)
+ .mock.calls.map(([args]) => args)
+ .find((args) => !!args.sources?.groups)?.sources?.groups;
+ };
+
+ it('stamps an unselected cash back group with the positive figure its own entry would produce', async () => {
+ const groups = await renderGroupedSearch();
+
+ // selectionBuilders gives a selected cash back row groupAmount: +Math.abs(total), so a negative stamp here
+ // would never match the freshness check and the cached conversion could never be reused.
+ expect(groups?.[CASH_BACK_KEY]).toEqual({[PAYMENT_CURRENCY]: 2500});
+ });
+
+ it('keeps stamping an unselected settlement group expense-negative', async () => {
+ const groups = await renderGroupedSearch();
+
+ expect(groups?.[SETTLEMENT_KEY]).toEqual({[PAYMENT_CURRENCY]: -40000});
+ });
+ });
});
diff --git a/tests/unit/Search/SearchUIUtilsTest.ts b/tests/unit/Search/SearchUIUtilsTest.ts
index e6ec008cdb34..dfc4eedcc9b2 100644
--- a/tests/unit/Search/SearchUIUtilsTest.ts
+++ b/tests/unit/Search/SearchUIUtilsTest.ts
@@ -17,6 +17,7 @@ import type {
} from '@components/Search/SearchList/ListItem/types';
import {getExpenseHeaders} from '@components/Search/SearchTableHeader';
import type {SearchColumnType, SelectedTransactionInfo, SortOrder} from '@components/Search/types';
+import type {ListItem} from '@components/SelectionList/types';
import Navigation from '@navigation/Navigation';
@@ -1840,6 +1841,7 @@ const transactionWithdrawalIDGroupListItems: TransactionWithdrawalIDGroupListIte
state: 8,
groupedBy: 'withdrawal-id',
formattedWithdrawalID: '5',
+ settlementStatusRank: 8,
transactions: [],
transactionsQueryJSON: undefined,
keyForList: 'group_5',
@@ -1856,6 +1858,7 @@ const transactionWithdrawalIDGroupListItems: TransactionWithdrawalIDGroupListIte
state: 8,
groupedBy: 'withdrawal-id',
formattedWithdrawalID: '6',
+ settlementStatusRank: 8,
transactions: [],
transactionsQueryJSON: undefined,
keyForList: 'group_30303030',
@@ -1875,6 +1878,7 @@ const transactionWithdrawalIDGroupListItemsSorted: TransactionWithdrawalIDGroupL
state: 8,
groupedBy: 'withdrawal-id',
formattedWithdrawalID: '5',
+ settlementStatusRank: 8,
transactions: [],
transactionsQueryJSON: undefined,
keyForList: 'group_5',
@@ -1891,6 +1895,7 @@ const transactionWithdrawalIDGroupListItemsSorted: TransactionWithdrawalIDGroupL
total: 20,
groupedBy: 'withdrawal-id',
formattedWithdrawalID: '6',
+ settlementStatusRank: 8,
transactions: [],
transactionsQueryJSON: undefined,
keyForList: 'group_30303030',
@@ -7025,6 +7030,62 @@ describe('SearchUIUtils', () => {
expect(sortGroups(CONST.SEARCH.TABLE_COLUMNS.GROUP_AMOUNT_REIMBURSED, CONST.SEARCH.SORT_ORDER.DESC)).toStrictEqual(['group_large', 'group_small', 'group_domestic']);
});
+ it('should keep cash back rows out of the settlement states when sorting by withdrawal status', () => {
+ const statusGroup = (state: number, isCashBack = false) => ({
+ bankName: CONST.BANK_NAMES.CHASE,
+ entryID,
+ accountNumber,
+ debitPosted: '2025-08-12 17:11:22',
+ count: isCashBack ? 0 : 4,
+ currency: 'USD',
+ total: isCashBack ? -2500 : 40,
+ state,
+ ...(isCashBack ? {isCashBack} : {}),
+ });
+ // Both cash back rows carry state 8, the same state a cleared settlement carries.
+ const data: OnyxTypes.SearchResults['data'] = {
+ personalDetailsList: {},
+ [`${CONST.SEARCH.GROUP_PREFIX}cashBackA` as const]: statusGroup(8, true),
+ [`${CONST.SEARCH.GROUP_PREFIX}cleared8` as const]: statusGroup(8),
+ [`${CONST.SEARCH.GROUP_PREFIX}cashBackB` as const]: statusGroup(8, true),
+ [`${CONST.SEARCH.GROUP_PREFIX}cleared9` as const]: statusGroup(9),
+ [`${CONST.SEARCH.GROUP_PREFIX}failed` as const]: statusGroup(5),
+ [`${CONST.SEARCH.GROUP_PREFIX}pending` as const]: statusGroup(1),
+ };
+
+ const [sections] = getSectionsByType(
+ SearchUIUtils.getSections({
+ dateFnsLocale: undefined,
+ type: CONST.SEARCH.DATA_TYPES.EXPENSE,
+ data,
+ currentAccountID: 2074551,
+ currentUserEmail: '',
+ translate: translateLocal,
+ formatPhoneNumber,
+ bankAccountList: {},
+ groupBy: CONST.SEARCH.GROUP_BY.WITHDRAWAL_ID,
+ conciergeReportID: undefined,
+ convertToDisplayString,
+ reportAttributesDerivedValue: {},
+ }),
+ SearchUIUtils.isTransactionGroupListItemType,
+ );
+
+ const sortGroups = (sortOrder: SortOrder) =>
+ SearchUIUtils.getSortedSections(
+ CONST.SEARCH.DATA_TYPES.EXPENSE,
+ [...sections],
+ localeCompare,
+ translateLocal,
+ CONST.SEARCH.TABLE_COLUMNS.GROUP_WITHDRAWAL_STATUS,
+ sortOrder,
+ CONST.SEARCH.GROUP_BY.WITHDRAWAL_ID,
+ ).map((group) => group.keyForList);
+
+ expect(sortGroups(CONST.SEARCH.SORT_ORDER.ASC)).toStrictEqual(['group_pending', 'group_failed', 'group_cleared8', 'group_cleared9', 'group_cashBackA', 'group_cashBackB']);
+ expect(sortGroups(CONST.SEARCH.SORT_ORDER.DESC)).toStrictEqual(['group_cashBackA', 'group_cashBackB', 'group_cleared9', 'group_cleared8', 'group_failed', 'group_pending']);
+ });
+
it('should sort expense reports by each conversion amount, leaving the reports that did not convert at the empty end', () => {
const withConversion = (keyForList: string, debitedAmount?: number, creditedAmount?: number) =>
createMock({
@@ -12123,6 +12184,42 @@ describe('getWithdrawalStatusDisplayText', () => {
});
});
+describe('isCashBackWithdrawalGroup', () => {
+ const withdrawalGroup = (overrides: Partial = {}): TransactionWithdrawalIDGroupListItemType => ({
+ groupedBy: CONST.SEARCH.GROUP_BY.WITHDRAWAL_ID,
+ transactions: [],
+ entryID: 88002,
+ count: 0,
+ total: -2500,
+ currency: 'USD',
+ accountNumber: '4321',
+ bankName: CONST.BANK_NAMES.AMERICAN_EXPRESS,
+ debitPosted: '2025-07-20',
+ state: 8,
+ keyForList: 'group_88002',
+ ...overrides,
+ });
+
+ it('returns true for a withdrawal group flagged as cash back', () => {
+ expect(SearchUIUtils.isCashBackWithdrawalGroup(withdrawalGroup({isCashBack: true}))).toBe(true);
+ });
+
+ it('returns false for a normal settlement withdrawal group', () => {
+ expect(SearchUIUtils.isCashBackWithdrawalGroup(withdrawalGroup())).toBe(false);
+ expect(SearchUIUtils.isCashBackWithdrawalGroup(withdrawalGroup({isCashBack: false}))).toBe(false);
+ });
+
+ it('stays false for a group of another type that happens to carry the flag', () => {
+ const cardGroup = {...withdrawalGroup({isCashBack: true}), groupedBy: CONST.SEARCH.GROUP_BY.CARD};
+ expect(SearchUIUtils.isCashBackWithdrawalGroup(cardGroup)).toBe(false);
+ });
+
+ it('stays false for a row that is not a transaction group at all', () => {
+ const plainRow: ListItem = {keyForList: 'not-a-group'};
+ expect(SearchUIUtils.isCashBackWithdrawalGroup(plainRow)).toBe(false);
+ });
+});
+
describe('getViolationsFromSearchData', () => {
const violation: OnyxTypes.TransactionViolation = {name: 'missingCategory', type: 'violation'};
diff --git a/tests/unit/TransactionGroupListItemTest.tsx b/tests/unit/TransactionGroupListItemTest.tsx
index 018465698a13..1742ec156545 100644
--- a/tests/unit/TransactionGroupListItemTest.tsx
+++ b/tests/unit/TransactionGroupListItemTest.tsx
@@ -39,6 +39,7 @@ jest.mock('@libs/SearchUIUtils', () => ({
getTableMinWidth: jest.fn(() => 0),
getSuggestedSearches: jest.fn(() => ({})),
getSuggestedSearchesVisibility: jest.fn(() => ({topSpendersPolicyIDs: []})),
+ isCashBackWithdrawalGroup: jest.fn(() => false),
}));
jest.mock('@react-navigation/native', () => ({