mirror of
https://github.com/actualbudget/actual.git
synced 2026-10-12 02:10:19 +00:00
[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:
@@ -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.
|
||||
Reference in New Issue
Block a user