mirror of
https://github.com/actualbudget/actual.git
synced 2026-04-29 19:14:22 -05:00
* add options to override reimportDeleted * doc and default in ui to false * pr note * period * wording * [autofix.ci] apply automated fixes * docs clarity * actually test default behavior * Update upcoming-release-notes/6926.md Co-authored-by: Matiss Janis Aboltins <matiss@mja.lv> * use new ImportTransactionOpts type for consistency * Release note wording Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * [autofix.ci] apply automated fixes * [autofix.ci] apply automated fixes (attempt 2/3) --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Matiss Janis Aboltins <matiss@mja.lv> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1354 lines
41 KiB
TypeScript
1354 lines
41 KiB
TypeScript
// @ts-strict-ignore
|
|
import React, { useCallback, useEffect, useEffectEvent, useState } from 'react';
|
|
import type {
|
|
ComponentProps,
|
|
Dispatch,
|
|
ReactNode,
|
|
SetStateAction,
|
|
} from 'react';
|
|
import { Trans, useTranslation } from 'react-i18next';
|
|
|
|
import { Button, ButtonWithLoading } from '@actual-app/components/button';
|
|
import { Input } from '@actual-app/components/input';
|
|
import { Select } from '@actual-app/components/select';
|
|
import { SpaceBetween } from '@actual-app/components/space-between';
|
|
import { styles } from '@actual-app/components/styles';
|
|
import { Text } from '@actual-app/components/text';
|
|
import { theme } from '@actual-app/components/theme';
|
|
import { View } from '@actual-app/components/view';
|
|
import { useQueryClient } from '@tanstack/react-query';
|
|
|
|
import { send } from 'loot-core/platform/client/connection';
|
|
import type { ParseFileOptions } from 'loot-core/server/transactions/import/parse-file';
|
|
import { amountToInteger } from 'loot-core/shared/util';
|
|
|
|
import { DateFormatSelect } from './DateFormatSelect';
|
|
import { FieldMappings } from './FieldMappings';
|
|
import { InOutOption } from './InOutOption';
|
|
import { MultiplierOption } from './MultiplierOption';
|
|
import { Transaction } from './Transaction';
|
|
import {
|
|
applyFieldMappings,
|
|
dateFormats,
|
|
filterByStartDate,
|
|
isDateFormat,
|
|
parseAmountFields,
|
|
parseDate,
|
|
stripCsvImportTransaction,
|
|
} from './utils';
|
|
import type { DateFormat, FieldMapping, ImportTransaction } from './utils';
|
|
|
|
import {
|
|
useImportPreviewTransactionsMutation,
|
|
useImportTransactionsMutation,
|
|
} from '@desktop-client/accounts';
|
|
import {
|
|
Modal,
|
|
ModalCloseButton,
|
|
ModalHeader,
|
|
} from '@desktop-client/components/common/Modal';
|
|
import { SectionLabel } from '@desktop-client/components/forms';
|
|
import { LabeledCheckbox } from '@desktop-client/components/forms/LabeledCheckbox';
|
|
import {
|
|
TableHeader,
|
|
TableWithNavigator,
|
|
} from '@desktop-client/components/table';
|
|
import { useCategories } from '@desktop-client/hooks/useCategories';
|
|
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';
|
|
const rawType = m[1].toLowerCase();
|
|
if (rawType === 'tsv') return 'csv';
|
|
return rawType;
|
|
}
|
|
|
|
function getInitialDateFormat(transactions, mappings) {
|
|
if (transactions.length === 0 || mappings.date == null) {
|
|
return 'yyyy mm dd';
|
|
}
|
|
|
|
const transaction = transactions[0];
|
|
const date = transaction[mappings.date];
|
|
|
|
const found =
|
|
date == null
|
|
? null
|
|
: dateFormats.find(f => parseDate(date, f.format) != null);
|
|
return found ? found.format : 'mm dd yyyy';
|
|
}
|
|
|
|
function getInitialMappings(transactions) {
|
|
if (transactions.length === 0) {
|
|
return {};
|
|
}
|
|
|
|
const transaction = stripCsvImportTransaction(transactions[0]);
|
|
const fields = Object.entries(transaction);
|
|
|
|
function key(entry) {
|
|
return entry ? entry[0] : null;
|
|
}
|
|
|
|
const dateField = key(
|
|
fields.find(([name]) => name.toLowerCase().includes('date')) ||
|
|
fields.find(([, value]) => String(value)?.match(/^\d+[-/]\d+[-/]\d+$/)),
|
|
);
|
|
|
|
const amountField = key(
|
|
fields.find(([name]) => name.toLowerCase().includes('amount')) ||
|
|
fields.find(([, value]) => String(value)?.match(/^-?[.,\d]+$/)),
|
|
);
|
|
|
|
const categoryField = key(
|
|
fields.find(([name]) => name.toLowerCase().includes('category')),
|
|
);
|
|
|
|
const payeeField = key(
|
|
fields.find(([name]) => name.toLowerCase().includes('payee')) ||
|
|
fields.find(
|
|
([name]) =>
|
|
name !== dateField && name !== amountField && name !== categoryField,
|
|
),
|
|
);
|
|
|
|
const notesField = key(
|
|
fields.find(([name]) => name.toLowerCase().includes('notes')) ||
|
|
fields.find(
|
|
([name]) =>
|
|
name !== dateField &&
|
|
name !== amountField &&
|
|
name !== categoryField &&
|
|
name !== payeeField,
|
|
),
|
|
);
|
|
|
|
const inOutField = key(
|
|
fields.find(
|
|
([name]) =>
|
|
name !== dateField &&
|
|
name !== amountField &&
|
|
name !== payeeField &&
|
|
name !== notesField,
|
|
),
|
|
);
|
|
|
|
return {
|
|
date: dateField,
|
|
amount: amountField,
|
|
payee: payeeField,
|
|
notes: notesField,
|
|
inOut: inOutField,
|
|
category: categoryField,
|
|
};
|
|
}
|
|
|
|
function parseCategoryFields(trans, categories) {
|
|
let match = null;
|
|
categories.forEach(category => {
|
|
if (category.id === trans.category) {
|
|
return null;
|
|
}
|
|
if (category.name === trans.category) {
|
|
match = category.id;
|
|
}
|
|
});
|
|
return match;
|
|
}
|
|
|
|
export function ImportTransactionsModal({
|
|
filename: originalFileName,
|
|
accountId,
|
|
onImported,
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const queryClient = useQueryClient();
|
|
const dateFormat = useDateFormat() || ('MM/dd/yyyy' as const);
|
|
const [prefs, savePrefs] = useSyncedPrefs();
|
|
const { data: { list: categories } = { list: [] } } = useCategories();
|
|
|
|
const [multiplierAmount, setMultiplierAmount] = useState('');
|
|
const [loadingState, setLoadingState] = useState<
|
|
null | 'parsing' | 'importing'
|
|
>('parsing');
|
|
const [error, setError] = useState<{
|
|
parsed: boolean;
|
|
message: string;
|
|
} | null>(null);
|
|
const [filename, setFilename] = useState(originalFileName);
|
|
const [transactions, setTransactions] = useState<ImportTransaction[]>([]);
|
|
const [parsedTransactions, setParsedTransactions] = useState<
|
|
ImportTransaction[]
|
|
>([]);
|
|
const [filetype, setFileType] = useState('unknown');
|
|
const [fieldMappings, setFieldMappings] = useState<FieldMapping | null>(null);
|
|
const [splitMode, setSplitMode] = useState(false);
|
|
const [flipAmount, setFlipAmount] = useState(false);
|
|
const [multiplierEnabled, setMultiplierEnabled] = useState(false);
|
|
const [reconcile, setReconcile] = useState(true);
|
|
const [reimportDeleted, setReimportDeleted] = useState(false);
|
|
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
|
|
// options which are simple post-processing. That means if you
|
|
// parsed different files without closing the modal, it wouldn't
|
|
// re-read this.
|
|
const [delimiter, setDelimiter] = useState(
|
|
prefs[`csv-delimiter-${accountId}`] ||
|
|
(filename.endsWith('.tsv') ? '\t' : ','),
|
|
);
|
|
const [skipStartLines, setSkipStartLines] = useState(
|
|
parseInt(prefs[`csv-skip-start-lines-${accountId}`], 10) || 0,
|
|
);
|
|
const [skipEndLines, setSkipEndLines] = useState(
|
|
parseInt(prefs[`csv-skip-end-lines-${accountId}`], 10) || 0,
|
|
);
|
|
const [inOutMode, setInOutMode] = useState(
|
|
String(prefs[`csv-in-out-mode-${accountId}`]) === 'true',
|
|
);
|
|
const [outValue, setOutValue] = useState(
|
|
prefs[`csv-out-value-${accountId}`] ?? '',
|
|
);
|
|
const [hasHeaderRow, setHasHeaderRow] = useState(
|
|
String(prefs[`csv-has-header-${accountId}`]) !== 'false',
|
|
);
|
|
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,
|
|
);
|
|
|
|
const [clearOnImport, setClearOnImport] = useState(true);
|
|
const [startDate, setStartDate] = useState('');
|
|
|
|
const getImportPreview = useCallback(
|
|
async (
|
|
transactions: ImportTransaction[],
|
|
filetype: string,
|
|
flipAmount: boolean,
|
|
fieldMappings: FieldMapping | null,
|
|
splitMode: boolean,
|
|
parseDateFormat: DateFormat,
|
|
inOutMode: boolean,
|
|
outValue: string,
|
|
multiplierAmount: string,
|
|
) => {
|
|
const previewTransactions = [];
|
|
const inOutModeEnabled = isOfxFile(filetype) ? false : inOutMode;
|
|
const getTransDate: (trans: ImportTransaction) => string | null =
|
|
isOfxFile(filetype)
|
|
? trans => trans.date ?? null
|
|
: trans => parseDate(trans.date, parseDateFormat);
|
|
|
|
// Note that the sort will behave unpredictably if any date fails to parse.
|
|
transactions.sort((a, b) => {
|
|
const aDate = getTransDate(a);
|
|
const bDate = getTransDate(b);
|
|
|
|
return aDate < bDate ? 1 : aDate === bDate ? 0 : -1;
|
|
});
|
|
|
|
for (let trans of transactions) {
|
|
if (trans.isMatchedTransaction) {
|
|
// skip transactions that are matched transaction (existing transaction added to show update changes)
|
|
continue;
|
|
}
|
|
|
|
trans = fieldMappings
|
|
? applyFieldMappings(trans, fieldMappings)
|
|
: trans;
|
|
|
|
const date = getTransDate(trans);
|
|
if (date == null) {
|
|
console.log(
|
|
`Unable to parse date ${
|
|
trans.date || '(empty)'
|
|
} with given date format`,
|
|
);
|
|
break;
|
|
}
|
|
if (trans.payee_name == null || typeof trans.payee_name !== 'string') {
|
|
console.log(`Unable·to·parse·payee·${trans.payee_name || '(empty)'}`);
|
|
break;
|
|
}
|
|
|
|
const { amount } = parseAmountFields(
|
|
trans,
|
|
splitMode,
|
|
inOutModeEnabled,
|
|
outValue,
|
|
flipAmount,
|
|
multiplierAmount,
|
|
);
|
|
if (amount == null) {
|
|
console.log(`Transaction on ${trans.date} has no amount`);
|
|
break;
|
|
}
|
|
|
|
const category_id = parseCategoryFields(trans, categories);
|
|
if (category_id != null) {
|
|
trans.category = category_id;
|
|
}
|
|
|
|
const {
|
|
inflow: _inflow,
|
|
outflow: _outflow,
|
|
inOut: _inOut,
|
|
existing: _existing,
|
|
ignored: _ignored,
|
|
selected: _selected,
|
|
selected_merge: _selected_merge,
|
|
tombstone: _tombstone,
|
|
...finalTransaction
|
|
} = trans;
|
|
previewTransactions.push({
|
|
...finalTransaction,
|
|
date,
|
|
amount: amountToInteger(amount),
|
|
cleared: clearOnImport,
|
|
});
|
|
}
|
|
|
|
return previewTransactions;
|
|
},
|
|
[categories, clearOnImport],
|
|
);
|
|
|
|
const parse = useCallback(
|
|
async (filename: string, options: ParseFileOptions) => {
|
|
setLoadingState('parsing');
|
|
|
|
const filetype = getFileType(filename);
|
|
setFilename(filename);
|
|
setFileType(filetype);
|
|
|
|
const { errors, transactions: parsedTransactions = [] } = await send(
|
|
'transactions-parse-file',
|
|
{
|
|
filepath: filename,
|
|
options,
|
|
},
|
|
);
|
|
|
|
let index = 0;
|
|
const transactions = parsedTransactions.map(trans => {
|
|
// Add a transient transaction id to match preview with imported transactions
|
|
// @ts-expect-error - trans is unknown type, adding properties dynamically
|
|
trans.trx_id = String(index++);
|
|
// Select all parsed transactions before first preview run
|
|
// @ts-expect-error - trans is unknown type, adding properties dynamically
|
|
trans.selected = true;
|
|
return trans;
|
|
});
|
|
|
|
setError(null);
|
|
|
|
/// Do fine grained reporting between the old and new OFX importers.
|
|
if (errors.length > 0) {
|
|
setError({
|
|
parsed: true,
|
|
message: errors[0].message || 'Internal error',
|
|
});
|
|
} else {
|
|
if (filetype === 'csv' || filetype === 'qif') {
|
|
const flipAmount =
|
|
String(prefs[`flip-amount-${accountId}-${filetype}`]) === 'true';
|
|
setFlipAmount(flipAmount);
|
|
}
|
|
|
|
if (filetype === 'csv') {
|
|
let mappings = prefs[`csv-mappings-${accountId}`];
|
|
mappings = mappings
|
|
? JSON.parse(mappings)
|
|
: getInitialMappings(transactions);
|
|
|
|
// @ts-expect-error - mappings might not have outflow/inflow properties
|
|
setFieldMappings(mappings);
|
|
|
|
// Set initial split mode based on any saved mapping
|
|
// @ts-expect-error - mappings might not have outflow/inflow properties
|
|
const splitMode = !!(mappings.outflow || mappings.inflow);
|
|
setSplitMode(splitMode);
|
|
|
|
const parseDateFormat =
|
|
prefs[`parse-date-${accountId}-${filetype}`] ||
|
|
getInitialDateFormat(transactions, mappings);
|
|
setParseDateFormat(
|
|
isDateFormat(parseDateFormat) ? parseDateFormat : null,
|
|
);
|
|
} else if (filetype === 'qif') {
|
|
const parseDateFormat =
|
|
prefs[`parse-date-${accountId}-${filetype}`] ||
|
|
getInitialDateFormat(transactions, { date: 'date' });
|
|
setParseDateFormat(
|
|
isDateFormat(parseDateFormat) ? parseDateFormat : null,
|
|
);
|
|
} else {
|
|
setFieldMappings(null);
|
|
setParseDateFormat(null);
|
|
}
|
|
|
|
setParsedTransactions(transactions as ImportTransaction[]);
|
|
}
|
|
|
|
setLoadingState(null);
|
|
},
|
|
// We use some state variables from the component, but do not want to re-parse when they change
|
|
[accountId, prefs],
|
|
);
|
|
|
|
function onMultiplierChange(e) {
|
|
const amt = e;
|
|
if (!amt || amt.match(/^\d{1,}(\.\d{0,4})?$/)) {
|
|
setMultiplierAmount(amt);
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
const fileType = getFileType(originalFileName);
|
|
const parseOptions = getParseOptions(fileType, {
|
|
delimiter,
|
|
hasHeaderRow,
|
|
skipStartLines,
|
|
skipEndLines,
|
|
fallbackMissingPayeeToMemo,
|
|
importNotes,
|
|
swapPayeeAndMemo: getSwapOption(
|
|
fileType,
|
|
ofxSwapPayeeAndMemo,
|
|
qifSwapPayeeAndMemo,
|
|
camtSwapPayeeAndMemo,
|
|
),
|
|
});
|
|
|
|
void parse(originalFileName, parseOptions);
|
|
}, [
|
|
originalFileName,
|
|
delimiter,
|
|
hasHeaderRow,
|
|
skipStartLines,
|
|
skipEndLines,
|
|
fallbackMissingPayeeToMemo,
|
|
importNotes,
|
|
ofxSwapPayeeAndMemo,
|
|
qifSwapPayeeAndMemo,
|
|
camtSwapPayeeAndMemo,
|
|
parse,
|
|
]);
|
|
|
|
function onSplitMode() {
|
|
if (fieldMappings == null) {
|
|
return;
|
|
}
|
|
|
|
const isSplit = !splitMode;
|
|
setSplitMode(isSplit);
|
|
|
|
// Run auto-detection on the fields to try to detect the fields
|
|
// automatically
|
|
const mappings = getInitialMappings(transactions);
|
|
|
|
const newFieldMappings = isSplit
|
|
? {
|
|
amount: null,
|
|
outflow: mappings.amount,
|
|
inflow: null,
|
|
}
|
|
: {
|
|
amount: mappings.amount,
|
|
outflow: null,
|
|
inflow: null,
|
|
};
|
|
setFieldMappings({ ...fieldMappings, ...newFieldMappings });
|
|
}
|
|
|
|
async function onNewFile() {
|
|
const res = await window.Actual.openFileDialog({
|
|
filters: [
|
|
{
|
|
name: 'Financial Files',
|
|
extensions: ['qif', 'ofx', 'qfx', 'csv', 'tsv', 'xml'],
|
|
},
|
|
],
|
|
});
|
|
|
|
const fileType = getFileType(res[0]);
|
|
const parseOptions = getParseOptions(fileType, {
|
|
delimiter,
|
|
hasHeaderRow,
|
|
skipStartLines,
|
|
skipEndLines,
|
|
fallbackMissingPayeeToMemo,
|
|
importNotes,
|
|
swapPayeeAndMemo: getSwapOption(
|
|
fileType,
|
|
ofxSwapPayeeAndMemo,
|
|
qifSwapPayeeAndMemo,
|
|
camtSwapPayeeAndMemo,
|
|
),
|
|
});
|
|
|
|
void parse(res[0], parseOptions);
|
|
}
|
|
|
|
function onUpdateFields(field, name) {
|
|
const newFieldMappings = {
|
|
...fieldMappings,
|
|
[field]: name === '' ? null : name,
|
|
};
|
|
setFieldMappings(newFieldMappings);
|
|
}
|
|
|
|
function onCheckTransaction(trx_id: string) {
|
|
const newTransactions = transactions.map(trans => {
|
|
if (trans.trx_id === trx_id) {
|
|
if (trans.existing) {
|
|
// 3-states management for transactions with existing (merged transactions)
|
|
// flow of states:
|
|
// (selected true && selected_merge true)
|
|
// => (selected true && selected_merge false)
|
|
// => (selected false)
|
|
// => back to (selected true && selected_merge true)
|
|
if (!trans.selected) {
|
|
return {
|
|
...trans,
|
|
selected: true,
|
|
selected_merge: true,
|
|
};
|
|
} else if (trans.selected_merge) {
|
|
return {
|
|
...trans,
|
|
selected: true,
|
|
selected_merge: false,
|
|
};
|
|
} else {
|
|
return {
|
|
...trans,
|
|
selected: false,
|
|
selected_merge: false,
|
|
};
|
|
}
|
|
} else {
|
|
return {
|
|
...trans,
|
|
selected: !trans.selected,
|
|
};
|
|
}
|
|
}
|
|
return trans;
|
|
});
|
|
|
|
setTransactions(newTransactions);
|
|
}
|
|
|
|
const importTransactions = useImportTransactionsMutation();
|
|
|
|
async function onImport(close) {
|
|
setLoadingState('importing');
|
|
|
|
const finalTransactions = [];
|
|
let errorMessage;
|
|
|
|
for (let trans of transactions) {
|
|
if (
|
|
trans.isMatchedTransaction ||
|
|
(reconcile && !trans.selected && !trans.ignored)
|
|
) {
|
|
// skip transactions that are
|
|
// - matched transaction (existing transaction added to show update changes)
|
|
// - unselected transactions that are not ignored by the reconcilation algorithm (only when reconcilation is enabled)
|
|
continue;
|
|
}
|
|
|
|
trans = fieldMappings ? applyFieldMappings(trans, fieldMappings) : trans;
|
|
|
|
const date =
|
|
isOfxFile(filetype) || isCamtFile(filetype)
|
|
? trans.date
|
|
: parseDate(trans.date, parseDateFormat);
|
|
if (date == null) {
|
|
errorMessage = t(
|
|
'Unable to parse date {{date}} with given date format',
|
|
{ date: trans.date || t('(empty)') },
|
|
);
|
|
break;
|
|
}
|
|
|
|
const { amount } = parseAmountFields(
|
|
trans,
|
|
splitMode,
|
|
isOfxFile(filetype) ? false : inOutMode,
|
|
outValue,
|
|
flipAmount,
|
|
multiplierAmount,
|
|
);
|
|
if (amount == null) {
|
|
errorMessage = t('Transaction on {{date}} has no amount', {
|
|
date: trans.date,
|
|
});
|
|
break;
|
|
}
|
|
|
|
const category_id = parseCategoryFields(trans, categories);
|
|
trans.category = category_id;
|
|
|
|
const {
|
|
inflow: _inflow,
|
|
outflow: _outflow,
|
|
inOut: _inOut,
|
|
existing: _existing,
|
|
ignored: _ignored,
|
|
selected: _selected,
|
|
selected_merge: _selected_merge,
|
|
trx_id: _trx_id,
|
|
...finalTransaction
|
|
} = trans;
|
|
|
|
if (
|
|
reconcile &&
|
|
((trans.ignored && trans.selected) ||
|
|
(trans.existing && trans.selected && !trans.selected_merge))
|
|
) {
|
|
// in reconcile mode, force transaction add for
|
|
// - ignored transactions (aleardy existing) that are checked
|
|
// - transactions with existing (merged transactions) that are not selected_merge
|
|
finalTransaction.forceAddTransaction = true;
|
|
}
|
|
|
|
finalTransactions.push({
|
|
...finalTransaction,
|
|
date,
|
|
amount: amountToInteger(amount),
|
|
cleared: clearOnImport,
|
|
notes: importNotes ? finalTransaction.notes : null,
|
|
});
|
|
}
|
|
|
|
if (errorMessage) {
|
|
setLoadingState(null);
|
|
setError({ parsed: false, message: errorMessage });
|
|
return;
|
|
}
|
|
|
|
if (!isOfxFile(filetype) && !isCamtFile(filetype)) {
|
|
const key = `parse-date-${accountId}-${filetype}`;
|
|
savePrefs({ [key]: parseDateFormat });
|
|
}
|
|
|
|
if (isOfxFile(filetype)) {
|
|
savePrefs({
|
|
[`ofx-fallback-missing-payee-${accountId}`]: String(
|
|
fallbackMissingPayeeToMemo,
|
|
),
|
|
[`ofx-swap-payee-memo-${accountId}`]: String(ofxSwapPayeeAndMemo),
|
|
});
|
|
}
|
|
|
|
if (filetype === 'csv') {
|
|
savePrefs({
|
|
[`csv-mappings-${accountId}`]: JSON.stringify(fieldMappings),
|
|
});
|
|
savePrefs({ [`csv-delimiter-${accountId}`]: delimiter });
|
|
savePrefs({ [`csv-has-header-${accountId}`]: String(hasHeaderRow) });
|
|
savePrefs({
|
|
[`csv-skip-start-lines-${accountId}`]: String(skipStartLines),
|
|
});
|
|
savePrefs({ [`csv-skip-end-lines-${accountId}`]: String(skipEndLines) });
|
|
savePrefs({ [`csv-in-out-mode-${accountId}`]: String(inOutMode) });
|
|
savePrefs({ [`csv-out-value-${accountId}`]: String(outValue) });
|
|
}
|
|
|
|
if (filetype === 'csv' || filetype === 'qif') {
|
|
savePrefs({
|
|
[`flip-amount-${accountId}-${filetype}`]: String(flipAmount),
|
|
[`import-notes-${accountId}-${filetype}`]: String(importNotes),
|
|
});
|
|
}
|
|
|
|
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,
|
|
transactions: finalTransactions,
|
|
reconcile,
|
|
reimportDeleted,
|
|
},
|
|
{
|
|
onSuccess: async didChange => {
|
|
if (didChange) {
|
|
void queryClient.invalidateQueries(payeeQueries.list());
|
|
}
|
|
|
|
if (onImported) {
|
|
onImported(didChange);
|
|
}
|
|
|
|
close();
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
const importPreviewTransactions = useImportPreviewTransactionsMutation();
|
|
|
|
const onImportPreview = useEffectEvent(async () => {
|
|
// Filter by start date before preview and deduplication
|
|
const isPreParsed = isOfxFile(filetype) || isCamtFile(filetype);
|
|
const filteredTransactions = filterByStartDate(
|
|
parsedTransactions,
|
|
startDate,
|
|
isPreParsed,
|
|
fieldMappings,
|
|
parseDateFormat,
|
|
);
|
|
|
|
// always start from the original parsed transactions, not the previewed ones to ensure rules run
|
|
const previewTransactionsToImport = await getImportPreview(
|
|
filteredTransactions,
|
|
filetype,
|
|
flipAmount,
|
|
fieldMappings,
|
|
splitMode,
|
|
parseDateFormat,
|
|
inOutMode,
|
|
outValue,
|
|
multiplierAmount,
|
|
);
|
|
|
|
// Retreive the transactions that would be updated (along with the existing trx)
|
|
importPreviewTransactions.mutate(
|
|
{
|
|
accountId,
|
|
transactions: previewTransactionsToImport,
|
|
reimportDeleted,
|
|
},
|
|
{
|
|
onSuccess: previewTrx => {
|
|
const matchedUpdateMap = previewTrx.reduce((map, entry) => {
|
|
// @ts-expect-error - entry.transaction might not have trx_id property
|
|
map[entry.transaction.trx_id] = entry;
|
|
return map;
|
|
}, {});
|
|
|
|
const previewTransactions = filteredTransactions
|
|
.filter(trans => !trans.isMatchedTransaction)
|
|
.reduce((previous, currentTrx) => {
|
|
let next = previous;
|
|
const entry = matchedUpdateMap[currentTrx.trx_id];
|
|
const existingTrx = entry?.existing;
|
|
|
|
// if the transaction is matched with an existing one for update
|
|
currentTrx.existing = !!existingTrx;
|
|
// if the transaction is an update that will be ignored
|
|
// (reconciled transactions or no change detected)
|
|
currentTrx.ignored = entry?.ignored || false;
|
|
|
|
currentTrx.tombstone = entry?.tombstone || false;
|
|
|
|
currentTrx.selected = !currentTrx.ignored;
|
|
currentTrx.selected_merge = currentTrx.existing;
|
|
|
|
next = next.concat({ ...currentTrx });
|
|
|
|
if (existingTrx) {
|
|
// add the updated existing transaction in the list, with the
|
|
// isMatchedTransaction flag to identify it in display and not send it again
|
|
existingTrx.isMatchedTransaction = true;
|
|
existingTrx.category = categories.find(
|
|
cat => cat.id === existingTrx.category,
|
|
)?.name;
|
|
// add parent transaction attribute to mimic behaviour
|
|
existingTrx.trx_id = currentTrx.trx_id;
|
|
existingTrx.existing = currentTrx.existing;
|
|
existingTrx.selected = currentTrx.selected;
|
|
existingTrx.selected_merge = currentTrx.selected_merge;
|
|
|
|
next = next.concat({ ...existingTrx });
|
|
}
|
|
|
|
return next;
|
|
}, []);
|
|
|
|
setTransactions(previewTransactions);
|
|
},
|
|
},
|
|
);
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (parsedTransactions.length === 0 || loadingState === 'parsing') {
|
|
return;
|
|
}
|
|
|
|
void onImportPreview();
|
|
}, [
|
|
loadingState,
|
|
parsedTransactions.length,
|
|
startDate,
|
|
fieldMappings,
|
|
parseDateFormat,
|
|
reimportDeleted,
|
|
]);
|
|
|
|
const headers: ComponentProps<typeof TableHeader>['headers'] = [
|
|
{ name: t('Date'), width: 200 },
|
|
{ name: t('Payee'), width: 'flex' },
|
|
{ name: t('Notes'), width: 'flex' },
|
|
{ name: t('Category'), width: 'flex' },
|
|
];
|
|
|
|
if (reconcile) {
|
|
headers.unshift({ name: ' ', width: 31 });
|
|
}
|
|
if (inOutMode) {
|
|
headers.push({
|
|
name: t('In/Out'),
|
|
width: 90,
|
|
style: { textAlign: 'left' },
|
|
});
|
|
}
|
|
if (splitMode) {
|
|
headers.push({
|
|
name: t('Outflow'),
|
|
width: 90,
|
|
style: { textAlign: 'right' },
|
|
});
|
|
headers.push({
|
|
name: t('Inflow'),
|
|
width: 90,
|
|
style: { textAlign: 'right' },
|
|
});
|
|
} else {
|
|
headers.push({
|
|
name: t('Amount'),
|
|
width: 90,
|
|
style: { textAlign: 'right' },
|
|
});
|
|
}
|
|
|
|
return (
|
|
<Modal
|
|
name="import-transactions"
|
|
isLoading={loadingState === 'parsing'}
|
|
containerProps={{ style: { width: 800 } }}
|
|
>
|
|
{({ state }) => (
|
|
<>
|
|
<ModalHeader
|
|
title={
|
|
t('Import transactions') +
|
|
(filetype ? ` (${filetype.toUpperCase()})` : '')
|
|
}
|
|
rightContent={<ModalCloseButton onPress={() => state.close()} />}
|
|
/>
|
|
{error && !error.parsed && (
|
|
<View style={{ alignItems: 'center', marginBottom: 15 }}>
|
|
<Text style={{ marginRight: 10, color: theme.errorText }}>
|
|
<strong>
|
|
<Trans>Error:</Trans>
|
|
</strong>{' '}
|
|
{error.message}
|
|
</Text>
|
|
</View>
|
|
)}
|
|
{(!error || !error.parsed) && (
|
|
<View
|
|
style={{ ...styles.tableContainer, height: 300, flex: 'unset' }}
|
|
>
|
|
<TableHeader headers={headers} />
|
|
|
|
{/* @ts-expect-error - ImportTransaction is not a TableItem */}
|
|
<TableWithNavigator<ImportTransaction>
|
|
items={transactions.filter(
|
|
trans =>
|
|
!trans.isMatchedTransaction ||
|
|
(trans.isMatchedTransaction && reconcile),
|
|
)}
|
|
fields={['payee', 'category', 'amount']}
|
|
style={{ backgroundColor: theme.tableHeaderBackground }}
|
|
getItemKey={index => String(index)}
|
|
renderEmpty={() => {
|
|
return (
|
|
<View
|
|
style={{
|
|
textAlign: 'center',
|
|
marginTop: 25,
|
|
color: theme.tableHeaderText,
|
|
fontStyle: 'italic',
|
|
}}
|
|
>
|
|
<Trans>No transactions found</Trans>
|
|
</View>
|
|
);
|
|
}}
|
|
renderItem={({ item }) => (
|
|
<View>
|
|
<Transaction
|
|
transaction={item}
|
|
showParsed={filetype === 'csv' || filetype === 'qif'}
|
|
parseDateFormat={parseDateFormat}
|
|
dateFormat={dateFormat}
|
|
fieldMappings={fieldMappings}
|
|
splitMode={splitMode}
|
|
inOutMode={inOutMode}
|
|
outValue={outValue}
|
|
flipAmount={flipAmount}
|
|
multiplierAmount={multiplierAmount}
|
|
categories={categories}
|
|
onCheckTransaction={onCheckTransaction}
|
|
reconcile={reconcile}
|
|
/>
|
|
</View>
|
|
)}
|
|
/>
|
|
</View>
|
|
)}
|
|
{error && error.parsed && (
|
|
<View
|
|
style={{
|
|
color: theme.errorText,
|
|
alignItems: 'center',
|
|
marginTop: 10,
|
|
}}
|
|
>
|
|
<Text style={{ maxWidth: 450, marginBottom: 15 }}>
|
|
<strong>Error:</strong> {error.message}
|
|
</Text>
|
|
{error.parsed && (
|
|
<Button onPress={() => onNewFile()}>
|
|
<Trans>Select new file...</Trans>
|
|
</Button>
|
|
)}
|
|
</View>
|
|
)}
|
|
|
|
<View
|
|
style={{
|
|
marginTop: 10,
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 5,
|
|
}}
|
|
>
|
|
<label
|
|
htmlFor="start-date-filter"
|
|
style={{
|
|
display: 'flex',
|
|
flexDirection: 'row',
|
|
gap: 5,
|
|
alignItems: 'baseline',
|
|
}}
|
|
>
|
|
<Trans>Only import transactions since:</Trans>
|
|
<Input
|
|
id="start-date-filter"
|
|
type="date"
|
|
value={startDate}
|
|
onChangeValue={value => setStartDate(value)}
|
|
style={{ width: 150 }}
|
|
/>
|
|
</label>
|
|
{startDate && (
|
|
<Button onPress={() => setStartDate('')}>
|
|
<Trans>Clear</Trans>
|
|
</Button>
|
|
)}
|
|
</View>
|
|
|
|
{filetype === 'csv' && (
|
|
<View style={{ marginTop: 10 }}>
|
|
<FieldMappings
|
|
transactions={transactions}
|
|
onChange={onUpdateFields}
|
|
mappings={fieldMappings || undefined}
|
|
splitMode={splitMode}
|
|
inOutMode={inOutMode}
|
|
hasHeaderRow={hasHeaderRow}
|
|
/>
|
|
</View>
|
|
)}
|
|
|
|
{isOfxFile(filetype) && (
|
|
<>
|
|
<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' && (
|
|
<CheckboxToggle
|
|
id="import_notes"
|
|
checked={importNotes}
|
|
onChange={setImportNotes}
|
|
>
|
|
<Trans>Import notes from file</Trans>
|
|
</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)) && (
|
|
<CheckboxToggle
|
|
id="form_dont_reconcile"
|
|
checked={reconcile}
|
|
onChange={setReconcile}
|
|
>
|
|
<Trans>Merge with existing transactions</Trans>
|
|
</CheckboxToggle>
|
|
)}
|
|
|
|
{(isOfxFile(filetype) || isCamtFile(filetype)) && reconcile && (
|
|
<CheckboxToggle
|
|
id="form_reimport_deleted"
|
|
checked={reimportDeleted}
|
|
onChange={setReimportDeleted}
|
|
>
|
|
<Trans>Reimport deleted transactions</Trans>
|
|
</CheckboxToggle>
|
|
)}
|
|
|
|
{/*Import Options */}
|
|
{(filetype === 'qif' || filetype === 'csv') && (
|
|
<View style={{ marginTop: 10 }}>
|
|
<SpaceBetween
|
|
gap={5}
|
|
style={{ marginTop: 5, alignItems: 'flex-start' }}
|
|
>
|
|
{/* Date Format */}
|
|
<View>
|
|
{(filetype === 'qif' || filetype === 'csv') && (
|
|
<DateFormatSelect
|
|
transactions={transactions}
|
|
fieldMappings={fieldMappings || undefined}
|
|
parseDateFormat={parseDateFormat || undefined}
|
|
onChange={value => {
|
|
setParseDateFormat(isDateFormat(value) ? value : null);
|
|
}}
|
|
/>
|
|
)}
|
|
</View>
|
|
|
|
{/* CSV Options */}
|
|
{filetype === 'csv' && (
|
|
<View style={{ marginLeft: 10, gap: 5 }}>
|
|
<SectionLabel title={t('CSV OPTIONS')} />
|
|
<label
|
|
htmlFor="csv-delimiter-select"
|
|
style={{
|
|
display: 'flex',
|
|
flexDirection: 'row',
|
|
gap: 5,
|
|
alignItems: 'baseline',
|
|
}}
|
|
>
|
|
<Trans>Delimiter:</Trans>
|
|
<Select
|
|
id="csv-delimiter-select"
|
|
options={[
|
|
[',', ','],
|
|
[';', ';'],
|
|
['|', '|'],
|
|
['\t', 'tab'],
|
|
['~', '~'],
|
|
]}
|
|
value={delimiter}
|
|
onChange={value => {
|
|
setDelimiter(value);
|
|
}}
|
|
style={{ width: 50 }}
|
|
/>
|
|
</label>
|
|
<label
|
|
htmlFor="csv-skip-start-lines"
|
|
style={{
|
|
display: 'flex',
|
|
flexDirection: 'row',
|
|
gap: 5,
|
|
alignItems: 'baseline',
|
|
}}
|
|
>
|
|
<Trans>Skip start lines:</Trans>
|
|
<Input
|
|
id="csv-skip-start-lines"
|
|
type="number"
|
|
value={skipStartLines}
|
|
min="0"
|
|
step="1"
|
|
onChangeValue={value => {
|
|
setSkipStartLines(Math.abs(parseInt(value, 10) || 0));
|
|
}}
|
|
style={{ width: 50 }}
|
|
/>
|
|
</label>
|
|
<label
|
|
htmlFor="csv-skip-end-lines"
|
|
style={{
|
|
display: 'flex',
|
|
flexDirection: 'row',
|
|
gap: 5,
|
|
alignItems: 'baseline',
|
|
}}
|
|
>
|
|
<Trans>Skip end lines:</Trans>
|
|
<Input
|
|
id="csv-skip-end-lines"
|
|
type="number"
|
|
value={skipEndLines}
|
|
min="0"
|
|
step="1"
|
|
onChangeValue={value => {
|
|
setSkipEndLines(Math.abs(parseInt(value, 10) || 0));
|
|
}}
|
|
style={{ width: 50 }}
|
|
/>
|
|
</label>
|
|
<CheckboxToggle
|
|
id="form_has_header"
|
|
checked={hasHeaderRow}
|
|
onChange={setHasHeaderRow}
|
|
>
|
|
<Trans>File has header row</Trans>
|
|
</CheckboxToggle>
|
|
<CheckboxToggle
|
|
id="clear_on_import"
|
|
checked={clearOnImport}
|
|
onChange={setClearOnImport}
|
|
>
|
|
<Trans>Clear transactions on import</Trans>
|
|
</CheckboxToggle>
|
|
<CheckboxToggle
|
|
id="form_dont_reconcile"
|
|
checked={reconcile}
|
|
onChange={setReconcile}
|
|
>
|
|
<Trans>Merge with existing transactions</Trans>
|
|
</CheckboxToggle>
|
|
{reconcile && (
|
|
<CheckboxToggle
|
|
id="form_reimport_deleted_csv"
|
|
checked={reimportDeleted}
|
|
onChange={setReimportDeleted}
|
|
>
|
|
<Trans>Reimport deleted transactions</Trans>
|
|
</CheckboxToggle>
|
|
)}
|
|
</View>
|
|
)}
|
|
|
|
<View style={{ flex: 1 }} />
|
|
|
|
<View style={{ marginRight: 10, gap: 5 }}>
|
|
<SectionLabel title={t('AMOUNT OPTIONS')} />
|
|
<CheckboxToggle
|
|
id="form_flip"
|
|
checked={flipAmount}
|
|
onChange={setFlipAmount}
|
|
>
|
|
<Trans>Flip amount</Trans>
|
|
</CheckboxToggle>
|
|
<MultiplierOption
|
|
multiplierEnabled={multiplierEnabled}
|
|
multiplierAmount={multiplierAmount}
|
|
onToggle={() => {
|
|
setMultiplierEnabled(!multiplierEnabled);
|
|
setMultiplierAmount('');
|
|
}}
|
|
onChangeAmount={onMultiplierChange}
|
|
/>
|
|
{filetype === 'csv' && (
|
|
<>
|
|
<LabeledCheckbox
|
|
id="form_split"
|
|
checked={splitMode}
|
|
onChange={() => {
|
|
onSplitMode();
|
|
}}
|
|
>
|
|
<Trans>
|
|
Split amount into separate inflow/outflow columns
|
|
</Trans>
|
|
</LabeledCheckbox>
|
|
<InOutOption
|
|
inOutMode={inOutMode}
|
|
outValue={outValue}
|
|
onToggle={() => {
|
|
setInOutMode(!inOutMode);
|
|
}}
|
|
onChangeText={setOutValue}
|
|
/>
|
|
</>
|
|
)}
|
|
</View>
|
|
</SpaceBetween>
|
|
</View>
|
|
)}
|
|
|
|
<View style={{ flexDirection: 'row', marginTop: 5 }}>
|
|
{/*Submit Button */}
|
|
<View
|
|
style={{
|
|
alignSelf: 'flex-end',
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: '1em',
|
|
}}
|
|
>
|
|
{(() => {
|
|
const count = transactions?.filter(
|
|
trans =>
|
|
!trans.isMatchedTransaction &&
|
|
trans.selected &&
|
|
!trans.tombstone,
|
|
).length;
|
|
|
|
return (
|
|
<ButtonWithLoading
|
|
variant="primary"
|
|
autoFocus
|
|
isDisabled={count === 0}
|
|
isLoading={loadingState === 'importing'}
|
|
onPress={() => {
|
|
void onImport(() => state.close());
|
|
}}
|
|
>
|
|
<Trans count={count}>Import {{ count }} transactions</Trans>
|
|
</ButtonWithLoading>
|
|
);
|
|
})()}
|
|
</View>
|
|
</View>
|
|
</>
|
|
)}
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
function getParseOptions(fileType: string, options: ParseFileOptions = {}) {
|
|
if (fileType === 'csv') {
|
|
const { delimiter, hasHeaderRow, skipStartLines, skipEndLines } = options;
|
|
return { delimiter, hasHeaderRow, skipStartLines, skipEndLines };
|
|
}
|
|
if (isOfxFile(fileType)) {
|
|
const { fallbackMissingPayeeToMemo, importNotes, swapPayeeAndMemo } =
|
|
options;
|
|
return { fallbackMissingPayeeToMemo, importNotes, swapPayeeAndMemo };
|
|
}
|
|
if (fileType === 'qif') {
|
|
const { importNotes, swapPayeeAndMemo } = options;
|
|
return { importNotes, swapPayeeAndMemo };
|
|
}
|
|
if (isCamtFile(fileType)) {
|
|
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';
|
|
}
|
|
|
|
function isCamtFile(fileType: string) {
|
|
return fileType === 'xml';
|
|
}
|