From f29c03173552a93a653bada6892a11407b4066dd Mon Sep 17 00:00:00 2001 From: Sylvercode <987564+sylvercode@users.noreply.github.com> Date: Wed, 11 Mar 2026 09:40:26 -0400 Subject: [PATCH] Add an option to swap payee/memo when importing transaction form file (#7101) * Add swap payee-memo to ofx import * Add mock files to test payee-memo swap * Add swap payee-memo to qif import * Add swap payee-memo to camt import * Minor code cleanup for swap payee-memo on import * Add release note * lint fixing * Fixe Payee and Memo capitalization * change swapPayeeAndMemo to ofxSwapPayeeAndMemo * correct release note typo * Add getSwapOption base on file type * Support qfx * Add CheckboxToggle to simplify ImportTransactionsModal options * Fix split reac import * Fix react import lint --- .../ImportTransactionsModal.tsx | 193 ++++++++++++++---- .../mocks/files/camt/camt.053.payee-memo.xml | 127 ++++++++++++ .../src/mocks/files/data-payee-memo.ofx | 75 +++++++ .../src/mocks/files/data-payee-memo.qif | 17 ++ .../server/transactions/import/parse-file.ts | 54 +++-- packages/loot-core/src/types/prefs.ts | 3 + upcoming-release-notes/7101.md | 6 + 7 files changed, 417 insertions(+), 58 deletions(-) create mode 100644 packages/loot-core/src/mocks/files/camt/camt.053.payee-memo.xml create mode 100644 packages/loot-core/src/mocks/files/data-payee-memo.ofx create mode 100644 packages/loot-core/src/mocks/files/data-payee-memo.qif create mode 100644 upcoming-release-notes/7101.md diff --git a/packages/desktop-client/src/components/modals/ImportTransactionsModal/ImportTransactionsModal.tsx b/packages/desktop-client/src/components/modals/ImportTransactionsModal/ImportTransactionsModal.tsx index c41d0419a7..ecb67993d8 100644 --- a/packages/desktop-client/src/components/modals/ImportTransactionsModal/ImportTransactionsModal.tsx +++ b/packages/desktop-client/src/components/modals/ImportTransactionsModal/ImportTransactionsModal.tsx @@ -1,6 +1,11 @@ // @ts-strict-ignore import React, { useCallback, useEffect, useEffectEvent, useState } from 'react'; -import type { ComponentProps } from 'react'; +import type { + ComponentProps, + Dispatch, + ReactNode, + SetStateAction, +} from 'react'; import { Trans, useTranslation } from 'react-i18next'; import { Button, ButtonWithLoading } from '@actual-app/components/button'; @@ -53,6 +58,28 @@ import { useDateFormat } from '@desktop-client/hooks/useDateFormat'; import { useSyncedPrefs } from '@desktop-client/hooks/useSyncedPrefs'; import { payeeQueries } from '@desktop-client/payees'; +function CheckboxToggle({ + id, + checked, + onChange, + children, +}: { + id: string; + checked: boolean; + onChange: Dispatch>; + children: ReactNode; +}) { + return ( + onChange(prev => !prev)} + > + {children} + + ); +} + function getFileType(filepath: string): string { const m = filepath.match(/\.([^.]*)$/); if (!m) return 'ofx'; @@ -213,6 +240,15 @@ export function ImportTransactionsModal({ const [fallbackMissingPayeeToMemo, setFallbackMissingPayeeToMemo] = useState( String(prefs[`ofx-fallback-missing-payee-${accountId}`]) !== 'false', ); + const [ofxSwapPayeeAndMemo, setOfxSwapPayeeAndMemo] = useState( + String(prefs[`ofx-swap-payee-memo-${accountId}`]) === 'true', + ); + const [qifSwapPayeeAndMemo, setQifSwapPayeeAndMemo] = useState( + String(prefs[`qif-swap-payee-memo-${accountId}`]) === 'true', + ); + const [camtSwapPayeeAndMemo, setCamtSwapPayeeAndMemo] = useState( + String(prefs[`camt-swap-payee-memo-${accountId}`]) === 'true', + ); const [parseDateFormat, setParseDateFormat] = useState( null, @@ -413,6 +449,12 @@ export function ImportTransactionsModal({ skipEndLines, fallbackMissingPayeeToMemo, importNotes, + swapPayeeAndMemo: getSwapOption( + fileType, + ofxSwapPayeeAndMemo, + qifSwapPayeeAndMemo, + camtSwapPayeeAndMemo, + ), }); void parse(originalFileName, parseOptions); @@ -424,6 +466,9 @@ export function ImportTransactionsModal({ skipEndLines, fallbackMissingPayeeToMemo, importNotes, + ofxSwapPayeeAndMemo, + qifSwapPayeeAndMemo, + camtSwapPayeeAndMemo, parse, ]); @@ -471,6 +516,12 @@ export function ImportTransactionsModal({ skipEndLines, fallbackMissingPayeeToMemo, importNotes, + swapPayeeAndMemo: getSwapOption( + fileType, + ofxSwapPayeeAndMemo, + qifSwapPayeeAndMemo, + camtSwapPayeeAndMemo, + ), }); void parse(res[0], parseOptions); @@ -625,6 +676,7 @@ export function ImportTransactionsModal({ [`ofx-fallback-missing-payee-${accountId}`]: String( fallbackMissingPayeeToMemo, ), + [`ofx-swap-payee-memo-${accountId}`]: String(ofxSwapPayeeAndMemo), }); } @@ -649,6 +701,18 @@ export function ImportTransactionsModal({ }); } + if (filetype === 'qif') { + savePrefs({ + [`qif-swap-payee-memo-${accountId}`]: String(qifSwapPayeeAndMemo), + }); + } + + if (isCamtFile(filetype)) { + savePrefs({ + [`camt-swap-payee-memo-${accountId}`]: String(camtSwapPayeeAndMemo), + }); + } + importTransactions.mutate( { accountId, @@ -949,39 +1013,62 @@ export function ImportTransactionsModal({ )} {isOfxFile(filetype) && ( - { - setFallbackMissingPayeeToMemo(state => !state); - }} - > - Use Memo as a fallback for empty Payees - + <> + + Use Memo as a fallback for empty Payees + + + Swap Payee and Memo + + )} {filetype !== 'csv' && ( - { - setImportNotes(!importNotes); - }} + onChange={setImportNotes} > Import notes from file - + + )} + + {filetype === 'qif' && ( + + Swap Payee and Memo + + )} + + {isCamtFile(filetype) && ( + + Swap Payee and Memo + )} {(isOfxFile(filetype) || isCamtFile(filetype)) && ( - { - setReconcile(!reconcile); - }} + onChange={setReconcile} > Merge with existing transactions - + )} {/*Import Options */} @@ -1079,33 +1166,27 @@ export function ImportTransactionsModal({ style={{ width: 50 }} /> - { - setHasHeaderRow(!hasHeaderRow); - }} + onChange={setHasHeaderRow} > File has header row - - + { - setClearOnImport(!clearOnImport); - }} + onChange={setClearOnImport} > Clear transactions on import - - + { - setReconcile(!reconcile); - }} + onChange={setReconcile} > Merge with existing transactions - + )} @@ -1113,15 +1194,13 @@ export function ImportTransactionsModal({ - { - setFlipAmount(!flipAmount); - }} + onChange={setFlipAmount} > Flip amount - + + + + + + 053D2019-01-23T00:00:00.0N000000001 + 2019-01-23T00:00:00.0+00:00 + + + TEST-PAYEE-MEMO + 2019-01-23T00:00:00.0+00:00 + + + DE14740618130000033626 + + EUR + + + + + 10.00 + DBIT + BOOK + +
2019-01-23
+
+ +
2019-01-23
+
+ test-payee-and-notes + + + + + test-payee-and-notes + + + + Payee Name + + + + Note Text + + + +
+ + + + 20.00 + DBIT + BOOK + +
2019-01-23
+
+ +
2019-01-23
+
+ test-payee-only + + + + + test-payee-only + + + + Payee Name Only + + + + +
+ + + + 30.00 + DBIT + BOOK + +
2019-01-23
+
+ +
2019-01-23
+
+ test-notes-only + + + + + test-notes-only + + + Note Only Text + + + +
+ + + + 40.00 + DBIT + BOOK + +
2019-01-23
+
+ +
2019-01-23
+
+ test-neither + + + + + test-neither + + + +
+ +
+
+
diff --git a/packages/loot-core/src/mocks/files/data-payee-memo.ofx b/packages/loot-core/src/mocks/files/data-payee-memo.ofx new file mode 100644 index 0000000000..85d56d414d --- /dev/null +++ b/packages/loot-core/src/mocks/files/data-payee-memo.ofx @@ -0,0 +1,75 @@ +OFXHEADER:100 +DATA:OFXSGML +VERSION:102 +SECURITY:NONE +ENCODING:USASCII +CHARSET:1252 +COMPRESSION:NONE +OLDFILEUID:NONE +NEWFILEUID:NONE + + + + + +0 +INFO + +20190124212851.000[0:UTC] +ENG + + + + +0 + +0 +INFO + + +USD + +012345678 +123456789123 +CHECKING + + +20190119120000 +20190124120000 + +DEBIT +20190123120000 +-10.00 +test-name-and-memo +Payee Name +Memo Text + + +DEBIT +20190123120000 +-20.00 +test-name-only +Payee Name Only + + +DEBIT +20190123120000 +-30.00 +test-memo-only +Memo Only Text + + +DEBIT +20190123120000 +-40.00 +test-neither + + + +1000.00 +20190124212851 + + + + + diff --git a/packages/loot-core/src/mocks/files/data-payee-memo.qif b/packages/loot-core/src/mocks/files/data-payee-memo.qif new file mode 100644 index 0000000000..a91587e41b --- /dev/null +++ b/packages/loot-core/src/mocks/files/data-payee-memo.qif @@ -0,0 +1,17 @@ +!Type:Bank +D01/23/2019 +PPayee Name +MNote Text +T-10.00 +^ +D01/23/2019 +PPayee Name Only +T-20.00 +^ +D01/23/2019 +MNote Only Text +T-30.00 +^ +D01/23/2019 +T-40.00 +^ 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 101056e5ce..6793e5751a 100644 --- a/packages/loot-core/src/server/transactions/import/parse-file.ts +++ b/packages/loot-core/src/server/transactions/import/parse-file.ts @@ -68,6 +68,7 @@ export type ParseFileOptions = { hasHeaderRow?: boolean; delimiter?: string; fallbackMissingPayeeToMemo?: boolean; + swapPayeeAndMemo?: boolean; skipStartLines?: number; skipEndLines?: number; importNotes?: boolean; @@ -172,16 +173,26 @@ async function parseQIF( return { errors, transactions: [] }; } + const swap = options.swapPayeeAndMemo; + return { errors: [], transactions: data.transactions - .map(trans => ({ - amount: trans.amount != null ? looselyParseAmount(trans.amount) : null, - date: trans.date, - payee_name: trans.payee, - imported_payee: trans.payee, - notes: options.importNotes ? trans.memo || null : null, - })) + .map(trans => { + const payeeSource = swap ? trans.memo : trans.payee; + const memoSource = swap ? trans.payee : trans.memo; + const fallbackUsed = !payeeSource && swap; + + return { + amount: + trans.amount != null ? looselyParseAmount(trans.amount) : null, + date: trans.date, + payee_name: payeeSource || (fallbackUsed ? memoSource : null), + imported_payee: payeeSource || (fallbackUsed ? memoSource : null), + notes: + options.importNotes && !fallbackUsed ? memoSource || null : null, + }; + }) .filter(trans => trans.date != null && trans.amount != null), }; } @@ -207,6 +218,7 @@ async function parseOFX( // Banks don't always implement the OFX standard properly // If no payee is available try and fallback to memo const useMemoFallback = options.fallbackMissingPayeeToMemo; + const swap = options.swapPayeeAndMemo; return { errors, @@ -219,13 +231,17 @@ async function parseOFX( }); } + const payeeSource = swap ? trans.memo : trans.name; + const memoSource = swap ? trans.name : trans.memo; + const fallbackUsed = !payeeSource && useMemoFallback; + return { amount: parsedAmount || 0, imported_id: trans.fitId, date: trans.date, - payee_name: trans.name || (useMemoFallback ? trans.memo : null), - imported_payee: trans.name || (useMemoFallback ? trans.memo : null), - notes: options.importNotes ? trans.memo || null : null, //memo used for payee + payee_name: payeeSource || (fallbackUsed ? memoSource : null), + imported_payee: payeeSource || (fallbackUsed ? memoSource : null), + notes: options.importNotes && !fallbackUsed ? memoSource || null : null, }; }), }; @@ -250,11 +266,21 @@ async function parseCAMT( return { errors }; } + const swap = options.swapPayeeAndMemo; + return { errors, - transactions: data.map(trans => ({ - ...trans, - notes: options.importNotes ? trans.notes : null, - })), + transactions: data.map(trans => { + const payeeSource = swap ? trans.notes : trans.payee_name; + const memoSource = swap ? trans.payee_name : trans.notes; + const fallbackUsed = !payeeSource && swap; + + return { + ...trans, + payee_name: payeeSource || (fallbackUsed ? memoSource : null), + imported_payee: payeeSource || (fallbackUsed ? memoSource : null), + notes: options.importNotes && !fallbackUsed ? memoSource || null : null, + }; + }), }; } diff --git a/packages/loot-core/src/types/prefs.ts b/packages/loot-core/src/types/prefs.ts index 1969d5f39e..0fa8f8e1df 100644 --- a/packages/loot-core/src/types/prefs.ts +++ b/packages/loot-core/src/types/prefs.ts @@ -46,6 +46,9 @@ export type SyncedPrefs = Partial< | `sync-import-transactions-${string}` | `sync-update-dates-${string}` | `ofx-fallback-missing-payee-${string}` + | `ofx-swap-payee-memo-${string}` + | `qif-swap-payee-memo-${string}` + | `camt-swap-payee-memo-${string}` | `flip-amount-${string}-${'csv' | 'qif'}` | `flags.${FeatureFlag}` | `learn-categories`, diff --git a/upcoming-release-notes/7101.md b/upcoming-release-notes/7101.md new file mode 100644 index 0000000000..9308fb1ee2 --- /dev/null +++ b/upcoming-release-notes/7101.md @@ -0,0 +1,6 @@ +--- +category: Enhancements +authors: [sylvercode] +--- + +Add an option to swap payee/memo when importing transaction from file.