diff --git a/src/libs/ExpensifyCardStatementUtils.ts b/src/libs/ExpensifyCardStatementUtils.ts index bbf852367926..09f142d6bc6a 100644 --- a/src/libs/ExpensifyCardStatementUtils.ts +++ b/src/libs/ExpensifyCardStatementUtils.ts @@ -111,16 +111,16 @@ function isWithdrawalIDGroup(value: SearchResultDataType[keyof SearchResultDataT return typeof value === 'object' && value !== null && 'entryID' in value && typeof value.entryID === 'number'; } -function getSelectedSettlementGroups(selectedTransactions: SelectedTransactions, searchData: SearchResultDataType | undefined): SearchWithdrawalIDGroup[] { +function getSelectedSettlementGroups(selectedTransactions: SelectedTransactions, searchData: SearchResultDataType | undefined): SearchWithdrawalIDGroup[] | undefined { if (!searchData) { return []; } - // Only offer the statement when a whole settlement is selected, never a single transaction inside it. A settlement - // is selected either directly (its group key is in selectedTransactions, e.g. a collapsed row) or by selecting all - // of its transactions (each tagged with the group key), so we count tagged children and require the full count. + // A settlement can be selected two ways: its collapsed row is checked directly (its group key lands in + // selectedTransactions), or its row is expanded and its transactions are checked (each tagged with the group key). + // Collect both so we can tell, per settlement, whether the whole thing or only part of it is selected. const directlySelectedGroupKeys = new Set(); - const selectedCountByGroupKey = new Map(); + const selectedTransactionCountByGroupKey = new Map(); for (const [key, selection] of Object.entries(selectedTransactions)) { if (!selection?.isSelected) { continue; @@ -129,20 +129,33 @@ function getSelectedSettlementGroups(selectedTransactions: SelectedTransactions, directlySelectedGroupKeys.add(key); } if (selection.groupKey?.startsWith(CONST.SEARCH.GROUP_PREFIX)) { - selectedCountByGroupKey.set(selection.groupKey, (selectedCountByGroupKey.get(selection.groupKey) ?? 0) + 1); + selectedTransactionCountByGroupKey.set(selection.groupKey, (selectedTransactionCountByGroupKey.get(selection.groupKey) ?? 0) + 1); } } const settlementGroups: SearchWithdrawalIDGroup[] = []; - for (const [key, value] of Object.entries(searchData)) { - if (!isWithdrawalIDGroup(value)) { + for (const [groupKey, group] of Object.entries(searchData)) { + if (!isWithdrawalIDGroup(group)) { continue; } - const isWholeSettlementSelected = directlySelectedGroupKeys.has(key) || (value.count > 0 && (selectedCountByGroupKey.get(key) ?? 0) >= value.count); - if (!isWholeSettlementSelected) { + + const selectedTransactionCount = selectedTransactionCountByGroupKey.get(groupKey) ?? 0; + const isRowSelectedDirectly = directlySelectedGroupKeys.has(groupKey); + const areAllTransactionsSelected = group.count > 0 && selectedTransactionCount >= group.count; + + // Whole settlement selected: include it in the statement. + if (isRowSelectedDirectly || areAllTransactionsSelected) { + settlementGroups.push(group); continue; } - settlementGroups.push(value); + + // Only some of this settlement's transactions are selected. The statement always covers the whole settlement, + // so a partial selection would export more than what's on screen. Hide the action entirely. + if (selectedTransactionCount > 0) { + return undefined; + } + + // Otherwise the settlement isn't part of the selection at all, so leave it out. } return settlementGroups; @@ -170,7 +183,7 @@ function getExpensifyCardStatementSelection( } const selectedSettlementGroups = getSelectedSettlementGroups(selectedTransactions, searchData); - if (selectedSettlementGroups.length === 0) { + if (!selectedSettlementGroups || selectedSettlementGroups.length === 0) { return undefined; } diff --git a/tests/unit/ExpensifyCardStatementUtilsTest.ts b/tests/unit/ExpensifyCardStatementUtilsTest.ts index ec47ef18cb51..69902e47ec8c 100644 --- a/tests/unit/ExpensifyCardStatementUtilsTest.ts +++ b/tests/unit/ExpensifyCardStatementUtilsTest.ts @@ -150,6 +150,23 @@ describe('ExpensifyCardStatementUtils', () => { expect(getExpensifyCardStatementSelection(expensifyCardStatementQueryJSON, selectedTransactions, searchData)).toBeUndefined(); }); + it('hides the export when one settlement is whole but another is only partially selected', () => { + const wholeGroupKey = `${CONST.SEARCH.GROUP_PREFIX}123`; + const partialGroupKey = `${CONST.SEARCH.GROUP_PREFIX}456`; + // Settlement 123 fully selected, settlement 456 missing one transaction. A partial settlement narrows the + // selection, so the action must disappear rather than silently exporting only the whole one. + const selectedTransactions: SelectedTransactions = { + ...makeSettlementSelection(wholeGroupKey, 2), + ...makeSettlementSelection(partialGroupKey, 1), + }; + const searchData = makeSearchData({ + [wholeGroupKey]: makeSettlementGroup({entryID: 123, count: 2}), + [partialGroupKey]: makeSettlementGroup({entryID: 456, count: 2}), + }); + + expect(getExpensifyCardStatementSelection(expensifyCardStatementQueryJSON, selectedTransactions, searchData)).toBeUndefined(); + }); + it('includes a settlement when all of its transactions are selected', () => { const groupKey = `${CONST.SEARCH.GROUP_PREFIX}123`; // Every transaction in the (expanded) settlement is selected, so the whole settlement is selected.