diff --git a/packages/loot-core/src/server/accounts/sync.test.ts b/packages/loot-core/src/server/accounts/sync.test.ts index 1ea00a4d0a59605f4a08428a8e43e7c1abb8f487..b4356875c32b1c6d46c7b7a62bfe657cf386e7d4 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 b89969dc4b9d06e820321d2d33eade251a6fa8d4..b83c5ddd6df555ba65b30573bfe0cf5499ea610f 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 0000000000000000000000000000000000000000..3681a1e521dec7f682c40e3b408ba158e6889401 --- /dev/null +++ b/upcoming-release-notes/2991.md @@ -0,0 +1,6 @@ +--- +category: Bugfix +authors: [ttlgeek, strazto, pmoon00] +--- + +Prevent transaction deduplication for imported transactions