diff --git a/packages/api/methods.test.ts b/packages/api/methods.test.ts index 53b39fa5d4..c1d9b8fc0f 100644 --- a/packages/api/methods.test.ts +++ b/packages/api/methods.test.ts @@ -654,4 +654,60 @@ describe('API CRUD operations', () => { ); expect(transactions).toHaveLength(1); }); + + test('Transactions: import notes are preserved when importing', async () => { + const accountId = await api.createAccount({ name: 'test-account' }, 0); + + // Test with notes + const transactionsWithNotes = [ + { + date: '2023-11-03', + imported_id: '11', + amount: 100, + notes: 'test note', + }, + ]; + + const addResultWithNotes = await api.addTransactions( + accountId, + transactionsWithNotes, + { + learnCategories: true, + runTransfers: true, + }, + ); + expect(addResultWithNotes).toBe('ok'); + + let transactions = await api.getTransactions( + accountId, + '2023-11-01', + '2023-11-30', + ); + expect(transactions[0].notes).toBe('test note'); + + // Clear transactions + await api.deleteTransaction(transactions[0].id); + + // Test without notes + const transactionsWithoutNotes = [ + { date: '2023-11-03', imported_id: '11', amount: 100, notes: null }, + ]; + + const addResultWithoutNotes = await api.addTransactions( + accountId, + transactionsWithoutNotes, + { + learnCategories: true, + runTransfers: true, + }, + ); + expect(addResultWithoutNotes).toBe('ok'); + + transactions = await api.getTransactions( + accountId, + '2023-11-01', + '2023-11-30', + ); + expect(transactions[0].notes).toBeNull(); + }); }); diff --git a/packages/desktop-client/e2e/accounts.test.ts b/packages/desktop-client/e2e/accounts.test.ts index 237a46010c..16dd2591f4 100644 --- a/packages/desktop-client/e2e/accounts.test.ts +++ b/packages/desktop-client/e2e/accounts.test.ts @@ -161,5 +161,28 @@ test.describe('Accounts', () => { await expect(importButton).not.toBeVisible(); }); + + test('import notes checkbox is not shown for CSV files', async () => { + const fileChooserPromise = page.waitForEvent('filechooser'); + await accountPage.page.getByRole('button', { name: 'Import' }).click(); + + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles(join(__dirname, 'data/test.csv')); + + // Verify the import notes checkbox is not visible for CSV files + const importNotesCheckbox = page.getByRole('checkbox', { + name: 'Import notes from file', + }); + await expect(importNotesCheckbox).not.toBeVisible(); + + // Import the transactions + const importButton = page.getByRole('button', { + name: /Import \d+ transactions/, + }); + await importButton.click(); + + // Verify the transactions were imported + await expect(importButton).not.toBeVisible(); + }); }); }); diff --git a/packages/desktop-client/e2e/accounts.test.ts-snapshots/Accounts-Import-Transactions-import-csv-file-twice-1-chromium-linux.png b/packages/desktop-client/e2e/accounts.test.ts-snapshots/Accounts-Import-Transactions-import-csv-file-twice-1-chromium-linux.png index bfe2ff0968..2ba32e6c6a 100644 Binary files a/packages/desktop-client/e2e/accounts.test.ts-snapshots/Accounts-Import-Transactions-import-csv-file-twice-1-chromium-linux.png and b/packages/desktop-client/e2e/accounts.test.ts-snapshots/Accounts-Import-Transactions-import-csv-file-twice-1-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/accounts.test.ts-snapshots/Accounts-Import-Transactions-import-csv-file-twice-2-chromium-linux.png b/packages/desktop-client/e2e/accounts.test.ts-snapshots/Accounts-Import-Transactions-import-csv-file-twice-2-chromium-linux.png index 019872cae4..510e4feda5 100644 Binary files a/packages/desktop-client/e2e/accounts.test.ts-snapshots/Accounts-Import-Transactions-import-csv-file-twice-2-chromium-linux.png and b/packages/desktop-client/e2e/accounts.test.ts-snapshots/Accounts-Import-Transactions-import-csv-file-twice-2-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/accounts.test.ts-snapshots/Accounts-Import-Transactions-import-csv-file-twice-3-chromium-linux.png b/packages/desktop-client/e2e/accounts.test.ts-snapshots/Accounts-Import-Transactions-import-csv-file-twice-3-chromium-linux.png index db130f46e7..60489e0a7e 100644 Binary files a/packages/desktop-client/e2e/accounts.test.ts-snapshots/Accounts-Import-Transactions-import-csv-file-twice-3-chromium-linux.png and b/packages/desktop-client/e2e/accounts.test.ts-snapshots/Accounts-Import-Transactions-import-csv-file-twice-3-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/accounts.test.ts-snapshots/Accounts-Import-Transactions-imports-transactions-from-a-CSV-file-1-chromium-linux.png b/packages/desktop-client/e2e/accounts.test.ts-snapshots/Accounts-Import-Transactions-imports-transactions-from-a-CSV-file-1-chromium-linux.png index da9a4772f5..0062aeaf77 100644 Binary files a/packages/desktop-client/e2e/accounts.test.ts-snapshots/Accounts-Import-Transactions-imports-transactions-from-a-CSV-file-1-chromium-linux.png and b/packages/desktop-client/e2e/accounts.test.ts-snapshots/Accounts-Import-Transactions-imports-transactions-from-a-CSV-file-1-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/accounts.test.ts-snapshots/Accounts-Import-Transactions-imports-transactions-from-a-CSV-file-2-chromium-linux.png b/packages/desktop-client/e2e/accounts.test.ts-snapshots/Accounts-Import-Transactions-imports-transactions-from-a-CSV-file-2-chromium-linux.png index 510fa9bf58..38f07cdb0e 100644 Binary files a/packages/desktop-client/e2e/accounts.test.ts-snapshots/Accounts-Import-Transactions-imports-transactions-from-a-CSV-file-2-chromium-linux.png and b/packages/desktop-client/e2e/accounts.test.ts-snapshots/Accounts-Import-Transactions-imports-transactions-from-a-CSV-file-2-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/accounts.test.ts-snapshots/Accounts-Import-Transactions-imports-transactions-from-a-CSV-file-3-chromium-linux.png b/packages/desktop-client/e2e/accounts.test.ts-snapshots/Accounts-Import-Transactions-imports-transactions-from-a-CSV-file-3-chromium-linux.png index 8607848c87..7d0c378fab 100644 Binary files a/packages/desktop-client/e2e/accounts.test.ts-snapshots/Accounts-Import-Transactions-imports-transactions-from-a-CSV-file-3-chromium-linux.png and b/packages/desktop-client/e2e/accounts.test.ts-snapshots/Accounts-Import-Transactions-imports-transactions-from-a-CSV-file-3-chromium-linux.png differ diff --git a/packages/desktop-client/src/components/modals/ImportTransactionsModal/ImportTransactionsModal.jsx b/packages/desktop-client/src/components/modals/ImportTransactionsModal/ImportTransactionsModal.jsx index 426a4d779d..4904a50ad9 100644 --- a/packages/desktop-client/src/components/modals/ImportTransactionsModal/ImportTransactionsModal.jsx +++ b/packages/desktop-client/src/components/modals/ImportTransactionsModal/ImportTransactionsModal.jsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useCallback } from 'react'; -import { useTranslation } from 'react-i18next'; +import { useTranslation, Trans } from 'react-i18next'; import { Button, ButtonWithLoading } from '@actual-app/components/button'; import { Input } from '@actual-app/components/input'; @@ -163,6 +163,7 @@ export function ImportTransactionsModal({ const [flipAmount, setFlipAmount] = useState(false); const [multiplierEnabled, setMultiplierEnabled] = useState(false); const [reconcile, setReconcile] = useState(true); + const [importNotes, setImportNotes] = useState(true); // This cannot be set after parsing the file, because changing it // requires re-parsing the file. This is different from the other @@ -429,6 +430,7 @@ export function ImportTransactionsModal({ hasHeaderRow, skipLines, fallbackMissingPayeeToMemo, + importNotes, }); parse(originalFileName, parseOptions); @@ -438,6 +440,7 @@ export function ImportTransactionsModal({ hasHeaderRow, skipLines, fallbackMissingPayeeToMemo, + importNotes, parse, ]); @@ -489,6 +492,7 @@ export function ImportTransactionsModal({ hasHeaderRow, skipLines, fallbackMissingPayeeToMemo, + importNotes, }); parse(res[0], parseOptions); @@ -619,6 +623,7 @@ export function ImportTransactionsModal({ date, amount: amountToInteger(amount), cleared: clearOnImport, + notes: importNotes ? finalTransaction.notes : null, }); } @@ -655,6 +660,7 @@ export function ImportTransactionsModal({ if (filetype === 'csv' || filetype === 'qif') { savePrefs({ [`flip-amount-${accountId}-${filetype}`]: String(flipAmount), + [`import-notes-${accountId}-${filetype}`]: String(importNotes), }); } @@ -843,6 +849,7 @@ export function ImportTransactionsModal({ filename, getParseOptions('ofx', { fallbackMissingPayeeToMemo: !fallbackMissingPayeeToMemo, + importNotes, }), ); }} @@ -850,6 +857,29 @@ export function ImportTransactionsModal({ {t('Use Memo as a fallback for empty Payees')} )} + + {filetype !== 'csv' && ( + { + setImportNotes(!importNotes); + parse( + filename, + getParseOptions(filetype, { + delimiter, + hasHeaderRow, + skipLines, + fallbackMissingPayeeToMemo, + importNotes: !importNotes, + }), + ); + }} + > + Import notes from file + + )} + {(isOfxFile(filetype) || isCamtFile(filetype)) && ( { console.warn = old; }); -async function getTransactions(accountId) { - return db.runQuery( +type Transaction = { + id: string; + amount: number; + date: string; + payee_name: string; + imported_payee: string; + notes: string | null; +}; + +async function getTransactions(accountId: string): Promise { + return await db.runQuery( 'SELECT * FROM transactions WHERE acct = ?', [accountId], true, @@ -33,11 +42,14 @@ async function importFileWithRealTime( accountId, filepath, dateFormat?: string, + options?: { importNotes: boolean }, ) { // Emscripten requires a real Date.now! global.restoreDateNow(); - const { errors, transactions: originalTransactions } = - await parseFile(filepath); + const { errors, transactions: originalTransactions } = await parseFile( + filepath, + options, + ); global.restoreFakeDateNow(); let transactions = originalTransactions; @@ -67,6 +79,7 @@ describe('File import', () => { 'one', __dirname + '/../../../mocks/files/data.qif', 'MM/dd/yy', + { importNotes: true }, ); expect(errors.length).toBe(0); expect(await getTransactions('one')).toMatchSnapshot(); @@ -79,6 +92,8 @@ describe('File import', () => { const { errors } = await importFileWithRealTime( 'one', __dirname + '/../../../mocks/files/data.ofx', + null, + { importNotes: true }, ); expect(errors.length).toBe(0); expect(await getTransactions('one')).toMatchSnapshot(); @@ -91,6 +106,8 @@ describe('File import', () => { const { errors } = await importFileWithRealTime( 'one', __dirname + '/../../../mocks/files/credit-card.ofx', + null, + { importNotes: true }, ); expect(errors.length).toBe(0); expect(await getTransactions('one')).toMatchSnapshot(); @@ -103,11 +120,44 @@ describe('File import', () => { const { errors } = await importFileWithRealTime( 'one', __dirname + '/../../../mocks/files/data.qfx', + null, + { importNotes: true }, ); expect(errors.length).toBe(0); expect(await getTransactions('one')).toMatchSnapshot(); }, 45000); + test('import notes are respected when importing', async () => { + prefs.loadPrefs(); + await db.insertAccount({ id: 'one', name: 'one' }); + + // Test with importNotes enabled + const { errors: errorsWithNotes } = await importFileWithRealTime( + 'one', + __dirname + '/../../../mocks/files/data.ofx', + null, + { importNotes: true }, + ); + expect(errorsWithNotes.length).toBe(0); + expect(await getTransactions('one')).toMatchSnapshot( + 'transactions with notes', + ); + + // Clear transactions + await db.runQuery('DELETE FROM transactions WHERE acct = ?', ['one']); + + // Test with importNotes disabled + const { errors: errorsWithoutNotes } = await importFileWithRealTime( + 'one', + __dirname + '/../../../mocks/files/data.ofx', + null, + { importNotes: false }, + ); + expect(errorsWithoutNotes.length).toBe(0); + const transactionsWithoutNotes = await getTransactions('one'); + expect(transactionsWithoutNotes.every(t => t.notes === null)).toBe(true); + }, 45000); + test('matches extensions correctly (case-insensitive, etc)', async () => { prefs.loadPrefs(); await db.insertAccount({ id: 'one', name: 'one' }); @@ -138,6 +188,7 @@ describe('File import', () => { 'one', __dirname + '/../../../mocks/files/8859-1.qfx', 'yyyy-MM-dd', + { importNotes: true }, ); expect(errors.length).toBe(0); expect(await getTransactions('one')).toMatchSnapshot(); @@ -151,6 +202,7 @@ describe('File import', () => { 'one', __dirname + '/../../../mocks/files/html-vals.qfx', 'yyyy-MM-dd', + { importNotes: true }, ); expect(errors.length).toBe(0); expect(await getTransactions('one')).toMatchSnapshot(); @@ -163,6 +215,8 @@ describe('File import', () => { const { errors } = await importFileWithRealTime( 'one', __dirname + '/../../../mocks/files/camt/camt.053.xml', + null, + { importNotes: true }, ); expect(errors.length).toBe(0); expect(await getTransactions('one')).toMatchSnapshot(); diff --git a/packages/loot-core/src/server/transactions/import/parse-file.ts b/packages/loot-core/src/server/transactions/import/parse-file.ts index f551306018..e5350f98b4 100644 --- a/packages/loot-core/src/server/transactions/import/parse-file.ts +++ b/packages/loot-core/src/server/transactions/import/parse-file.ts @@ -19,6 +19,7 @@ export type ParseFileOptions = { delimiter?: string; fallbackMissingPayeeToMemo?: boolean; skipLines?: number; + importNotes?: boolean; }; export async function parseFile( @@ -33,7 +34,7 @@ export async function parseFile( switch (ext.toLowerCase()) { case '.qif': - return parseQIF(filepath); + return parseQIF(filepath, options); case '.csv': case '.tsv': return parseCSV(filepath, options); @@ -41,7 +42,7 @@ export async function parseFile( case '.qfx': return parseOFX(filepath, options); case '.xml': - return parseCAMT(filepath); + return parseCAMT(filepath, options); default: } } @@ -88,7 +89,10 @@ async function parseCSV( return { errors, transactions: data }; } -async function parseQIF(filepath: string): Promise { +async function parseQIF( + filepath: string, + options: ParseFileOptions = {}, +): Promise { const errors = Array(); const contents = await fs.readFile(filepath); @@ -111,7 +115,7 @@ async function parseQIF(filepath: string): Promise { date: trans.date, payee_name: trans.payee, imported_payee: trans.payee, - notes: trans.memo || null, + notes: options.importNotes ? trans.memo || null : null, })) .filter(trans => trans.date != null && trans.amount != null), }; @@ -148,13 +152,16 @@ async function parseOFX( date: trans.date, payee_name: trans.name || (useMemoFallback ? trans.memo : null), imported_payee: trans.name || (useMemoFallback ? trans.memo : null), - notes: !!trans.name || !useMemoFallback ? trans.memo || null : null, //memo used for payee + notes: options.importNotes ? trans.memo || null : null, //memo used for payee }; }), }; } -async function parseCAMT(filepath: string): Promise { +async function parseCAMT( + filepath: string, + options: ParseFileOptions = {}, +): Promise { const errors = Array(); const contents = await fs.readFile(filepath); @@ -170,5 +177,11 @@ async function parseCAMT(filepath: string): Promise { return { errors }; } - return { errors, transactions: data }; + return { + errors, + transactions: data.map(trans => ({ + ...trans, + notes: options.importNotes ? trans.notes : null, + })), + }; } diff --git a/upcoming-release-notes/4593.md b/upcoming-release-notes/4593.md new file mode 100644 index 0000000000..8ff88f0747 --- /dev/null +++ b/upcoming-release-notes/4593.md @@ -0,0 +1,6 @@ +--- +category: Features +authors: [iaewing] +--- + +Add option to control whether notes are imported during transaction imports \ No newline at end of file