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', () => ({