[AI] Refactor YNAB importers to use server-side send() and handler API (#7050)

* [AI] Refactor YNAB importers to use server-side send() and handler API

Co-authored-by: Cursor <cursoragent@cursor.com>

* Rename 7049.md to 7050.md

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Matiss Janis Aboltins
2026-03-04 18:38:33 +00:00
committed by GitHub
parent 4caee99955
commit e1f834371b
4 changed files with 143 additions and 64 deletions

View File

@@ -1,10 +1,4 @@
// @ts-strict-ignore
// This is a special usage of the API because this package is embedded
// into Actual itself. We only want to pull in the methods in that
// case and ignore everything else; otherwise we'd be pulling in the
// entire backend bundle from the API
import { send } from '@actual-app/api/injected';
import * as actual from '@actual-app/api/methods';
import AdmZip from 'adm-zip';
import normalizePathSep from 'slash';
import { v4 as uuidv4 } from 'uuid';
@@ -12,6 +6,7 @@ import { v4 as uuidv4 } from 'uuid';
import { logger } from '../../platform/server/log';
import * as monthUtils from '../../shared/months';
import { amountToInteger, groupBy, sortByKey } from '../../shared/util';
import { send } from '../main-app';
import type * as YNAB4 from './ynab4-types';
@@ -26,10 +21,12 @@ async function importAccounts(
return Promise.all(
accounts.map(async account => {
if (!account.isTombstone) {
const id = await actual.createAccount({
name: account.accountName,
offbudget: account.onBudget ? false : true,
closed: account.hidden ? true : false,
const id = await send('api/account-create', {
account: {
name: account.accountName,
offbudget: account.onBudget ? false : true,
closed: account.hidden ? true : false,
},
});
entityIdMap.set(account.entityId, id);
}
@@ -51,13 +48,18 @@ async function importCategories(
masterCategory.subCategories &&
masterCategory.subCategories.some(cat => !cat.isTombstone)
) {
const id = await actual.createCategoryGroup({
name: masterCategory.name,
is_income: false,
const id = await send('api/category-group-create', {
group: {
name: masterCategory.name,
is_income: false,
},
});
entityIdMap.set(masterCategory.entityId, id);
if (masterCategory.note) {
send('notes-save', { id, note: masterCategory.note });
void send('notes-save', {
id,
note: masterCategory.note,
});
}
if (masterCategory.subCategories) {
@@ -87,13 +89,18 @@ async function importCategories(
categoryName = categoryNameParts.join('/').trim();
}
const id = await actual.createCategory({
name: categoryName,
group_id: entityIdMap.get(category.masterCategoryId),
const id = await send('api/category-create', {
category: {
name: categoryName,
group_id: entityIdMap.get(category.masterCategoryId),
},
});
entityIdMap.set(category.entityId, id);
if (category.note) {
send('notes-save', { id, note: category.note });
void send('notes-save', {
id,
note: category.note,
});
}
}
}
@@ -109,9 +116,11 @@ async function importPayees(
) {
for (const payee of data.payees) {
if (!payee.isTombstone) {
const id = await actual.createPayee({
name: payee.name,
transfer_acct: entityIdMap.get(payee.targetAccountId) || null,
const id = await send('api/payee-create', {
payee: {
name: payee.name,
transfer_acct: entityIdMap.get(payee.targetAccountId) || null,
},
});
// TODO: import payee rules
@@ -125,12 +134,14 @@ async function importTransactions(
data: YNAB4.YFull,
entityIdMap: Map<string, string>,
) {
const categories = await actual.getCategories();
const categories = await send('api/categories-get', {
grouped: false,
});
const incomeCategoryId: string = categories.find(
cat => cat.name === 'Income',
).id;
const accounts = await actual.getAccounts();
const payees = await actual.getPayees();
const accounts = await send('api/accounts-get');
const payees = await send('api/payees-get');
function getCategory(id: string) {
if (id == null || id === 'Category/__Split__') {
@@ -234,8 +245,11 @@ async function importTransactions(
})
.filter(x => x);
await actual.addTransactions(entityIdMap.get(accountId), toImport, {
await send('api/transactions-add', {
accountId: entityIdMap.get(accountId),
transactions: toImport,
learnCategories: true,
runTransfers: false,
});
}),
);
@@ -277,7 +291,8 @@ async function importBudgets(
) {
const budgets = sortByKey(data.monthlyBudgets, 'month');
await actual.batchBudgetUpdates(async () => {
await send('api/batch-budget-start');
try {
for (const budget of budgets) {
const filled = fillInBudgets(
data,
@@ -293,17 +308,31 @@ async function importBudgets(
return;
}
await actual.setBudgetAmount(month, catId, amount);
await send('api/budget-set-amount', {
month,
categoryId: catId,
amount,
});
if (catBudget.overspendingHandling === 'AffectsBuffer') {
await actual.setBudgetCarryover(month, catId, false);
await send('api/budget-set-carryover', {
month,
categoryId: catId,
flag: false,
});
} else if (catBudget.overspendingHandling === 'Confined') {
await actual.setBudgetCarryover(month, catId, true);
await send('api/budget-set-carryover', {
month,
categoryId: catId,
flag: true,
});
}
}),
);
}
});
} finally {
await send('api/batch-budget-end');
}
}
function estimateRecentness(str: string) {

View File

@@ -1,10 +1,4 @@
// @ts-strict-ignore
// This is a special usage of the API because this package is embedded
// into Actual itself. We only want to pull in the methods in that
// case and ignore everything else; otherwise we'd be pulling in the
// entire backend bundle from the API
import { send } from '@actual-app/api/injected';
import * as actual from '@actual-app/api/methods';
import { v4 as uuidv4 } from 'uuid';
import { logger } from '../../platform/server/log';
@@ -12,6 +6,7 @@ import * as monthUtils from '../../shared/months';
import { q } from '../../shared/query';
import { groupBy, sortByKey } from '../../shared/util';
import type { RecurConfig, RecurPattern, RuleEntity } from '../../types/models';
import { send } from '../main-app';
import { ruleModel } from '../transactions/transaction-rules';
import type {
@@ -276,10 +271,12 @@ function importAccounts(data: Budget, entityIdMap: Map<string, string>) {
return Promise.all(
data.accounts.map(async account => {
if (!account.deleted) {
const id = await actual.createAccount({
name: account.name,
offbudget: account.on_budget ? false : true,
closed: account.closed,
const id = await send('api/account-create', {
account: {
name: account.name,
offbudget: account.on_budget ? false : true,
closed: account.closed,
},
});
entityIdMap.set(account.id, id);
}
@@ -294,7 +291,9 @@ async function importCategories(
// Hidden categories are put in its own group by YNAB,
// so it's already handled.
const categories = await actual.getCategories();
const categories = await send('api/categories-get', {
grouped: false,
});
const incomeCatId = findIdByName(categories, 'Income');
const ynabIncomeCategories = ['To be Budgeted', 'Inflow: Ready to Assign'];
@@ -337,7 +336,9 @@ async function importCategories(
while (true) {
const name = count === 0 ? baseName : `${baseName} (${count})`;
try {
const id = await actual.createCategoryGroup({ ...params, name });
const id = await send('api/category-group-create', {
group: { ...params, name },
});
return { id, name };
} catch (e) {
if (count >= MAX_RETRY) {
@@ -360,7 +361,9 @@ async function importCategories(
while (true) {
const name = count === 0 ? baseName : `${baseName} (${count})`;
try {
const id = await actual.createCategory({ ...params, name });
const id = await send('api/category-create', {
category: { ...params, name },
});
return { id, name };
} catch (e) {
if (count >= MAX_RETRY) {
@@ -390,7 +393,10 @@ async function importCategories(
groupId = createdGroup.id;
entityIdMap.set(group.id, groupId);
if (group.note) {
send('notes-save', { id: groupId, note: group.note });
void send('notes-save', {
id: groupId,
note: group.note,
});
}
}
@@ -428,7 +434,7 @@ async function importCategories(
});
entityIdMap.set(cat.id, createdCategory.id);
if (cat.note) {
send('notes-save', {
void send('notes-save', {
id: createdCategory.id,
note: cat.note,
});
@@ -445,8 +451,8 @@ function importPayees(data: Budget, entityIdMap: Map<string, string>) {
return Promise.all(
data.payees.map(async payee => {
if (!payee.deleted) {
const id = await actual.createPayee({
name: payee.name,
const id = await send('api/payee-create', {
payee: { name: payee.name },
});
entityIdMap.set(payee.id, id);
}
@@ -510,8 +516,10 @@ async function importTransactions(
entityIdMap: Map<string, string>,
flagNameConflicts: Set<string>,
) {
const payees = await actual.getPayees();
const categories = await actual.getCategories();
const payees = await send('api/payees-get');
const categories = await send('api/categories-get', {
grouped: false,
});
const incomeCatId = findIdByName(categories, 'Income');
const startingBalanceCatId = findIdByName(categories, 'Starting Balances'); //better way to do it?
@@ -774,8 +782,11 @@ async function importTransactions(
})
.filter(x => x);
await actual.addTransactions(entityIdMap.get(accountId), toImport, {
await send('api/transactions-add', {
accountId: entityIdMap.get(accountId),
transactions: toImport,
learnCategories: true,
runTransfers: false,
});
}),
);
@@ -795,7 +806,7 @@ async function importScheduledTransactions(
return;
}
const payees = await actual.getPayees();
const payees = await send('api/payees-get');
const payeesByTransferAcct = payees
.filter(payee => payee?.transfer_acct)
.map(payee => [payee.transfer_acct, payee] as [string, Payee]);
@@ -818,7 +829,10 @@ async function importScheduledTransactions(
while (true) {
try {
return await actual.createSchedule({ ...params, name: params.name });
return await send('api/schedule-create', {
...params,
name: params.name,
});
} catch (e) {
if (count >= MAX_RETRY) {
const errorMsg = normalizeError(e);
@@ -833,16 +847,19 @@ async function importScheduledTransactions(
async function getRuleForSchedule(
scheduleId: string,
): Promise<RuleEntity | null> {
const { data: ruleId } = (await actual.aqlQuery(
q('schedules').filter({ id: scheduleId }).calculate('rule'),
)) as { data: string | null };
const { data: ruleId } = (await send('api/query', {
query: q('schedules')
.filter({ id: scheduleId })
.calculate('rule')
.serialize(),
})) as { data: string | null };
if (!ruleId) {
return null;
}
const { data: ruleData } = (await actual.aqlQuery(
q('rules').filter({ id: ruleId }).select('*'),
)) as { data: Array<Record<string, unknown>> };
const { data: ruleData } = (await send('api/query', {
query: q('rules').filter({ id: ruleId }).select('*').serialize(),
})) as { data: Array<Record<string, unknown>> };
const ruleRow = ruleData?.[0];
if (!ruleRow) {
return null;
@@ -901,7 +918,9 @@ async function importScheduledTransactions(
value: scheduleNotes,
});
await actual.updateRule(buildRuleUpdate(rule, actions));
await send('api/rule-update', {
rule: buildRuleUpdate(rule, actions),
});
}
}
@@ -934,7 +953,9 @@ async function importScheduledTransactions(
value: categoryId,
});
await actual.updateRule(buildRuleUpdate(rule, actions));
await send('api/rule-update', {
rule: buildRuleUpdate(rule, actions),
});
}
for (const [scheduleId, subtransactions] of scheduleSplitsMap.entries()) {
@@ -1011,7 +1032,9 @@ async function importScheduledTransactions(
}
});
await actual.updateRule(buildRuleUpdate(rule, actions));
await send('api/rule-update', {
rule: buildRuleUpdate(rule, actions),
});
}
}
}
@@ -1036,7 +1059,8 @@ async function importBudgets(data: Budget, entityIdMap: Map<string, string>) {
'Credit Card Payments',
);
await actual.batchBudgetUpdates(async () => {
await send('api/batch-budget-start');
try {
for (const budget of budgets) {
const month = monthUtils.monthFromDate(budget.month);
@@ -1053,11 +1077,17 @@ async function importBudgets(data: Budget, entityIdMap: Map<string, string>) {
return;
}
await actual.setBudgetAmount(month, catId, amount);
await send('api/budget-set-amount', {
month,
categoryId: catId,
amount,
});
}),
);
}
});
} finally {
await send('api/batch-budget-end');
}
}
export function parseFile(buffer: Buffer): Budget {

View File

@@ -2,6 +2,7 @@ import * as connection from '../platform/server/connection';
import type { Handlers } from '../types/handlers';
import { createApp } from './app';
import { runHandler } from './mutators';
// Main app
export const app = createApp<Handlers>();
@@ -9,3 +10,16 @@ export const app = createApp<Handlers>();
app.events.on('sync', event => {
connection.send('sync-event', event);
});
/**
* Run a handler by name (server-side). Same API shape as the client connection's send.
* Used by server code that needs to invoke handlers directly, e.g. importers.
*/
export async function send<K extends keyof Handlers>(
name: K,
args?: Parameters<Handlers[K]>[0],
): Promise<Awaited<ReturnType<Handlers[K]>>> {
return runHandler(app.handlers[name], args, { name }) as Promise<
Awaited<ReturnType<Handlers[K]>>
>;
}

View File

@@ -0,0 +1,6 @@
---
category: Maintenance
authors: [MatissJanis]
---
Remove reliance on the API package in YNAB importers