diff --git a/packages/desktop-client/src/components/Modals.tsx b/packages/desktop-client/src/components/Modals.tsx index 07711a2d2a3..a594d445fec 100644 --- a/packages/desktop-client/src/components/Modals.tsx +++ b/packages/desktop-client/src/components/Modals.tsx @@ -341,6 +341,7 @@ export function Modals() { key={name} transactionIds={options?.transactionIds} getTransaction={options?.getTransaction} + accountName={options?.accountName} /> ); diff --git a/packages/desktop-client/src/components/accounts/Header.jsx b/packages/desktop-client/src/components/accounts/Header.jsx index 997f36298ac..84f74aad9e2 100644 --- a/packages/desktop-client/src/components/accounts/Header.jsx +++ b/packages/desktop-client/src/components/accounts/Header.jsx @@ -337,6 +337,7 @@ export function AccountHeader({ ) : ( transactions.find(t => t.id === id)} onShow={onShowTransactions} onDuplicate={onBatchDuplicate} diff --git a/packages/desktop-client/src/components/modals/KeyboardShortcutModal.tsx b/packages/desktop-client/src/components/modals/KeyboardShortcutModal.tsx index 0860c39ad05..1d606abe431 100644 --- a/packages/desktop-client/src/components/modals/KeyboardShortcutModal.tsx +++ b/packages/desktop-client/src/components/modals/KeyboardShortcutModal.tsx @@ -206,31 +206,38 @@ export function KeyboardShortcutModal() { meta={ctrl} /> - + + + )} @@ -296,34 +303,31 @@ export function KeyboardShortcutModal() { shortcut="F" description="Filter transactions" /> - + - )} diff --git a/packages/desktop-client/src/components/schedules/ScheduleLink.tsx b/packages/desktop-client/src/components/schedules/ScheduleLink.tsx index 666a040c4fe..a5220a9f7c5 100644 --- a/packages/desktop-client/src/components/schedules/ScheduleLink.tsx +++ b/packages/desktop-client/src/components/schedules/ScheduleLink.tsx @@ -20,12 +20,14 @@ import { ROW_HEIGHT, SchedulesTable } from './SchedulesTable'; export function ScheduleLink({ transactionIds: ids, getTransaction, + accountName, }: { transactionIds: string[]; getTransaction: (transactionId: string) => TransactionEntity; + accountName: string; }) { const dispatch = useDispatch(); - const [filter, setFilter] = useState(''); + const [filter, setFilter] = useState(accountName); const scheduleData = useSchedules({ transform: useCallback((q: Query) => q.filter({ completed: false }), []), diff --git a/packages/desktop-client/src/components/transactions/SelectedTransactionsButton.jsx b/packages/desktop-client/src/components/transactions/SelectedTransactionsButton.jsx index 5d982ebbe9e..a6000bc5267 100644 --- a/packages/desktop-client/src/components/transactions/SelectedTransactionsButton.jsx +++ b/packages/desktop-client/src/components/transactions/SelectedTransactionsButton.jsx @@ -11,6 +11,7 @@ import { Menu } from '../common/Menu'; import { SelectedItemsButton } from '../table'; export function SelectedTransactionsButton({ + account, getTransaction, onShow, onDuplicate, @@ -112,6 +113,32 @@ export function SelectedTransactionsButton({ return areNoReconciledTransactions && areAllSplitTransactions; }, [selectedIds, types, getTransaction]); + function onLinkSchedule() { + dispatch( + pushModal('schedule-link', { + transactionIds: selectedIds, + getTransaction, + accountName: account?.name ?? '', + }), + ); + } + + function onViewSchedule() { + const firstId = selectedIds[0]; + let scheduleId; + if (isPreviewId(firstId)) { + const parts = firstId.split('/'); + scheduleId = parts[1]; + } else { + const trans = getTransaction(firstId); + scheduleId = trans && trans.schedule; + } + + if (scheduleId) { + dispatch(pushModal('schedule-edit', { id: scheduleId })); + } + } + const hotKeyOptions = { enabled: types.trans, scopes: ['app'], @@ -144,6 +171,14 @@ export function SelectedTransactionsButton({ onEdit, selectedIds, ]); + useHotkeys( + 's', + () => (!types.trans || linked ? onViewSchedule() : onLinkSchedule()), + { + scopes: ['app'], + }, + [onLinkSchedule, onViewSchedule, linked, selectedIds], + ); return ( 1, }, { name: 'unlink-schedule', text: 'Unlink schedule' }, @@ -176,6 +212,7 @@ export function SelectedTransactionsButton({ { name: 'link-schedule', text: 'Link schedule', + key: 'S', }, { name: 'create-rule', @@ -242,27 +279,10 @@ export function SelectedTransactionsButton({ onScheduleAction(name, selectedIds); break; case 'view-schedule': - const firstId = selectedIds[0]; - let scheduleId; - if (isPreviewId(firstId)) { - const parts = firstId.split('/'); - scheduleId = parts[1]; - } else { - const trans = getTransaction(firstId); - scheduleId = trans && trans.schedule; - } - - if (scheduleId) { - dispatch(pushModal('schedule-edit', { id: scheduleId })); - } + onViewSchedule(); break; case 'link-schedule': - dispatch( - pushModal('schedule-link', { - transactionIds: selectedIds, - getTransaction, - }), - ); + onLinkSchedule(); break; case 'unlink-schedule': onUnlink(selectedIds); diff --git a/packages/desktop-electron/index.ts b/packages/desktop-electron/index.ts index c7d9df81290..d5767d200fc 100644 --- a/packages/desktop-electron/index.ts +++ b/packages/desktop-electron/index.ts @@ -60,9 +60,21 @@ function createBackgroundProcess() { serverProcess = utilityProcess.fork( __dirname + '/server.js', ['--subprocess', app.getVersion()], - isDev ? { execArgv: ['--inspect'] } : undefined, + isDev ? { execArgv: ['--inspect'], stdio: 'pipe' } : { stdio: 'pipe' }, ); + serverProcess.stdout.on('data', (chunk: Buffer) => { + // Send the Server console.log messages to the main browser window + clientWin?.webContents.executeJavaScript(` + console.info('Server Log:', ${JSON.stringify(chunk.toString('utf8'))})`); + }); + + serverProcess.stderr.on('data', (chunk: Buffer) => { + // Send the Server console.error messages out to the main browser window + clientWin?.webContents.executeJavaScript(` + console.error('Server Log:', ${JSON.stringify(chunk.toString('utf8'))})`); + }); + serverProcess.on('message', msg => { switch (msg.type) { case 'captureEvent': diff --git a/packages/loot-core/src/platform/exceptions/index.electron.ts b/packages/loot-core/src/platform/exceptions/index.electron.ts index 95adc82959a..caeb9291726 100644 --- a/packages/loot-core/src/platform/exceptions/index.electron.ts +++ b/packages/loot-core/src/platform/exceptions/index.electron.ts @@ -1,7 +1,7 @@ import type * as T from '.'; export const captureException: T.CaptureException = function (exc) { - console.log('[Exception]', exc); + console.error('[Exception]', exc); }; export const captureBreadcrumb: T.CaptureBreadcrumb = function () {}; diff --git a/packages/loot-core/src/platform/server/fetch/index.electron.ts b/packages/loot-core/src/platform/server/fetch/index.electron.ts index 5bdf155ce7e..5aabd6dd82b 100644 --- a/packages/loot-core/src/platform/server/fetch/index.electron.ts +++ b/packages/loot-core/src/platform/server/fetch/index.electron.ts @@ -1,2 +1,12 @@ -// @ts-strict-ignore -export { default as fetch } from 'node-fetch'; +// // @ts-strict-ignore +import nodeFetch from 'node-fetch'; + +export const fetch = (input: RequestInfo | URL, options?: RequestInit) => { + return nodeFetch(input, { + ...options, + headers: { + ...options?.headers, + origin: 'app://actual', + }, + }); +}; diff --git a/packages/loot-core/src/server/accounts/sync.test.ts b/packages/loot-core/src/server/accounts/sync.test.ts index 1ea00a4d0a5..b4356875c32 100644 --- a/packages/loot-core/src/server/accounts/sync.test.ts +++ b/packages/loot-core/src/server/accounts/sync.test.ts @@ -342,4 +342,161 @@ describe('Account sync', () => { 'bakkerij-renamed', ]); }); + + test('reconcile does not merge transactions with different ‘imported_id’ values', async () => { + const { id } = await prepareDatabase(); + + let payees = await getAllPayees(); + expect(payees.length).toBe(0); + + // Add first transaction + await reconcileTransactions(id, [ + { + date: '2024-04-05', + amount: -1239, + imported_payee: 'Acme Inc.', + payee_name: 'Acme Inc.', + imported_id: 'b85cdd57-5a1c-4ca5-bd54-12e5b56fa02c', + notes: 'TEST TRANSACTION', + cleared: true, + }, + ]); + + payees = await getAllPayees(); + expect(payees.length).toBe(1); + + let transactions = await getAllTransactions(); + expect(transactions.length).toBe(1); + + // Add second transaction + await reconcileTransactions(id, [ + { + date: '2024-04-06', + amount: -1239, + imported_payee: 'Acme Inc.', + payee_name: 'Acme Inc.', + imported_id: 'ca1589b2-7bc3-4587-a157-476170b383a7', + notes: 'TEST TRANSACTION', + cleared: true, + }, + ]); + + payees = await getAllPayees(); + expect(payees.length).toBe(1); + + transactions = await getAllTransactions(); + expect(transactions.length).toBe(2); + + expect( + transactions.find( + t => t.imported_id === 'b85cdd57-5a1c-4ca5-bd54-12e5b56fa02c', + ).amount, + ).toBe(-1239); + expect( + transactions.find( + t => t.imported_id === 'ca1589b2-7bc3-4587-a157-476170b383a7', + ).amount, + ).toBe(-1239); + }); + + test( + 'given an imported tx with no imported_id, ' + + 'when using fuzzy search V2, existing transaction has an imported_id, matches amount, and is within 7 days of imported tx, ' + + 'then imported tx should reconcile with existing transaction from fuzzy match', + async () => { + const { id } = await prepareDatabase(); + + let payees = await getAllPayees(); + expect(payees.length).toBe(0); + + const existingTx = { + date: '2024-04-05', + amount: -1239, + imported_payee: 'Acme Inc.', + payee_name: 'Acme Inc.', + imported_id: 'b85cdd57-5a1c-4ca5-bd54-12e5b56fa02c', + notes: 'TEST TRANSACTION', + cleared: true, + }; + + // Add transaction to represent existing transaction with imoprted_id + await reconcileTransactions(id, [existingTx]); + + payees = await getAllPayees(); + expect(payees.length).toBe(1); + + let transactions = await getAllTransactions(); + expect(transactions.length).toBe(1); + + // Import transaction similar to existing but with different date and no imported_id + await reconcileTransactions(id, [ + { + ...existingTx, + date: '2024-04-06', + imported_id: null, + }, + ]); + + payees = await getAllPayees(); + expect(payees.length).toBe(1); + + transactions = await getAllTransactions(); + expect(transactions.length).toBe(1); + + expect(transactions[0].amount).toBe(-1239); + }, + ); + + test( + 'given an imported tx has an imported_id, ' + + 'when not using fuzzy search V2, existing transaction has an imported_id, matches amount, and is within 7 days of imported tx, ' + + 'then imported tx should reconcile with existing transaction from fuzzy match', + async () => { + const { id } = await prepareDatabase(); + + let payees = await getAllPayees(); + expect(payees.length).toBe(0); + + const existingTx = { + date: '2024-04-05', + amount: -1239, + imported_payee: 'Acme Inc.', + payee_name: 'Acme Inc.', + imported_id: 'b85cdd57-5a1c-4ca5-bd54-12e5b56fa02c', + notes: 'TEST TRANSACTION', + cleared: true, + }; + + // Add transaction to represent existing transaction with imoprted_id + await reconcileTransactions(id, [existingTx]); + + payees = await getAllPayees(); + expect(payees.length).toBe(1); + + let transactions = await getAllTransactions(); + expect(transactions.length).toBe(1); + + // Import transaction similar to existing but with different date and imported_id + await reconcileTransactions( + id, + [ + { + ...existingTx, + date: '2024-04-06', + imported_id: 'something-else-entirely', + }, + ], + false, + false, + ); + + payees = await getAllPayees(); + expect(payees.length).toBe(1); + + transactions = await getAllTransactions(); + expect(transactions.length).toBe(1); + + expect(transactions[0].amount).toBe(-1239); + }, + ); }); diff --git a/packages/loot-core/src/server/accounts/sync.ts b/packages/loot-core/src/server/accounts/sync.ts index b89969dc4b9..b83c5ddd6df 100644 --- a/packages/loot-core/src/server/accounts/sync.ts +++ b/packages/loot-core/src/server/accounts/sync.ts @@ -309,6 +309,7 @@ export async function reconcileTransactions( acctId, transactions, isBankSyncAccount = false, + strictIdChecking = true, isPreview = false, ) { console.log('Performing transaction reconciliation'); @@ -323,7 +324,12 @@ export async function reconcileTransactions( transactionsStep1, transactionsStep2, transactionsStep3, - } = await matchTransactions(acctId, transactions, isBankSyncAccount); + } = await matchTransactions( + acctId, + transactions, + isBankSyncAccount, + strictIdChecking, + ); // Finally, generate & commit the changes for (const { trans, subtransactions, match } of transactionsStep3) { @@ -416,6 +422,7 @@ export async function matchTransactions( acctId, transactions, isBankSyncAccount = false, + strictIdChecking = true, ) { console.log('Performing transaction reconciliation matching'); @@ -459,20 +466,39 @@ export async function matchTransactions( // If it didn't match, query data needed for fuzzy matching if (!match) { - // Look 7 days ahead and 7 days back when fuzzy matching. This + // Fuzzy matching looks 7 days ahead and 7 days back. This // needs to select all fields that need to be read from the // matched transaction. See the final pass below for the needed // fields. - fuzzyDataset = await db.all( - `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)), - db.toDateRepr(monthUtils.addDays(trans.date, 7)), - trans.amount || 0, - acctId, - ], - ); + const sevenDaysBefore = db.toDateRepr(monthUtils.subDays(trans.date, 7)); + const sevenDaysAfter = db.toDateRepr(monthUtils.addDays(trans.date, 7)); + // strictIdChecking has the added behaviour of only matching on transactions with no import ID + // if the transaction being imported has an import ID. + if (strictIdChecking) { + fuzzyDataset = await db.all( + `SELECT id, is_parent, date, imported_id, payee, imported_payee, category, notes, reconciled, cleared, amount + FROM v_transactions + WHERE + -- If both ids are set, and we didn't match earlier then skip dedup + (imported_id IS NULL OR ? IS NULL) + AND date >= ? AND date <= ? AND amount = ? + AND account = ?`, + [ + trans.imported_id || null, + sevenDaysBefore, + sevenDaysAfter, + trans.amount || 0, + acctId, + ], + ); + } else { + fuzzyDataset = await db.all( + `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 = ?`, + [sevenDaysBefore, sevenDaysAfter, trans.amount || 0, acctId], + ); + } // Sort the matched transactions according to the distance from the original // transactions date. i.e. if the original transaction is in 21-02-2024 and @@ -620,6 +646,10 @@ export async function syncAccount( ); const acctRow = await db.select('accounts', id); + // If syncing an account from sync source it must not use strictIdChecking. This allows + // the fuzzy search to match transactions where the import IDs are different. It is a known quirk + // that account sync sources can give two different transaction IDs even though it's the same transaction. + const useStrictIdChecking = !acctRow.account_sync_source; if (latestTransaction) { const startingTransaction = await db.first( @@ -670,7 +700,12 @@ export async function syncAccount( })); return runMutator(async () => { - const result = await reconcileTransactions(id, transactions, true); + const result = await reconcileTransactions( + id, + transactions, + true, + useStrictIdChecking, + ); await updateAccountBalance(id, accountBalance); return result; }); @@ -725,7 +760,12 @@ export async function syncAccount( starting_balance_flag: true, }); - const result = await reconcileTransactions(id, transactions, true); + const result = await reconcileTransactions( + id, + transactions, + true, + useStrictIdChecking, + ); return { ...result, added: [initialId, ...result.added], diff --git a/upcoming-release-notes/2991.md b/upcoming-release-notes/2991.md new file mode 100644 index 00000000000..3681a1e521d --- /dev/null +++ b/upcoming-release-notes/2991.md @@ -0,0 +1,6 @@ +--- +category: Bugfix +authors: [ttlgeek, strazto, pmoon00] +--- + +Prevent transaction deduplication for imported transactions diff --git a/upcoming-release-notes/3188.md b/upcoming-release-notes/3188.md new file mode 100644 index 00000000000..6ce11140b21 --- /dev/null +++ b/upcoming-release-notes/3188.md @@ -0,0 +1,6 @@ +--- +category: Enhancements +authors: [psybers] +--- + +Filter by account when linking schedules and add shortcut "S" to link schedule. diff --git a/upcoming-release-notes/3212.md b/upcoming-release-notes/3212.md new file mode 100644 index 00000000000..21391953f91 --- /dev/null +++ b/upcoming-release-notes/3212.md @@ -0,0 +1,6 @@ +--- +category: Bugfix +authors: [MikesGlitch] +--- + +Fix gocardless "Linking back account" integration in Desktop app. diff --git a/upcoming-release-notes/3219.md b/upcoming-release-notes/3219.md new file mode 100644 index 00000000000..4c73e3a5fc9 --- /dev/null +++ b/upcoming-release-notes/3219.md @@ -0,0 +1,6 @@ +--- +category: Maintenance +authors: [MikesGlitch] +--- + +Makinig Server logs visible in devtools on Electron