Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down
8 changes: 1 addition & 7 deletions src/libs/actions/Card.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 ?? '';
Expand Down
9 changes: 9 additions & 0 deletions src/libs/escapeCsvField.ts
Original file line number Diff line number Diff line change
@@ -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;
76 changes: 76 additions & 0 deletions src/pages/Insights/buildInsightsChartCSV.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/** 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 escapeCsvField from '@libs/escapeCsvField';

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 GROUP_HEADER_KEYS: Partial<Record<SearchGroupBy, TranslationPaths>> = {
[CONST.SEARCH.GROUP_BY.FROM]: 'common.from',
[CONST.SEARCH.GROUP_BY.MERCHANT]: 'common.merchant',
[CONST.SEARCH.GROUP_BY.CATEGORY]: 'common.category',
};

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};
});
}
Comment on lines +61 to +67

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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};
});
}
if (getDateRange && dateRange.start && dateRange.end) {
const start = parseISO(dateRange.start);
// Don't pad periods that haven't happened yet: a future period has no data yet, so a 0 row would read as "no spend".
const parsedEnd = parseISO(dateRange.end);
const end = parsedEnd > new Date() ? new Date() : parsedEnd;
// Skip zero-fill for an invalid or inverted range (start > end, which also covers a range that begins in the future).
if (isValid(start) && isValid(end) && start <= end) {
const rowsByDate = new Map(rows.map((row) => [row.label, row]));
rows = getPeriodStarts(groupBy, {start, end}).map((date) => {
const label = format(date, CONST.DATE.FNS_FORMAT_STRING);
return rowsByDate.get(label) ?? {label, count: 0, total: 0, currency};
});
}
}
Screen.Recording.2026-10-17.at.16.00.50.mov

@luacmartins I found an interesting case. Nov and Dec are periods that haven't happened yet, but they show up in the CSV as "periods with zero spend", when in reality there is no data for them yet.

Steps to reproduce:

  1. Set the Date filter to a custom range: Oct 1, 2026 to Dec 31, 2026.
  2. Download the CSV from the "Spend over time" chart and open it.

Actual result: the CSV contains rows for Nov and Dec with 0 expenses and 0 total:

Expected result: I think future periods shouldn't appear. They haven't happened yet, so a 0 row reads as "no spend" instead of "no data yet".

@luacmartins luacmartins Oct 19, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Expected result: I think future periods shouldn't appear.

I'm not sure. It's possible for users to create expenses in future dates. @JmillsExpensify what's your take here?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Future periods should appear, if they have future dated expenses. I'm not sure it's a bug for this PR really. It's more of a question of whether we should be showing an empty state in that case, versus plotting $0. Isn't that what this PR is doing though? #104337

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea, that makes sense. So NAB for this PR


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;
26 changes: 25 additions & 1 deletion src/pages/Insights/charts/InsightsChartWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -78,7 +82,8 @@ function InsightsChartWidget({
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, previousPeriodQueryJSON, previousPeriodSnapshot, averageQueryJSON, averageSnapshot}, filters);
const groupBy = chart.groupBy ?? getQueryGroupBy(filters);
Expand All @@ -88,6 +93,15 @@ function InsightsChartWidget({

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});

@huult huult Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Screen.Recording.2026-10-17.at.16.59.51.mov

the same way View on Spend builds its query:

@luacmartins Issue says the ranking export should follow View on Spend (no limit), but we export data, which is capped at 5 — is top 5 intended, or should it include every group with spend?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea, I think top 5 is intended here since that's what the chart shows

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, top 5 is intended.

localFileDownload(`Expensify-${chart.graphKey}.csv`, content, translate);

@huult huult Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The download reuses our existing export flow; no new component. Small files download immediately, and larger files are delivered by Concierge, as with other exports.

@luacmartins The issue says the download should reuse the existing export flow (Concierge for large files), but this generates the file locally with localFileDownload — is the local approach intended, e.g. to support offline export?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea, we changed the approach to local only since that allows for offline export of the data that's already available. cc @JmillsExpensify

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct. We changed it and I never updated the OP, sorry.

};

const groupByControl =
onGroupByChange && groupByOptions.length > 1 ? (
<InsightsGroupByDropdown
Expand All @@ -110,6 +124,16 @@ function InsightsChartWidget({
onSelected: () => 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;
Expand Down
38 changes: 38 additions & 0 deletions tests/actions/ExportExpensifyCardListToCSVTest.ts
Original file line number Diff line number Diff line change
@@ -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: '[email protected]', 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\[email protected],"\'=SUM(1,2)",1234,'), translate);
});
});
122 changes: 122 additions & 0 deletions tests/ui/InsightsChartWidgetTest.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
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(
<ComposeProviders components={[OnyxListItemProvider, LocaleContextProvider, CurrencyListContextProvider]}>
<InsightsChartWidget
chart={CHART}
queryJSON={QUERY}
snapshot={undefined}
previousPeriodQueryJSON={undefined}
previousPeriodSnapshot={undefined}
averageQueryJSON={undefined}
averageSnapshot={undefined}
filters={FILTERS}
onRetry={jest.fn()}
/>
</ComposeProviders>,
);
}

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();
},
);
});
20 changes: 19 additions & 1 deletion tests/ui/WorkspaceCompanyCardsTableControlsTest.tsx
Original file line number Diff line number Diff line change
@@ -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<void>}>;
};

const mockButtonProps: {current?: MockButtonProps} = {};
let mockProcessedData: Array<{selected?: boolean; disabled?: boolean; isAssigned?: boolean}> = [];
let mockProcessedData: Array<Partial<WorkspaceCompanyCardTableItemData> & {selected?: boolean}> = [];
let mockShouldUseNarrowLayout = true;

jest.mock('@components/ButtonWithDropdownMenu', () => {
Expand Down Expand Up @@ -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: '[email protected]'}}];
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\[email protected],"\'=SUM(1,2)",1234,'), expect.any(Function));
});
});
Loading
Loading