[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 : <Account Name>' 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
This commit is contained in:
Stephen Brown II
2026-07-08 14:13:02 +00:00
committed by GitHub
parent 9dbee1078a
commit f803a918ad
3 changed files with 174 additions and 5 deletions
@@ -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> = {}): 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<string, string>();
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<string, string>();
await importPayees(
{ payees: [deletedPayee] } as Parameters<typeof importPayees>[0],
entityIdMap,
);
expect(send).not.toHaveBeenCalled();
expect(entityIdMap.size).toBe(0);
});
});
function makeTransaction(overrides: Partial<Transaction> = {}): 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<string, string>([
['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<typeof importTransactions>[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(
@@ -447,10 +447,10 @@ async function importCategories(
}
}
function importPayees(data: Budget, entityIdMap: Map<string, string>) {
export function importPayees(data: Budget, entityIdMap: Map<string, string>) {
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<string, string>,
flagNameConflicts: Set<string>,
@@ -0,0 +1,6 @@
---
category: Bugfixes
authors: [claude]
---
Stop the nYNAB importer from creating an unused duplicate payee for each linked transfer account.