From 6ec372828090d1526bc33ae207bc08b178e36142 Mon Sep 17 00:00:00 2001 From: Wizmaster <code@wizmaster.fr> Date: Wed, 24 Apr 2024 03:16:33 +0200 Subject: [PATCH] Fix reconciling split translations from nYNAB import creates orphan transfers (#2502) * Reconciling split translations from nYNAB import creates orphan transfers - Fixed link between subtransaction transfers and transaction transfers - Reworked payee transaction and subtransaction import * Reconciling split translations from nYNAB import creates orphan transfers - Added release note * Reconciling split translations from nYNAB import creates orphan transfers - Reworkd data.transactions and data.subtransactions loops * Reconciling split translations from nYNAB import creates orphan transfers - Added comments --------- Co-authored-by: DJ Mountney <david@twkie.net> --- .../e2e/data/ynab5-demo-budget.json | 6 +- .../src/server/importers/ynab5-types.d.ts | 1 + .../loot-core/src/server/importers/ynab5.ts | 204 +++++++++++++++--- upcoming-release-notes/2502.md | 6 + 4 files changed, 188 insertions(+), 29 deletions(-) create mode 100644 upcoming-release-notes/2502.md diff --git a/packages/desktop-client/e2e/data/ynab5-demo-budget.json b/packages/desktop-client/e2e/data/ynab5-demo-budget.json index dfe791345..fd9993975 100644 --- a/packages/desktop-client/e2e/data/ynab5-demo-budget.json +++ b/packages/desktop-client/e2e/data/ynab5-demo-budget.json @@ -1704,8 +1704,8 @@ "payee_id": "", "category_id": null, "transfer_account_id": "bc1d862f-bab0-41c3-bd1e-6cee8c688e32", - "transfer_transaction_id": "213526fc-ba49-4790-8a96-cc2a50182728", - "matched_transaction_id": "", + "transfer_transaction_id": null, + "matched_transaction_id": null, "import_id": null, "import_payee_name": null, "import_payee_name_original": null, @@ -1729,7 +1729,7 @@ "transaction_id": "213526fc-ba49-4790-8a96-cc2a50182728", "amount": -50000, "memo": "split part b", - "payee_id": "2a20470a-634f-4efa-a7f6-f1c0b0bdda41", + "payee_id": "8d3017e0-2aa6-4fe2-b011-c53c9f147eb6", "category_id": null, "transfer_account_id": "125f339b-2a63-481e-84c0-f04d898905d2", "deleted": false diff --git a/packages/loot-core/src/server/importers/ynab5-types.d.ts b/packages/loot-core/src/server/importers/ynab5-types.d.ts index c96d06a09..524ede77c 100644 --- a/packages/loot-core/src/server/importers/ynab5-types.d.ts +++ b/packages/loot-core/src/server/importers/ynab5-types.d.ts @@ -63,6 +63,7 @@ export namespace YNAB5 { memo: string; amount: number; transfer_account_id: string; + payee_id: string; } interface Month { diff --git a/packages/loot-core/src/server/importers/ynab5.ts b/packages/loot-core/src/server/importers/ynab5.ts index 011b0d15b..4ef00137a 100644 --- a/packages/loot-core/src/server/importers/ynab5.ts +++ b/packages/loot-core/src/server/importers/ynab5.ts @@ -152,16 +152,157 @@ async function importTransactions( const payeeTransferAcctHashMap = new Map<string, YNAB5.Payee>( payeesByTransferAcct, ); + const orphanTransferMap = new Map<string, YNAB5.Transaction[]>(); + const orphanSubtransfer = [] as YNAB5.Subtransaction[]; + const orphanSubtransferTrxId = [] as string[]; + const orphanSubtransferAcctIdByTrxIdMap = new Map<string, string>(); + const orphanSubtransferDateByTrxIdMap = new Map<string, string>(); // Go ahead and generate ids for all of the transactions so we can // reliably resolve transfers - for (const transaction of data.transactions) { + // Also identify orphan transfer transactions and subtransactions. + for (const transaction of data.subtransactions) { entityIdMap.set(transaction.id, uuidv4()); + + if (transaction.transfer_account_id) { + orphanSubtransfer.push(transaction); + orphanSubtransferTrxId.push(transaction.transaction_id); + } } - for (const transaction of data.subtransactions) { + + for (const transaction of data.transactions) { entityIdMap.set(transaction.id, uuidv4()); + + if ( + transaction.transfer_account_id && + !transaction.transfer_transaction_id + ) { + const key = + transaction.account_id + '#' + transaction.transfer_account_id; + if (!orphanTransferMap.has(key)) { + orphanTransferMap.set(key, [transaction]); + } else { + orphanTransferMap.get(key).push(transaction); + } + } + + if (orphanSubtransferTrxId.includes(transaction.id)) { + orphanSubtransferAcctIdByTrxIdMap.set( + transaction.id, + transaction.account_id, + ); + orphanSubtransferDateByTrxIdMap.set(transaction.id, transaction.date); + } } + // Compute link between subtransaction transfers and orphaned transaction + // transfers. The goal is to match each transfer subtransaction to the related + // transfer transaction according to the accounts, date, amount and memo. + const orphanSubtransferMap = orphanSubtransfer.reduce( + (map, subtransaction) => { + const key = + subtransaction.transfer_account_id + + '#' + + orphanSubtransferAcctIdByTrxIdMap.get(subtransaction.transaction_id); + if (!map.has(key)) { + map.set(key, [subtransaction]); + } else { + map.get(key).push(subtransaction); + } + return map; + }, + new Map<string, YNAB5.Subtransaction[]>(), + ); + + // The comparator will be used to order transfer transactions and their + // corresponding tranfer subtransaction in two aligned list. Hopefully + // for every list index in the transactions list, the related subtransaction + // will be at the same index. + const orphanTransferComparator = ( + a: YNAB5.Transaction | YNAB5.Subtransaction, + b: YNAB5.Transaction | YNAB5.Subtransaction, + ) => { + // a and b can be a YNAB5.Transaction (having a date attribute) or a + // YNAB5.Subtransaction (missing that date attribute) + + const date_a = + 'date' in a + ? a.date + : orphanSubtransferDateByTrxIdMap.get(a.transaction_id); + const date_b = + 'date' in b + ? b.date + : orphanSubtransferDateByTrxIdMap.get(b.transaction_id); + // A transaction and the related subtransaction have inverted amounts. + // To have those in the same order, the subtransaction has to be reversed + // to have the same amount. + const amount_a = 'date' in a ? a.amount : -a.amount; + const amount_b = 'date' in b ? b.amount : -b.amount; + + // Transaction are ordered first by date, then by amount, and lastly by memo + if (date_a > date_b) return 1; + if (date_a < date_b) return -1; + if (amount_a > amount_b) return 1; + if (amount_a < amount_b) return -1; + if (a.memo > b.memo) return 1; + if (a.memo < b.memo) return -1; + return 0; + }; + + const orphanTrxIdSubtrxIdMap = new Map<string, string>(); + orphanTransferMap.forEach((transactions, key) => { + const subtransactions = orphanSubtransferMap.get(key); + if (subtransactions) { + transactions.sort(orphanTransferComparator); + subtransactions.sort(orphanTransferComparator); + + // Iterate on the two sorted lists transactions and subtransactions and + // find matching data to identify the related transaction ids. + let transactionIdx = 0; + let subtransactionIdx = 0; + do { + switch ( + orphanTransferComparator( + transactions[transactionIdx], + subtransactions[subtransactionIdx], + ) + ) { + case 0: + // The current list indexes are matching: the transaction and + // subtransaction are related (same date, amount and memo) + orphanTrxIdSubtrxIdMap.set( + transactions[transactionIdx].id, + entityIdMap.get(subtransactions[subtransactionIdx].id), + ); + orphanTrxIdSubtrxIdMap.set( + subtransactions[subtransactionIdx].id, + entityIdMap.get(transactions[transactionIdx].id), + ); + transactionIdx++; + subtransactionIdx++; + break; + case -1: + // The current list indexes are not matching: + // The current transaction is "smaller" than the current subtransaction + // (earlier date, smaller amount, memo value sorted before) + // So we advance to the next transaction and see if it match with + // the current subtransaction + transactionIdx++; + break; + case 1: + // Inverse of the previous case: + // The current subtransaction is "smaller" than the current transaction + // So we advance to the next subtransaction + subtransactionIdx++; + break; + } + } while ( + transactionIdx < transactions.length && + subtransactionIdx < subtransactions.length + ); + } + }); + await Promise.all( [...transactionsGrouped.keys()].map(async accountId => { const transactions = transactionsGrouped.get(accountId); @@ -186,25 +327,20 @@ async function importTransactions( notes: transaction.memo || null, imported_id: transaction.import_id || null, transfer_id: - entityIdMap.get(transaction.transfer_transaction_id) || null, + entityIdMap.get(transaction.transfer_transaction_id) || + orphanTrxIdSubtrxIdMap.get(transaction.id) || + null, subtransactions: subtransactions ? subtransactions.map(subtrans => { - let payee = null; - if (subtrans.transfer_account_id) { - const mappedTransferAccountId = entityIdMap.get( - subtrans.transfer_account_id, - ); - payee = payeeTransferAcctHashMap.get( - mappedTransferAccountId, - )?.id; - } - return { id: entityIdMap.get(subtrans.id), amount: amountFromYnab(subtrans.amount), category: entityIdMap.get(subtrans.category_id) || null, notes: subtrans.memo, - payee, + transfer_id: + orphanTrxIdSubtrxIdMap.get(subtrans.id) || null, + payee: null, + imported_payee: null, }; }) : null, @@ -212,18 +348,34 @@ async function importTransactions( imported_payee: null, }; - // Handle transfer payee - if (transaction.transfer_account_id) { - newTransaction.payee = payees.find( - p => - p.transfer_acct === - entityIdMap.get(transaction.transfer_account_id), - ).id; - } else { - newTransaction.payee = entityIdMap.get(transaction.payee_id); - newTransaction.imported_payee = data.payees.find( - p => !p.deleted && p.id === transaction.payee_id, - )?.name; + // Handle transactions and subtransactions payee + const transactionPayeeUpdate = ( + trx: YNAB5.Transaction | YNAB5.Subtransaction, + newTrx, + ) => { + if (trx.transfer_account_id) { + const mappedTransferAccountId = entityIdMap.get( + trx.transfer_account_id, + ); + newTrx.payee = payeeTransferAcctHashMap.get( + mappedTransferAccountId, + )?.id; + } else { + newTrx.payee = entityIdMap.get(trx.payee_id); + newTrx.imported_payee = data.payees.find( + p => !p.deleted && p.id === trx.payee_id, + )?.name; + } + }; + + transactionPayeeUpdate(transaction, newTransaction); + if (newTransaction.subtransactions) { + subtransactions.forEach(subtrans => { + const newSubtransaction = newTransaction.subtransactions.find( + newSubtrans => newSubtrans.id === entityIdMap.get(subtrans.id), + ); + transactionPayeeUpdate(subtrans, newSubtransaction); + }); } // Handle starting balances diff --git a/upcoming-release-notes/2502.md b/upcoming-release-notes/2502.md new file mode 100644 index 000000000..3be520069 --- /dev/null +++ b/upcoming-release-notes/2502.md @@ -0,0 +1,6 @@ +--- +category: Bugfix +authors: [Wizmaster] +--- + +Fix reconciling split translations from nYNAB import creates orphan transfers -- GitLab