mirror of
https://github.com/actualbudget/actual.git
synced 2026-03-09 06:02:22 -05:00
[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:
committed by
GitHub
parent
4caee99955
commit
e1f834371b
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]>>
|
||||
>;
|
||||
}
|
||||
|
||||
6
upcoming-release-notes/7050.md
Normal file
6
upcoming-release-notes/7050.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Maintenance
|
||||
authors: [MatissJanis]
|
||||
---
|
||||
|
||||
Remove reliance on the API package in YNAB importers
|
||||
Reference in New Issue
Block a user