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
This commit is contained in:
Sylvercode
2026-03-11 09:40:26 -04:00
committed by GitHub
parent c06f96f015
commit f29c031735
7 changed files with 417 additions and 58 deletions

View File

@@ -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<SetStateAction<boolean>>;
children: ReactNode;
}) {
return (
<LabeledCheckbox
id={id}
checked={checked}
onChange={() => onChange(prev => !prev)}
>
{children}
</LabeledCheckbox>
);
}
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<DateFormat | null>(
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) && (
<LabeledCheckbox
id="form_fallback_missing_payee"
checked={fallbackMissingPayeeToMemo}
onChange={() => {
setFallbackMissingPayeeToMemo(state => !state);
}}
>
<Trans>Use Memo as a fallback for empty Payees</Trans>
</LabeledCheckbox>
<>
<CheckboxToggle
id="form_fallback_missing_payee"
checked={fallbackMissingPayeeToMemo}
onChange={setFallbackMissingPayeeToMemo}
>
<Trans>Use Memo as a fallback for empty Payees</Trans>
</CheckboxToggle>
<CheckboxToggle
id="form_ofx_swap_payee_memo"
checked={ofxSwapPayeeAndMemo}
onChange={setOfxSwapPayeeAndMemo}
>
<Trans>Swap Payee and Memo</Trans>
</CheckboxToggle>
</>
)}
{filetype !== 'csv' && (
<LabeledCheckbox
<CheckboxToggle
id="import_notes"
checked={importNotes}
onChange={() => {
setImportNotes(!importNotes);
}}
onChange={setImportNotes}
>
<Trans>Import notes from file</Trans>
</LabeledCheckbox>
</CheckboxToggle>
)}
{filetype === 'qif' && (
<CheckboxToggle
id="form_qif_swap_payee_memo"
checked={qifSwapPayeeAndMemo}
onChange={setQifSwapPayeeAndMemo}
>
<Trans>Swap Payee and Memo</Trans>
</CheckboxToggle>
)}
{isCamtFile(filetype) && (
<CheckboxToggle
id="form_camt_swap_payee_memo"
checked={camtSwapPayeeAndMemo}
onChange={setCamtSwapPayeeAndMemo}
>
<Trans>Swap Payee and Memo</Trans>
</CheckboxToggle>
)}
{(isOfxFile(filetype) || isCamtFile(filetype)) && (
<LabeledCheckbox
<CheckboxToggle
id="form_dont_reconcile"
checked={reconcile}
onChange={() => {
setReconcile(!reconcile);
}}
onChange={setReconcile}
>
<Trans>Merge with existing transactions</Trans>
</LabeledCheckbox>
</CheckboxToggle>
)}
{/*Import Options */}
@@ -1079,33 +1166,27 @@ export function ImportTransactionsModal({
style={{ width: 50 }}
/>
</label>
<LabeledCheckbox
<CheckboxToggle
id="form_has_header"
checked={hasHeaderRow}
onChange={() => {
setHasHeaderRow(!hasHeaderRow);
}}
onChange={setHasHeaderRow}
>
<Trans>File has header row</Trans>
</LabeledCheckbox>
<LabeledCheckbox
</CheckboxToggle>
<CheckboxToggle
id="clear_on_import"
checked={clearOnImport}
onChange={() => {
setClearOnImport(!clearOnImport);
}}
onChange={setClearOnImport}
>
<Trans>Clear transactions on import</Trans>
</LabeledCheckbox>
<LabeledCheckbox
</CheckboxToggle>
<CheckboxToggle
id="form_dont_reconcile"
checked={reconcile}
onChange={() => {
setReconcile(!reconcile);
}}
onChange={setReconcile}
>
<Trans>Merge with existing transactions</Trans>
</LabeledCheckbox>
</CheckboxToggle>
</View>
)}
@@ -1113,15 +1194,13 @@ export function ImportTransactionsModal({
<View style={{ marginRight: 10, gap: 5 }}>
<SectionLabel title={t('AMOUNT OPTIONS')} />
<LabeledCheckbox
<CheckboxToggle
id="form_flip"
checked={flipAmount}
onChange={() => {
setFlipAmount(!flipAmount);
}}
onChange={setFlipAmount}
>
<Trans>Flip amount</Trans>
</LabeledCheckbox>
</CheckboxToggle>
<MultiplierOption
multiplierEnabled={multiplierEnabled}
multiplierAmount={multiplierAmount}
@@ -1205,17 +1284,43 @@ function getParseOptions(fileType: string, options: ParseFileOptions = {}) {
return { delimiter, hasHeaderRow, skipStartLines, skipEndLines };
}
if (isOfxFile(fileType)) {
const { fallbackMissingPayeeToMemo, importNotes } = options;
return { fallbackMissingPayeeToMemo, importNotes };
const { fallbackMissingPayeeToMemo, importNotes, swapPayeeAndMemo } =
options;
return { fallbackMissingPayeeToMemo, importNotes, swapPayeeAndMemo };
}
if (fileType === 'qif') {
const { importNotes, swapPayeeAndMemo } = options;
return { importNotes, swapPayeeAndMemo };
}
if (isCamtFile(fileType)) {
const { importNotes } = options;
return { importNotes };
const { importNotes, swapPayeeAndMemo } = options;
return { importNotes, swapPayeeAndMemo };
}
const { importNotes } = options;
return { importNotes };
}
function getSwapOption(
fileType: string,
ofxSwapPayeeAndMemo: boolean,
qifSwapPayeeAndMemo: boolean,
camtSwapPayeeAndMemo: boolean,
) {
if (isOfxFile(fileType)) {
return ofxSwapPayeeAndMemo;
}
if (fileType === 'qif') {
return qifSwapPayeeAndMemo;
}
if (isCamtFile(fileType)) {
return camtSwapPayeeAndMemo;
}
return false;
}
function isOfxFile(fileType: string) {
return fileType === 'ofx' || fileType === 'qfx';
}