From f219c20a21ef3bdb47c4172a47c2b5961b322276 Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Fri, 16 Oct 2026 12:36:27 -0600 Subject: [PATCH 1/2] Add local CSV downloads to Insights charts --- src/pages/Insights/buildInsightsChartCSV.ts | 81 ++++++++++++ .../Insights/charts/InsightsChartWidget.tsx | 26 +++- tests/ui/InsightsChartWidgetTest.tsx | 120 +++++++++++++++++ tests/unit/buildInsightsChartCSVTest.ts | 122 ++++++++++++++++++ 4 files changed, 348 insertions(+), 1 deletion(-) create mode 100644 src/pages/Insights/buildInsightsChartCSV.ts create mode 100644 tests/ui/InsightsChartWidgetTest.tsx create mode 100644 tests/unit/buildInsightsChartCSVTest.ts diff --git a/src/pages/Insights/buildInsightsChartCSV.ts b/src/pages/Insights/buildInsightsChartCSV.ts new file mode 100644 index 000000000000..7eef39cc7b6c --- /dev/null +++ b/src/pages/Insights/buildInsightsChartCSV.ts @@ -0,0 +1,81 @@ +/** Builds a CSV from the current period's loaded chart groups, without exporting individual expenses. */ +import type {LocalizedTranslate} from '@components/LocaleContextProvider'; +import CHART_GROUP_BY_CONFIG from '@components/Search/chartGroupByConfig'; +import type {GroupedItem, SearchGroupBy} from '@components/Search/types'; + +import {convertToFrontendAmountAsString} from '@libs/CurrencyUtils'; + +import CONST from '@src/CONST'; +import type {TranslationPaths} from '@src/languages/types'; + +import type {Interval} from 'date-fns'; + +import {eachDayOfInterval, eachMonthOfInterval, eachQuarterOfInterval, eachWeekOfInterval, eachYearOfInterval, format, parseISO} from 'date-fns'; + +type BuildInsightsChartCSVParams = { + data: GroupedItem[]; + groupBy: SearchGroupBy; + dateRange: {start?: string; end?: string}; + currency: string; + getCurrencyDecimals: (currency: string) => number; + translate: LocalizedTranslate; +}; + +const CSV_FORMULA_PREFIX_REGEXP = /^(?:[\t\r\n]|\s*[=+\-@])/; +const GROUP_HEADER_KEYS: Partial> = { + [CONST.SEARCH.GROUP_BY.FROM]: 'common.from', + [CONST.SEARCH.GROUP_BY.MERCHANT]: 'common.merchant', + [CONST.SEARCH.GROUP_BY.CATEGORY]: 'common.category', +}; + +function escapeCsvField(value: string): string { + const safeValue = CSV_FORMULA_PREFIX_REGEXP.test(value) ? `'${value}` : value; + return /[",\r\n]/.test(safeValue) ? `"${safeValue.replaceAll('"', '""')}"` : safeValue; +} + +function getPeriodStarts(groupBy: SearchGroupBy, interval: Interval): Date[] { + switch (groupBy) { + case CONST.SEARCH.GROUP_BY.DAY: + return eachDayOfInterval(interval); + case CONST.SEARCH.GROUP_BY.WEEK: + // Search weeks start on Sunday, regardless of the display locale. + return eachWeekOfInterval(interval, {weekStartsOn: 0}); + case CONST.SEARCH.GROUP_BY.MONTH: + return eachMonthOfInterval(interval); + case CONST.SEARCH.GROUP_BY.QUARTER: + return eachQuarterOfInterval(interval); + case CONST.SEARCH.GROUP_BY.YEAR: + return eachYearOfInterval(interval); + default: + return []; + } +} + +function buildInsightsChartCSV({data, groupBy, dateRange, currency, getCurrencyDecimals, translate}: BuildInsightsChartCSVParams): string { + const {getLabel, getDateRange} = CHART_GROUP_BY_CONFIG[groupBy]; + const groupHeader = translate(GROUP_HEADER_KEYS[groupBy] ?? 'common.date'); + const header = [groupHeader, translate('common.expenses'), translate('common.total'), translate('common.currency')].map(escapeCsvField).join(','); + + let rows = data.map((item) => ({ + label: getDateRange?.(item)?.start ?? getLabel(item), + count: item.count ?? 0, + total: item.total ?? 0, + currency: item.currency ?? currency, + })); + + if (getDateRange && dateRange.start && dateRange.end) { + const rowsByDate = new Map(rows.map((row) => [row.label, row])); + rows = getPeriodStarts(groupBy, {start: parseISO(dateRange.start), end: parseISO(dateRange.end)}).map((date) => { + const label = format(date, CONST.DATE.FNS_FORMAT_STRING); + return rowsByDate.get(label) ?? {label, count: 0, total: 0, currency}; + }); + } + + const csvRows = rows.map((row) => + [escapeCsvField(row.label), row.count, convertToFrontendAmountAsString(row.total, getCurrencyDecimals(row.currency)), escapeCsvField(row.currency)].join(','), + ); + + return `\uFEFF${[header, ...csvRows].join('\r\n')}`; +} + +export default buildInsightsChartCSV; diff --git a/src/pages/Insights/charts/InsightsChartWidget.tsx b/src/pages/Insights/charts/InsightsChartWidget.tsx index 0b75eba886e9..1e730f5bbc12 100644 --- a/src/pages/Insights/charts/InsightsChartWidget.tsx +++ b/src/pages/Insights/charts/InsightsChartWidget.tsx @@ -7,14 +7,18 @@ import type {SearchQueryJSON} from '@components/Search/types'; import WidgetContainer from '@components/WidgetContainer'; import WidgetHeaderMenu from '@components/WidgetHeaderMenu'; +import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useLayoutSpacing from '@hooks/useLayoutSpacing'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; +import localFileDownload from '@libs/localFileDownload'; import Navigation from '@libs/Navigation/Navigation'; import {INSIGHTS_CHART_STATE} from '@libs/resolveInsightsChartData'; +import {getDateFilterRange} from '@libs/SearchQueryUtils'; +import buildInsightsChartCSV from '@pages/Insights/buildInsightsChartCSV'; import InsightsGroupByDropdown from '@pages/Insights/controls/InsightsGroupByDropdown'; import type {InsightsChartSpec} from '@pages/Insights/dashboardSpecs'; import type {InsightsFilters} from '@pages/Insights/insightsFilters'; @@ -61,7 +65,8 @@ function InsightsChartWidget({chart, queryJSON, snapshot, averageQueryJSON, aver const styles = useThemeStyles(); const {translate} = useLocalize(); const {cardPaddingHorizontal, cardPaddingBottom} = useLayoutSpacing(); - const icons = useMemoizedLazyExpensifyIcons(['Expand']); + const icons = useMemoizedLazyExpensifyIcons(['Expand', 'Table']); + const {getCurrencyDecimals} = useCurrencyListActions(); const {data, state, comparison} = useInsightsChartData({chart, queryJSON, snapshot, averageQueryJSON, averageSnapshot}, filters); const groupBy = chart.groupBy ?? getQueryGroupBy(filters); @@ -70,6 +75,15 @@ function InsightsChartWidget({chart, queryJSON, snapshot, averageQueryJSON, aver const groupByOptions = getInsightsGroupByOptions(filters.date); + const downloadCSV = () => { + if (!queryJSON || state !== INSIGHTS_CHART_STATE.READY) { + return; + } + + const content = buildInsightsChartCSV({data, groupBy, dateRange: getDateFilterRange(queryJSON), currency: filters.groupCurrency, getCurrencyDecimals, translate}); + localFileDownload(`Expensify-${chart.graphKey}.csv`, content, translate); + }; + const groupByControl = onGroupByChange && groupByOptions.length > 1 ? ( Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: buildViewOnSpendQuery(queryJSON)})), shouldCallAfterModalHide: true, }, + ...(state === INSIGHTS_CHART_STATE.READY + ? [ + { + text: translate('spreadsheet.downloadCSV'), + icon: icons.Table, + onSelected: downloadCSV, + shouldCallAfterModalHide: true, + }, + ] + : []), ]} /> ) : null; diff --git a/tests/ui/InsightsChartWidgetTest.tsx b/tests/ui/InsightsChartWidgetTest.tsx new file mode 100644 index 000000000000..4dedd6dd3bc4 --- /dev/null +++ b/tests/ui/InsightsChartWidgetTest.tsx @@ -0,0 +1,120 @@ +import {render} 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 {GroupedItem} from '@components/Search/types'; +import WidgetHeaderMenu from '@components/WidgetHeaderMenu'; + +import localFileDownload from '@libs/localFileDownload'; +import {INSIGHTS_CHART_STATE} from '@libs/resolveInsightsChartData'; +import {buildSearchQueryJSON} from '@libs/SearchQueryUtils'; + +import InsightsChartWidget from '@pages/Insights/charts/InsightsChartWidget'; +import INSIGHTS_DASHBOARD_SPECS from '@pages/Insights/dashboardSpecs'; +import type {InsightsFilters} from '@pages/Insights/insightsFilters'; +import useInsightsChartData from '@pages/Insights/useInsightsChartData'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import currencyList from '../unit/currencyList.json'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +jest.mock('@components/WidgetHeaderMenu', () => jest.fn(() => null)); +jest.mock('@components/Search/SearchChartView', () => jest.fn(() => null)); +jest.mock('@libs/localFileDownload', () => jest.fn()); +jest.mock('@libs/Navigation/Navigation'); +jest.mock('@pages/Insights/useInsightsChartData', () => ({__esModule: true, default: jest.fn()})); + +const CHART = INSIGHTS_DASHBOARD_SPECS[CONST.INSIGHTS.DASHBOARD.SPEND].headlineChart; +const QUERY = buildSearchQueryJSON('type:expense group-by:month date>=2026-01-01 date<=2026-03-31 group-currency:USD'); +const FILTERS: InsightsFilters = {date: {from: '2026-01-01', to: '2026-03-31'}, policyIDs: [], groupBy: CONST.SEARCH.GROUP_BY.MONTH, groupCurrency: CONST.CURRENCY.USD}; +const DATA: GroupedItem[] = [ + { + groupedBy: CONST.SEARCH.GROUP_BY.MONTH, + year: 2026, + month: 2, + keyForList: 'group_february', + count: 2, + total: 12345, + currency: CONST.CURRENCY.USD, + transactions: [], + formattedMonth: 'February 2026', + shortFormattedMonth: 'Feb', + sortKey: 202602, + }, +]; + +function renderWidget() { + return render( + + + , + ); +} + +describe('Insights chart CSV action', () => { + beforeAll(() => Onyx.init({keys: ONYXKEYS})); + + beforeEach(async () => { + jest.clearAllMocks(); + await Onyx.set(ONYXKEYS.CURRENCY_LIST, currencyList); + await waitForBatchedUpdatesWithAct(); + jest.mocked(useInsightsChartData).mockReturnValue({data: DATA, state: INSIGHTS_CHART_STATE.READY, comparison: undefined}); + }); + + afterEach(() => Onyx.clear()); + + it('offers a local CSV below View on Spend and exports only the current period', async () => { + // Given a ready chart compared against a different set of totals + jest.mocked(useInsightsChartData).mockReturnValue({ + data: DATA, + state: INSIGHTS_CHART_STATE.READY, + comparison: {data: DATA.map((item) => ({...item, total: 99999})), label: 'Average', color: '#000000'}, + }); + + // When the user selects Download CSV + renderWidget(); + await waitForBatchedUpdatesWithAct(); + const menuItems = jest.mocked(WidgetHeaderMenu).mock.lastCall?.[0].menuItems; + expect(menuItems?.map((item) => item.text)).toEqual(['View on Spend', 'Download CSV']); + menuItems?.at(1)?.onSelected?.(); + + // Then the existing local download helper receives only grouped current-period values and zero-filled gaps + expect(localFileDownload).toHaveBeenCalledWith( + `Expensify-${CHART.graphKey}.csv`, + '\uFEFFDate,Expenses,Total,Currency\r\n2026-01-01,0,0.00,USD\r\n2026-02-01,2,123.45,USD\r\n2026-03-01,0,0.00,USD', + expect.any(Function), + ); + }); + + it.each([INSIGHTS_CHART_STATE.LOADING, INSIGHTS_CHART_STATE.EMPTY, INSIGHTS_CHART_STATE.ERROR, INSIGHTS_CHART_STATE.OFFLINE])( + 'hides Download CSV while the chart is %s', + async (state) => { + // Given chart data that is not ready to export + jest.mocked(useInsightsChartData).mockReturnValue({data: [], state, comparison: undefined}); + + // When its header is rendered + renderWidget(); + await waitForBatchedUpdatesWithAct(); + const menuItems = jest.mocked(WidgetHeaderMenu).mock.lastCall?.[0].menuItems ?? []; + + // Then no stale or incomplete data can be downloaded, while View on Spend remains available during loading + expect(menuItems.map((item) => item.text)).toEqual(state === INSIGHTS_CHART_STATE.LOADING ? ['View on Spend'] : []); + expect(localFileDownload).not.toHaveBeenCalled(); + }, + ); +}); diff --git a/tests/unit/buildInsightsChartCSVTest.ts b/tests/unit/buildInsightsChartCSVTest.ts new file mode 100644 index 000000000000..27013818e109 --- /dev/null +++ b/tests/unit/buildInsightsChartCSVTest.ts @@ -0,0 +1,122 @@ +import type {LocalizedTranslate} from '@components/LocaleContextProvider'; +import type {GroupedItem} from '@components/Search/types'; + +import {translate as translatePhrase} from '@libs/Localize'; + +import buildInsightsChartCSV from '@pages/Insights/buildInsightsChartCSV'; + +import CONST from '@src/CONST'; +import IntlStore from '@src/languages/IntlStore'; + +const translate: LocalizedTranslate = (key, ...parameters) => translatePhrase(CONST.LOCALES.EN, key, ...parameters); +const getCurrencyDecimals = (currency: string) => (currency === 'JPY' ? 0 : 2); +const GROUP_FIELDS = {keyForList: 'group_test', count: 2, total: 12345, currency: CONST.CURRENCY.USD, transactions: []}; + +const TIME_GROUPS: Array<{item: GroupedItem; start: string; end: string; dates: string[]}> = [ + { + item: {...GROUP_FIELDS, groupedBy: CONST.SEARCH.GROUP_BY.DAY, day: '2026-01-02', formattedDay: 'Jan 2', shortFormattedDay: '2', lineChartFormattedDay: 'Jan 2'}, + start: '2026-01-01', + end: '2026-01-03', + dates: ['2026-01-01', '2026-01-02', '2026-01-03'], + }, + { + item: {...GROUP_FIELDS, groupedBy: CONST.SEARCH.GROUP_BY.WEEK, week: '2026-01-04', formattedWeek: 'Jan 4–10', shortFormattedWeek: 'Jan 4–10', lineChartFormattedWeek: 'Jan 4'}, + start: '2026-01-01', + end: '2026-01-11', + dates: ['2025-12-28', '2026-01-04', '2026-01-11'], + }, + { + item: {...GROUP_FIELDS, groupedBy: CONST.SEARCH.GROUP_BY.MONTH, year: 2026, month: 2, formattedMonth: 'February 2026', shortFormattedMonth: 'Feb', sortKey: 202602}, + start: '2026-01-15', + end: '2026-03-15', + dates: ['2026-01-01', '2026-02-01', '2026-03-01'], + }, + { + item: {...GROUP_FIELDS, groupedBy: CONST.SEARCH.GROUP_BY.QUARTER, year: 2026, quarter: 2, formattedQuarter: 'Q2 2026', shortFormattedQuarter: 'Q2', sortKey: 20262}, + start: '2026-02-01', + end: '2026-08-01', + dates: ['2026-01-01', '2026-04-01', '2026-07-01'], + }, + { + item: {...GROUP_FIELDS, groupedBy: CONST.SEARCH.GROUP_BY.YEAR, year: 2026, formattedYear: '2026', sortKey: 2026}, + start: '2025-06-01', + end: '2027-06-01', + dates: ['2025-01-01', '2026-01-01', '2027-01-01'], + }, +]; + +describe('buildInsightsChartCSV', () => { + beforeAll(async () => { + await IntlStore.load(CONST.LOCALES.EN); + await IntlStore.load(CONST.LOCALES.FR); + }); + + it.each(TIME_GROUPS)('includes empty $item.groupedBy periods across the selected range', ({item, start, end, dates}) => { + // Given a filtered time range with spend only in its middle period + const data = [item]; + + // When the loaded groups are exported without fetching their expenses + const csv = buildInsightsChartCSV({data, groupBy: item.groupedBy, dateRange: {start, end}, currency: CONST.CURRENCY.USD, getCurrencyDecimals, translate}); + + // Then partial boundary periods and empty periods remain in chronological order + expect(csv).toBe(`\uFEFFDate,Expenses,Total,Currency\r\n${dates.at(0)},0,0.00,USD\r\n${dates.at(1)},2,123.45,USD\r\n${dates.at(2)},0,0.00,USD`); + }); + + it('exports only the supplied ranking groups in chart order', () => { + // Given the ranking rows the chart plots, rather than their individual expenses + const data: GroupedItem[] = [ + {...GROUP_FIELDS, groupedBy: CONST.SEARCH.GROUP_BY.MERCHANT, merchant: 'Café', formattedMerchant: 'Café'}, + {...GROUP_FIELDS, groupedBy: CONST.SEARCH.GROUP_BY.MERCHANT, merchant: 'Refund', formattedMerchant: 'Refund', total: -150, count: 1}, + ]; + + // When the ranking is exported for a date range that includes empty days + const csv = buildInsightsChartCSV({ + data, + groupBy: CONST.SEARCH.GROUP_BY.MERCHANT, + dateRange: {start: '2026-01-01', end: '2026-01-03'}, + currency: CONST.CURRENCY.USD, + getCurrencyDecimals, + translate, + }); + + // Then no empty ranking groups or expense rows are added, and refunds stay numeric + expect(csv).toBe('\uFEFFMerchant,Expenses,Total,Currency\r\nCafé,2,123.45,USD\r\nRefund,1,-1.50,USD'); + }); + + it('escapes labels and prevents spreadsheet formulas without changing numeric totals', () => { + // Given merchant names containing CSV delimiters, line breaks and formula prefixes + const names = ['Shop, "Central"\nBranch', '=1+1', ' @SUM(A1:A2)']; + const data: GroupedItem[] = names.map((merchant) => ({...GROUP_FIELDS, groupedBy: CONST.SEARCH.GROUP_BY.MERCHANT, merchant, formattedMerchant: merchant})); + + // When user-controlled names are written into the spreadsheet + const csv = buildInsightsChartCSV({data, groupBy: CONST.SEARCH.GROUP_BY.MERCHANT, dateRange: {}, currency: CONST.CURRENCY.USD, getCurrencyDecimals, translate}); + + // Then each name stays in one cell and cannot execute as a formula + expect(csv).toBe('\uFEFFMerchant,Expenses,Total,Currency\r\n"Shop, ""Central""\nBranch",2,123.45,USD\r\n\'=1+1,2,123.45,USD\r\n\' @SUM(A1:A2),2,123.45,USD'); + }); + + it('uses the group currency precision and localized category header', () => { + // Given a category chart converted to a currency without decimal places + const data: GroupedItem[] = [{...GROUP_FIELDS, groupedBy: CONST.SEARCH.GROUP_BY.CATEGORY, category: 'Travel', formattedCategory: 'Travel', currency: 'JPY'}]; + const translateFrench: LocalizedTranslate = (key, ...parameters) => translatePhrase(CONST.LOCALES.FR, key, ...parameters); + + // When its grouped totals are exported + const csv = buildInsightsChartCSV({data, groupBy: CONST.SEARCH.GROUP_BY.CATEGORY, dateRange: {}, currency: 'JPY', getCurrencyDecimals, translate: translateFrench}); + + // Then amounts use currency precision, without symbols or thousands separators + expect(csv).toBe( + `\uFEFF${translateFrench('common.category')},${translateFrench('common.expenses')},${translateFrench('common.total')},${translateFrench('common.currency')}\r\nTravel,2,123,JPY`, + ); + }); + + it('exports the same full submitter names shown in the graph', () => { + // Given a submitter group with a resolved display name + const data: GroupedItem[] = [{...GROUP_FIELDS, groupedBy: CONST.SEARCH.GROUP_BY.FROM, accountID: 1, formattedFrom: 'Alex Smith'}]; + + // When the chart is exported + const csv = buildInsightsChartCSV({data, groupBy: CONST.SEARCH.GROUP_BY.FROM, dateRange: {}, currency: CONST.CURRENCY.USD, getCurrencyDecimals, translate}); + + // Then the CSV names the person rather than exposing only their internal account ID + expect(csv).toBe('\uFEFFFrom,Expenses,Total,Currency\r\nAlex Smith,2,123.45,USD'); + }); +}); From 7396587a98ea9d075c276af7d4ecedd705936eb1 Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Fri, 16 Oct 2026 16:45:10 -0600 Subject: [PATCH 2/2] Share CSV field escaping across local exports --- .../WorkspaceCompanyCardsTableControls.tsx | 11 +----- src/libs/actions/Card.ts | 8 +--- src/libs/escapeCsvField.ts | 9 +++++ src/pages/Insights/buildInsightsChartCSV.ts | 7 +--- .../ExportExpensifyCardListToCSVTest.ts | 38 +++++++++++++++++++ ...WorkspaceCompanyCardsTableControlsTest.tsx | 20 +++++++++- tests/unit/escapeCsvFieldTest.ts | 28 ++++++++++++++ 7 files changed, 97 insertions(+), 24 deletions(-) create mode 100644 src/libs/escapeCsvField.ts create mode 100644 tests/actions/ExportExpensifyCardListToCSVTest.ts create mode 100644 tests/unit/escapeCsvFieldTest.ts diff --git a/src/components/Tables/WorkspaceCompanyCardsTable/WorkspaceCompanyCardsTableControls.tsx b/src/components/Tables/WorkspaceCompanyCardsTable/WorkspaceCompanyCardsTableControls.tsx index 0dde14a96366..ad26cfad9c22 100644 --- a/src/components/Tables/WorkspaceCompanyCardsTable/WorkspaceCompanyCardsTableControls.tsx +++ b/src/components/Tables/WorkspaceCompanyCardsTable/WorkspaceCompanyCardsTableControls.tsx @@ -15,6 +15,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {unassignWorkspaceCompanyCard} from '@libs/actions/CompanyCards'; import navigateToCardTransactions from '@libs/CardNavigationUtils'; import {formatMaskedCardName} from '@libs/CardUtils'; +import escapeCsvField from '@libs/escapeCsvField'; import localFileDownload from '@libs/localFileDownload'; import CONST from '@src/CONST'; @@ -42,16 +43,6 @@ type WorkspaceCompanyCardsTableControlsProps = { isSelectionModeEnabled: boolean; }; -const CSV_FORMULA_PREFIX_REGEXP = /^(?:[\t\r\n]|\s*[=+\-@])/; - -function escapeCsvField(value: string): string { - const safeValue = CSV_FORMULA_PREFIX_REGEXP.test(value) ? `'${value}` : value; - if (safeValue.includes('"') || safeValue.includes(',') || safeValue.includes('\n') || safeValue.includes('\r')) { - return `"${safeValue.replaceAll('"', '""')}"`; - } - return safeValue; -} - function WorkspaceCompanyCardsTableControls({ policyID, domainOrWorkspaceAccountID, diff --git a/src/libs/actions/Card.ts b/src/libs/actions/Card.ts index 80c6e0c26942..1c123a37b475 100644 --- a/src/libs/actions/Card.ts +++ b/src/libs/actions/Card.ts @@ -30,6 +30,7 @@ import {getDisplayedExpensifyCardLimitType, getTranslationKeyForLimitType} from import {convertToShortDisplayString} from '@libs/CurrencyUtils'; import DateUtils from '@libs/DateUtils'; import * as ErrorUtils from '@libs/ErrorUtils'; +import escapeCsvField from '@libs/escapeCsvField'; import localFileDownload from '@libs/localFileDownload'; import Log from '@libs/Log'; import deferNavigate from '@libs/Navigation/deferNavigate'; @@ -1987,13 +1988,6 @@ function resolveFraudAlert( API.write(WRITE_COMMANDS.RESOLVE_FRAUD_ALERT, parameters, {optimisticData, successData, failureData}); } -function escapeCsvField(value: string): string { - if (value.includes('"') || value.includes(',') || value.includes('\n') || value.includes('\r')) { - return `"${value.replaceAll('"', '""')}"`; - } - return value; -} - function getOwnerEmailForCard(card: Card, personalDetailsList: PersonalDetailsList | undefined): string { const accountID = card.accountID ?? CONST.DEFAULT_NUMBER_ID; return personalDetailsList?.[String(accountID)]?.login ?? ''; diff --git a/src/libs/escapeCsvField.ts b/src/libs/escapeCsvField.ts new file mode 100644 index 000000000000..b5df641b6244 --- /dev/null +++ b/src/libs/escapeCsvField.ts @@ -0,0 +1,9 @@ +/** Escapes CSV text fields and prevents spreadsheet formula injection. */ +const CSV_FORMULA_PREFIX_REGEXP = /^(?:[\t\r\n]|\s*[=+\-@])/; + +function escapeCsvField(value: string): string { + const safeValue = CSV_FORMULA_PREFIX_REGEXP.test(value) ? `'${value}` : value; + return /[",\r\n]/.test(safeValue) ? `"${safeValue.replaceAll('"', '""')}"` : safeValue; +} + +export default escapeCsvField; diff --git a/src/pages/Insights/buildInsightsChartCSV.ts b/src/pages/Insights/buildInsightsChartCSV.ts index 7eef39cc7b6c..6128b3ad39ab 100644 --- a/src/pages/Insights/buildInsightsChartCSV.ts +++ b/src/pages/Insights/buildInsightsChartCSV.ts @@ -4,6 +4,7 @@ import CHART_GROUP_BY_CONFIG from '@components/Search/chartGroupByConfig'; import type {GroupedItem, SearchGroupBy} from '@components/Search/types'; import {convertToFrontendAmountAsString} from '@libs/CurrencyUtils'; +import escapeCsvField from '@libs/escapeCsvField'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; @@ -21,18 +22,12 @@ type BuildInsightsChartCSVParams = { translate: LocalizedTranslate; }; -const CSV_FORMULA_PREFIX_REGEXP = /^(?:[\t\r\n]|\s*[=+\-@])/; const GROUP_HEADER_KEYS: Partial> = { [CONST.SEARCH.GROUP_BY.FROM]: 'common.from', [CONST.SEARCH.GROUP_BY.MERCHANT]: 'common.merchant', [CONST.SEARCH.GROUP_BY.CATEGORY]: 'common.category', }; -function escapeCsvField(value: string): string { - const safeValue = CSV_FORMULA_PREFIX_REGEXP.test(value) ? `'${value}` : value; - return /[",\r\n]/.test(safeValue) ? `"${safeValue.replaceAll('"', '""')}"` : safeValue; -} - function getPeriodStarts(groupBy: SearchGroupBy, interval: Interval): Date[] { switch (groupBy) { case CONST.SEARCH.GROUP_BY.DAY: diff --git a/tests/actions/ExportExpensifyCardListToCSVTest.ts b/tests/actions/ExportExpensifyCardListToCSVTest.ts new file mode 100644 index 000000000000..09932bfd34ba --- /dev/null +++ b/tests/actions/ExportExpensifyCardListToCSVTest.ts @@ -0,0 +1,38 @@ +import type {LocalizedTranslate} from '@components/LocaleContextProvider'; + +import {exportExpensifyCardListToCSV} from '@libs/actions/Card'; +import localFileDownload from '@libs/localFileDownload'; +import {translate as translatePhrase} from '@libs/Localize'; + +import CONST from '@src/CONST'; +import IntlStore from '@src/languages/IntlStore'; + +import {createRandomExpensifyCard} from '../utils/collections/card'; + +jest.mock('@libs/localFileDownload', () => jest.fn()); + +const translate: LocalizedTranslate = (key, ...parameters) => translatePhrase(CONST.LOCALES.EN, key, ...parameters); + +describe('exportExpensifyCardListToCSV', () => { + beforeAll(() => IntlStore.load(CONST.LOCALES.EN)); + + it('escapes cardholder names with the shared formula protection', () => { + // Given a cardholder name that could otherwise become a spreadsheet formula + const accountID = 1; + const card = {...createRandomExpensifyCard(1, {accountID}), lastFourPAN: '1234'}; + + // When the selected card is downloaded as CSV + exportExpensifyCardListToCSV({ + policyID: 'policy123', + cards: [card], + personalDetailsList: {[accountID]: {accountID, login: 'alex@example.com', displayName: '=SUM(1,2)'}}, + settlementCurrency: CONST.CURRENCY.USD, + defaultLimitType: CONST.EXPENSIFY_CARD.LIMIT_TYPES.MONTHLY, + translate, + formatPhoneNumber: (login) => login, + }); + + // Then the name stays in one text cell instead of executing as a formula + expect(localFileDownload).toHaveBeenCalledWith('ExpensifyCards_policy123.csv', expect.stringContaining('\r\nalex@example.com,"\'=SUM(1,2)",1234,'), translate); + }); +}); diff --git a/tests/ui/WorkspaceCompanyCardsTableControlsTest.tsx b/tests/ui/WorkspaceCompanyCardsTableControlsTest.tsx index 5eced387c3f2..1df42c526da5 100644 --- a/tests/ui/WorkspaceCompanyCardsTableControlsTest.tsx +++ b/tests/ui/WorkspaceCompanyCardsTableControlsTest.tsx @@ -1,16 +1,21 @@ import {render, screen} from '@testing-library/react-native'; import WorkspaceCompanyCardsTableControls from '@components/Tables/WorkspaceCompanyCardsTable/WorkspaceCompanyCardsTableControls'; +import type {WorkspaceCompanyCardTableItemData} from '@components/Tables/WorkspaceCompanyCardsTable/WorkspaceCompanyCardsTableRow'; + +import {formatMaskedCardName} from '@libs/CardUtils'; +import localFileDownload from '@libs/localFileDownload'; import React from 'react'; type MockButtonProps = { customText: string; isDisabled?: boolean; + options?: Array<{value: string; onSelected?: () => void | Promise}>; }; const mockButtonProps: {current?: MockButtonProps} = {}; -let mockProcessedData: Array<{selected?: boolean; disabled?: boolean; isAssigned?: boolean}> = []; +let mockProcessedData: Array & {selected?: boolean}> = []; let mockShouldUseNarrowLayout = true; jest.mock('@components/ButtonWithDropdownMenu', () => { @@ -127,4 +132,17 @@ describe('WorkspaceCompanyCardsTableControls narrow-layout selection mode', () = expect(screen.queryByTestId('WorkspaceCompanyCardsBulkActions')).toBeNull(); expect(screen.getByTestId('WorkspaceCompanyCardsFilterBar')).toBeTruthy(); }); + + it('exports formula-like card names as quoted text', async () => { + // Given a selected company card with a formula-like custom name + mockProcessedData = [{selected: true, isAssigned: true, cardName: 'card123', customCardName: '=SUM(1,2)', cardholder: {accountID: 1, login: 'alex@example.com'}}]; + jest.mocked(formatMaskedCardName).mockReturnValue('1234'); + renderControls(true); + + // When the CSV export action is selected + await mockButtonProps.current?.options?.find((option) => option.value === 'exportCSV')?.onSelected?.(); + + // Then the existing export still uses one safe text cell for the card name + expect(localFileDownload).toHaveBeenCalledWith('CompanyCards_policy123.csv', expect.stringContaining('\r\nalex@example.com,"\'=SUM(1,2)",1234,'), expect.any(Function)); + }); }); diff --git a/tests/unit/escapeCsvFieldTest.ts b/tests/unit/escapeCsvFieldTest.ts new file mode 100644 index 000000000000..6f92ef4b8e43 --- /dev/null +++ b/tests/unit/escapeCsvFieldTest.ts @@ -0,0 +1,28 @@ +import escapeCsvField from '@libs/escapeCsvField'; + +describe('escapeCsvField', () => { + it.each([ + ['', ''], + ['Café', 'Café'], + ['123.45', '123.45'], + ['Shop, "Central"\r\nBranch', '"Shop, ""Central""\r\nBranch"'], + ])('escapes CSV text %p without changing its contents', (value, expected) => { + // Given text that must remain in one spreadsheet cell + // When CSV escaping is applied + const result = escapeCsvField(value); + + // Then delimiters are quoted and embedded quotes are doubled + expect(result).toBe(expected); + }); + + it.each(['=1+1', '+1+1', '-1+1', '@SUM(A1:A2)', ' =1+1', '\tvalue', '\rvalue', '\nvalue'])('prevents spreadsheet formulas in %p', (value) => { + // Given text beginning with a spreadsheet formula or control character + // When it is exported as a CSV field + const result = escapeCsvField(value); + + // Then an apostrophe forces text interpretation, while line breaks stay inside quotes + const safeValue = `'${value}`; + const expected = /[\r\n]/.test(value) ? `"${safeValue}"` : safeValue; + expect(result).toBe(expected); + }); +});