diff --git a/packages/desktop-client/src/components/forms.tsx b/packages/desktop-client/src/components/forms.tsx index cf13ec0d2903cd47f7c9c47b904e592a9294f354..1fcd8fcfd3fd640c00a37c8b2729d4b5ab91534b 100644 --- a/packages/desktop-client/src/components/forms.tsx +++ b/packages/desktop-client/src/components/forms.tsx @@ -105,6 +105,17 @@ export const Checkbox = (props: CheckboxProps) => { content: ' ', }, }, + ':disabled': { + border: '1px solid ' + theme.buttonNormalDisabledBorder, + backgroundColor: theme.buttonNormalDisabledBorder, + }, + ':checked:disabled': { + border: '1px solid ' + theme.buttonNormalDisabledBorder, + backgroundColor: theme.buttonNormalDisabledBorder, + '::after': { + backgroundColor: theme.buttonNormalDisabledBorder, + }, + }, '&.focus-visible:focus': { '::before': { position: 'absolute', diff --git a/packages/desktop-client/src/components/modals/ImportTransactions.jsx b/packages/desktop-client/src/components/modals/ImportTransactions.jsx index cfe9133c7731afee0cddb36c159c3dec14a5c52b..b1675e77e61cf1fd8de4f1e28cb79d599e761a23 100644 --- a/packages/desktop-client/src/components/modals/ImportTransactions.jsx +++ b/packages/desktop-client/src/components/modals/ImportTransactions.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useMemo } from 'react'; +import React, { useState, useEffect, useMemo, useCallback } from 'react'; import * as d from 'date-fns'; @@ -12,6 +12,7 @@ import { import { useActions } from '../../hooks/useActions'; import { useDateFormat } from '../../hooks/useDateFormat'; import { useLocalPrefs } from '../../hooks/useLocalPrefs'; +import { SvgDownAndRightArrow } from '../../icons/v2'; import { theme, styles } from '../../style'; import { Button, ButtonWithLoading } from '../common/Button'; import { Input } from '../common/Input'; @@ -19,6 +20,7 @@ import { Modal } from '../common/Modal'; import { Select } from '../common/Select'; import { Stack } from '../common/Stack'; import { Text } from '../common/Text'; +import { Tooltip } from '../common/Tooltip'; import { View } from '../common/View'; import { Checkbox, SectionLabel } from '../forms'; import { TableHeader, TableWithNavigator, Row, Field } from '../table'; @@ -245,6 +247,12 @@ function applyFieldMappings(transaction, mappings) { result[field] = transaction[target || field]; } + // Keep preview fields on the mapped transactions + result.trx_id = transaction.trx_id; + result.existing = transaction.existing; + result.ignored = transaction.ignored; + result.selected = transaction.selected; + result.selected_merge = transaction.selected_merge; return result; } @@ -335,33 +343,124 @@ function Transaction({ flipAmount, multiplierAmount, categories, + onCheckTransaction, + reconcile, }) { const categoryList = categories.map(category => category.name); const transaction = useMemo( () => - fieldMappings + fieldMappings && !rawTransaction.isMatchedTransaction ? applyFieldMappings(rawTransaction, fieldMappings) : rawTransaction, [rawTransaction, fieldMappings], ); - const { amount, outflow, inflow } = parseAmountFields( - transaction, - splitMode, - inOutMode, - outValue, - flipAmount, - multiplierAmount, - ); + let amount, outflow, inflow; + if (rawTransaction.isMatchedTransaction) { + amount = rawTransaction.amount; + if (splitMode) { + outflow = amount < 0 ? -amount : 0; + inflow = amount > 0 ? amount : 0; + } + } else { + ({ amount, outflow, inflow } = parseAmountFields( + transaction, + splitMode, + inOutMode, + outValue, + flipAmount, + multiplierAmount, + )); + } return ( <Row style={{ backgroundColor: theme.tableBackground, + color: + (transaction.isMatchedTransaction && !transaction.selected_merge) || + !transaction.selected + ? theme.tableTextInactive + : theme.tableText, }} > + {reconcile && ( + <Field width={31}> + {!transaction.isMatchedTransaction && ( + <Tooltip + content={ + !transaction.existing && !transaction.ignored + ? 'New transaction. You can import it, or skip it.' + : transaction.ignored + ? 'Already imported transaction. You can skip it, or import it again.' + : transaction.existing + ? 'Updated transaction. You can update it, import it again, or skip it.' + : '' + } + placement="right top" + > + <Checkbox + checked={transaction.selected} + onChange={() => onCheckTransaction(transaction.trx_id)} + style={ + transaction.selected_merge + ? { + ':checked': { + '::after': { + background: + theme.checkboxBackgroundSelected + + // update sign from packages/desktop-client/src/icons/v1/layer.svg + // eslint-disable-next-line rulesdir/typography + ' url(\'data:image/svg+xml; utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path fill="white" d="M10 1l10 6-10 6L0 7l10-6zm6.67 10L20 13l-10 6-10-6 3.33-2L10 15l6.67-4z" /></svg>\') 9px 9px', + }, + }, + } + : { + '&': { + border: + '1px solid ' + theme.buttonNormalDisabledBorder, + backgroundColor: theme.buttonNormalDisabledBorder, + '::after': { + display: 'block', + background: + theme.buttonNormalDisabledBorder + + // minus sign adapted from packages/desktop-client/src/icons/v1/add.svg + // eslint-disable-next-line rulesdir/typography + ' url(\'data:image/svg+xml; utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="white" className="path" d="M23,11.5 L23,11.5 L23,11.5 C23,12.3284271 22.3284271,13 21.5,13 L1.5,13 L1.5,13 C0.671572875,13 1.01453063e-16,12.3284271 0,11.5 L0,11.5 L0,11.5 C-1.01453063e-16,10.6715729 0.671572875,10 1.5,10 L21.5,10 L21.5,10 C22.3284271,10 23,10.6715729 23,11.5 Z" /></svg>\') 9px 9px', + width: 9, + height: 9, + content: ' ', + }, + }, + ':checked': { + border: '1px solid ' + theme.checkboxBorderSelected, + backgroundColor: theme.checkboxBackgroundSelected, + '::after': { + background: + theme.checkboxBackgroundSelected + + // plus sign from packages/desktop-client/src/icons/v1/add.svg + // eslint-disable-next-line rulesdir/typography + ' url(\'data:image/svg+xml; utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="white" className="path" d="M23,11.5 L23,11.5 L23,11.5 C23,12.3284271 22.3284271,13 21.5,13 L1.5,13 L1.5,13 C0.671572875,13 1.01453063e-16,12.3284271 0,11.5 L0,11.5 L0,11.5 C-1.01453063e-16,10.6715729 0.671572875,10 1.5,10 L21.5,10 L21.5,10 C22.3284271,10 23,10.6715729 23,11.5 Z" /><path fill="white" className="path" d="M11.5,23 C10.6715729,23 10,22.3284271 10,21.5 L10,1.5 C10,0.671572875 10.6715729,1.52179594e-16 11.5,0 C12.3284271,-1.52179594e-16 13,0.671572875 13,1.5 L13,21.5 C13,22.3284271 12.3284271,23 11.5,23 Z" /></svg>\') 9px 9px', + }, + }, + } + } + /> + </Tooltip> + )} + </Field> + )} <Field width={200}> - {showParsed ? ( + {transaction.isMatchedTransaction ? ( + <View> + <Stack direction="row" align="flex-start"> + <View> + <SvgDownAndRightArrow width={16} height={16} /> + </View> + <View>{formatDate(transaction.date, dateFormat)}</View> + </Stack> + </View> + ) : showParsed ? ( <ParsedDate parseDateFormat={parseDateFormat} dateFormat={dateFormat} @@ -625,7 +724,9 @@ function FieldMappings({ return null; } - const options = Object.keys(transactions[0]); + const { existing, ignored, selected, selected_merge, trx_id, ...trans } = + transactions[0]; + const options = Object.keys(trans); mappings = mappings || {}; return ( @@ -738,8 +839,13 @@ function FieldMappings({ export function ImportTransactions({ modalProps, options }) { const dateFormat = useDateFormat() || 'MM/dd/yyyy'; const prefs = useLocalPrefs(); - const { parseTransactions, importTransactions, getPayees, savePrefs } = - useActions(); + const { + parseTransactions, + importTransactions, + importPreviewTransactions, + getPayees, + savePrefs, + } = useActions(); const [multiplierAmount, setMultiplierAmount] = useState(''); const [loadingState, setLoadingState] = useState('parsing'); @@ -754,6 +860,7 @@ export function ImportTransactions({ modalProps, options }) { const [flipAmount, setFlipAmount] = useState(false); const [multiplierEnabled, setMultiplierEnabled] = useState(false); const [reconcile, setReconcile] = useState(true); + const [previewTrigger, setPreviewTrigger] = useState(0); const { accountId, categories, onImported } = options; // This cannot be set after parsing the file, because changing it @@ -783,7 +890,18 @@ export function ImportTransactions({ modalProps, options }) { setFilename(filename); setFileType(filetype); - const { errors, transactions } = await parseTransactions(filename, options); + const { errors, transactions: parsedTransactions } = + await parseTransactions(filename, options); + + let index = 0; + const transactions = parsedTransactions.map(trans => { + // Add a transient transaction id to match preview with imported transactions + trans.trx_id = index++; + // Select all parsed transactions before first preview run + trans.selected = true; + return trans; + }); + setLoadingState(null); setError(null); @@ -794,8 +912,14 @@ export function ImportTransactions({ modalProps, options }) { message: errors[0].message || 'Internal error', }); } else { + let flipAmount = false; + let fieldMappings = null; + let splitMode = false; + let parseDateFormat = null; + if (filetype === 'csv' || filetype === 'qif') { - setFlipAmount(prefs[`flip-amount-${accountId}-${filetype}`] || false); + flipAmount = prefs[`flip-amount-${accountId}-${filetype}`] || false; + setFlipAmount(flipAmount); } if (filetype === 'csv') { @@ -804,21 +928,22 @@ export function ImportTransactions({ modalProps, options }) { ? JSON.parse(mappings) : getInitialMappings(transactions); + fieldMappings = mappings; setFieldMappings(mappings); // Set initial split mode based on any saved mapping - const initialSplitMode = !!(mappings.outflow || mappings.inflow); - setSplitMode(initialSplitMode); + splitMode = !!(mappings.outflow || mappings.inflow); + setSplitMode(splitMode); - setParseDateFormat( + parseDateFormat = prefs[`parse-date-${accountId}-${filetype}`] || - getInitialDateFormat(transactions, mappings), - ); + getInitialDateFormat(transactions, mappings); + setParseDateFormat(parseDateFormat); } else if (filetype === 'qif') { - setParseDateFormat( + parseDateFormat = prefs[`parse-date-${accountId}-${filetype}`] || - getInitialDateFormat(transactions, { date: 'date' }), - ); + getInitialDateFormat(transactions, { date: 'date' }); + setParseDateFormat(parseDateFormat); } else { setFieldMappings(null); setParseDateFormat(null); @@ -827,7 +952,18 @@ export function ImportTransactions({ modalProps, options }) { // Reverse the transactions because it's very common for them to // be ordered ascending, but we show transactions descending by // date. This is purely cosmetic. - setTransactions(transactions.reverse()); + const transactionPreview = await getImportPreview( + transactions.reverse(), + filetype, + flipAmount, + fieldMappings, + splitMode, + parseDateFormat, + inOutMode, + outValue, + multiplierAmount, + ); + setTransactions(transactionPreview); } } @@ -835,6 +971,7 @@ export function ImportTransactions({ modalProps, options }) { const amt = e; if (!amt || amt.match(/^\d{1,}(\.\d{0,4})?$/)) { setMultiplierAmount(amt); + runImportPreview(); } } @@ -902,7 +1039,54 @@ export function ImportTransactions({ modalProps, options }) { } function onUpdateFields(field, name) { - setFieldMappings({ ...fieldMappings, [field]: name === '' ? null : name }); + const newFieldMappings = { + ...fieldMappings, + [field]: name === '' ? null : name, + }; + setFieldMappings(newFieldMappings); + runImportPreview(); + } + + function onCheckTransaction(trx_id) { + 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); } async function onImport() { @@ -912,6 +1096,16 @@ export function ImportTransactions({ modalProps, options }) { 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 = @@ -941,7 +1135,29 @@ export function ImportTransactions({ modalProps, options }) { const category_id = parseCategoryFields(trans, categories.list); trans.category = category_id; - const { inflow, outflow, inOut, ...finalTransaction } = trans; + const { + inflow, + outflow, + inOut, + existing, + ignored, + selected, + selected_merge, + 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, @@ -994,6 +1210,156 @@ export function ImportTransactions({ modalProps, options }) { modalProps.onClose(); } + const runImportPreviewCallback = useCallback(async () => { + const transactionPreview = await getImportPreview( + transactions, + filetype, + flipAmount, + fieldMappings, + splitMode, + parseDateFormat, + inOutMode, + outValue, + multiplierAmount, + ); + setTransactions(transactionPreview); + }, [ + transactions, + filetype, + flipAmount, + fieldMappings, + splitMode, + parseDateFormat, + inOutMode, + outValue, + multiplierAmount, + ]); + + useEffect(() => { + runImportPreviewCallback(); + }, [previewTrigger]); + + function runImportPreview() { + setPreviewTrigger(value => value + 1); + } + + async function getImportPreview( + transactions, + filetype, + flipAmount, + fieldMappings, + splitMode, + parseDateFormat, + inOutMode, + outValue, + multiplierAmount, + ) { + const previewTransactions = []; + + 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 = isOfxFile(filetype) + ? trans.date + : parseDate(trans.date, parseDateFormat); + if (date == null) { + console.log( + `Unable to parse date ${ + trans.date || '(empty)' + } with given date format`, + ); + break; + } + + const { amount } = parseAmountFields( + trans, + splitMode, + inOutMode, + outValue, + flipAmount, + multiplierAmount, + ); + if (amount == null) { + console.log(`Transaction on ${trans.date} has no amount`); + break; + } + + const category_id = parseCategoryFields(trans, categories.list); + if (category_id != null) { + trans.category = category_id; + } + + const { + inflow, + outflow, + inOut, + existing, + ignored, + selected, + selected_merge, + ...finalTransaction + } = trans; + previewTransactions.push({ + ...finalTransaction, + date, + amount: amountToInteger(amount), + cleared: clearOnImport, + }); + } + + // Retreive the transactions that would be updated (along with the existing trx) + const previewTrx = await importPreviewTransactions( + accountId, + previewTransactions, + ); + const matchedUpdateMap = previewTrx.reduce((map, entry) => { + map[entry.transaction.trx_id] = entry; + return map; + }, {}); + + return transactions + .filter(trans => !trans.isMatchedTransaction) + .reduce((previous, current_trx) => { + let next = previous; + const entry = matchedUpdateMap[current_trx.trx_id]; + const existing_trx = entry?.existing; + + // if the transaction is matched with an existing one for update + current_trx.existing = !!existing_trx; + // if the transaction is an update that will be ignored + // (reconciled transactions or no change detected) + current_trx.ignored = entry?.ignored || false; + + current_trx.selected = !current_trx.ignored; + current_trx.selected_merge = current_trx.existing; + + next = next.concat({ ...current_trx }); + + if (existing_trx) { + // add the updated existing transaction in the list, with the + // isMatchedTransaction flag to identify it in display and not send it again + existing_trx.isMatchedTransaction = true; + existing_trx.category = categories.list.find( + cat => cat.id === existing_trx.category, + )?.name; + // add parent transaction attribute to mimic behaviour + existing_trx.trx_id = current_trx.trx_id; + existing_trx.existing = current_trx.existing; + existing_trx.selected = current_trx.selected; + existing_trx.selected_merge = current_trx.selected_merge; + + next = next.concat({ ...existing_trx }); + } + + return next; + }, []); + } + const headers = [ { name: 'Date', width: 200 }, { name: 'Payee', width: 'flex' }, @@ -1001,6 +1367,9 @@ export function ImportTransactions({ modalProps, options }) { { name: 'Category', width: 'flex' }, ]; + if (reconcile) { + headers.unshift({ name: ' ', width: 31 }); + } if (inOutMode) { headers.push({ name: 'In/Out', width: 90, style: { textAlign: 'left' } }); } @@ -1038,7 +1407,11 @@ export function ImportTransactions({ modalProps, options }) { <TableHeader headers={headers} /> <TableWithNavigator - items={transactions} + items={transactions.filter( + trans => + !trans.isMatchedTransaction || + (trans.isMatchedTransaction && reconcile), + )} fields={['payee', 'category', 'amount']} style={{ backgroundColor: theme.tableHeaderBackground }} getItemKey={index => index} @@ -1070,6 +1443,8 @@ export function ImportTransactions({ modalProps, options }) { flipAmount={flipAmount} multiplierAmount={multiplierAmount} categories={categories.list} + onCheckTransaction={onCheckTransaction} + reconcile={reconcile} /> </View> )} @@ -1094,7 +1469,7 @@ export function ImportTransactions({ modalProps, options }) { )} {filetype === 'csv' && ( - <View style={{ marginTop: 25 }}> + <View style={{ marginTop: 10 }}> <FieldMappings transactions={transactions} onChange={onUpdateFields} @@ -1128,16 +1503,16 @@ export function ImportTransactions({ modalProps, options }) { id="form_dont_reconcile" checked={reconcile} onChange={() => { - setReconcile(state => !state); + setReconcile(!reconcile); }} > - Reconcile transactions + Merge with existing transactions </CheckboxOption> )} {/*Import Options */} {(filetype === 'qif' || filetype === 'csv') && ( - <View style={{ marginTop: 25 }}> + <View style={{ marginTop: 10 }}> <Stack direction="row" align="flex-start" @@ -1151,14 +1526,17 @@ export function ImportTransactions({ modalProps, options }) { transactions={transactions} fieldMappings={fieldMappings} parseDateFormat={parseDateFormat} - onChange={setParseDateFormat} + onChange={value => { + setParseDateFormat(value); + runImportPreview(); + }} /> )} </View> {/* CSV Options */} {filetype === 'csv' && ( - <View style={{ marginLeft: 25, gap: 5 }}> + <View style={{ marginLeft: 10, gap: 5 }}> <SectionLabel title="CSV OPTIONS" /> <label style={{ @@ -1219,23 +1597,26 @@ export function ImportTransactions({ modalProps, options }) { id="form_dont_reconcile" checked={reconcile} onChange={() => { - setReconcile(state => !state); + setReconcile(!reconcile); }} > - Reconcile transactions + Merge with existing transactions </CheckboxOption> </View> )} <View style={{ flex: 1 }} /> - <View style={{ marginRight: 25, gap: 5 }}> + <View style={{ marginRight: 10, gap: 5 }}> <SectionLabel title="AMOUNT OPTIONS" /> <CheckboxOption id="form_flip" checked={flipAmount} disabled={splitMode || inOutMode} - onChange={() => setFlipAmount(!flipAmount)} + onChange={() => { + setFlipAmount(!flipAmount); + runImportPreview(); + }} > Flip amount </CheckboxOption> @@ -1245,7 +1626,10 @@ export function ImportTransactions({ modalProps, options }) { id="form_split" checked={splitMode} disabled={inOutMode || flipAmount} - onChange={onSplitMode} + onChange={() => { + onSplitMode(); + runImportPreview(); + }} > Split amount into separate inflow/outflow columns </CheckboxOption> @@ -1253,7 +1637,10 @@ export function ImportTransactions({ modalProps, options }) { inOutMode={inOutMode} outValue={outValue} disabled={splitMode || flipAmount} - onToggle={() => setInOutMode(!inOutMode)} + onToggle={() => { + setInOutMode(!inOutMode); + runImportPreview(); + }} onChangeText={setOutValue} /> </> @@ -1264,6 +1651,7 @@ export function ImportTransactions({ modalProps, options }) { onToggle={() => { setMultiplierEnabled(!multiplierEnabled); setMultiplierAmount(''); + runImportPreview(); }} onChangeAmount={onMultiplierChange} /> @@ -1279,15 +1667,21 @@ export function ImportTransactions({ modalProps, options }) { alignSelf: 'flex-end', flexDirection: 'row', alignItems: 'center', + gap: '1em', }} > <ButtonWithLoading type="primary" - disabled={transactions.length === 0} + disabled={ + transactions?.filter(trans => !trans.isMatchedTransaction) + .length === 0 + } loading={loadingState === 'importing'} onClick={onImport} > - Import {transactions.length} transactions + Import{' '} + {transactions?.filter(trans => !trans.isMatchedTransaction).length}{' '} + transactions </ButtonWithLoading> </View> </View> diff --git a/packages/desktop-client/src/icons/v2/DownAndRightArrow.tsx b/packages/desktop-client/src/icons/v2/DownAndRightArrow.tsx new file mode 100644 index 0000000000000000000000000000000000000000..44f3d82fc7fd4fb2c2953906ee78d1a019ebe9ac --- /dev/null +++ b/packages/desktop-client/src/icons/v2/DownAndRightArrow.tsx @@ -0,0 +1,18 @@ +import * as React from 'react'; +import type { SVGProps } from 'react'; +export const SvgDownAndRightArrow = (props: SVGProps<SVGSVGElement>) => ( + <svg + {...props} + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 20 20" + style={{ + color: 'inherit', + ...props.style, + }} + > + <path + d="M4.092 3.658 1.267 6.484l.708.708.708.708 1.609-1.608L5.9 4.684V7.4c.001 2.838.022 3.466.132 3.96.393 1.766 1.732 2.972 3.985 3.59.238.065.53.133.65.151.119.018 1.229.055 2.466.082 1.238.028 2.458.06 2.712.072l.462.021-1.561 1.562-1.562 1.562.708.708.708.708 2.7-2.699 2.7-2.7v-.5l-2.7-2.7-2.7-2.7-.7.7-.7.699 1.675 1.679 1.675 1.678-.617-.019c-.339-.011-1.584-.043-2.766-.071-1.191-.028-2.232-.067-2.334-.087a6.822 6.822 0 0 1-1.283-.426c-.754-.356-1.201-.777-1.447-1.365-.167-.399-.17-.463-.17-3.649V4.684L9.55 6.3l1.617 1.616.708-.708.708-.708L9.75 3.667 6.917.833 4.092 3.658" + fill="currentColor" + /> + </svg> +); diff --git a/packages/desktop-client/src/icons/v2/down-and-right-arrow.svg b/packages/desktop-client/src/icons/v2/down-and-right-arrow.svg new file mode 100644 index 0000000000000000000000000000000000000000..100aa0668380bcb4408500f1c36df4b4b196bd55 --- /dev/null +++ b/packages/desktop-client/src/icons/v2/down-and-right-arrow.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path d="M4.092 3.658 1.267 6.484l.708.708.708.708 1.609-1.608L5.9 4.684V7.4c.001 2.838.022 3.466.132 3.96.393 1.766 1.732 2.972 3.985 3.59.238.065.53.133.65.151.119.018 1.229.055 2.466.082 1.238.028 2.458.06 2.712.072l.462.021-1.561 1.562-1.562 1.562.708.708.708.708 2.7-2.699 2.7-2.7v-.5l-2.7-2.7-2.7-2.7-.7.7-.7.699 1.675 1.679 1.675 1.678-.617-.019c-.339-.011-1.584-.043-2.766-.071-1.191-.028-2.232-.067-2.334-.087a6.822 6.822 0 0 1-1.283-.426c-.754-.356-1.201-.777-1.447-1.365-.167-.399-.17-.463-.17-3.649V4.684L9.55 6.3l1.617 1.616.708-.708.708-.708L9.75 3.667 6.917.833 4.092 3.658" /></svg> \ No newline at end of file diff --git a/packages/desktop-client/src/icons/v2/index.ts b/packages/desktop-client/src/icons/v2/index.ts index 82e3d55308797cbd5fdf58af490559cc8e23ebfd..23af708a141c198749ae606e31dc8ecdc0d59f6b 100644 --- a/packages/desktop-client/src/icons/v2/index.ts +++ b/packages/desktop-client/src/icons/v2/index.ts @@ -16,6 +16,7 @@ export { SvgCheck } from './Check'; export { SvgCloudUnknown } from './CloudUnknown'; export { SvgCloudUpload } from './CloudUpload'; export { SvgCustomNotesPaper } from './CustomNotesPaper'; +export { SvgDownAndRightArrow } from './DownAndRightArrow'; export { SvgDownloadThickBottom } from './DownloadThickBottom'; export { SvgEditSkull1 } from './EditSkull1'; export { SvgFavoriteStar } from './FavoriteStar'; diff --git a/packages/loot-core/src/client/actions/account.ts b/packages/loot-core/src/client/actions/account.ts index 120d20b9df4c3527da20433d207c9b7a235a21a4..f2c80730d2678a1eae0ec678ca84f073f86da2b3 100644 --- a/packages/loot-core/src/client/actions/account.ts +++ b/packages/loot-core/src/client/actions/account.ts @@ -185,6 +185,27 @@ export function parseTransactions(filepath, options) { }; } +export function importPreviewTransactions(id: string, transactions) { + return async (dispatch: Dispatch): Promise<boolean> => { + const { errors = [], updatedPreview } = await send('transactions-import', { + accountId: id, + transactions, + isPreview: true, + }); + + errors.forEach(error => { + dispatch( + addNotification({ + type: 'error', + message: error.message, + }), + ); + }); + + return updatedPreview; + }; +} + export function importTransactions(id: string, transactions, reconcile = true) { return async (dispatch: Dispatch): Promise<boolean> => { if (!reconcile) { @@ -203,6 +224,7 @@ export function importTransactions(id: string, transactions, reconcile = true) { } = await send('transactions-import', { accountId: id, transactions, + isPreview: false, }); errors.forEach(error => { diff --git a/packages/loot-core/src/server/accounts/sync.ts b/packages/loot-core/src/server/accounts/sync.ts index 618c48918e211d1bc334fac49e03009e9bb29dea..51300ba1ddce400ff38ddd02828a5e9f624c06c5 100644 --- a/packages/loot-core/src/server/accounts/sync.ts +++ b/packages/loot-core/src/server/accounts/sync.ts @@ -8,7 +8,11 @@ import { makeChild as makeChildTransaction, recalculateSplit, } from '../../shared/transactions'; -import { hasFieldsChanged, amountToInteger } from '../../shared/util'; +import { + hasFieldsChanged, + amountToInteger, + integerToAmount, +} from '../../shared/util'; import * as db from '../db'; import { runMutator } from '../mutators'; import { post } from '../post'; @@ -349,12 +353,117 @@ export async function reconcileTransactions( acctId, transactions, isBankSyncAccount = false, + isPreview = false, ) { console.log('Performing transaction reconciliation'); - const hasMatched = new Set(); const updated = []; const added = []; + const updatedPreview = []; + const existingPayeeMap = new Map<string, string>(); + + const { + payeesToCreate, + transactionsStep1, + transactionsStep2, + transactionsStep3, + } = await matchTransactions(acctId, transactions, isBankSyncAccount); + + // Finally, generate & commit the changes + for (const { trans, subtransactions, match } of transactionsStep3) { + if (match && !trans.forceAddTransaction) { + // Skip updating already reconciled (locked) transactions + if (match.reconciled) { + updatedPreview.push({ transaction: trans, ignored: true }); + continue; + } + + // TODO: change the above sql query to use aql + const existing = { + ...match, + cleared: match.cleared === 1, + date: db.fromDateRepr(match.date), + }; + + // Update the transaction + const updates = { + imported_id: trans.imported_id || null, + payee: existing.payee || trans.payee || null, + category: existing.category || trans.category || null, + imported_payee: trans.imported_payee || null, + notes: existing.notes || trans.notes || null, + cleared: trans.cleared != null ? trans.cleared : true, + }; + + if (hasFieldsChanged(existing, updates, Object.keys(updates))) { + updated.push({ id: existing.id, ...updates }); + if (!existingPayeeMap.has(existing.payee)) { + const payee = await db.getPayee(existing.payee); + existingPayeeMap.set(existing.payee, payee?.name); + } + existing.payee_name = existingPayeeMap.get(existing.payee); + existing.amount = integerToAmount(existing.amount); + updatedPreview.push({ transaction: trans, existing }); + } else { + updatedPreview.push({ transaction: trans, ignored: true }); + } + + if (existing.is_parent && existing.cleared !== updates.cleared) { + const children = await db.all( + 'SELECT id FROM v_transactions WHERE parent_id = ?', + [existing.id], + ); + for (const child of children) { + updated.push({ id: child.id, cleared: updates.cleared }); + } + } + } else { + // Insert a new transaction + const { forceAddTransaction, ...newTrans } = trans; + const finalTransaction = { + ...newTrans, + id: uuidv4(), + category: trans.category || null, + cleared: trans.cleared != null ? trans.cleared : true, + }; + + if (subtransactions && subtransactions.length > 0) { + added.push(...makeSplitTransaction(finalTransaction, subtransactions)); + } else { + added.push(finalTransaction); + } + } + } + + if (!isPreview) { + await createNewPayees(payeesToCreate, [...added, ...updated]); + await batchUpdateTransactions({ added, updated }); + } + + console.log('Debug data for the operations:', { + transactionsStep1, + transactionsStep2, + transactionsStep3, + added, + updated, + updatedPreview, + }); + + return { + added: added.map(trans => trans.id), + updated: updated.map(trans => trans.id), + updatedPreview, + }; +} + +export async function matchTransactions( + acctId, + transactions, + isBankSyncAccount = false, +) { + console.log('Performing transaction reconciliation matching'); + + const hasMatched = new Set(); const transactionNormalization = isBankSyncAccount ? normalizeBankSyncTransactions @@ -399,7 +508,7 @@ export async function reconcileTransactions( // matched transaction. See the final pass below for the needed // fields. fuzzyDataset = await db.all( - `SELECT id, is_parent, date, imported_id, payee, category, notes, reconciled FROM v_transactions + `SELECT id, is_parent, date, imported_id, payee, imported_payee, category, notes, reconciled, cleared, amount FROM v_transactions WHERE date >= ? AND date <= ? AND amount = ? AND account = ?`, [ db.toDateRepr(monthUtils.subDays(trans.date, 7)), @@ -474,75 +583,11 @@ export async function reconcileTransactions( return data; }); - // Finally, generate & commit the changes - for (const { trans, subtransactions, match } of transactionsStep3) { - if (match) { - // Skip updating already reconciled (locked) transactions - if (match.reconciled) { - continue; - } - - // TODO: change the above sql query to use aql - const existing = { - ...match, - cleared: match.cleared === 1, - date: db.fromDateRepr(match.date), - }; - - // Update the transaction - const updates = { - imported_id: trans.imported_id || null, - payee: existing.payee || trans.payee || null, - category: existing.category || trans.category || null, - imported_payee: trans.imported_payee || null, - notes: existing.notes || trans.notes || null, - cleared: trans.cleared != null ? trans.cleared : true, - }; - - if (hasFieldsChanged(existing, updates, Object.keys(updates))) { - updated.push({ id: existing.id, ...updates }); - } - - if (existing.is_parent && existing.cleared !== updates.cleared) { - const children = await db.all( - 'SELECT id FROM v_transactions WHERE parent_id = ?', - [existing.id], - ); - for (const child of children) { - updated.push({ id: child.id, cleared: updates.cleared }); - } - } - } else { - // Insert a new transaction - const finalTransaction = { - ...trans, - id: uuidv4(), - category: trans.category || null, - cleared: trans.cleared != null ? trans.cleared : true, - }; - - if (subtransactions && subtransactions.length > 0) { - added.push(...makeSplitTransaction(finalTransaction, subtransactions)); - } else { - added.push(finalTransaction); - } - } - } - - await createNewPayees(payeesToCreate, [...added, ...updated]); - await batchUpdateTransactions({ added, updated }); - - console.log('Debug data for the operations:', { + return { + payeesToCreate, transactionsStep1, transactionsStep2, transactionsStep3, - added, - updated, - }); - - return { - added: added.map(trans => trans.id), - updated: updated.map(trans => trans.id), }; } diff --git a/packages/loot-core/src/server/api.ts b/packages/loot-core/src/server/api.ts index b49ef58f18893ad369c33e7528e3761134ad9501..d50cf08e61c66cbe92cafbd7eff4d0f3e19747c8 100644 --- a/packages/loot-core/src/server/api.ts +++ b/packages/loot-core/src/server/api.ts @@ -411,9 +411,14 @@ handlers['api/transactions-export'] = async function ({ handlers['api/transactions-import'] = withMutation(async function ({ accountId, transactions, + isPreview = false, }) { checkFileOpen(); - return handlers['transactions-import']({ accountId, transactions }); + return handlers['transactions-import']({ + accountId, + transactions, + isPreview, + }); }); handlers['api/transactions-add'] = withMutation(async function ({ diff --git a/packages/loot-core/src/server/main.ts b/packages/loot-core/src/server/main.ts index c137cc123a1a07cb89df023048eb798e84a17e31..c85fb311699e8295f2aa09b6247c67b3d15ab2bf 100644 --- a/packages/loot-core/src/server/main.ts +++ b/packages/loot-core/src/server/main.ts @@ -1131,6 +1131,7 @@ handlers['accounts-bank-sync'] = async function ({ id }) { handlers['transactions-import'] = mutator(function ({ accountId, transactions, + isPreview, }) { return withUndo(async () => { if (typeof accountId !== 'string') { @@ -1138,10 +1139,20 @@ handlers['transactions-import'] = mutator(function ({ } try { - return await bankSync.reconcileTransactions(accountId, transactions); + return await bankSync.reconcileTransactions( + accountId, + transactions, + false, + isPreview, + ); } catch (err) { if (err instanceof TransactionError) { - return { errors: [{ message: err.message }], added: [], updated: [] }; + return { + errors: [{ message: err.message }], + added: [], + updated: [], + updatedPreview: [], + }; } throw err; diff --git a/packages/loot-core/src/types/server-handlers.d.ts b/packages/loot-core/src/types/server-handlers.d.ts index 50736f01325d8b5e4a9f2cb4d2002562f81fc34f..9e3b91fb60fe9b4836ad592197255fc2437cab5d 100644 --- a/packages/loot-core/src/types/server-handlers.d.ts +++ b/packages/loot-core/src/types/server-handlers.d.ts @@ -222,10 +222,15 @@ export interface ServerHandlers { updatedAccounts; }>; - 'transactions-import': (arg: { accountId; transactions }) => Promise<{ + 'transactions-import': (arg: { + accountId; + transactions; + isPreview; + }) => Promise<{ errors?: { message: string }[]; added; updated; + updatedPreview; }>; 'account-unlink': (arg: { id }) => Promise<'ok'>; diff --git a/upcoming-release-notes/2717.md b/upcoming-release-notes/2717.md new file mode 100644 index 0000000000000000000000000000000000000000..63aff1f265ec33239aac1e48d229748e1fb8a333 --- /dev/null +++ b/upcoming-release-notes/2717.md @@ -0,0 +1,6 @@ +--- +category: Enhancements +authors: [Wizmaster] +--- + +Explicitly ask when reconciling transactions on manual import