From f803a918adf42897aa26f39daa227d1a0771cf85 Mon Sep 17 00:00:00 2001 From: Stephen Brown II Date: Wed, 8 Jul 2026 10:13:02 -0400 Subject: [PATCH] [AI] Skip unused transfer payees in nYNAB import (#8424) * [AI] fix(ynab-import): skip literal transfer payees in nYNAB import YNAB auto-generates a 'Transfer : ' payee for every account-to-account link. Actual already has its own account-transfer payee per account and transactions resolve to that instead, so the literal-text payee was being created but never used, just cluttering the Payees list after import. * [AI] test(ynab-import): cover transfer-payee resolution in importTransactions Regression test proving a transfer transaction resolves to the real Actual account-transfer payee via transfer_account_id, and never needs an entityIdMap entry for the (now-skipped) YNAB transfer payee. * [AI] docs: add release note for skipping ynab transfer payees --- .../src/server/importers/ynab5.test.ts | 167 +++++++++++++++++- .../loot-core/src/server/importers/ynab5.ts | 6 +- .../skip-ynab-transfer-payees.md | 6 + 3 files changed, 174 insertions(+), 5 deletions(-) create mode 100644 upcoming-release-notes/skip-ynab-transfer-payees.md diff --git a/packages/loot-core/src/server/importers/ynab5.test.ts b/packages/loot-core/src/server/importers/ynab5.test.ts index 6b232bff59..8867982b1c 100644 --- a/packages/loot-core/src/server/importers/ynab5.test.ts +++ b/packages/loot-core/src/server/importers/ynab5.test.ts @@ -1,11 +1,174 @@ -import { describe, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { getBudgetName, parseFile } from './ynab5'; +import { send } from '#server/main-app'; + +import { + getBudgetName, + importPayees, + importTransactions, + parseFile, +} from './ynab5'; +import type { Payee, Transaction } from './ynab5-types'; + +vi.mock('#server/main-app', () => ({ + send: vi.fn(), +})); + +beforeEach(() => { + vi.mocked(send).mockReset(); +}); function toBuffer(obj: unknown): Buffer { return Buffer.from(JSON.stringify(obj)); } +function makePayee(overrides: Partial = {}): Payee { + return { + id: 'payee-1', + name: 'Some Payee', + deleted: false, + ...overrides, + }; +} + +describe('importPayees', () => { + it('does not create an Actual payee for a YNAB transfer-linked payee', async () => { + vi.mocked(send).mockResolvedValue('created-payee-id'); + + const transferPayee = makePayee({ + id: 'ynab-transfer-payee', + name: 'Transfer : Savings', + transfer_account_id: 'ynab-account-2', + }); + const normalPayee = makePayee({ + id: 'ynab-normal-payee', + name: 'Coffee Shop', + }); + + const entityIdMap = new Map(); + await importPayees( + { payees: [transferPayee, normalPayee] } as Parameters< + typeof importPayees + >[0], + entityIdMap, + ); + + expect(send).toHaveBeenCalledTimes(1); + expect(send).toHaveBeenCalledWith('api/payee-create', { + payee: { name: 'Coffee Shop' }, + }); + expect(entityIdMap.has('ynab-transfer-payee')).toBe(false); + expect(entityIdMap.get('ynab-normal-payee')).toBe('created-payee-id'); + }); + + it('still skips deleted payees', async () => { + vi.mocked(send).mockResolvedValue('created-payee-id'); + + const deletedPayee = makePayee({ id: 'deleted-1', deleted: true }); + const entityIdMap = new Map(); + + await importPayees( + { payees: [deletedPayee] } as Parameters[0], + entityIdMap, + ); + + expect(send).not.toHaveBeenCalled(); + expect(entityIdMap.size).toBe(0); + }); +}); + +function makeTransaction(overrides: Partial = {}): Transaction { + return { + id: 'txn-1', + date: '2026-01-01', + amount: -10000, + cleared: 'cleared', + approved: true, + account_id: 'ynab-account-1', + deleted: false, + ...overrides, + }; +} + +describe('importTransactions', () => { + it('resolves a transfer transaction to the account-transfer payee', async () => { + const entityIdMap = new Map([ + ['ynab-account-1', 'actual-account-1'], + ['ynab-account-2', 'actual-account-2'], + ]); + + const transactionOut = makeTransaction({ + id: 'txn-out', + account_id: 'ynab-account-1', + amount: -10000, + transfer_account_id: 'ynab-account-2', + transfer_transaction_id: 'txn-in', + payee_id: 'ynab-transfer-payee', + }); + const transactionIn = makeTransaction({ + id: 'txn-in', + account_id: 'ynab-account-2', + amount: 10000, + transfer_account_id: 'ynab-account-1', + transfer_transaction_id: 'txn-out', + payee_id: 'ynab-transfer-payee', + }); + + vi.mocked(send).mockImplementation(async (name: string) => { + if (name === 'api/payees-get') { + return [ + { + id: 'actual-payee-for-account-1', + transfer_acct: 'actual-account-1', + }, + { + id: 'actual-payee-for-account-2', + transfer_acct: 'actual-account-2', + }, + ]; + } + if (name === 'api/categories-get') { + return []; + } + if (name === 'api/transactions-add') { + return null; + } + throw new Error(`Unexpected send call: ${name}`); + }); + + await importTransactions( + { + payees: [], + transactions: [transactionOut, transactionIn], + subtransactions: [], + } as unknown as Parameters[0], + entityIdMap, + new Set(), + ); + + const transactionsAddCalls = vi + .mocked(send) + .mock.calls.filter(([name]) => name === 'api/transactions-add'); + expect(transactionsAddCalls).toHaveLength(2); + + const allImportedTransactions = transactionsAddCalls.flatMap( + ([, args]) => args.transactions, + ); + const outImported = allImportedTransactions.find( + t => t.id === entityIdMap.get('txn-out'), + ); + const inImported = allImportedTransactions.find( + t => t.id === entityIdMap.get('txn-in'), + ); + + expect(outImported.payee).toBe('actual-payee-for-account-2'); + expect(inImported.payee).toBe('actual-payee-for-account-1'); + + expect(outImported.transfer_id).toBe(inImported.id); + expect(inImported.transfer_id).toBe(outImported.id); + }); +}); + describe('ynab5 parseFile', () => { it('unwraps the legacy `budget` wrapper', () => { const data = parseFile( diff --git a/packages/loot-core/src/server/importers/ynab5.ts b/packages/loot-core/src/server/importers/ynab5.ts index d58db97444..b51ff9b1f1 100644 --- a/packages/loot-core/src/server/importers/ynab5.ts +++ b/packages/loot-core/src/server/importers/ynab5.ts @@ -447,10 +447,10 @@ async function importCategories( } } -function importPayees(data: Budget, entityIdMap: Map) { +export function importPayees(data: Budget, entityIdMap: Map) { return Promise.all( data.payees.map(async payee => { - if (!payee.deleted) { + if (!payee.deleted && !payee.transfer_account_id) { const id = await send('api/payee-create', { payee: { name: payee.name }, }); @@ -566,7 +566,7 @@ async function importFlagsAsTags( ); } -async function importTransactions( +export async function importTransactions( data: Budget, entityIdMap: Map, flagNameConflicts: Set, diff --git a/upcoming-release-notes/skip-ynab-transfer-payees.md b/upcoming-release-notes/skip-ynab-transfer-payees.md new file mode 100644 index 0000000000..e621c9d105 --- /dev/null +++ b/upcoming-release-notes/skip-ynab-transfer-payees.md @@ -0,0 +1,6 @@ +--- +category: Bugfixes +authors: [claude] +--- + +Stop the nYNAB importer from creating an unused duplicate payee for each linked transfer account.