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