From deadd9aefc475a9da441a789e73bba549909bf31 Mon Sep 17 00:00:00 2001 From: Stephen Brown II Date: Thu, 5 Feb 2026 16:22:01 -0600 Subject: [PATCH] Apply tag colors to YNAB flag tags (#6866) * Apply tag colors to match YNAB flags * Update tag colors to match YNAB, add description on import * Tighten types * Use custom colors * Use Actual palette equivalents for tag colors * Nitpick fixes * Fix nitpick 'fix' * Handle YNAB flag tag conflicts * Handle YNAB flag tag conflicts without creating separate color tags * Simplify * Reorganize --- .../e2e/data/ynab5-demo-budget.json | 20 +- .../src/server/importers/ynab5-types.ts | 8 +- .../loot-core/src/server/importers/ynab5.ts | 828 ++++++++++-------- upcoming-release-notes/6866.md | 6 + 4 files changed, 497 insertions(+), 365 deletions(-) create mode 100644 upcoming-release-notes/6866.md diff --git a/packages/desktop-client/e2e/data/ynab5-demo-budget.json b/packages/desktop-client/e2e/data/ynab5-demo-budget.json index fe236e8bb9..432d240deb 100644 --- a/packages/desktop-client/e2e/data/ynab5-demo-budget.json +++ b/packages/desktop-client/e2e/data/ynab5-demo-budget.json @@ -1710,7 +1710,8 @@ "memo": "sending to savings", "cleared": "cleared", "approved": true, - "flag_color": null, + "flag_color": "blue", + "flag_name": "Savings", "account_id": "bc1d862f-bab0-41c3-bd1e-6cee8c688e32", "payee_id": "8d3017e0-2aa6-4fe2-b011-c53c9f147eb6", "category_id": null, @@ -1730,7 +1731,8 @@ "memo": null, "cleared": "cleared", "approved": true, - "flag_color": null, + "flag_color": "red", + "flag_name": "One-off", "account_id": "bc1d862f-bab0-41c3-bd1e-6cee8c688e32", "payee_id": "2a20470a-634f-4efa-a7f6-f1c0b0bdda41", "category_id": "225be370-37da-4cf8-8b6b-4c6d61a0dd95", @@ -1750,7 +1752,8 @@ "memo": "getting paid", "cleared": "reconciled", "approved": true, - "flag_color": null, + "flag_color": "green", + "flag_name": "JOB", "account_id": "bc1d862f-bab0-41c3-bd1e-6cee8c688e32", "payee_id": "c843e030-5a77-4dc5-9b93-f8acc64b74f8", "category_id": "36120d44-6c61-4402-985a-891a8d267858", @@ -1770,7 +1773,8 @@ "memo": null, "cleared": "cleared", "approved": true, - "flag_color": null, + "flag_color": "purple", + "flag_name": "Savings", "account_id": "125f339b-2a63-481e-84c0-f04d898905d2", "payee_id": "c843e030-5a77-4dc5-9b93-f8acc64b74f8", "category_id": "36120d44-6c61-4402-985a-891a8d267858", @@ -1984,7 +1988,8 @@ "frequency": "monthly", "amount": -100000, "memo": "Scheduled - repeated monthly", - "flag_color": null, + "flag_color": "purple", + "flag_name": "Savings", "account_id": "bc1d862f-bab0-41c3-bd1e-6cee8c688e32", "payee_id": "0f0899e3-242f-42e6-aae9-a751060d878e", "category_id": "419ae801-27c8-424b-8f39-9611825803db", @@ -1999,6 +2004,7 @@ "amount": -100000, "memo": "Scheduled - repeated weekly", "flag_color": "blue", + "flag_name": "Savings", "account_id": "bc1d862f-bab0-41c3-bd1e-6cee8c688e32", "payee_id": "0f0899e3-242f-42e6-aae9-a751060d878e", "category_id": "419ae801-27c8-424b-8f39-9611825803db", @@ -2083,7 +2089,7 @@ "frequency": "daily", "amount": -100000, "memo": "Scheduled - repeated daily", - "flag_color": "purple", + "flag_color": null, "account_id": "bc1d862f-bab0-41c3-bd1e-6cee8c688e32", "payee_id": "0f0899e3-242f-42e6-aae9-a751060d878e", "category_id": "419ae801-27c8-424b-8f39-9611825803db", @@ -2097,7 +2103,7 @@ "frequency": "monthly", "amount": -100000, "memo": "Scheduled - split categories monthly", - "flag_color": "green", + "flag_color": "yellow", "flag_name": "Split", "account_id": "bc1d862f-bab0-41c3-bd1e-6cee8c688e32", "payee_id": "0f0899e3-242f-42e6-aae9-a751060d878e", diff --git a/packages/loot-core/src/server/importers/ynab5-types.ts b/packages/loot-core/src/server/importers/ynab5-types.ts index f4e35fe48c..6d05b455c4 100644 --- a/packages/loot-core/src/server/importers/ynab5-types.ts +++ b/packages/loot-core/src/server/importers/ynab5-types.ts @@ -17,9 +17,9 @@ export type Budget = { category_groups: CategoryGroup[]; categories: Category[]; months: MonthDetail[]; - transactions: TransactionSummary[]; + transactions: Transaction[]; subtransactions: Subtransaction[]; - scheduled_transactions: ScheduledTransactionSummary[]; + scheduled_transactions: ScheduledTransaction[]; scheduled_subtransactions: ScheduledSubtransaction[]; }; @@ -155,7 +155,7 @@ export type Category = { export type GoalType = 'TB' | 'TBD' | 'MF' | 'NEED' | 'DEBT'; // Source: https://api.ynab.com/papi/open_api_spec.yaml#/components/schemas/TransactionSummary -export type TransactionSummary = { +export type Transaction = { id: string; date: string; amount: number; @@ -225,7 +225,7 @@ export type Subtransaction = { }; // Source: https://api.ynab.com/papi/open_api_spec.yaml#/components/schemas/ScheduledTransactionSummary -export type ScheduledTransactionSummary = { +export type ScheduledTransaction = { id: string; date_first: string; date_next: string; diff --git a/packages/loot-core/src/server/importers/ynab5.ts b/packages/loot-core/src/server/importers/ynab5.ts index 6242c32d20..12cb8d60f2 100644 --- a/packages/loot-core/src/server/importers/ynab5.ts +++ b/packages/loot-core/src/server/importers/ynab5.ts @@ -18,15 +18,53 @@ import { } from '../../types/models'; import { ruleModel } from '../transactions/transaction-rules'; -import type { - Budget, - Payee, - ScheduledSubtransaction, - ScheduledTransactionSummary, - Subtransaction, - TransactionSummary, +import { + type Budget, + type Payee, + type ScheduledSubtransaction, + type ScheduledTransaction, + type Subtransaction, + type Transaction, } from './ynab5-types'; +type FlaggedTransaction = Pick< + Transaction | ScheduledTransaction, + 'flag_name' | 'flag_color' | 'deleted' +>; + +const flagColorMap: Record = { + red: '#FF6666', + orange: '#F57C00', + yellow: '#FBC02D', + green: '#689F38', + blue: '#1976D2', + purple: '#512DA8', + null: null, + '': null, +}; + +function equalsIgnoreCase(stringa: string, stringb: string): boolean { + return ( + stringa.localeCompare(stringb, undefined, { + sensitivity: 'base', + }) === 0 + ); +} + +function findByNameIgnoreCase( + categories: T[], + name: string, +) { + return categories.find(cat => equalsIgnoreCase(cat.name, name)); +} + +function findIdByName( + categories: Array, + name: string, +) { + return findByNameIgnoreCase(categories, name)?.id; +} + function amountFromYnab(amount: number) { // YNAB multiplies amount by 1000 and Actual by 100 // so, this function divides by 10 @@ -127,7 +165,7 @@ function mapYnabFrequency( } function getScheduleDateValue( - scheduled: ScheduledTransactionSummary, + scheduled: ScheduledTransaction, ): RecurConfig | string { const dateFirst = scheduled.date_first; const frequency = scheduled.frequency; @@ -148,6 +186,84 @@ function getScheduleDateValue( }; } +function getFlaggedTransactions(data: Budget): FlaggedTransaction[] { + return [...data.transactions, ...data.scheduled_transactions]; +} + +function getFlagTag( + transaction: FlaggedTransaction, + flagNameConflicts: Set, +): string { + const tagName = transaction.flag_name?.trim() ?? ''; + const colorKey = transaction.flag_color?.trim() ?? ''; + + if (tagName.length === 0) { + return colorKey.length > 0 ? `#${colorKey}` : ''; + } + + if (flagNameConflicts.has(tagName)) { + return `#${tagName}-${colorKey}`; + } + + return `#${tagName}`; +} + +function getFlagNameConflicts(data: Budget): Set { + const colorsByName = new Map>(); + const flaggedTransactions = getFlaggedTransactions(data); + + for (const transaction of flaggedTransactions) { + if (transaction.deleted) { + continue; + } + + const tagName = transaction.flag_name?.trim() ?? ''; + const colorKey = transaction.flag_color?.trim() ?? ''; + if (tagName.length === 0 || !flagColorMap[colorKey]) { + continue; + } + + let colors = colorsByName.get(tagName); + if (!colors) { + colors = new Set(); + colorsByName.set(tagName, colors); + } + colors.add(colorKey); + } + + const conflicts = new Set(); + colorsByName.forEach((colors, name) => { + if (colors.size > 1) { + conflicts.add(name); + } + }); + + return conflicts; +} + +function buildTransactionNotes( + transaction: Transaction | ScheduledTransaction, + flagNameConflicts: Set, +): string | null { + const normalizedMemo = transaction.memo?.trim() ?? ''; + const tagText = getFlagTag(transaction, flagNameConflicts); + const notes = `${normalizedMemo} ${tagText}`.trim(); + return notes.length > 0 ? notes : null; +} + +function buildRuleUpdate( + rule: RuleEntity, + actions: RuleEntity['actions'], +): RuleEntity { + return { + id: rule.id, + stage: rule.stage ?? null, + conditionsOp: rule.conditionsOp ?? 'and', + conditions: rule.conditions, + actions, + }; +} + function importAccounts(data: Budget, entityIdMap: Map) { return Promise.all( data.accounts.map(async account => { @@ -309,9 +425,337 @@ function importPayees(data: Budget, entityIdMap: Map) { ); } +async function importFlagsAsTags( + data: Budget, + flagNameConflicts: Set, +): Promise { + const tagsToCreate = new Map(); + const flaggedTransactions = getFlaggedTransactions(data); + + for (const transaction of flaggedTransactions) { + if (transaction.deleted) { + continue; + } + + const tagName = transaction.flag_name?.trim() ?? ''; + const colorKey = transaction.flag_color?.trim() ?? ''; + const tagColor = flagColorMap[colorKey] ?? null; + + if (!tagColor) { + continue; + } + + if (tagName.length === 0) { + if (!tagsToCreate.has(colorKey)) { + tagsToCreate.set(colorKey, tagColor); + } + continue; + } + + const mappedName = flagNameConflicts.has(tagName) + ? `${tagName}-${colorKey}` + : tagName; + + if (!tagsToCreate.has(mappedName)) { + tagsToCreate.set(mappedName, tagColor); + } + } + + if (tagsToCreate.size === 0) { + return; + } + + await Promise.all( + [...tagsToCreate.entries()].map(async ([tag, color]) => { + await send('tags-create', { + tag, + color, + description: 'Imported from YNAB', + }); + }), + ); +} + +async function importTransactions( + data: Budget, + entityIdMap: Map, + flagNameConflicts: Set, +) { + const payees = await actual.getPayees(); + const categories = await actual.getCategories(); + const incomeCatId = findIdByName(categories, 'Income'); + const startingBalanceCatId = findIdByName(categories, 'Starting Balances'); //better way to do it? + + const startingPayeeYNAB = findIdByName(data.payees, 'Starting Balance'); + + const transactionsGrouped = groupBy(data.transactions, 'account_id'); + const subtransactionsGrouped = groupBy( + data.subtransactions, + 'transaction_id', + ); + + const payeesByTransferAcct = payees + .filter(payee => payee?.transfer_acct) + .map(payee => [payee.transfer_acct, payee] as [string, Payee]); + const payeeTransferAcctHashMap = new Map(payeesByTransferAcct); + const orphanTransferMap = new Map(); + const orphanSubtransfer = [] as Subtransaction[]; + const orphanSubtransferTrxId = [] as string[]; + const orphanSubtransferAcctIdByTrxIdMap = new Map(); + const orphanSubtransferDateByTrxIdMap = new Map(); + + // Go ahead and generate ids for all of the transactions so we can + // reliably resolve transfers + // 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.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(), + ); + + // 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. + function orphanTransferComparator( + a: Transaction | Subtransaction, + b: Transaction | Subtransaction, + ) { + // a and b can be a Transaction (having a date attribute) or a + // 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(); + 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; + default: + throw new Error(`Unrecognized orphan transfer comparator result`); + } + } while ( + transactionIdx < transactions.length && + subtransactionIdx < subtransactions.length + ); + } + }); + + await Promise.all( + [...transactionsGrouped.keys()].map(async accountId => { + const transactions = transactionsGrouped.get(accountId); + + const toImport = transactions + .map(transaction => { + if (transaction.deleted) { + return null; + } + + const subtransactions = subtransactionsGrouped.get(transaction.id); + + // Add transaction + const newTransaction = { + id: entityIdMap.get(transaction.id), + account: entityIdMap.get(transaction.account_id), + date: transaction.date, + amount: amountFromYnab(transaction.amount), + category: entityIdMap.get(transaction.category_id) || null, + cleared: ['cleared', 'reconciled'].includes(transaction.cleared), + reconciled: transaction.cleared === 'reconciled', + notes: buildTransactionNotes(transaction, flagNameConflicts), + imported_id: transaction.import_id || null, + transfer_id: + entityIdMap.get(transaction.transfer_transaction_id) || + orphanTrxIdSubtrxIdMap.get(transaction.id) || + null, + subtransactions: subtransactions + ? subtransactions.map(subtrans => { + return { + id: entityIdMap.get(subtrans.id), + amount: amountFromYnab(subtrans.amount), + category: entityIdMap.get(subtrans.category_id) || null, + notes: subtrans.memo, + transfer_id: + orphanTrxIdSubtrxIdMap.get(subtrans.id) || null, + payee: null, + imported_payee: null, + }; + }) + : null, + payee: null, + imported_payee: null, + }; + + // Handle transactions and subtransactions payee + function transactionPayeeUpdate( + trx: Transaction | Subtransaction, + newTrx, + fallbackPayeeId?: string | null, + ) { + if (trx.transfer_account_id) { + const mappedTransferAccountId = entityIdMap.get( + trx.transfer_account_id, + ); + newTrx.payee = payeeTransferAcctHashMap.get( + mappedTransferAccountId, + )?.id; + } else if (trx.payee_id) { + newTrx.payee = entityIdMap.get(trx.payee_id); + newTrx.imported_payee = data.payees.find( + p => !p.deleted && p.id === trx.payee_id, + )?.name; + } else if (fallbackPayeeId) { + newTrx.payee = fallbackPayeeId; + } + } + + transactionPayeeUpdate(transaction, newTransaction); + if (newTransaction.subtransactions) { + subtransactions.forEach(subtrans => { + const newSubtransaction = newTransaction.subtransactions.find( + newSubtrans => newSubtrans.id === entityIdMap.get(subtrans.id), + ); + transactionPayeeUpdate( + subtrans, + newSubtransaction, + newTransaction.payee, + ); + }); + } + + // Handle starting balances + if ( + transaction.payee_id === startingPayeeYNAB && + entityIdMap.get(transaction.category_id) === incomeCatId + ) { + newTransaction.category = startingBalanceCatId; + newTransaction.payee = null; + } + return newTransaction; + }) + .filter(x => x); + + await actual.addTransactions(entityIdMap.get(accountId), toImport, { + learnCategories: true, + }); + }), + ); +} + async function importScheduledTransactions( data: Budget, entityIdMap: Map, + flagNameConflicts: Set, ) { const scheduledTransactions = data.scheduled_transactions; const scheduledSubtransactionsGrouped = groupBy( @@ -417,7 +861,7 @@ async function importScheduledTransactions( }); schedulePayeeMap.set(scheduleId, mappedPayeeId); - const scheduleNotes = buildTransactionNotes(scheduled); + const scheduleNotes = buildTransactionNotes(scheduled, flagNameConflicts); if (scheduleNotes) { const rule = await getRuleForSchedule(scheduleId); if (rule) { @@ -543,281 +987,6 @@ async function importScheduledTransactions( } } -async function importTransactions( - data: Budget, - entityIdMap: Map, -) { - const payees = await actual.getPayees(); - const categories = await actual.getCategories(); - const incomeCatId = findIdByName(categories, 'Income'); - const startingBalanceCatId = findIdByName(categories, 'Starting Balances'); //better way to do it? - - const startingPayeeYNAB = findIdByName(data.payees, 'Starting Balance'); - - const transactionsGrouped = groupBy(data.transactions, 'account_id'); - const subtransactionsGrouped = groupBy( - data.subtransactions, - 'transaction_id', - ); - - const payeesByTransferAcct = payees - .filter(payee => payee?.transfer_acct) - .map(payee => [payee.transfer_acct, payee] as [string, Payee]); - const payeeTransferAcctHashMap = new Map(payeesByTransferAcct); - const orphanTransferMap = new Map(); - const orphanSubtransfer = [] as Subtransaction[]; - const orphanSubtransferTrxId = [] as string[]; - const orphanSubtransferAcctIdByTrxIdMap = new Map(); - const orphanSubtransferDateByTrxIdMap = new Map(); - - // Go ahead and generate ids for all of the transactions so we can - // reliably resolve transfers - // 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.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(), - ); - - // 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: TransactionSummary | Subtransaction, - b: TransactionSummary | Subtransaction, - ) => { - // a and b can be a TransactionSummary (having a date attribute) or a - // 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(); - 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; - default: - throw new Error(`Unrecognized orphan transfer comparator result`); - } - } while ( - transactionIdx < transactions.length && - subtransactionIdx < subtransactions.length - ); - } - }); - - await Promise.all( - [...transactionsGrouped.keys()].map(async accountId => { - const transactions = transactionsGrouped.get(accountId); - - const toImport = transactions - .map(transaction => { - if (transaction.deleted) { - return null; - } - - const subtransactions = subtransactionsGrouped.get(transaction.id); - - // Add transaction - const newTransaction = { - id: entityIdMap.get(transaction.id), - account: entityIdMap.get(transaction.account_id), - date: transaction.date, - amount: amountFromYnab(transaction.amount), - category: entityIdMap.get(transaction.category_id) || null, - cleared: ['cleared', 'reconciled'].includes(transaction.cleared), - reconciled: transaction.cleared === 'reconciled', - notes: buildTransactionNotes(transaction), - imported_id: transaction.import_id || null, - transfer_id: - entityIdMap.get(transaction.transfer_transaction_id) || - orphanTrxIdSubtrxIdMap.get(transaction.id) || - null, - subtransactions: subtransactions - ? subtransactions.map(subtrans => { - return { - id: entityIdMap.get(subtrans.id), - amount: amountFromYnab(subtrans.amount), - category: entityIdMap.get(subtrans.category_id) || null, - notes: subtrans.memo, - transfer_id: - orphanTrxIdSubtrxIdMap.get(subtrans.id) || null, - payee: null, - imported_payee: null, - }; - }) - : null, - payee: null, - imported_payee: null, - }; - - // Handle transactions and subtransactions payee - function transactionPayeeUpdate( - trx: TransactionSummary | Subtransaction, - newTrx, - fallbackPayeeId?: string | null, - ) { - if (trx.transfer_account_id) { - const mappedTransferAccountId = entityIdMap.get( - trx.transfer_account_id, - ); - newTrx.payee = payeeTransferAcctHashMap.get( - mappedTransferAccountId, - )?.id; - } else if (trx.payee_id) { - newTrx.payee = entityIdMap.get(trx.payee_id); - newTrx.imported_payee = data.payees.find( - p => !p.deleted && p.id === trx.payee_id, - )?.name; - } else if (fallbackPayeeId) { - newTrx.payee = fallbackPayeeId; - } - } - - transactionPayeeUpdate(transaction, newTransaction); - if (newTransaction.subtransactions) { - subtransactions.forEach(subtrans => { - const newSubtransaction = newTransaction.subtransactions.find( - newSubtrans => newSubtrans.id === entityIdMap.get(subtrans.id), - ); - transactionPayeeUpdate( - subtrans, - newSubtransaction, - newTransaction.payee, - ); - }); - } - - // Handle starting balances - if ( - transaction.payee_id === startingPayeeYNAB && - entityIdMap.get(transaction.category_id) === incomeCatId - ) { - newTransaction.category = startingBalanceCatId; - newTransaction.payee = null; - } - return newTransaction; - }) - .filter(x => x); - - await actual.addTransactions(entityIdMap.get(accountId), toImport, { - learnCategories: true, - }); - }), - ); -} - async function importBudgets(data: Budget, entityIdMap: Map) { // There should be info in the docs to deal with // no credit card category and how YNAB and Actual @@ -862,32 +1031,6 @@ async function importBudgets(data: Budget, entityIdMap: Map) { }); } -// Utils - -export async function doImport(data: Budget) { - const entityIdMap = new Map(); - - logger.log('Importing Accounts...'); - await importAccounts(data, entityIdMap); - - logger.log('Importing Categories...'); - await importCategories(data, entityIdMap); - - logger.log('Importing Payees...'); - await importPayees(data, entityIdMap); - - logger.log('Importing Transactions...'); - await importTransactions(data, entityIdMap); - - logger.log('Importing Scheduled Transactions...'); - await importScheduledTransactions(data, entityIdMap); - - logger.log('Importing Budgets...'); - await importBudgets(data, entityIdMap); - - logger.log('Setting up...'); -} - export function parseFile(buffer: Buffer): Budget { let data = JSON.parse(buffer.toString()); if (data.data) { @@ -904,53 +1047,30 @@ export function getBudgetName(_filepath: string, data: Budget) { return data.budget_name || data.name; } -function equalsIgnoreCase(stringa: string, stringb: string): boolean { - return ( - stringa.localeCompare(stringb, undefined, { - sensitivity: 'base', - }) === 0 - ); -} +export async function doImport(data: Budget) { + const entityIdMap = new Map(); + const flagNameConflicts = getFlagNameConflicts(data); -function findByNameIgnoreCase( - categories: T[], - name: string, -) { - return categories.find(cat => equalsIgnoreCase(cat.name, name)); -} + logger.log('Importing Accounts...'); + await importAccounts(data, entityIdMap); -function findIdByName( - categories: Array, - name: string, -) { - return findByNameIgnoreCase(categories, name)?.id; -} + logger.log('Importing Categories...'); + await importCategories(data, entityIdMap); -type FlaggedMemoTransaction = { - memo?: string | null; - flag_name?: string | null; - flag_color?: string | null; -}; + logger.log('Importing Payees...'); + await importPayees(data, entityIdMap); -function buildTransactionNotes(transaction: FlaggedMemoTransaction) { - const normalizedMemo = transaction.memo?.trim() ?? ''; - const normalizedFlag = - transaction.flag_name?.trim() ?? transaction.flag_color?.trim() ?? ''; - const notes = `${normalizedMemo} ${ - normalizedFlag ? `#${normalizedFlag}` : '' - }`.trim(); - return notes.length > 0 ? notes : null; -} + logger.log('Importing Tags...'); + await importFlagsAsTags(data, flagNameConflicts); -function buildRuleUpdate( - rule: RuleEntity, - actions: RuleEntity['actions'], -): RuleEntity { - return { - id: rule.id, - stage: rule.stage ?? null, - conditionsOp: rule.conditionsOp ?? 'and', - conditions: rule.conditions, - actions, - }; + logger.log('Importing Transactions...'); + await importTransactions(data, entityIdMap, flagNameConflicts); + + logger.log('Importing Scheduled Transactions...'); + await importScheduledTransactions(data, entityIdMap, flagNameConflicts); + + logger.log('Importing Budgets...'); + await importBudgets(data, entityIdMap); + + logger.log('Setting up...'); } diff --git a/upcoming-release-notes/6866.md b/upcoming-release-notes/6866.md new file mode 100644 index 0000000000..3eb5e96b99 --- /dev/null +++ b/upcoming-release-notes/6866.md @@ -0,0 +1,6 @@ +--- +category: Enhancements +authors: [StephenBrown2] +--- + +Apply tag colors to match YNAB flags when importing from nYNAB